From b6a274183b2809c1d541b666606915e19113d062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 4 May 2026 22:24:54 +0100 Subject: [PATCH 01/33] release: app v0.39, cli 0.17.0 --- CHANGELOG.md | 96 ++++++++++++++++++++++++++++++++++++++++++- apps/cli/package.json | 2 +- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90755344f4..ea6c36b4ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,99 @@ All notable changes to Kandev. +## 0.39 - 2026-05-04 + +### Features + +- attribute cross-task agent messages with a sender badge ([#819](https://github.com/kdlbs/kandev/pull/819)) +- add 10 ACP agents, set_config_option, auth_required flow ([#807](https://github.com/kdlbs/kandev/pull/807)) +- polish workbench topbars and task-create dialog ([#792](https://github.com/kdlbs/kandev/pull/792)) +- show question icon only for pending input ([#782](https://github.com/kdlbs/kandev/pull/782)) +- add recent task switcher ([#779](https://github.com/kdlbs/kandev/pull/779)) +- collapse commits and PR changes by default in changes panel ([#781](https://github.com/kdlbs/kandev/pull/781)) +- support tasks spanning multiple repositories ([#767](https://github.com/kdlbs/kandev/pull/767)) +- allow reordering sidebar views ([#764](https://github.com/kdlbs/kandev/pull/764)) +- refine workbench chrome ([#761](https://github.com/kdlbs/kandev/pull/761)) +- add get_task_conversation tool ([#756](https://github.com/kdlbs/kandev/pull/756)) +- add improve kandev in-app contribution flow ([#740](https://github.com/kdlbs/kandev/pull/740)) +- refresh + smarter filter for new-task branch selector ([#750](https://github.com/kdlbs/kandev/pull/750)) +- poll JQL queries to auto-create tasks (issue watchers) ([#746](https://github.com/kdlbs/kandev/pull/746)) +- key terminals + dockview layout to TaskEnvironment ([#755](https://github.com/kdlbs/kandev/pull/755)) +- add Linear integration ([#736](https://github.com/kdlbs/kandev/pull/736)) +- show active time and elapsed span per task ([#748](https://github.com/kdlbs/kandev/pull/748)) +- eager-init agent on profile selection ([#747](https://github.com/kdlbs/kandev/pull/747)) +- add message_task_kandev tool ([#745](https://github.com/kdlbs/kandev/pull/745)) +- deferred task move with hand-off prompt + boot/turn-end event split ([#743](https://github.com/kdlbs/kandev/pull/743)) +- mobile terminal key-bar — Ctrl/Shift modify OS-keyboard input + iOS keyboard fixes ([#741](https://github.com/kdlbs/kandev/pull/741)) +- make --port the user-facing port flag ([#737](https://github.com/kdlbs/kandev/pull/737)) +- expose MCP server to external coding agents ([#732](https://github.com/kdlbs/kandev/pull/732)) +- hide Jira buttons when disabled or auth failing ([#725](https://github.com/kdlbs/kandev/pull/725)) +- add GitHub dashboard shortcut to command panel ([#721](https://github.com/kdlbs/kandev/pull/721)) + +### Bug Fixes + +- dedup topbar PR button against auto-shown PR panel ([#812](https://github.com/kdlbs/kandev/pull/812)) +- stabilize task switch flow and session recovery ([#818](https://github.com/kdlbs/kandev/pull/818)) +- suppress spurious task-failed toasts on resume and reconnect ([#814](https://github.com/kdlbs/kandev/pull/814)) +- scope commits/diff to live branch divergence; flatten single-repo ([#816](https://github.com/kdlbs/kandev/pull/816)) +- sanitize multi-repo worktree dirs and polish task-create chip dropdown ([#815](https://github.com/kdlbs/kandev/pull/815)) +- return mcp message_task once dispatched, not after target turn ends ([#817](https://github.com/kdlbs/kandev/pull/817)) +- correct task_environments migration order for older DBs ([#811](https://github.com/kdlbs/kandev/pull/811)) +- retry CLI passthrough launch without resume flag after fast-fail ([#810](https://github.com/kdlbs/kandev/pull/810)) +- close tooltips on popover close and refine branch chip UX ([#808](https://github.com/kdlbs/kandev/pull/808)) +- make executors_running the single source of truth for agent_execution_id ([#799](https://github.com/kdlbs/kandev/pull/799)) +- use shared prompt template with placeholder autocomplete ([#801](https://github.com/kdlbs/kandev/pull/801)) +- tab session bugs — primary star drift + tab close re-creation ([#800](https://github.com/kdlbs/kandev/pull/800)) +- retry session commits fetch when workspace not ready ([#789](https://github.com/kdlbs/kandev/pull/789)) +- focus task name on create dialog open ([#788](https://github.com/kdlbs/kandev/pull/788)) +- dedupe cancel-turn clicks to stop "turn cancelled" cascade ([#784](https://github.com/kdlbs/kandev/pull/784)) +- heal legacy PR watches so single-repo tasks don't dupe PRs ([#785](https://github.com/kdlbs/kandev/pull/785)) +- add border to task name input in create dialog ([#783](https://github.com/kdlbs/kandev/pull/783)) +- prevent inline code from rendering diagrams ([#773](https://github.com/kdlbs/kandev/pull/773)) +- remove settings topbar border ([#774](https://github.com/kdlbs/kandev/pull/774)) +- re-key user-shell RPCs to task_environment_id ([#770](https://github.com/kdlbs/kandev/pull/770)) +- unblock terminals stuck on Connecting and heal task_environments ([#769](https://github.com/kdlbs/kandev/pull/769)) +- prevent orphaned agent subprocesses from concurrent execution creates ([#768](https://github.com/kdlbs/kandev/pull/768)) +- restore readable boxed chat hotkey tooltip ([#765](https://github.com/kdlbs/kandev/pull/765)) +- make mermaid sanitizer quote-aware and detect parens in bracket labels ([#763](https://github.com/kdlbs/kandev/pull/763)) +- refine workbench action button layout ([#762](https://github.com/kdlbs/kandev/pull/762)) +- server-authoritative session ensure for kanban preview ([#760](https://github.com/kdlbs/kandev/pull/760)) +- guide recovery when session profile is deleted ([#752](https://github.com/kdlbs/kandev/pull/752)) +- drop stuck pending permission_request messages ([#723](https://github.com/kdlbs/kandev/pull/723)) +- plan panel misses agent updates emitted before WS connects ([#749](https://github.com/kdlbs/kandev/pull/749)) +- render passthrough terminal in quick chat ([#744](https://github.com/kdlbs/kandev/pull/744)) +- contain kanban card badges within card width ([#739](https://github.com/kdlbs/kandev/pull/739)) +- improve create_task_kandev repository and workspace resolution ([#733](https://github.com/kdlbs/kandev/pull/733)) +- publish agentctl events on workspace restore ([#731](https://github.com/kdlbs/kandev/pull/731)) +- self-recover dockview layout from corrupt persisted state ([#729](https://github.com/kdlbs/kandev/pull/729)) +- populate diff for renamed-and-modified files in git status ([#730](https://github.com/kdlbs/kandev/pull/730)) +- preserve chat scroll position when maximizing panel ([#728](https://github.com/kdlbs/kandev/pull/728)) +- prevent crowding in narrow changes panel and dockview tabs ([#727](https://github.com/kdlbs/kandev/pull/727)) +- drain queued message on workflow transition to non-auto-start step ([#726](https://github.com/kdlbs/kandev/pull/726)) +- apply profile cli_flags to passthrough command ([#722](https://github.com/kdlbs/kandev/pull/722)) +- restore release changelog boundaries ([#716](https://github.com/kdlbs/kandev/pull/716)) + +### Performance + +- speed up stats endpoint aggregation ([#766](https://github.com/kdlbs/kandev/pull/766)) + +### Refactoring + +- move Configuration Chat Agent to utility agents page ([#809](https://github.com/kdlbs/kandev/pull/809)) +- drop redundant VCS split button from task top bar ([#804](https://github.com/kdlbs/kandev/pull/804)) +- drop dead taskPRs loading state and unused removeTaskPR ([#791](https://github.com/kdlbs/kandev/pull/791)) +- move integrations to top-level settings with install-wide configs ([#787](https://github.com/kdlbs/kandev/pull/787)) +- use GetOrEnsureExecution for workspace ops ([#786](https://github.com/kdlbs/kandev/pull/786)) +- extract shared shapes between jira and linear integrations ([#759](https://github.com/kdlbs/kandev/pull/759)) +- route user terminals by environment ([#758](https://github.com/kdlbs/kandev/pull/758)) +- drop unused flat-layout worktrees/ path ([#708](https://github.com/kdlbs/kandev/pull/708)) + +### Documentation + +- add integrations banner to README ([#790](https://github.com/kdlbs/kandev/pull/790)) +- add issue templates ([#754](https://github.com/kdlbs/kandev/pull/754)) +- add star history chart ([#753](https://github.com/kdlbs/kandev/pull/753)) + ## 0.38 - 2026-04-27 ### Features @@ -100,7 +193,7 @@ All notable changes to Kandev. ### Bug Fixes -- release script tags fetching ([986410ab](https://github.com/kdlbs/kandev/commit/986410ab2ebb1cc25e1ad046c9daae18a58cc150)) +- release script tags fetching - show repo name instead of full path in task sidebar ([#652](https://github.com/kdlbs/kandev/pull/652)) - unstick agent session when cancel times out ([#651](https://github.com/kdlbs/kandev/pull/651)) - anchor PR detail panel to session group on auto-open ([#646](https://github.com/kdlbs/kandev/pull/646)) @@ -1156,3 +1249,4 @@ All notable changes to Kandev. - resolve conflicts with main + diff --git a/apps/cli/package.json b/apps/cli/package.json index 26284de2ad..e78ec42e37 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "kandev", - "version": "0.16.0", + "version": "0.17.0", "private": false, "description": "NPX launcher for Kandev", "license": "AGPL-3.0-only", From 379230892be2a08d826df1b78826d1f46c9141bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 5 May 2026 00:43:34 +0100 Subject: [PATCH 02/33] feat: brew install + npm sibling channels via OIDC trusted publishing (#806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: ignore local secret file * feat(cli): replace self-update with package-manager-owned runtime resolution The CLI no longer downloads runtime bundles from GitHub on first run. Instead: - A new resolveRuntime() in apps/cli/src/runtime.ts resolves the bundle in three steps: KANDEV_BUNDLE_DIR env var (Homebrew wrapper, tests) → installed @kdlbs/runtime-{platform} npm package → cached download (only when --runtime-version is given explicitly). - apps/cli/src/run.ts is split: launch logic separated from download logic. The download path stays for the explicit --runtime-version flag only. - The --version flag now prints CLI semver (was: runtime tag selector). The old behavior moves to --runtime-version, documented as advanced/debug. - The npm self-update prompt (apps/cli/src/update.ts) is removed entirely. - apps/cli/package.json declares optionalDependencies for five @kdlbs/runtime-{platform} packages and engines.npm: ">=7" (required for optional-dep resolution during npx). - Adds esbuild + a "bundle" script that produces dist/cli.bundle.js — a single-file CJS bundle with all Node deps inlined. The Homebrew formula uses this; the npm package keeps the unbundled dist/cli.js. Tests: 130/130 passing. Bundle smoke-tested in a directory with no node_modules to confirm tree-kill is correctly inlined. * feat(release): unified vX.Y.Z release flow with brew + npm sibling channels GitHub release artifacts become the canonical build output. npm and Homebrew are independent sibling channels — both consume the same GitHub release tarballs; neither depends on the other. Workflow changes (.github/workflows/release.yml): - Trigger is now workflow_dispatch only (maintainer-only, gated by repo write access). Inputs: bump dropdown (patch/minor/major) + dry_run checkbox. - Single job graph orchestrates everything in one run: prepare (bump, PR, merge, tag) → build-web/build-cli → build-bundles (5 platforms) → docker → publish-release → publish-npm + update-homebrew-tap. - The prepare job inlines version bump, optionalDependencies sync, CHANGELOG generation, PR create + squash-merge, and tag push. Replaces what release-pr.sh used to do locally. - Tag-push trigger removed; everything runs from the dispatch flow. New scripts (called by the workflow): - package-cli.sh: builds + esbuild-bundles the CLI for inclusion in release bundles (cli/bin/cli.js + cli/dist/cli.bundle.js + package.json). - package-npm-runtime.sh: generates per-platform @kdlbs/runtime-* npm packages from release bundles. Sets npm os/cpu fields so npm 7+ skips non-matching platforms during npx kandev install. - publish-npm.sh: verifies GitHub assets exist, downloads them, repackages, publishes all 5 @kdlbs/runtime-* packages + main kandev@X.Y.Z. - update-homebrew-tap.sh: clones kdlbs/homebrew-kandev via SSH using the HOMEBREW_TAP_DEPLOY_KEY repo secret, generates Formula/kandev.rb pointing at the GitHub release assets, pushes directly to main. Removed scripts: - release.sh and release-pr.sh — both are redundant with the new workflow. Required CI secrets: - NPM_TOKEN: granular access token with publish access to kandev + @kdlbs/* - HOMEBREW_TAP_DEPLOY_KEY: SSH private key for kdlbs/homebrew-kandev * docs: brew install, runtime resolution, and GHA-driven release flow - README.md: add Homebrew quick start (brew install kdlbs/kandev/kandev), update NPX docs to require npm 7+, document the three update paths. - docs/cli.md: rewrite architecture diagram to show three-step runtime resolution. Replace --version docs with --version (CLI semver) + --runtime-version (advanced/debug). Remove KANDEV_NO_UPDATE_PROMPT. - apps/cli/README.md: add Homebrew install path; document package-manager-owned updates. - apps/cli/README_internal.md: rewrite to describe the npm package vs GitHub bundle artifact-shape distinction and the three-step resolver. Document the GHA-driven release UI (Run workflow + bump dropdown). - AGENTS.md: add Release & Versioning Convention section describing the unified single-SemVer flow, the sibling channel model, and the GHA flow. * fix(release): release-bundle bin/cli.js must require the esbuild bundle The previous package-cli.sh copied apps/cli/bin/cli.js verbatim into the release bundle. That file requires `../dist/cli.js` (unbundled), which only works when node_modules is present alongside it. In the GitHub release bundle (used by Homebrew and manual installs), there is no node_modules — `require('tree-kill')` and `require('tar')` inside dist/cli.js would crash at startup. Now package-cli.sh writes a release-specific bin/cli.js that requires `../dist/cli.bundle.js` instead. The bundled file has all Node deps inlined via esbuild, so it runs standalone in the Cellar. The npm package keeps its own bin/cli.js (in apps/cli/bin/) requiring the unbundled dist/cli.js, since npm installs tree-kill etc. normally. Caught while smoke-testing the release bundle locally with KANDEV_BUNDLE_DIR — backend + web boot succeeded after this fix. * feat(release): switch npm publishing to Trusted Publishers (OIDC) The previous flow required an NPM_TOKEN secret. Local testing with that token failed with EOTP because the granular access token still required a 2FA OTP on publish despite the account-level setting change. npm now recommends Trusted Publishers (OIDC) over long-lived tokens. Workflow change (release.yml): - publish-npm job declares `permissions: id-token: write` so GitHub Actions can mint OIDC tokens. - Removes NODE_AUTH_TOKEN env var. The npm CLI auto-detects the OIDC environment and exchanges the GitHub OIDC token for a short-lived npmjs.com publish credential. - Comment block updated to reflect the new auth model. NPM_TOKEN is no longer listed in required secrets (already deleted from repo secrets). Script change (publish-npm.sh): - Updates `npm publish` calls to include `--provenance` so each published package ships with a signed attestation linking it back to this commit and workflow run. - Header comment explains the OIDC auth model. Setup required on npmjs.com (per package, before first OIDC publish): For each of the 6 packages — kandev plus @kdlbs/runtime-{linux-x64, linux-arm64,darwin-x64,darwin-arm64,win32-x64} — go to the package's "Settings → Publishing access" and add a Trusted Publisher pointing at this repo (kdlbs/kandev), workflow filename release.yml, and (optionally) an environment. * fix(release): pin optionalDependencies to bootstrap version in committed source CI was failing because apps/cli/package.json declared optionalDependencies at 0.16.0 but only 0.0.0-bootstrap exists on npm for those packages. The lockfile couldn't resolve, and `pnpm install --frozen-lockfile` rejected the working tree. Pin optionalDependencies to 0.0.0-bootstrap in the committed source so the lockfile resolves cleanly. The release workflow now bumps them to the real version inside publish-npm.sh, just before publishing main kandev — at which point the matching @kdlbs/runtime-*@VERSION tarballs have already been published earlier in the same step. This avoids the chicken-and-egg where the prepare job tried to bump optional deps to a version that didn't yet exist on npm. Workflow changes: - prepare job: only bumps the main package version, not optional deps. - publish-npm.sh: rewrites optionalDependencies to $VERSION right before the main `npm publish`, after the 5 runtime packages have been published. The published kandev@X.Y.Z still has its optionalDependencies pinned to matching @kdlbs/runtime-*@X.Y.Z. Only the local source uses the bootstrap pin for development convenience. * fix: address PR review comments on release flow + runtime resolution PR reviewers (Greptile, Claude, CodeRabbit) flagged six concrete issues. Each is addressed below. Critical: - publish-npm.sh: previously exited 0 even when runtime packages failed to publish, which would leave kandev@VERSION on the registry pointing at @kdlbs/runtime-*@VERSION that never got published. Now: capture full npm output on failure, distinguish "already published" (idempotent skip) from real failures, abort before publishing main kandev if any runtime fails. - release.yml: build-bundles referenced needs.prepare.outputs.tag without declaring prepare in its needs array. Static analysis flagged this. Added prepare to the needs list. Major: - update-homebrew-tap.sh: the formula's write_env_script only injected KANDEV_BUNDLE_DIR. run.ts:174 reads KANDEV_VERSION to display the running version in startup logs; without it Homebrew users would see "release: (env)" instead of "release: 0.17.0". Pass KANDEV_VERSION: version.to_s through the wrapper. - runtime.ts: the catch {} block silently swallowed validateBundle errors, hiding "package installed but corrupt" cases behind the generic "no runtime found" message. Split the lookup so MODULE_NOT_FOUND falls through, but validation errors propagate. - run.ts: the --runtime-version code path skipped validateBundle, missing the file-existence checks that the npm/env paths get. Added it. Minor: - release.yml: workflow-level permissions defaulted to write for everyone. Tightened to read-only at the workflow level; jobs that need writes (prepare, docker, publish-release) declare them explicitly. - args.test.ts: added missing tests for --runtime-version with no value and --runtime-version followed by another flag, parallel to the existing --port tests. Tests: 132 passing (was 130; +2 new). * fix: address second-round PR review (cleanup traps, host-key pinning, supply chain) Latest review (Claude + Greptile) flagged three security/cleanup items on top of the previous fix commit. All three addressed below. publish-npm.sh: - Add `trap 'rm -rf "$WORK_DIR"' EXIT` immediately after `mktemp -d` so the tmpdir is cleaned up even when `pnpm build` or `npm publish` fails partway through (set -e would otherwise abort before the explicit rm at the end). - Remove redundant `pnpm build` before `npm publish` — the `prepublishOnly` script in package.json already runs the build. update-homebrew-tap.sh: - Combine `cleanup_ssh` + WORK_DIR cleanup into one EXIT trap so `git clone` / `git push` failures don't leak the tmpdir. - Pin GitHub's published SSH host keys (ed25519, ECDSA, RSA) into a temp `known_hosts` file instead of using `accept-new` + `/dev/null`. This closes the "trust any host key on first connect" gap. The pinned keys come from https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints. release.yml: - Replace the curl|tar git-cliff install with `orhun/git-cliff-action@v4` (already used in publish-release). The action pins to a digest, so we no longer need to verify a tarball checksum manually. Removes a supply-chain attack surface where a tampered git-cliff binary could run during `prepare` (which has contents:write + pull-requests:write). * revert: keep curl install for git-cliff, add sha512 verification instead The previous commit (18b2a5c) replaced our `curl | tar` install with orhun/git-cliff-action@v4 under the banner "supply chain". On reflection that swap doesn't actually improve anything: - @v4 is a moving tag, not a digest pin — same trust as a tagged curl URL. - Both depend on trusting the same maintainer (orhun). - The action adds a third-party step we don't audit. The legitimate concern from the review was "no integrity verification on the downloaded tarball." Address that directly: pin the asset version, embed its published sha512, and abort if the download doesn't match. The hash for v2.6.1's x86_64-linux-gnu tarball came from https://github.com/orhun/git-cliff/releases/download/v2.6.1/.sha512. This restores the original setup with a real integrity check and one fewer external dependency. * fix: address remaining PR nits (env restore, sha256 download dir) runtime.test.ts: drop the redundant `delete process.env.KANDEV_BUNDLE_DIR` in afterEach — `process.env = { ...origEnv }` already restores the original environment. The redundant delete would silently un-set the var if a developer had it in their shell, leaking test side effects across suites. update-homebrew-tap.sh: fetch_sha256 now downloads into $WORK_DIR instead of /tmp/. The EXIT trap covers WORK_DIR, so the .sha256 files are cleaned up automatically. Avoids leaking 5 tmpfiles per release run on long-lived runners. * fix: address third-round PR comments - update-homebrew-tap.sh: Homebrew formula was calling `bin.write_env_script` on the bin directory with target `cli/bin/cli.js`, which would create the wrapper at `$HOMEBREW_PREFIX/bin/cli.js` (named after the target's basename) instead of `$HOMEBREW_PREFIX/bin/kandev`. Result: `brew install kandev` would have produced `command not found`. Use `(bin/"kandev").write_env_script(...)` to name the wrapper explicitly. Caught by Claude as a P0 blocker. - publish-npm.sh: main `kandev` publish lacked the EPUBLISHCONFLICT idempotency handling that the runtime-package loop has. If publish-npm was re-run after a partial failure (e.g. the runtime packages succeeded but the main package failed and was then manually published), the re-run would fail on the main package even though the version was already on the registry. Now: capture output, treat "already published" as success, fail loudly with full output on real errors. Caught by Greptile as P1. * refactor(release): direct push to main instead of PR-and-merge dance The prepare job was opening a release branch, creating a PR, and immediately squash-merging it via gh CLI. That pattern was carried over from the old release-pr.sh, but in this workflow it earns nothing: - main has no branch protection, so direct push is allowed. - The PR was force-merged in the same step (no `--auto`, no waiting for required checks), so it provided no pre-merge validation. - Discoverability is fine without a PR — git tag, GitHub release, workflow run, and main commit are all visible. The PR-and-merge sequence had ~5 GitHub API roundtrips, each a potential failure point, plus added 30+ lines of YAML and required pull-requests: write on the job. Replaced with a straight-line commit + tag + push: git fetch origin main && git checkout main && git reset --hard origin/main ... bump pkg.json, regen CHANGELOG, npm install ... git add ... && git commit && git tag -a && git push origin main "$TAG" Job permissions tightened to contents: write only. Race condition note: if main advances between fetch and push, the push fails non-fast-forward — same outcome as the old PR pattern, where the auto-merge would have failed. For workflow_dispatch releases with the maintainer actively driving, the race is essentially never going to happen, and the recovery (re-run the workflow) is the same. The dry-run path still bumps + regenerates CHANGELOG inside the runner so the maintainer can verify the computed values look right, just without committing or pushing anything. * chore(release): drop verbose comment about direct-push rationale * docs: drop 'no first-run download' note from user-facing docs The phrasing only makes sense to readers who knew the previous model required a first-run download. New users have no baseline to compare against, so it adds noise. Removed from docs/cli.md and apps/cli/README.md (which renders on npmjs.com). Kept in apps/cli/README_internal.md as a design note for contributors. * Revert "chore: ignore local secret file" The /secret entry was added defensively after the bootstrap test, but it clutters .gitignore with a project-specific path that nobody else needs. Drop it; secrets stay out of the repo by other means (don't commit them). * fix: address third-pass review (stale workflow comments + missing test) release.yml: - The header comment block and dry_run input description still referenced the PR-and-merge step that was removed in 53c2e45. Updated both to describe the direct-commit flow. runtime.test.ts: - The runtime resolver's npm-package branch (step 2 — the primary path after npx kandev@latest or npm install -g) had no positive coverage. In the test env, require.resolve for @kdlbs/runtime-* throws MODULE_NOT_FOUND, so the existing tests only exercised step 1 (env var) and step 3 (cache via --runtime-version), plus the no-runtime-found fallthrough. Added two tests using vi.spyOn on Module._resolveFilename to point at a fake bundle inside tmpDir: - happy path: installed npm package resolves to source=npm with the right bundleDir - corrupt-bundle path: validateBundle errors propagate (rather than being swallowed and falling through to "no runtime found") These cover the real-world scenarios users will hit on either install channel. Tests: 132 → 134. * fix(release): push commit + tag atomically Without --atomic, git push can succeed for the tag ref and fail for the main ref on a non-fast-forward, leaving an orphaned tag at the pre-bump commit. The orphan then trips the 'Tag already exists on origin' guard on the next re-run, requiring manual tag deletion to unblock. --atomic makes the multi-ref push succeed-or-fail as a unit. Caught by Greptile as P1. * refactor(cli): drop runtime.ts step 3 — cache fallback was unreachable resolveRuntime's runtimeVersion parameter and cache-fallback step were dead code from production's perspective. The --runtime-version path is handled entirely in run.ts (findCachedRelease + downloadRuntimeVersion + findBundleRoot + validateBundle), and run.ts only ever called resolveRuntime() with no argument. The parameter and step 3 only existed because tests exercised them. Two parallel "compute cache dir" implementations (runtime.ts:65 and run.ts:157) were a drift hazard if the cache layout ever changes. Changes: - Drop the runtimeVersion parameter from resolveRuntime. - Drop the cache step from runtime.ts entirely; remove the unused CACHE_DIR + findBundleRoot imports. - Narrow RuntimeSource from "env" | "npm" | "cache" to "env" | "npm", and drop the now-unused tag field from ResolvedRuntime. - Remove the three "cache fallback" tests from runtime.test.ts. The --runtime-version cache lookup is still covered by run.test.ts via findCachedRelease tests. Tests: 134 → 131 (three deleted tests are obsolete, the production behavior they were verifying is now covered by run.test.ts). * chore(test): drop dead mock setup left over from runtime.ts step 3 removal After feb7c5a removed the cache fallback from runtime.ts, the test file had two dangling artifacts: - The vi.mock("./constants", ...) block + the mockCacheDir holder + the mockCacheDir.value = tmpDir assignment in beforeEach. runtime.ts no longer imports anything from ./constants, so the mock was a no-op that would mislead readers tracing why constants are mocked here. - The PLATFORM = "macos-arm64" constant was unused — platform determinism is already enforced by the vi.mock("./platform", ...) block one section above. Caught by Claude and the github-code-quality bot. --- .github/workflows/release.yml | 329 +++++++++- AGENTS.md | 30 + README.md | 28 +- apps/cli/README.md | 30 +- apps/cli/README_internal.md | 236 +++---- apps/cli/package.json | 14 +- apps/cli/src/args.test.ts | 25 +- apps/cli/src/args.ts | 19 +- apps/cli/src/cli.ts | 33 +- apps/cli/src/run.ts | 147 ++--- apps/cli/src/runtime.test.ts | 152 +++++ apps/cli/src/runtime.ts | 83 +++ apps/cli/src/update.ts | 86 --- apps/pnpm-lock.yaml | 353 ++++++++++- docs/cli.md | 171 +++--- scripts/release/package-cli.sh | 49 ++ scripts/release/package-npm-runtime.sh | 110 ++++ scripts/release/publish-npm.sh | 181 ++++++ scripts/release/release-pr.sh | 812 ------------------------- scripts/release/release.sh | 648 -------------------- scripts/release/update-homebrew-tap.sh | 203 +++++++ 21 files changed, 1864 insertions(+), 1875 deletions(-) create mode 100644 apps/cli/src/runtime.test.ts create mode 100644 apps/cli/src/runtime.ts delete mode 100644 apps/cli/src/update.ts create mode 100755 scripts/release/package-cli.sh create mode 100755 scripts/release/package-npm-runtime.sh create mode 100755 scripts/release/publish-npm.sh delete mode 100755 scripts/release/release-pr.sh delete mode 100755 scripts/release/release.sh create mode 100755 scripts/release/update-homebrew-tap.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a1ab444a0..855e754bdc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,20 +1,213 @@ name: release -on: - push: - tags: - - "v*" - workflow_dispatch: +# Maintainer-only release workflow. +# Trigger: GitHub Actions UI → "Run workflow" (workflow_dispatch). +# +# Flow (one click → everything below): +# 1. prepare — bump version, regen CHANGELOG, commit to main, push tag vX.Y.Z +# 2. build-web — build Next.js standalone bundle +# 3. build-cli — build + esbuild-bundle CLI +# 4. build-bundles — assemble bin/ + web/ + cli/ tarballs for 5 platforms +# 5. docker — build & push multi-arch container image to GHCR +# 6. publish-release — create GH release with .tar.gz + .sha256 assets + auto-generated notes +# 7. publish-npm — publish kandev@X.Y.Z + @kdlbs/runtime-*@X.Y.Z to npmjs +# 8. update-homebrew-tap — push updated Formula/kandev.rb to kdlbs/homebrew-kandev +# +# npm publishing uses Trusted Publishers (OIDC) — no NPM_TOKEN required. +# Each package (kandev + 5 @kdlbs/runtime-* packages) must have this workflow +# configured as its trusted publisher on npmjs.com (Settings → Publishing access). +# +# Required secrets: +# HOMEBREW_TAP_DEPLOY_KEY — SSH private key for kdlbs/homebrew-kandev deploy key +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump" + required: true + type: choice + default: minor + options: + - patch + - minor + - major + dry_run: + description: "Dry run (no commit, tag, npm publish, or tap update)" + type: boolean + default: false + +# Default to read-only at the workflow level. Each job opts into the writes +# it actually needs. permissions: - contents: write - packages: write + contents: read jobs: + prepare: + name: Prepare release (bump version, commit, tag) + runs-on: ubuntu-latest + permissions: + contents: write # commit + push to main, push tag + outputs: + version: ${{ steps.compute.outputs.version }} + tag: ${{ steps.compute.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: pnpm/action-setup@v4 + with: + version: "9" + + - uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install git-cliff + env: + # Pinned to v2.6.1. To bump, also update the sha512 below — fetch the + # new one from: + # https://github.com/orhun/git-cliff/releases/download//.sha512 + CLIFF_VERSION: "2.6.1" + CLIFF_ASSET: "git-cliff-2.6.1-x86_64-unknown-linux-gnu.tar.gz" + CLIFF_SHA512: "b1e7a6b70fc794accdccd9717eff5a8bf8b507f0308da44d837d935be31b18b19db3758d1a9bb4975fbca7d01ddaae251229a753ae2cd2ecc24845cfc50d067f" + run: | + set -euo pipefail + curl -sSL "https://github.com/orhun/git-cliff/releases/download/v${CLIFF_VERSION}/${CLIFF_ASSET}" -o /tmp/git-cliff.tar.gz + echo "${CLIFF_SHA512} /tmp/git-cliff.tar.gz" | sha512sum -c - + tar -xz -C /tmp -f /tmp/git-cliff.tar.gz + sudo mv "/tmp/git-cliff-${CLIFF_VERSION}/git-cliff" /usr/local/bin/ + rm -rf /tmp/git-cliff.tar.gz "/tmp/git-cliff-${CLIFF_VERSION}" + git-cliff --version + + - name: Configure git identity + run: | + git config user.email "release-bot@kandev" + git config user.name "kandev release bot" + + - name: Compute next version + id: compute + env: + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + PKG_VERSION="$(node -p "require('./apps/cli/package.json').version")" + + # Find latest git tag, normalizing legacy two-part tags (vM.m → M.m.0) + LATEST_TAG="$(git tag --list 'v*' | python3 -c " + import sys, re + best = (0, 0, 0) + for t in (l.strip() for l in sys.stdin if l.strip()): + s = t.lstrip('v') + m3 = re.match(r'^(\d+)\.(\d+)\.(\d+)$', s) + m2 = re.match(r'^(\d+)\.(\d+)$', s) + if m3: + v = tuple(int(x) for x in m3.groups()) + elif m2: + v = (int(m2.group(1)), int(m2.group(2)), 0) + else: + continue + if v > best: + best = v + print(f'{best[0]}.{best[1]}.{best[2]}') + ")" + + # Use the higher of package.json version and the latest tag as baseline + BASELINE="$(python3 -c " + a = '$PKG_VERSION'.split('.') + b = '$LATEST_TAG'.split('.') + ai = tuple(int(x) for x in a) + bi = tuple(int(x) for x in b) + print('$PKG_VERSION' if ai >= bi else '$LATEST_TAG') + ")" + + IFS=. read -r MAJ MIN PAT <<<"$BASELINE" + case "$BUMP" in + patch) NEXT="${MAJ}.${MIN}.$((PAT + 1))" ;; + minor) NEXT="${MAJ}.$((MIN + 1)).0" ;; + major) NEXT="$((MAJ + 1)).0.0" ;; + *) echo "Invalid bump '$BUMP'" >&2; exit 1 ;; + esac + + TAG="v${NEXT}" + echo "Current package.json version: $PKG_VERSION" + echo "Latest git tag (normalized): $LATEST_TAG" + echo "Baseline: $BASELINE" + echo "Bump: $BUMP" + echo "Next version: $NEXT" + echo "Next tag: $TAG" + echo "version=$NEXT" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + # Sanity: tag must not already exist + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists locally" >&2 + exit 1 + fi + if git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then + echo "Tag $TAG already exists on origin" >&2 + exit 1 + fi + + - name: Bump version, generate CHANGELOG, commit, tag, push + if: ${{ !inputs.dry_run }} + env: + NEXT: ${{ steps.compute.outputs.version }} + TAG: ${{ steps.compute.outputs.tag }} + run: | + set -euo pipefail + git fetch origin main + git checkout main + git reset --hard origin/main + + # Bump only the main package version. optionalDependencies stay + # pinned to 0.0.0-bootstrap in committed source — publish-npm.sh + # rewrites them to the real version in-flight before publishing, + # once the matching @kdlbs/runtime-* tarballs exist on npm. + (cd apps/cli && npm version --no-git-tag-version "$NEXT") + pnpm -C apps install --no-frozen-lockfile + + git-cliff --tag "$TAG" -o CHANGELOG.md + + git add apps/cli/package.json CHANGELOG.md apps/pnpm-lock.yaml + git commit -m "release: $NEXT" + git tag -a "$TAG" -m "release: $NEXT" + + # Push commit and tag atomically — without --atomic, Git could + # accept the tag push but reject the main update on a non-fast-forward, + # leaving an orphaned tag at the pre-bump commit (and tripping the + # "tag already exists" guard on the next re-run). --atomic makes the + # whole multi-ref push succeed or fail as a unit. + git push --atomic origin main "$TAG" + + - name: Dry-run summary + if: ${{ inputs.dry_run }} + env: + NEXT: ${{ steps.compute.outputs.version }} + TAG: ${{ steps.compute.outputs.tag }} + run: | + # In dry-run we still bump + run git-cliff locally so the maintainer + # can verify the computed version + changelog look right, but nothing + # is committed or pushed. + (cd apps/cli && npm version --no-git-tag-version "$NEXT") + pnpm -C apps install --no-frozen-lockfile + git-cliff --tag "$TAG" -o CHANGELOG.md + echo + echo "DRY RUN: Would have committed + tagged $TAG and pushed to origin/main." + echo "DRY RUN: apps/cli/package.json bumped to $NEXT (in-runner only)." + echo "DRY RUN: CHANGELOG.md regenerated (in-runner only)." + build-web: + name: Build web standalone + needs: prepare + if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} - uses: pnpm/action-setup@v4 with: version: "9" @@ -25,7 +218,7 @@ jobs: run: pnpm -C apps install --frozen-lockfile - name: Build web (standalone) env: - KANDEV_VERSION: ${{ github.ref_name }} + KANDEV_VERSION: ${{ needs.prepare.outputs.tag }} run: pnpm -C apps --filter @kandev/web build - name: Package web bundle run: bash scripts/release/package-web.sh @@ -35,8 +228,34 @@ jobs: path: dist/web include-hidden-files: true + build-cli: + name: Build & bundle CLI + needs: prepare + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + - uses: pnpm/action-setup@v4 + with: + version: "9" + - uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Install dependencies + run: pnpm -C apps install --frozen-lockfile + - name: Build and bundle CLI + run: bash scripts/release/package-cli.sh + - uses: actions/upload-artifact@v4 + with: + name: cli-bundle + path: dist/kandev/cli + build-bundles: - needs: build-web + name: Build platform bundle (${{ matrix.platform }}) + needs: [prepare, build-web, build-cli] + if: ${{ !inputs.dry_run }} strategy: matrix: include: @@ -69,6 +288,8 @@ jobs: GOARCH: ${{ matrix.goarch }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} - uses: actions/setup-go@v6 with: go-version-file: apps/backend/go.mod @@ -77,6 +298,10 @@ jobs: with: name: web-bundle path: dist/web + - uses: actions/download-artifact@v4 + with: + name: cli-bundle + path: dist/kandev/cli - name: Install cross-compilation tools if: matrix.cc @@ -96,19 +321,19 @@ jobs: - name: Package bundle run: | - rm -rf dist/kandev + rm -rf dist/kandev/bin dist/kandev/web mkdir -p dist/kandev/bin EXT=${{ matrix.goos == 'windows' && '.exe' || '' }} cp apps/backend/bin/kandev${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl${EXT} dist/kandev/bin/ mkdir -p dist/kandev/web cp -R dist/web/. dist/kandev/web/ + # cli/ was already placed by the build-cli job (downloaded above) cd dist tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256" - - name: Upload bundle artifacts - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: name: bundle-${{ matrix.platform }} path: | @@ -116,9 +341,17 @@ jobs: dist/kandev-${{ matrix.platform }}.tar.gz.sha256 docker: + name: Build & push container image + needs: prepare + if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest + permissions: + contents: read + packages: write # push to GHCR steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} - name: Docker meta id: meta @@ -126,10 +359,10 @@ jobs: with: images: ghcr.io/kdlbs/kandev tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} + type=raw,value=${{ needs.prepare.outputs.version }} + type=raw,value=${{ needs.prepare.outputs.tag }} type=sha - type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} + type=raw,value=latest - uses: docker/setup-buildx-action@v3 @@ -152,11 +385,16 @@ jobs: cache-to: type=gha,mode=max publish-release: - needs: build-bundles + name: Publish GitHub release + needs: [prepare, build-bundles] + if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest + permissions: + contents: write # softprops/action-gh-release creates the release steps: - uses: actions/checkout@v4 with: + ref: ${{ needs.prepare.outputs.tag }} fetch-depth: 0 - name: Generate release notes @@ -167,8 +405,7 @@ jobs: env: OUTPUT: RELEASE_NOTES.md - - name: Download bundle artifacts - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v4 with: pattern: bundle-* path: dist/release-assets @@ -192,10 +429,64 @@ jobs: done ls -lh dist/release-assets - - name: Publish release assets + - name: Publish release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ needs.prepare.outputs.tag }} + name: ${{ needs.prepare.outputs.tag }} body_path: RELEASE_NOTES.md files: | dist/release-assets/kandev-*.tar.gz dist/release-assets/kandev-*.tar.gz.sha256 + + publish-npm: + name: Publish npm packages + needs: [prepare, publish-release] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC trusted publishing + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - uses: pnpm/action-setup@v4 + with: + version: "9" + + - uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: pnpm -C apps install --frozen-lockfile + + - name: Publish npm packages + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + bash scripts/release/publish-npm.sh \ + "${{ needs.prepare.outputs.version }}" \ + "${{ needs.prepare.outputs.tag }}" + + update-homebrew-tap: + name: Update Homebrew tap + needs: [prepare, publish-release] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - name: Update Homebrew tap formula + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} + run: | + bash scripts/release/update-homebrew-tap.sh \ + "${{ needs.prepare.outputs.version }}" \ + "${{ needs.prepare.outputs.tag }}" diff --git a/AGENTS.md b/AGENTS.md index bfe39f030c..42dcd27818 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,6 +274,36 @@ lib/api/domains/ # API clients Commits to `main` **must** follow [Conventional Commits](https://www.conventionalcommits.org/) (`type: description`). PRs are squash-merged — the PR title becomes the commit, validated by CI. Changelog is auto-generated from these via git-cliff (`cliff.toml`). See `.agents/skills/commit/SKILL.md` for allowed types and examples. +### Release & Versioning Convention + +Kandev uses a **single SemVer** `X.Y.Z` shared across all distribution channels: + +- `apps/cli/package.json` version → `X.Y.Z` +- npm main package: `kandev@X.Y.Z` +- npm runtime packages: `@kdlbs/runtime-{platform}@X.Y.Z` (5 platforms; declared as `optionalDependencies` in main package) +- Git tag: `vX.Y.Z` (three-part; legacy `vM.m` tags normalize to `M.m.0`) +- Homebrew formula: `kdlbs/homebrew-kandev` `Formula/kandev.rb` `version "X.Y.Z"` +- GitHub release: `vX.Y.Z` with platform tarballs `kandev-{platform}.tar.gz` + `.sha256` + +**npm and Homebrew are sibling channels**, not chained. Both consume the same GitHub release artifacts; neither depends on the other. + +**Release flow** (entirely in CI via `.github/workflows/release.yml`, triggered by maintainer from GitHub Actions UI): + +1. Maintainer clicks "Run workflow" → picks `bump` (patch/minor/major) → optional `dry_run`. +2. `prepare` job bumps version + regenerates CHANGELOG, opens release PR, squash-merges, tags `vX.Y.Z`. +3. `build-web` + `build-cli` + `build-bundles` (5 platforms) build the release artifacts. +4. `publish-release` creates the GitHub release with platform tarballs + sha256 + auto-generated notes. +5. `publish-npm` publishes 5 `@kdlbs/runtime-*` packages + main `kandev` package to npmjs. +6. `update-homebrew-tap` pushes updated `Formula/kandev.rb` to `kdlbs/homebrew-kandev` via SSH deploy key. + +There is no local release script — the entire flow runs in GHA. + +**Runtime resolution** (in `apps/cli/src/runtime.ts`): + +1. `KANDEV_BUNDLE_DIR` env var (set by Homebrew wrapper, used by tests). +2. Installed `@kdlbs/runtime-{platform}` npm package via `require.resolve()`. +3. `--runtime-version ` cache fallback (debug only — downloads from GitHub). + ### Code Quality (enforced by linters) Static analysis runs in CI and pre-commit. New code **must** stay within these limits: diff --git a/README.md b/README.md index 9ee11f277a..6d0af035c6 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,35 @@ Each executor uses git worktrees for workspace isolation, preventing concurrent ## Quick Start -### NPX (recommended) +### Homebrew (macOS, Linux) ```bash -npx kandev +brew install kdlbs/kandev/kandev +kandev ``` -This downloads pre-built backend + frontend bundles and starts them locally. The worktrees and sqlite db will be created in `~/.kandev` by default. Should work on macOS, Linux, and Windows (WSL or native). +### NPX + +```bash +npx kandev@latest +``` + +Requires npm 7+. Installs the CLI plus a platform-matched runtime bundle in one step. + +### NPM (global) + +```bash +npm install -g kandev@latest +kandev +``` + +### Updates + +- `brew upgrade kandev` +- `npx kandev@latest` (always uses the latest published version) +- `npm install -g kandev@latest` + +The worktrees and sqlite db live in `~/.kandev` by default. Works on macOS, Linux, and Windows (WSL or native). ### From Source diff --git a/apps/cli/README.md b/apps/cli/README.md index e65a916fcd..b086054566 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -4,18 +4,36 @@ Manage tasks. Orchestrate agents. Review changes. Ship value. ## Quick Start +### Homebrew + ```bash -npx kandev +brew install kdlbs/kandev/kandev +kandev ``` -Downloads the latest release, starts the backend + web app, and opens your browser. Data (worktrees, SQLite DB) is stored in `~/.kandev` by default. +### NPX (requires npm 7+) + +```bash +npx kandev@latest +``` + +Either install path resolves a platform-matched runtime (Go backend, agentctl, Next.js standalone web), launches the backend + web, and opens your browser. Data (worktrees, SQLite DB) is stored in `~/.kandev` by default. ## Version and Updates -- `npx kandev` (run mode) uses the latest GitHub release bundle by default. -- At startup, the launcher prints the resolved release tag (for example: `[kandev] release: v0.2.3 (github latest)`). -- Use `--version ` to pin a specific runtime bundle release. -- CLI package updates are checked from npm and shown as a prompt. They are not forced. +The package manager owns the runtime version. `kandev@X.Y.Z` ships with the matching runtime. + +- **Update via Homebrew**: `brew upgrade kandev` +- **Update via npm/npx**: `npx kandev@latest` or `npm install -g kandev@latest` +- **Print CLI version**: `kandev --version` + +### Advanced: pin a specific runtime tag + +`--runtime-version ` downloads a specific GitHub release runtime instead of using the installed one. For debugging compatibility issues only: + +```bash +kandev --runtime-version v0.16.0 +``` ## What You Get diff --git a/apps/cli/README_internal.md b/apps/cli/README_internal.md index 3b33a0b7f7..7779383417 100644 --- a/apps/cli/README_internal.md +++ b/apps/cli/README_internal.md @@ -1,158 +1,170 @@ -# Kandev Launcher (npx) +# Kandev CLI (internal docs) ## Architecture ```mermaid flowchart TD - A["npx kandev"] --> B["npm downloads kandev package"] - B --> C["CLI parses arguments"] - C --> D{"Command?"} + A["kandev"] --> B["CLI parses arguments"] + B --> C{"Command?"} - D -->|run| E["Fetch latest GitHub release"] - D -->|dev| F["Use local repo"] - D -->|start| G["Use local build"] + C -->|run / default| D["resolveRuntime()"] + C -->|dev| F["Use local repo"] + C -->|start| G["Use local build"] - E --> E1["Download platform zip"] - E1 --> E2["Extract to ~/.kandev/bin/"] - E2 --> E3["Start backend + Next.js standalone"] + D --> D1{"KANDEV_BUNDLE_DIR set?"} + D1 -->|yes| D2["Use env bundle (Homebrew)"] + D1 -->|no| D3["require.resolve('@kdlbs/runtime-{platform}')"] + D3 -->|hit| D4["Use installed npm bundle"] + D3 -->|miss| D5{"--runtime-version given?"} + D5 -->|yes| D6["Download from GitHub Releases (cache)"] + D5 -->|no| D7["Throw: 'No runtime found'"] + + D2 --> E["Start backend + Next.js standalone"] + D4 --> E + D6 --> E F --> F1["make dev + next dev"] - G --> G1["binary + next start"] ``` ## Overview -This package powers `npx kandev` by downloading prebuilt release bundles from GitHub Releases and running them locally. It: +This package provides the `kandev` CLI launcher. The runtime bundle (Go backend, agentctl, Next.js standalone web) is **installed by the package manager** — there is no first-run download. -- Detects OS/arch and fetches the matching bundle ZIP. -- Verifies the SHA256 checksum when available. -- Extracts the bundle into `~/.kandev/bin///`. -- Starts the backend binary and waits for the `/health` endpoint. -- Starts the Next.js standalone server with runtime `KANDEV_API_BASE_URL`. -- Uses the latest GitHub Release by default, so runtime bundles update automatically. +Two install channels share the same release artifacts: -It also supports local dev and production runs from a repo checkout. +- **npm/npx**: `kandev@X.Y.Z` declares `optionalDependencies` for `@kdlbs/runtime-{platform}@X.Y.Z`. npm 7+ filters by `os`/`cpu` and installs only the matching one. +- **Homebrew**: `kdlbs/homebrew-kandev` formula downloads the GitHub release tarball into the Cellar and sets `KANDEV_BUNDLE_DIR`. + +Both channels resolve to the same runtime bundle layout (`bin/kandev`, `bin/agentctl`, `web/server.js`). The CLI launcher (`runtime.ts`) abstracts over them. + +## Artifact shapes + +The GitHub release bundle and the npm runtime package are **different shapes** because they serve different consumers: + +``` +# GitHub release bundle (used by Homebrew + manual installs) +kandev/ +├── bin/{kandev,agentctl} +├── web/server.js +└── cli/ + ├── bin/cli.js # #!/usr/bin/env node + chmod +x + └── dist/cli.bundle.js # esbuild single-file bundle (deps inlined) + +# npm runtime package (@kdlbs/runtime-{platform}) +@kdlbs/runtime-{platform}/ +├── bin/{kandev,agentctl} +└── web/server.js +``` + +The `cli/` directory is only in the GitHub bundle (Homebrew needs the launcher). For npm installs, the main `kandev` package already provides the CLI; the runtime package only ships the binaries + web bundle. ## Commands | Command | Description | | ------------------------ | -------------------------------------------------------- | -| `kandev` or `kandev run` | Download and run release bundles (default) | +| `kandev` or `kandev run` | Run from installed runtime bundle (default) | | `kandev dev` | Run local repo with hot-reload (requires repo checkout) | | `kandev start` | Run local production build (requires `make build` first) | -## Updates - -On `run`, the launcher checks npm for the latest `kandev` CLI version and prompts: - -``` -Update available: -> . Update now? [y/N] -``` - -If you accept, it re-runs `npx kandev@latest` with the same arguments. -This check is skipped in `dev` and `start` modes. - -Note: the runtime bundles are pulled from the latest GitHub Release by default, even if the CLI version is unchanged. So: - -- **New runtime release without CLI publish**: users get new runtime automatically, but no update prompt. -- **New CLI publish**: users get an update prompt and then re-run with the new CLI. -- On startup, `run` also prints the resolved runtime release tag. -- Runtime assets are published atomically in CI (single final release upload job), so `/releases/latest` - does not surface partially uploaded platform bundles. - -You can disable the prompt with: - -```bash -KANDEV_NO_UPDATE_PROMPT=1 npx kandev -``` - -## Usage - -```bash -# Run the latest release bundle -npx kandev - -# Run a specific release tag -npx kandev run --version v0.1.0 - -# Override ports -npx kandev --backend-port 18080 --web-port 13000 - -# Local dev with hot-reload (from repo) -npx kandev dev - -# Local production build (from repo, after `make build`) -npx kandev start - -# Local test the built CLI (from repo root) -pnpm -C apps/cli build -pnpm -C apps/cli start -``` - ## CLI Options -| Option | Description | -| ----------------------- | ---------------------------------------- | -| `run` | Use release bundles (default) | -| `dev` | Use local repo with hot-reload | -| `start` | Use local production build | -| `--version ` | Release tag to install (default: latest) | -| `--backend-port ` | Override backend port | -| `--web-port ` | Override web port | -| `--help`, `-h` | Show help | +| Option | Description | +| -------------------------- | ---------------------------------------------------------- | +| `--version`, `-V` | Print CLI version and exit | +| `--port`, `--backend-port` | Backend port | +| `--web-internal-port` | Override internal Next.js port | +| `--verbose`, `-v` | Show info logs | +| `--debug` | Show debug logs + agent message dumps | +| `--runtime-version ` | **Advanced/debug**: download a specific GitHub runtime tag | +| `--help`, `-h` | Show help | + +## Updates (package-manager owned) + +The CLI no longer self-updates. Updates flow through the install channel: + +```bash +brew upgrade kandev # Homebrew +npm install -g kandev@latest # npm global +npx kandev@latest # always pulls latest +``` + +There is no `KANDEV_NO_UPDATE_PROMPT` or `KANDEV_SKIP_UPDATE` env var anymore — they're gone with the self-updater. + +## Bundling for Homebrew + +The Homebrew bundle uses `dist/cli.bundle.js`, an esbuild single-file output that inlines `tree-kill`, `tar`, and other Node deps. Required because the Cellar has no `node_modules`. The npm package uses the unbundled `dist/cli.js` and resolves deps via npm. + +```bash +pnpm -C apps/cli build # produces dist/cli.js +pnpm -C apps/cli bundle # produces dist/cli.bundle.js (~212KB) +``` + +Smoke test the bundle in a clean directory: + +```bash +mkdir /tmp/smoke && cd /tmp/smoke +node /path/to/apps/cli/dist/cli.bundle.js --help +``` + +If `Cannot find module 'tree-kill'` appears, the bundle is broken. ## Local Development ```bash -# Run CLI in dev mode +# Run CLI in dev mode (uses tsx) pnpm -C apps/cli dev # Run with arguments pnpm -C apps/cli dev -- start -pnpm -C apps/cli dev -- --backend-port 9000 +pnpm -C apps/cli dev -- --port 9000 ``` -## Build / Publish - -```bash -pnpm -C apps/cli build -npm publish --access public -``` - -The published package name is `kandev`, with a bin entry `kandev`. - ## Release -```bash -scripts/release/release.sh -``` +Releases run entirely in GitHub Actions. From the GHA UI: -The unified release script supports `cli`, `app`, or `both` in one flow. -Versioning strategy: +1. Open the **Release** workflow. +2. Click **Run workflow**. +3. Choose `bump` (patch / minor / major) from the dropdown. +4. Optionally tick `dry_run` to validate without publishing. +5. Click **Run workflow**. -- App release tags: `vM.m` -- CLI npm version: `M.m.0` -- Default bump mode: `minor` +The workflow does everything: version bump, CHANGELOG, PR, merge, tag, build, npm publish, Homebrew tap update. See [/.github/workflows/release.yml](../../.github/workflows/release.yml). + +Single SemVer flow: `apps/cli/package.json` version, git tag, npm packages, and Homebrew formula are all bumped to the same `X.Y.Z`. + +Versioning: + +- One SemVer `X.Y.Z` for everything (npm, GitHub tag, Homebrew formula) +- Git tag format: `vX.Y.Z` +- Legacy `vM.m` tags normalized to `M.m.0` during migration; new releases always use `vX.Y.Z` ## Environment Overrides -| Variable | Description | -| --------------------------- | ---------------------------------------------- | -| `KANDEV_GITHUB_OWNER` | Override GitHub repo owner (default: `kandev`) | -| `KANDEV_GITHUB_REPO` | Override GitHub repo name (default: `kandev`) | -| `KANDEV_GITHUB_TOKEN` | Optional token for GitHub API rate limits | -| `KANDEV_NO_UPDATE_PROMPT=1` | Disable the CLI update prompt | -| `KANDEV_SKIP_UPDATE=1` | Internal guard to avoid update loops | -| `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout in milliseconds | +| Variable | Description | +| ------------------------------------- | --------------------------------------------------------------------- | +| `KANDEV_BUNDLE_DIR` | Force runtime bundle location (set by Homebrew wrapper, tests) | +| `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port | +| `KANDEV_WEB_PORT` | Internal Next.js port | +| `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) | +| `KANDEV_GITHUB_OWNER` | Override GitHub repo owner (default: `kdlbs`) for `--runtime-version` | +| `KANDEV_GITHUB_REPO` | Override GitHub repo name (default: `kandev`) for `--runtime-version` | +| `KANDEV_GITHUB_TOKEN` | Optional GitHub token for rate limits | ## Supported Platforms -| Platform | Asset Name | -| --------------------- | ------------------------------------------- | -| macOS (Apple Silicon) | `kandev-macos-arm64.zip` | -| macOS (Intel) | `kandev-macos-x64.zip` | -| Linux (x64) | `kandev-linux-x64.zip` | -| Linux (ARM64) | `kandev-linux-arm64.zip` | -| Windows (x64) | `kandev-windows-x64.zip` | -| Windows (ARM64) | Falls back to `windows-x64` (x64 emulation) | +| Platform | npm runtime package | GitHub asset name | +| --------------------- | ------------------------------------------- | --------------------------- | +| macOS (Apple Silicon) | `@kdlbs/runtime-darwin-arm64` | `kandev-macos-arm64.tar.gz` | +| macOS (Intel) | `@kdlbs/runtime-darwin-x64` | `kandev-macos-x64.tar.gz` | +| Linux (x64) | `@kdlbs/runtime-linux-x64` | `kandev-linux-x64.tar.gz` | +| Linux (ARM64) | `@kdlbs/runtime-linux-arm64` | `kandev-linux-arm64.tar.gz` | +| Windows (x64) | `@kdlbs/runtime-win32-x64` | `kandev-windows-x64.tar.gz` | +| Windows (ARM64) | Falls back to `windows-x64` (x64 emulation) | | + +Note: `platform.ts` uses internal naming (`macos`, `windows`); npm `os` field uses npm conventions (`darwin`, `win32`). `runtime.ts` maps between the two. + +## npm requirements + +The optional-dependency runtime resolution requires **npm 7 or newer**. npm 6 silently skips optional deps during `npx`, which would leave users with no runtime. `package.json` declares `engines.npm: ">=7"` to surface this at install time as a clear error. diff --git a/apps/cli/package.json b/apps/cli/package.json index e78ec42e37..a545526b7f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -2,7 +2,7 @@ "name": "kandev", "version": "0.17.0", "private": false, - "description": "NPX launcher for Kandev", + "description": "Launcher for Kandev — manage tasks, orchestrate agents, review changes, and ship value", "license": "AGPL-3.0-only", "type": "commonjs", "bin": { @@ -13,12 +13,23 @@ "bin", "dist" ], + "engines": { + "npm": ">=7" + }, + "optionalDependencies": { + "@kdlbs/runtime-linux-x64": "0.0.0-bootstrap", + "@kdlbs/runtime-linux-arm64": "0.0.0-bootstrap", + "@kdlbs/runtime-darwin-x64": "0.0.0-bootstrap", + "@kdlbs/runtime-darwin-arm64": "0.0.0-bootstrap", + "@kdlbs/runtime-win32-x64": "0.0.0-bootstrap" + }, "dependencies": { "tar": "^7.5.11", "tree-kill": "^1.2.2" }, "devDependencies": { "@types/node": "^20", + "esbuild": "^0.24.0", "tsx": "^4.15.7", "typescript": "^5", "vitest": "^1.6.0" @@ -26,6 +37,7 @@ "scripts": { "dev": "unset npm_config_prefix && tsx src/cli.ts", "build": "tsc -p tsconfig.json", + "bundle": "esbuild dist/cli.js --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.js", "start": "node dist/cli.js", "test": "vitest run", "prepublishOnly": "pnpm build" diff --git a/apps/cli/src/args.test.ts b/apps/cli/src/args.test.ts index 1e83ab7ede..c7bbaddac6 100644 --- a/apps/cli/src/args.test.ts +++ b/apps/cli/src/args.test.ts @@ -66,8 +66,29 @@ describe("parseArgs", () => { expect(() => parseArgs(["--port="])).toThrow(/--port value must be an integer/); }); - it("throws ParseError on empty --version=", () => { - expect(() => parseArgs(["--version="])).toThrow(/--version requires a value/); + it("sets showVersion for --version and -V flags", () => { + expect(parseArgs(["--version"]).options.showVersion).toBe(true); + expect(parseArgs(["-V"]).options.showVersion).toBe(true); + }); + + it("parses --runtime-version as runtimeVersion", () => { + expect(parseArgs(["--runtime-version", "v0.16.0"]).options.runtimeVersion).toBe("v0.16.0"); + expect(parseArgs(["--runtime-version=v0.16.0"]).options.runtimeVersion).toBe("v0.16.0"); + }); + + it("throws ParseError on empty --runtime-version=", () => { + expect(() => parseArgs(["--runtime-version="])).toThrow(/--runtime-version requires a value/); + }); + + it("throws ParseError when --runtime-version has no value", () => { + expect(() => parseArgs(["--runtime-version"])).toThrow(ParseError); + expect(() => parseArgs(["--runtime-version"])).toThrow(/--runtime-version requires a value/); + }); + + it("throws ParseError when the next token after --runtime-version is another flag", () => { + expect(() => parseArgs(["--runtime-version", "--debug"])).toThrow( + /--runtime-version requires a value/, + ); }); }); diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 147a0cca45..254f5bc577 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,11 +2,12 @@ export type Command = "run" | "dev" | "start"; export type CliOptions = { command: Command; - version?: string; + runtimeVersion?: string; backendPort?: number; webPort?: number; verbose?: boolean; debug?: boolean; + showVersion?: boolean; }; export type ParseResult = { @@ -31,19 +32,23 @@ export function parseArgs(argv: string[]): ParseResult { showHelp = true; continue; } + if (arg === "--version" || arg === "-V") { + opts.showVersion = true; + continue; + } if (arg === "dev" || arg === "run" || arg === "start") { opts.command = arg; continue; } - if (arg === "--version") { - opts.version = takeValue(argv, i, "--version"); + if (arg === "--runtime-version") { + opts.runtimeVersion = takeValue(argv, i, "--runtime-version"); i += 1; continue; } - if (arg.startsWith("--version=")) { - const value = arg.slice("--version=".length); - if (value.length === 0) throw new ParseError("--version requires a value"); - opts.version = value; + if (arg.startsWith("--runtime-version=")) { + const value = arg.slice("--runtime-version=".length); + if (value.length === 0) throw new ParseError("--runtime-version requires a value"); + opts.runtimeVersion = value; continue; } if (arg === "--dev") { diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index b1d02c7dc5..2d3baa25e1 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -7,16 +7,15 @@ import { runDev } from "./dev"; import { runRelease } from "./run"; import { runStart } from "./start"; import { ensureValidPort } from "./ports"; -import { maybePromptForUpdate } from "./update"; function printHelp() { console.log(`kandev launcher Usage: - kandev run [--version ] [--port ] [--verbose] [--debug] + kandev run [--port ] [--verbose] [--debug] kandev dev [--port ] kandev start [--port ] [--verbose] [--debug] - kandev [--version ] [--port ] [--verbose] [--debug] + kandev [--port ] [--verbose] [--debug] kandev --dev [--port ] Examples: @@ -25,16 +24,16 @@ Examples: kandev --dev kandev dev kandev start - kandev --version v0.1.0 + kandev --version kandev --port 3000 kandev --debug Options: dev Use local repo for dev (make dev + next dev) if available. start Use local production build (make build + next start). - run Use release bundles (default). + run Use installed runtime bundle (default). --dev Alias for "dev". - --version Release tag to install (default: latest). + --version, -V Print CLI version and exit. --port Port for the Go backend (the URL kandev opens on in start/run). Alias for --backend-port. Also reads KANDEV_PORT or KANDEV_BACKEND_PORT. @@ -43,11 +42,14 @@ Options: --help, -h Show help. Advanced: - --backend-port Same as --port. - --web-internal-port Override the internal Next.js port. The Go backend - reverse-proxies to it; users hit the backend port. - Also reads KANDEV_WEB_PORT. - --web-port Deprecated alias for --web-internal-port. + --backend-port Same as --port. + --web-internal-port Override the internal Next.js port. The Go backend + reverse-proxies to it; users hit the backend port. + Also reads KANDEV_WEB_PORT. + --web-port Deprecated alias for --web-internal-port. + --runtime-version Download and use a specific release tag instead of + the installed runtime. For debugging only. + Example: kandev --runtime-version v0.16.0 `); } @@ -76,6 +78,12 @@ function findRepoRoot(startDir: string): string | null { async function main(): Promise { const { options, showHelp, deprecatedFlags } = parseArgs(process.argv.slice(2)); + + if (options.showVersion) { + console.log(pkg.version); + return; + } + if (showHelp) { printHelp(); return; @@ -113,9 +121,8 @@ async function main(): Promise { return; } - await maybePromptForUpdate(pkg.version, process.argv.slice(2)); await runRelease({ - version: options.version, + runtimeVersion: options.runtimeVersion, backendPort, webPort, verbose: options.verbose, diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 82693ab875..a64db0919f 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -17,11 +17,12 @@ import { getBinaryName, getPlatformDir } from "./platform"; import { sortVersionsDesc } from "./version"; import { pickAvailablePort } from "./ports"; import { createProcessSupervisor } from "./process"; +import { resolveRuntime, validateBundle } from "./runtime"; import { attachBackendExitHandler, logStartupInfo } from "./shared"; import { launchWebApp, openBrowser } from "./web"; export type RunOptions = { - version?: string; + runtimeVersion?: string; backendPort?: number; webPort?: number; /** Show info logs from backend + web */ @@ -30,14 +31,13 @@ export type RunOptions = { debug?: boolean; }; -type PreparedRelease = { +type PreparedBundle = { bundleDir: string; backendBin: string; backendUrl: string; backendEnv: NodeJS.ProcessEnv; webEnv: NodeJS.ProcessEnv; releaseTag: string; - requestedVersion?: string; webPort: number; agentctlPort: number; dbPath: string; @@ -109,60 +109,71 @@ export function cleanOldReleases(currentTag: string) { } } -async function prepareReleaseBundle({ - version, +/** + * Download a specific release version into the local cache. + * Only used when --runtime-version is given explicitly. + */ +async function downloadRuntimeVersion(runtimeVersion: string): Promise { + const platformDir = getPlatformDir(); + const release = await getRelease(runtimeVersion); + const tag = release.tag_name; + const assetName = `kandev-${platformDir}.tar.gz`; + const cacheDir = path.join(CACHE_DIR, tag, platformDir); + + const archivePath = await ensureAsset(tag, assetName, cacheDir, (downloaded, total) => { + const percent = total ? Math.round((downloaded / total) * 100) : 0; + const mb = (downloaded / (1024 * 1024)).toFixed(1); + const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; + process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`); + }); + process.stderr.write("\n"); + + ensureExtracted(archivePath, cacheDir); + cleanOldReleases(tag); + return tag; +} + +async function prepareBundleForLaunch({ + runtimeVersion, backendPort, webPort, verbose = false, debug = false, -}: RunOptions): Promise { - const platformDir = getPlatformDir(); - let tag: string; - let cacheDir: string; +}: RunOptions): Promise { + let bundleDir: string; + let releaseTag: string; - try { - const release = await getRelease(version); - tag = release.tag_name; - const assetName = `kandev-${platformDir}.tar.gz`; - cacheDir = path.join(CACHE_DIR, tag, platformDir); - - const archivePath = await ensureAsset(tag, assetName, cacheDir, (downloaded, total) => { - const percent = total ? Math.round((downloaded / total) * 100) : 0; - const mb = (downloaded / (1024 * 1024)).toFixed(1); - const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; - process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`); - }); - process.stderr.write("\n"); - - ensureExtracted(archivePath, cacheDir); - cleanOldReleases(tag); - } catch (err) { - // GitHub unreachable — try to launch from cache. - const cached = findCachedRelease(platformDir, version); - if (!cached) { - const target = version ? `version ${version}` : "latest version"; - const reason = err instanceof Error ? err.message : String(err); - throw new Error( - `Failed to fetch ${target} and no cached release found.\n` + - ` Reason: ${reason}\n` + - ` Run kandev once while online to cache a release for offline use.`, - ); + if (runtimeVersion) { + // Explicit version: ensure it is in the cache (downloading if needed), then resolve. + const platformDir = getPlatformDir(); + const cached = findCachedRelease(platformDir, runtimeVersion); + let tag: string; + if (cached) { + tag = cached.tag; + bundleDir = findBundleRoot(cached.cacheDir); + } else { + try { + tag = await downloadRuntimeVersion(runtimeVersion); + const cacheDir = path.join(CACHE_DIR, tag, platformDir); + bundleDir = findBundleRoot(cacheDir); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to fetch runtime version ${runtimeVersion}.\n` + + ` Reason: ${reason}\n` + + ` Run kandev once while online to cache a release for offline use.`, + ); + } } - tag = cached.tag; - cacheDir = cached.cacheDir; - process.stderr.write(`[kandev] GitHub unreachable — using cached release ${tag}\n`); - } - - const bundleDir = findBundleRoot(cacheDir); - - const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); - if (!fs.existsSync(backendBin)) { - throw new Error(`Backend binary not found at ${backendBin}`); - } - - const agentctlBin = path.join(bundleDir, "bin", getBinaryName("agentctl")); - if (!fs.existsSync(agentctlBin)) { - throw new Error(`agentctl binary not found at ${agentctlBin}`); + // Validate the resolved bundle has all required binaries before launching. + validateBundle(bundleDir); + releaseTag = tag; + } else { + // Default path: resolve from KANDEV_BUNDLE_DIR or installed npm runtime package. + const resolved = resolveRuntime(); + bundleDir = resolved.bundleDir; + // Use KANDEV_VERSION if set (e.g. by Homebrew wrapper), otherwise show source. + releaseTag = process.env.KANDEV_VERSION ?? `(${resolved.source})`; } const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT)); @@ -176,10 +187,8 @@ async function prepareReleaseBundle({ fs.mkdirSync(DATA_DIR, { recursive: true }); const dbPath = path.join(DATA_DIR, "kandev.db"); - // Note: Release mode doesn't configure MCP server ports as it uses - // the bundled configuration. Only backend and agentctl ports are set. - // Log level defaults to warn for clean output and can be overridden - // via KANDEV_LOG_LEVEL or --verbose/--debug flags. + const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); + const backendEnv: NodeJS.ProcessEnv = { ...process.env, KANDEV_SERVER_PORT: String(actualBackendPort), @@ -194,8 +203,6 @@ async function prepareReleaseBundle({ ...process.env, KANDEV_API_BASE_URL: backendUrl, PORT: String(actualWebPort), - // Ensure Next.js standalone server binds to 127.0.0.1 so localhost health checks work. - // Without this, HOSTNAME from the host environment can cause binding issues. HOSTNAME: "127.0.0.1", }; (webEnv as Record).NODE_ENV = "production"; @@ -206,8 +213,7 @@ async function prepareReleaseBundle({ backendUrl, backendEnv, webEnv, - releaseTag: tag, - requestedVersion: version, + releaseTag, webPort: actualWebPort, agentctlPort, dbPath, @@ -235,17 +241,14 @@ export function attachRingBuffer( return () => buf; } -function launchReleaseApps(prepared: PreparedRelease): { +function launchBundle(prepared: PreparedBundle): { supervisor: ReturnType; backendProc: ReturnType; webServerPath: string; dumpBackendLogs: () => void; } { - const releaseSource = prepared.requestedVersion - ? `(requested: ${prepared.requestedVersion})` - : "(github latest)"; logStartupInfo({ - header: `release: ${prepared.releaseTag} ${releaseSource}`, + header: `release: ${prepared.releaseTag}`, ports: { backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), webPort: prepared.webPort, @@ -259,10 +262,6 @@ function launchReleaseApps(prepared: PreparedRelease): { const supervisor = createProcessSupervisor(); supervisor.attachSignalHandlers(); - // Start backend: ignore stdin, always show stderr immediately. - // In verbose/debug mode stream stdout live; otherwise capture it into a ring - // buffer so we can dump the last few KB if the healthcheck fails (users were - // previously seeing opaque "timed out" errors with no backend context). const backendProc = spawn(prepared.backendBin, [], { cwd: path.dirname(prepared.backendBin), env: prepared.backendEnv, @@ -293,14 +292,20 @@ function launchReleaseApps(prepared: PreparedRelease): { } export async function runRelease({ - version, + runtimeVersion, backendPort, webPort, verbose = false, debug = false, }: RunOptions): Promise { - const prepared = await prepareReleaseBundle({ version, backendPort, webPort, verbose, debug }); - const { supervisor, backendProc, webServerPath, dumpBackendLogs } = launchReleaseApps(prepared); + const prepared = await prepareBundleForLaunch({ + runtimeVersion, + backendPort, + webPort, + verbose, + debug, + }); + const { supervisor, backendProc, webServerPath, dumpBackendLogs } = launchBundle(prepared); const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_RELEASE); console.log("[kandev] starting backend..."); await waitForHealth(prepared.backendUrl, backendProc, healthTimeoutMs, dumpBackendLogs); diff --git a/apps/cli/src/runtime.test.ts b/apps/cli/src/runtime.test.ts new file mode 100644 index 0000000000..c0212277f8 --- /dev/null +++ b/apps/cli/src/runtime.test.ts @@ -0,0 +1,152 @@ +import fs from "node:fs"; +import Module from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { resolveRuntime, validateBundle } from "./runtime"; + +// Keep getPlatformDir deterministic across test environments. +vi.mock("./platform", () => ({ + getPlatformDir: () => "macos-arm64" as const, + getBinaryName: (base: string) => base, +})); + +function createFakeBundle(dir: string) { + fs.mkdirSync(path.join(dir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(dir, "bin", "kandev"), "fake"); + fs.writeFileSync(path.join(dir, "bin", "agentctl"), "fake"); + fs.mkdirSync(path.join(dir, "web"), { recursive: true }); + fs.writeFileSync(path.join(dir, "web", "server.js"), "fake"); +} + +describe("validateBundle", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-validate-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("passes for a complete bundle", () => { + createFakeBundle(tmpDir); + expect(() => validateBundle(tmpDir)).not.toThrow(); + }); + + it("throws when backend binary is missing", () => { + fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "bin", "agentctl"), "fake"); + fs.mkdirSync(path.join(tmpDir, "web"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "web", "server.js"), "fake"); + expect(() => validateBundle(tmpDir)).toThrow(/Backend binary not found/); + }); + + it("throws when agentctl is missing", () => { + fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "bin", "kandev"), "fake"); + fs.mkdirSync(path.join(tmpDir, "web"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "web", "server.js"), "fake"); + expect(() => validateBundle(tmpDir)).toThrow(/agentctl binary not found/); + }); + + it("throws when web server.js is missing", () => { + fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "bin", "kandev"), "fake"); + fs.writeFileSync(path.join(tmpDir, "bin", "agentctl"), "fake"); + expect(() => validateBundle(tmpDir)).toThrow(/Web server.*not found/); + }); +}); + +describe("resolveRuntime", () => { + let tmpDir: string; + const origEnv = { ...process.env }; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-runtime-")); + // Clear relevant env vars before each test + delete process.env.KANDEV_BUNDLE_DIR; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + // Restore the original env exactly. Don't `delete process.env.KANDEV_BUNDLE_DIR` + // afterwards — if a developer had that env var set when the suite started, + // it's already in origEnv and should remain. + process.env = { ...origEnv }; + }); + + describe("KANDEV_BUNDLE_DIR (step 1)", () => { + it("resolves from KANDEV_BUNDLE_DIR when set and valid", () => { + const bundleDir = path.join(tmpDir, "bundle"); + createFakeBundle(bundleDir); + process.env.KANDEV_BUNDLE_DIR = bundleDir; + + const result = resolveRuntime(); + expect(result.source).toBe("env"); + expect(result.bundleDir).toBe(bundleDir); + }); + + it("throws when KANDEV_BUNDLE_DIR points to an invalid bundle", () => { + const bundleDir = path.join(tmpDir, "empty-bundle"); + fs.mkdirSync(bundleDir); + process.env.KANDEV_BUNDLE_DIR = bundleDir; + + expect(() => resolveRuntime()).toThrow(/Backend binary not found/); + }); + }); + + describe("installed npm runtime package (step 2)", () => { + // Step 2 normally relies on require.resolve('@kdlbs/runtime-darwin-arm64') + // succeeding because the npm package is installed alongside the kandev + // package. In this test environment that package isn't present, so we + // intercept Node's module resolver to point at a fake bundle inside tmpDir. + function mockResolve(target: string): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ModuleAny = Module as unknown as { _resolveFilename: any }; + vi.spyOn(ModuleAny, "_resolveFilename").mockImplementationOnce(() => target); + } + + it("resolves from the installed npm runtime package", () => { + const pkgRoot = path.join(tmpDir, "npm-runtime"); + createFakeBundle(pkgRoot); + // require.resolve('/package.json') normally returns the path to the + // package.json inside node_modules. Point it at our fake bundle instead. + const pkgJsonPath = path.join(pkgRoot, "package.json"); + fs.writeFileSync(pkgJsonPath, "{}"); + mockResolve(pkgJsonPath); + + const result = resolveRuntime(); + expect(result.source).toBe("npm"); + expect(result.bundleDir).toBe(pkgRoot); + }); + + it("surfaces validateBundle errors when the npm package is installed but corrupt", () => { + const pkgRoot = path.join(tmpDir, "broken-npm-runtime"); + fs.mkdirSync(pkgRoot); + // package.json present (so require.resolve succeeds), but no bin/ or web/ + const pkgJsonPath = path.join(pkgRoot, "package.json"); + fs.writeFileSync(pkgJsonPath, "{}"); + mockResolve(pkgJsonPath); + + expect(() => resolveRuntime()).toThrow(/Backend binary not found/); + }); + }); + + describe("no runtime found", () => { + it("throws an actionable error message mentioning install paths", () => { + let error: Error | null = null; + try { + resolveRuntime(); + } catch (e) { + error = e as Error; + } + expect(error).not.toBeNull(); + expect(error!.message).toMatch(/npx kandev@latest/); + expect(error!.message).toMatch(/brew install/); + expect(error!.message).toMatch(/--runtime-version/); + }); + }); +}); diff --git a/apps/cli/src/runtime.ts b/apps/cli/src/runtime.ts new file mode 100644 index 0000000000..11fbaf6e2a --- /dev/null +++ b/apps/cli/src/runtime.ts @@ -0,0 +1,83 @@ +import path from "node:path"; +import fs from "node:fs"; + +import { resolveWebServerPath } from "./bundle"; +import { getBinaryName, getPlatformDir, type PlatformDir } from "./platform"; + +const PLATFORM_TO_NPM_PACKAGE: Record = { + "linux-x64": "@kdlbs/runtime-linux-x64", + "linux-arm64": "@kdlbs/runtime-linux-arm64", + "macos-x64": "@kdlbs/runtime-darwin-x64", + "macos-arm64": "@kdlbs/runtime-darwin-arm64", + "windows-x64": "@kdlbs/runtime-win32-x64", +}; + +export type RuntimeSource = "env" | "npm"; + +export type ResolvedRuntime = { + bundleDir: string; + source: RuntimeSource; +}; + +/** + * Resolve the runtime bundle directory using a two-step priority chain: + * + * 1. KANDEV_BUNDLE_DIR env var — set by the Homebrew formula wrapper and + * useful for local testing. Skips all other resolution. + * 2. Installed npm runtime package — looks for @kdlbs/runtime-{platform} + * in node_modules via Node module resolution. Works after + * `npx kandev@latest` or `npm install -g kandev` (requires npm 7+). + * + * The explicit `--runtime-version ` download path is handled directly + * in run.ts (which manages the GitHub download + cache itself); it does + * not flow through this function. + * + * Throws with an actionable error message if no runtime is found. + */ +export function resolveRuntime(): ResolvedRuntime { + const envBundleDir = process.env.KANDEV_BUNDLE_DIR; + if (envBundleDir) { + validateBundle(envBundleDir); + return { bundleDir: envBundleDir, source: "env" }; + } + + const platformDir = getPlatformDir(); + const packageName = PLATFORM_TO_NPM_PACKAGE[platformDir]; + let pkgJsonPath: string | null = null; + try { + pkgJsonPath = require.resolve(`${packageName}/package.json`); + } catch { + // MODULE_NOT_FOUND — npm runtime package is not installed. Fall through + // to the actionable error below. + } + if (pkgJsonPath) { + // The package IS installed. If validateBundle throws here, the bundle is + // present but corrupt — surface the error rather than the generic + // "no runtime found" message below. + const packageRoot = path.dirname(pkgJsonPath); + validateBundle(packageRoot); + return { bundleDir: packageRoot, source: "npm" }; + } + + throw new Error( + `No Kandev runtime found for ${platformDir}.\n` + + ` Install via npm (requires npm 7+): npx kandev@latest\n` + + ` Install via Homebrew: brew install kdlbs/kandev/kandev\n` + + ` Download a specific version (debug): kandev --runtime-version `, + ); +} + +export function validateBundle(bundleDir: string): void { + const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); + if (!fs.existsSync(backendBin)) { + throw new Error(`Backend binary not found in bundle at ${bundleDir}`); + } + const agentctlBin = path.join(bundleDir, "bin", getBinaryName("agentctl")); + if (!fs.existsSync(agentctlBin)) { + throw new Error(`agentctl binary not found in bundle at ${bundleDir}`); + } + const webServerPath = resolveWebServerPath(bundleDir); + if (!webServerPath) { + throw new Error(`Web server (server.js) not found in bundle at ${bundleDir}`); + } +} diff --git a/apps/cli/src/update.ts b/apps/cli/src/update.ts deleted file mode 100644 index 749d7a999f..0000000000 --- a/apps/cli/src/update.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { spawn } from "node:child_process"; -import https from "node:https"; -import readline from "node:readline"; - -import { compareVersions } from "./version"; - -function requestJson(url: string): Promise { - return new Promise((resolve, reject) => { - const req = https.get(url, { headers: { "User-Agent": "kandev-npx" } }, (res) => { - if (res.statusCode !== 200) { - return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`)); - } - let body = ""; - res.on("data", (chunk) => (body += chunk)); - res.on("end", () => { - try { - resolve(JSON.parse(body) as T); - } catch { - reject(new Error(`Failed to parse JSON from ${url}`)); - } - }); - }); - req.setTimeout(5000, () => { - req.destroy(new Error(`Request timed out fetching ${url}`)); - }); - req.on("error", reject); - }); -} - -async function getLatestNpmVersion(): Promise { - const data = await requestJson<{ "dist-tags"?: { latest?: string } }>( - "https://registry.npmjs.org/kandev", - ); - return data?.["dist-tags"]?.latest; -} - -function promptYesNo(question: string, defaultYes = false): Promise { - return new Promise((resolve) => { - if (!process.stdin.isTTY) { - resolve(false); - return; - } - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - const suffix = defaultYes ? "[Y/n]" : "[y/N]"; - rl.question(`${question} ${suffix} `, (answer: string) => { - rl.close(); - const normalized = String(answer || "") - .trim() - .toLowerCase(); - if (!normalized) { - resolve(Boolean(defaultYes)); - return; - } - resolve(normalized === "y" || normalized === "yes"); - }); - }); -} - -export async function maybePromptForUpdate(currentVersion: string, args: string[]) { - // Allow disabling update checks for CI or automation. - if (process.env.KANDEV_SKIP_UPDATE === "1" || process.env.KANDEV_NO_UPDATE_PROMPT === "1") { - return; - } - try { - const latest = await getLatestNpmVersion(); - if (!latest) return; - if (compareVersions(latest, currentVersion) <= 0) return; - const wantsUpdate = await promptYesNo( - `Update available: ${currentVersion} -> ${latest}. Update now?`, - false, - ); - if (!wantsUpdate) return; - const env = { ...process.env, KANDEV_SKIP_UPDATE: "1" }; - const child = spawn("npx", ["kandev@latest", ...args], { - stdio: "inherit", - env, - }); - child.on("exit", (code) => process.exit(code || 0)); - await new Promise(() => {}); - } catch { - // ignore update errors - } -} diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 7e200e42a7..fd80492793 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -32,7 +32,7 @@ importers: dependencies: tar: specifier: ^7.5.11 - version: 7.5.11 + version: 7.5.13 tree-kill: specifier: ^1.2.2 version: 1.2.2 @@ -40,6 +40,9 @@ importers: '@types/node': specifier: ^20 version: 20.19.28 + esbuild: + specifier: ^0.24.0 + version: 0.24.2 tsx: specifier: ^4.15.7 version: 4.21.0 @@ -49,6 +52,22 @@ importers: vitest: specifier: ^1.6.0 version: 1.6.1(@types/node@20.19.28)(happy-dom@20.8.9)(lightningcss@1.30.2) + optionalDependencies: + '@kdlbs/runtime-darwin-arm64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-darwin-x64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-linux-arm64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-linux-x64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap + '@kdlbs/runtime-win32-x64': + specifier: 0.0.0-bootstrap + version: 0.0.0-bootstrap packages/theme: dependencies: @@ -400,7 +419,7 @@ importers: version: 0.55.1 next: specifier: 16.1.7 - version: 16.1.7(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.7(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -454,7 +473,7 @@ importers: version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) styled-jsx: specifier: 5.1.6 - version: 5.1.6(@babel/core@7.28.6)(react@19.2.3) + version: 5.1.6(react@19.2.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -890,6 +909,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.24.2': + resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -902,6 +927,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.24.2': + resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.2': resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} @@ -914,6 +945,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.24.2': + resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.2': resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} @@ -926,6 +963,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.24.2': + resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.2': resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} @@ -938,6 +981,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.24.2': + resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.2': resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} @@ -950,6 +999,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.24.2': + resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.2': resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} @@ -962,6 +1017,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.24.2': + resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.2': resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} @@ -974,6 +1035,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.24.2': + resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} @@ -986,6 +1053,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.24.2': + resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.2': resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} @@ -998,6 +1071,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.24.2': + resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.2': resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} @@ -1010,6 +1089,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.24.2': + resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.2': resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} @@ -1022,6 +1107,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.24.2': + resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.2': resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} @@ -1034,6 +1125,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.24.2': + resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.2': resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} @@ -1046,6 +1143,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.24.2': + resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.2': resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} @@ -1058,6 +1161,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.24.2': + resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.2': resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} @@ -1070,6 +1179,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.24.2': + resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.2': resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} @@ -1082,12 +1197,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.24.2': + resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.2': resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.24.2': + resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.2': resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} @@ -1100,12 +1227,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.24.2': + resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.24.2': + resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.2': resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} @@ -1118,6 +1257,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.24.2': + resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} @@ -1136,6 +1281,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.24.2': + resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.2': resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} @@ -1148,6 +1299,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.24.2': + resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.2': resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} @@ -1160,6 +1317,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.24.2': + resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.2': resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} @@ -1172,6 +1335,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.24.2': + resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.2': resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} @@ -1483,6 +1652,31 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kdlbs/runtime-darwin-arm64@0.0.0-bootstrap': + resolution: {integrity: sha512-i3H7KOcw5m1KEhXhTSa9+jNBlAh0Vc8NVkiGL9BpKB5n/COrhBol7YcN1WNkxs/6WdpD9LFo2My+51wODrC9tQ==} + cpu: [arm64] + os: [darwin] + + '@kdlbs/runtime-darwin-x64@0.0.0-bootstrap': + resolution: {integrity: sha512-ia8qU58B1pWr2AM9/qsPP514L6SftSYYmbFG49kcWNZMpRhosdbKNm1GfDce4yE+MKeOpmLmjPu/pR7ZnyMJpg==} + cpu: [x64] + os: [darwin] + + '@kdlbs/runtime-linux-arm64@0.0.0-bootstrap': + resolution: {integrity: sha512-m3UUB0GYOKNVwgW8wwdsjV6dFFmTYxI9vmLMtvgWz01GW1y+Ms5YSRhb0RT5NRMvYpn7xDntlJCjtPATE+RZ7A==} + cpu: [arm64] + os: [linux] + + '@kdlbs/runtime-linux-x64@0.0.0-bootstrap': + resolution: {integrity: sha512-XpcVCGngqq9wsZR2OASQwCiylTFUVD7iDoqeeWUuzZZ1wRxdGqYNV31xrEciq+96O/pI+dn5zWgbMYQEXc309w==} + cpu: [x64] + os: [linux] + + '@kdlbs/runtime-win32-x64@0.0.0-bootstrap': + resolution: {integrity: sha512-x/KRLCugV2eOLCJQWTUmBGU08rz7ep83GdTNZLeyQ3UmOKErVARk5eC9lWNO8LnlQr8LbahK9KdRW6Jhieee2A==} + cpu: [x64] + os: [win32] + '@lezer/common@1.5.0': resolution: {integrity: sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==} @@ -4219,6 +4413,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.24.2: + resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -6411,8 +6610,8 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - tar@7.5.11: - resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} + tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} engines: {node: '>=18'} tiny-invariant@1.3.3: @@ -7474,120 +7673,183 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.24.2': + optional: true + '@esbuild/aix-ppc64@0.27.2': optional: true '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.24.2': + optional: true + '@esbuild/android-arm64@0.27.2': optional: true '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.24.2': + optional: true + '@esbuild/android-arm@0.27.2': optional: true '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.24.2': + optional: true + '@esbuild/android-x64@0.27.2': optional: true '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.24.2': + optional: true + '@esbuild/darwin-arm64@0.27.2': optional: true '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.24.2': + optional: true + '@esbuild/darwin-x64@0.27.2': optional: true '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.24.2': + optional: true + '@esbuild/freebsd-arm64@0.27.2': optional: true '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.24.2': + optional: true + '@esbuild/freebsd-x64@0.27.2': optional: true '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.24.2': + optional: true + '@esbuild/linux-arm64@0.27.2': optional: true '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.24.2': + optional: true + '@esbuild/linux-arm@0.27.2': optional: true '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.24.2': + optional: true + '@esbuild/linux-ia32@0.27.2': optional: true '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.24.2': + optional: true + '@esbuild/linux-loong64@0.27.2': optional: true '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.24.2': + optional: true + '@esbuild/linux-mips64el@0.27.2': optional: true '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.24.2': + optional: true + '@esbuild/linux-ppc64@0.27.2': optional: true '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.24.2': + optional: true + '@esbuild/linux-riscv64@0.27.2': optional: true '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.24.2': + optional: true + '@esbuild/linux-s390x@0.27.2': optional: true '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.24.2': + optional: true + '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.24.2': + optional: true + '@esbuild/netbsd-arm64@0.27.2': optional: true '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.24.2': + optional: true + '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.24.2': + optional: true + '@esbuild/openbsd-arm64@0.27.2': optional: true '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.24.2': + optional: true + '@esbuild/openbsd-x64@0.27.2': optional: true @@ -7597,24 +7859,36 @@ snapshots: '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.24.2': + optional: true + '@esbuild/sunos-x64@0.27.2': optional: true '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.24.2': + optional: true + '@esbuild/win32-arm64@0.27.2': optional: true '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.24.2': + optional: true + '@esbuild/win32-ia32@0.27.2': optional: true '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.24.2': + optional: true + '@esbuild/win32-x64@0.27.2': optional: true @@ -7866,6 +8140,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kdlbs/runtime-darwin-arm64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-darwin-x64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-linux-arm64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-linux-x64@0.0.0-bootstrap': + optional: true + + '@kdlbs/runtime-win32-x64@0.0.0-bootstrap': + optional: true + '@lezer/common@1.5.0': {} '@lezer/common@1.5.1': {} @@ -10724,6 +11013,34 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.24.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.2 + '@esbuild/android-arm': 0.24.2 + '@esbuild/android-arm64': 0.24.2 + '@esbuild/android-x64': 0.24.2 + '@esbuild/darwin-arm64': 0.24.2 + '@esbuild/darwin-x64': 0.24.2 + '@esbuild/freebsd-arm64': 0.24.2 + '@esbuild/freebsd-x64': 0.24.2 + '@esbuild/linux-arm': 0.24.2 + '@esbuild/linux-arm64': 0.24.2 + '@esbuild/linux-ia32': 0.24.2 + '@esbuild/linux-loong64': 0.24.2 + '@esbuild/linux-mips64el': 0.24.2 + '@esbuild/linux-ppc64': 0.24.2 + '@esbuild/linux-riscv64': 0.24.2 + '@esbuild/linux-s390x': 0.24.2 + '@esbuild/linux-x64': 0.24.2 + '@esbuild/netbsd-arm64': 0.24.2 + '@esbuild/netbsd-x64': 0.24.2 + '@esbuild/openbsd-arm64': 0.24.2 + '@esbuild/openbsd-x64': 0.24.2 + '@esbuild/sunos-x64': 0.24.2 + '@esbuild/win32-arm64': 0.24.2 + '@esbuild/win32-ia32': 0.24.2 + '@esbuild/win32-x64': 0.24.2 + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -10766,8 +11083,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.1 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10789,7 +11106,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10800,22 +11117,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10826,7 +11143,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -12382,7 +12699,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.7(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.7(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.7 '@swc/helpers': 0.5.15 @@ -12391,7 +12708,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.7 '@next/swc-darwin-x64': 16.1.7 @@ -13534,12 +13851,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3): + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 - optionalDependencies: - '@babel/core': 7.28.6 stylis@4.3.6: {} @@ -13561,7 +13876,7 @@ snapshots: tapable@2.3.0: {} - tar@7.5.11: + tar@7.5.13: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 diff --git a/docs/cli.md b/docs/cli.md index 8ec15e2757..eca8d01230 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,17 +4,23 @@ ```mermaid flowchart TB - subgraph CLI["npx kandev"] + subgraph CLI["kandev"] run["run"] dev["dev"] start["start"] end - run --> gh["GitHub Release"] + run --> resolve["Resolve installed runtime"] dev --> makedev["make dev"] - start --> binary["Binary + next start"] + start --> binary["Local binary + next start"] - gh --> supervisor + resolve --> envvar["KANDEV_BUNDLE_DIR
(Homebrew, tests)"] + resolve --> npmpkg["@kdlbs/runtime-{platform}
(npm/npx)"] + resolve --> cache["~/.kandev/bin cache
(--runtime-version only)"] + + envvar --> supervisor + npmpkg --> supervisor + cache --> supervisor makedev --> supervisor binary --> supervisor @@ -28,51 +34,66 @@ flowchart TB ## Overview -The Kandev CLI (`kandev`) is the primary way to run the Kandev application. It handles downloading release bundles, managing processes, and providing a unified interface for both end users and developers. +The Kandev CLI (`kandev`) is the primary way to run the Kandev application. It launches the backend and web processes from an installed runtime bundle and provides a unified interface for end users and developers. ## Installation -No installation required. Use npx: +### Homebrew (macOS, Linux) ```bash -npx kandev +brew install kdlbs/kandev/kandev ``` -Or install globally: +### NPX (requires npm 7+) ```bash -npm install -g kandev +npx kandev@latest +``` + +`npx` installs the `kandev` CLI plus the matching `@kdlbs/runtime-{platform}` package via npm optional dependencies. Older npm versions silently skip optional deps and won't work — npm 7 is the floor. + +### NPM (global) + +```bash +npm install -g kandev@latest ``` ## Quick Start ```bash -# Run the latest release (recommended for users) -npx kandev +# Run the installed runtime +kandev -# Opens the app at http://localhost:37429 (or next available port) +# Opens the app at http://localhost:38429 (or next available port) +``` + +## Updates + +The package manager controls the runtime version: + +```bash +brew upgrade kandev # Homebrew users +npm install -g kandev@latest # global npm users +npx kandev@latest # npx users (always latest) ``` ## Commands ### `kandev` / `kandev run` -Downloads and runs the latest release bundle from GitHub. This is the default command and recommended for end users. +Runs the installed runtime bundle. This is the default command. ```bash -npx kandev -npx kandev run -npx kandev run --version v0.1.0 +kandev +kandev run ``` **What happens:** -1. Checks for CLI updates (prompts if newer version available) -2. Downloads the platform-specific release bundle from GitHub -3. Extracts to `~/.kandev/bin///` -4. Starts the backend server -5. Waits for backend health check -6. Starts the web app -7. Prints URLs when ready +1. Resolves the runtime bundle (KANDEV_BUNDLE_DIR → npm package → cache). +2. Starts the backend server. +3. Waits for backend health check. +4. Starts the web app. +5. Opens browser when ready. ### `kandev dev` @@ -80,53 +101,49 @@ Runs the application in development mode with hot-reloading. Requires a local re ```bash # From the repo root or any subdirectory -npx kandev dev +kandev dev ``` **What happens:** -1. Locates the repo root (looks for `apps/backend` and `apps/web`) -2. Runs `make dev` for the backend (Go with hot-reload) -3. Runs `pnpm dev` for the web app (Next.js dev server) -4. Both processes run with hot-reloading enabled +1. Locates the repo root (looks for `apps/backend` and `apps/web`). +2. Runs `make dev` for the backend (Go with hot-reload). +3. Runs `pnpm dev` for the web app (Next.js dev server). ### `kandev start` Runs the application using local production builds. Requires running `make build` first. ```bash -# Build first make build - -# Then start -npx kandev start +kandev start ``` -**What happens:** -1. Locates the repo root -2. Runs the compiled backend binary (`apps/backend/bin/kandev`) -3. Runs `pnpm start` for the web app (Next.js production server) -4. Both run in production mode without hot-reloading - ## Options | Option | Description | Example | |--------|-------------|---------| -| `--version ` | Use a specific release version | `--version v0.1.0` | -| `--backend-port ` | Override backend port | `--backend-port 18080` | -| `--web-port ` | Override web port | `--web-port 13000` | +| `--version`, `-V` | Print CLI version and exit | `kandev --version` | +| `--port ` | Backend port (alias: `--backend-port`) | `--port 3000` | +| `--web-internal-port ` | Override internal Next.js port | `--web-internal-port 13000` | +| `--verbose`, `-v` | Show info logs from backend + web | `--verbose` | +| `--debug` | Show debug logs + agent message dumps | `--debug` | | `--help`, `-h` | Show help | `--help` | +| `--runtime-version ` | **Advanced/debug only**: download a specific runtime tag from GitHub releases instead of using the installed runtime | `--runtime-version v0.16.0` | ### Examples ```bash -# Use specific release -npx kandev --version v0.1.0 +# Print CLI version +kandev --version # Custom ports -npx kandev --backend-port 18080 --web-port 13000 +kandev --port 18080 --web-internal-port 13000 -# Dev mode with custom ports -npx kandev dev --backend-port 18080 --web-port 13000 +# Dev mode +kandev dev --port 18080 + +# Force a specific runtime version (debug) +kandev --runtime-version v0.16.0 ``` ## Port Selection @@ -135,10 +152,9 @@ By default, the CLI automatically finds available ports: | Service | Default Port | Fallback | |---------|--------------|----------| -| Backend | 38429 | Auto-selects from 10000-60000 | -| Web | 37429 | Auto-selects from 10000-60000 | -| AgentCtl | 39429 | Auto-selects from 10000-60000 | -| MCP Server | 40429 | Auto-selects from 10000-60000 | +| Backend | 38429 | Auto-selects from 10000–60000 | +| Web | 37429 | Auto-selects from 10000–60000 | +| AgentCtl | 39429 | Auto-selects from 10000–60000 | If the default port is in use, the CLI finds the next available port automatically. @@ -146,25 +162,21 @@ If the default port is in use, the CLI finds the next available port automatical | Variable | Description | |----------|-------------| -| `KANDEV_GITHUB_OWNER` | Override GitHub repo owner for releases | -| `KANDEV_GITHUB_REPO` | Override GitHub repo name for releases | -| `KANDEV_GITHUB_TOKEN` | GitHub token for API rate limits | -| `KANDEV_NO_UPDATE_PROMPT=1` | Disable CLI update prompts | +| `KANDEV_BUNDLE_DIR` | Force the runtime bundle location (set by Homebrew wrapper) | +| `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port (CLI flag wins) | +| `KANDEV_WEB_PORT` | Internal Next.js port | | `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) | +| `KANDEV_GITHUB_OWNER` / `KANDEV_GITHUB_REPO` | Override GitHub repo for `--runtime-version` downloads | +| `KANDEV_GITHUB_TOKEN` | GitHub token for `--runtime-version` API access | ## Makefile Integration The repo includes a Makefile that wraps the CLI for common operations: ```bash -# Install deps, build, and start in production mode -make start - -# Run in development mode -make dev - -# Build everything -make build +make start # Production mode (after make build) +make dev # Development mode +make build # Build everything ``` See `make help` for all available commands. @@ -173,7 +185,7 @@ See `make help` for all available commands. | Feature | `run` | `dev` | `start` | |---------|-------|-------|---------| -| Source | GitHub releases | Local repo | Local build | +| Source | Installed runtime | Local repo | Local build | | Hot-reload | No | Yes | No | | Requires repo | No | Yes | Yes | | Requires build | No | No | Yes | @@ -181,12 +193,25 @@ See `make help` for all available commands. ## Troubleshooting +### "No Kandev runtime found for {platform}" + +The CLI couldn't find an installed runtime. Install one: + +```bash +# via npm (requires npm 7+ for optional dep resolution) +npx kandev@latest +# via Homebrew +brew install kdlbs/kandev/kandev +``` + +If you're on npm 6 or older, optional dependencies aren't installed by `npx`. Upgrade npm: `npm install -g npm@latest`. + ### Port Already in Use The CLI automatically finds available ports. If you need a specific port: ```bash -npx kandev --backend-port 18080 --web-port 13000 +kandev --port 18080 ``` ### Backend Takes Too Long to Start @@ -194,15 +219,7 @@ npx kandev --backend-port 18080 --web-port 13000 Increase the health check timeout: ```bash -KANDEV_HEALTH_TIMEOUT_MS=60000 npx kandev -``` - -### GitHub Rate Limits - -If you hit GitHub API rate limits, provide a token: - -```bash -KANDEV_GITHUB_TOKEN=ghp_xxx npx kandev +KANDEV_HEALTH_TIMEOUT_MS=60000 kandev ``` ### Dev Mode: "Unable to locate repo root" @@ -211,7 +228,7 @@ Run from within the kandev repository: ```bash cd /path/to/kandev -npx kandev dev +kandev dev ``` ### Start Mode: "Backend binary not found" @@ -220,12 +237,14 @@ Build the project first: ```bash make build -npx kandev start +kandev start ``` ## Data Storage | Path | Contents | |------|----------| -| `~/.kandev/bin/` | Downloaded release bundles | +| `~/.kandev/bin/` | Cached downloads from `--runtime-version` (debug only) | | `~/.kandev/data/` | SQLite database and app data | +| Homebrew Cellar | Installed runtime (when installed via brew) | +| `/node_modules/@kdlbs/runtime-{platform}/` | Installed runtime (when installed via npm/npx) | diff --git a/scripts/release/package-cli.sh b/scripts/release/package-cli.sh new file mode 100755 index 0000000000..573f08e6aa --- /dev/null +++ b/scripts/release/package-cli.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Build the CLI and copy artifacts into dist/kandev/cli/ for inclusion in release bundles. +# +# The CLI bundle (dist/cli.bundle.js) is a single-file esbuild output with all Node +# dependencies inlined. This is required for Homebrew installs where there is no +# node_modules directory in the Cellar. +# +# Output layout inside dist/kandev/cli/: +# bin/cli.js - Node entrypoint (requires #!/usr/bin/env node shebang + chmod +x) +# dist/cli.bundle.js - Self-contained bundle with inlined deps (used by Homebrew) +# package.json - Package metadata (used to read version at runtime) +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CLI_DIR="$ROOT_DIR/apps/cli" +OUT_DIR="$ROOT_DIR/dist/kandev/cli" + +echo "Building CLI for release bundle..." + +# Build TypeScript +pnpm -C "$ROOT_DIR/apps" --filter kandev build +echo " TypeScript build complete" + +# Bundle with esbuild (inlines tree-kill and all other deps) +pnpm -C "$ROOT_DIR/apps/cli" bundle +echo " esbuild bundle complete" + +# Copy artifacts into dist/kandev/cli/ +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR/bin" "$OUT_DIR/dist" + +# Write a bin/cli.js that points at the bundled dist (for Homebrew/manual installs +# where there is no node_modules to resolve the unbundled dist/cli.js's deps). +# The npm package's bin/cli.js is different — it requires the unbundled dist/cli.js +# because tree-kill etc. are installed normally via npm. +cat > "$OUT_DIR/bin/cli.js" <<'EOF' +#!/usr/bin/env node + +require("../dist/cli.bundle.js"); +EOF +chmod +x "$OUT_DIR/bin/cli.js" + +cp "$CLI_DIR/dist/cli.bundle.js" "$OUT_DIR/dist/cli.bundle.js" +cp "$CLI_DIR/package.json" "$OUT_DIR/package.json" + +echo "CLI artifacts packaged at $OUT_DIR" +echo " bin/cli.js (entrypoint, requires dist/cli.bundle.js, chmod +x)" +echo " dist/cli.bundle.js (self-contained bundle, deps inlined)" +echo " package.json" diff --git a/scripts/release/package-npm-runtime.sh b/scripts/release/package-npm-runtime.sh new file mode 100755 index 0000000000..2bf4004eb0 --- /dev/null +++ b/scripts/release/package-npm-runtime.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Generate per-platform npm runtime packages from built dist/release-assets. +# +# Each platform bundle (kandev-{platform}.tar.gz) is repackaged as an npm package +# containing only bin/ and web/ (no cli/). These are the @kdlbs/runtime-* packages +# that the main kandev npm package declares as optionalDependencies. +# +# Usage: +# package-npm-runtime.sh +# +# Arguments: +# version SemVer string (e.g. 0.17.0) +# release-assets-dir Directory containing kandev-*.tar.gz files +# output-dir Directory where per-platform npm packages are written +# +# Output (one directory per platform ready for npm publish): +# /@kdlbs/runtime-linux-x64/ +# /@kdlbs/runtime-linux-arm64/ +# /@kdlbs/runtime-darwin-x64/ +# /@kdlbs/runtime-darwin-arm64/ +# /@kdlbs/runtime-win32-x64/ +set -euo pipefail + +VERSION="${1:?Usage: $0 }" +ASSETS_DIR="${2:?Usage: $0 }" +OUT_DIR="${3:?Usage: $0 }" + +# Maps platform dir name → npm package name + npm os/cpu fields +declare -A PLATFORM_TO_PACKAGE=( + ["linux-x64"]="@kdlbs/runtime-linux-x64" + ["linux-arm64"]="@kdlbs/runtime-linux-arm64" + ["macos-x64"]="@kdlbs/runtime-darwin-x64" + ["macos-arm64"]="@kdlbs/runtime-darwin-arm64" + ["windows-x64"]="@kdlbs/runtime-win32-x64" +) + +declare -A PLATFORM_TO_OS=( + ["linux-x64"]='["linux"]' + ["linux-arm64"]='["linux"]' + ["macos-x64"]='["darwin"]' + ["macos-arm64"]='["darwin"]' + ["windows-x64"]='["win32"]' +) + +declare -A PLATFORM_TO_CPU=( + ["linux-x64"]='["x64"]' + ["linux-arm64"]='["arm64"]' + ["macos-x64"]='["x64"]' + ["macos-arm64"]='["arm64"]' + ["windows-x64"]='["x64"]' +) + +echo "Packaging npm runtime packages for version $VERSION..." +echo " assets dir: $ASSETS_DIR" +echo " output dir: $OUT_DIR" + +for platform in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do + archive="$ASSETS_DIR/kandev-${platform}.tar.gz" + if [[ ! -f "$archive" ]]; then + echo "Error: missing archive $archive" >&2 + exit 1 + fi + + package_name="${PLATFORM_TO_PACKAGE[$platform]}" + # @kdlbs/runtime-linux-x64 → scope=@kdlbs, name=runtime-linux-x64 + scope_dir="${package_name%%/*}" # @kdlbs + pkg_dir="${package_name##*/}" # runtime-linux-x64 + pkg_out="$OUT_DIR/${scope_dir}/${pkg_dir}" + + rm -rf "$pkg_out" + mkdir -p "$pkg_out" + + # Extract only bin/ and web/ from the archive (skip cli/) + local_tmp="$pkg_out/.extract_tmp" + mkdir -p "$local_tmp" + tar -xzf "$archive" -C "$local_tmp" + + # Find the extracted bundle root (named "kandev" inside the archive) + bundle_root="$local_tmp/kandev" + if [[ ! -d "$bundle_root" ]]; then + echo "Error: expected kandev/ directory in $archive" >&2 + exit 1 + fi + + cp -R "$bundle_root/bin" "$pkg_out/bin" + cp -R "$bundle_root/web" "$pkg_out/web" + rm -rf "$local_tmp" + + os_field="${PLATFORM_TO_OS[$platform]}" + cpu_field="${PLATFORM_TO_CPU[$platform]}" + + cat > "$pkg_out/package.json" < must already exist (verified before publishing). +# - Running inside GitHub Actions with `id-token: write` permission set on +# the publish-npm job. (npm publish from a local shell will fall back to +# classic auth — but tokens are not the recommended path going forward.) +# +# Usage: +# publish-npm.sh +# +# Arguments: +# version SemVer string (e.g. 0.17.0) +# tag Git tag (e.g. v0.17.0) — used to verify GitHub release assets exist +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +VERSION="${1:?Usage: $0 }" +TAG="${2:?Usage: $0 }" + +bold() { printf '\033[1m%s\033[0m' "$*"; } +green() { printf '\033[32m%s\033[0m' "$*"; } +red() { printf '\033[31m%s\033[0m' "$*"; } +yellow(){ printf '\033[33m%s\033[0m' "$*"; } + +log() { echo " >> $*"; } +log_ok() { echo " $(green "ok") $*"; } + +die() { + echo "$(red "Error:") $*" >&2 + exit 1 +} + +# -- Verify GitHub release assets exist before publishing --------------------- + +REQUIRED_PLATFORMS=(linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64) + +log "Verifying GitHub release assets exist for $TAG..." +for platform in "${REQUIRED_PLATFORMS[@]}"; do + asset="kandev-${platform}.tar.gz" + if ! gh release view "$TAG" --json assets --jq ".assets[].name" 2>/dev/null | grep -q "^${asset}$"; then + die "GitHub release asset missing: $asset in release $TAG. Run release workflow first." + fi +done +log_ok "All 5 platform assets present in GitHub release $TAG" + +# -- Download release assets for packaging ------------------------------------ + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT +ASSETS_DIR="$WORK_DIR/assets" +mkdir -p "$ASSETS_DIR" + +log "Downloading release assets for $TAG..." +for platform in "${REQUIRED_PLATFORMS[@]}"; do + asset="kandev-${platform}.tar.gz" + log " downloading $asset..." + gh release download "$TAG" --pattern "$asset" --dir "$ASSETS_DIR" +done +log_ok "Assets downloaded to $ASSETS_DIR" + +# -- Generate npm runtime packages -------------------------------------------- + +NPM_PKG_DIR="$WORK_DIR/npm-packages" +bash "$ROOT_DIR/scripts/release/package-npm-runtime.sh" "$VERSION" "$ASSETS_DIR" "$NPM_PKG_DIR" + +# -- Publish @kdlbs/runtime-* packages first --------------------------------- + +RUNTIME_PACKAGES=( + "@kdlbs/runtime-linux-x64" + "@kdlbs/runtime-linux-arm64" + "@kdlbs/runtime-darwin-x64" + "@kdlbs/runtime-darwin-arm64" + "@kdlbs/runtime-win32-x64" +) + +echo +echo "$(bold "Publishing @kdlbs/runtime-* packages...")" +FAILED_PACKAGES=() +ALREADY_PUBLISHED=() + +for pkg in "${RUNTIME_PACKAGES[@]}"; do + scope="${pkg%%/*}" # @kdlbs + name="${pkg##*/}" # runtime-linux-x64 + pkg_dir="$NPM_PKG_DIR/${scope}/${name}" + + if [[ ! -d "$pkg_dir" ]]; then + echo " $(red "missing") $pkg_dir (package directory was not generated)" >&2 + FAILED_PACKAGES+=("$pkg") + continue + fi + + log "Publishing $pkg@$VERSION..." + # Capture full npm output so we can show the real error on failure rather + # than just a generic warning. Distinguish "already published" (idempotent + # case — fine) from real failures (must abort). + if output="$(cd "$pkg_dir" && npm publish --access public --provenance 2>&1)"; then + log_ok "$pkg@$VERSION published" + elif echo "$output" | grep -qE "EPUBLISHCONFLICT|cannot publish over the previously published versions|You cannot publish over"; then + echo " $(yellow "skip") $pkg@$VERSION already published (treated as idempotent success)" >&2 + ALREADY_PUBLISHED+=("$pkg") + else + echo " $(red "FAIL") Failed to publish $pkg@$VERSION:" >&2 + echo "$output" | sed 's/^/ /' >&2 + FAILED_PACKAGES+=("$pkg") + fi +done + +# Abort before publishing main kandev if any runtime publish failed. +# Otherwise users on those platforms would get "No Kandev runtime found" +# after install, with kandev pointing at @kdlbs/runtime-*@VERSION that +# doesn't exist on npm. +if [[ "${#FAILED_PACKAGES[@]}" -gt 0 ]]; then + echo + echo "$(red "Error:") The following runtime packages failed to publish:" >&2 + for pkg in "${FAILED_PACKAGES[@]}"; do + echo " - $pkg" >&2 + done + echo >&2 + echo "Refusing to publish main kandev@$VERSION. Fix the runtime publish failures" >&2 + echo "and re-run this script (already-published runtime packages will be skipped)." >&2 + exit 1 +fi + +# -- Pin optionalDependencies before publishing main kandev ------------------ +# +# In committed source, optionalDependencies reference 0.0.0-bootstrap so the +# lockfile resolves during normal development. For the published kandev@VERSION +# package, we want optionalDependencies to point at @kdlbs/runtime-*@VERSION +# so users get matching runtime bundles. The runtime packages were just +# published above, so this version exists on npm now. +log "Pinning optionalDependencies to $VERSION before publishing main package..." +node -e " + const fs = require('fs'); + const path = '$ROOT_DIR/apps/cli/package.json'; + const pkg = JSON.parse(fs.readFileSync(path, 'utf8')); + if (pkg.optionalDependencies) { + for (const k of Object.keys(pkg.optionalDependencies)) { + pkg.optionalDependencies[k] = '$VERSION'; + } + } + fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); +" +log_ok "optionalDependencies pinned to $VERSION" + +# -- Publish main kandev package ---------------------------------------------- + +echo +echo "$(bold "Publishing kandev@$VERSION...")" +# Same idempotency handling as the runtime packages: capture output, treat +# "already published" as success so partial-failure re-runs converge. +# `prepublishOnly` (in package.json) runs `pnpm build` automatically. +if main_output="$(cd "$ROOT_DIR/apps/cli" && npm publish --access public --provenance 2>&1)"; then + log_ok "kandev@$VERSION published" +elif echo "$main_output" | grep -qE "EPUBLISHCONFLICT|cannot publish over the previously published versions|You cannot publish over"; then + echo " $(yellow "skip") kandev@$VERSION already published (treated as idempotent success)" >&2 + ALREADY_PUBLISHED+=("kandev") +else + echo " $(red "FAIL") Failed to publish kandev@$VERSION:" >&2 + echo "$main_output" | sed 's/^/ /' >&2 + exit 1 +fi + +# -- Report ------------------------------------------------------------------- +# (WORK_DIR cleanup happens via the EXIT trap above.) + +echo +echo "$(green "$(bold "All npm packages published successfully!")")" +if [[ "${#ALREADY_PUBLISHED[@]}" -gt 0 ]]; then + echo " $(yellow "note") The following runtime packages were already published at $VERSION:" + for pkg in "${ALREADY_PUBLISHED[@]}"; do + echo " - $pkg" + done +fi diff --git a/scripts/release/release-pr.sh b/scripts/release/release-pr.sh deleted file mode 100755 index cd3d1d7e32..0000000000 --- a/scripts/release/release-pr.sh +++ /dev/null @@ -1,812 +0,0 @@ -#!/usr/bin/env bash -# ----------------------------------------------------------------------------- -# release-pr.sh -# -# Unified release script for: -# - CLI npm release (apps/cli) -# - App runtime release (git tag that triggers GitHub Actions release workflow) -# - Both in one run -# -# Versioning model: -# - App tag format: vM.m -# - CLI npm version format: M.m.0 -# - Default bump mode: minor (major bump is explicit) -# -# High-level flow: -# 1) Validate preconditions (tools, clean tree, main branch, fetch tags) -# 2) Select release target (cli | app | both) -# 3) Auto-detect current versions/tags and compute next versions -# 4) Show a release plan and require confirmation -# 5) Execute release actions: -# - CLI: bump package version, install deps, build -# - App: generate CHANGELOG.md -# - Create release branch, commit, open PR, squash-merge -# - App: create/push release tag (after PR merge) -# - CLI: npm publish (after PR merge) -# -# Safety: -# - Use --dry-run to preview every action without mutating repo or publishing. -# - Use --yes for non-interactive confirmation. -# ----------------------------------------------------------------------------- -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -CLI_PACKAGE_JSON="$ROOT_DIR/apps/cli/package.json" - -TARGET="" -DEFAULT_BUMP="minor" -AUTO_YES=0 -DRY_RUN=0 - -CLI_SELECTED=0 -APP_SELECTED=0 - -CURRENT_BRANCH="" -CURRENT_CLI_VERSION="" -CURRENT_CLI_MAJOR=0 -CURRENT_CLI_MINOR=0 -CURRENT_CLI_PATCH=0 -CURRENT_APP_TAG="" -CURRENT_APP_TAG_FOUND=0 -CURRENT_APP_MAJOR=0 -CURRENT_APP_MINOR=0 - -CLI_BUMP="" -APP_BUMP="" -NEXT_CLI_VERSION="" -NEXT_APP_TAG="" -RELEASE_BRANCH="" - -CURRENT_STEP=0 -TOTAL_STEPS=0 - -# -- Formatting helpers ------------------------------------------------------- - -bold() { printf '\033[1m%s\033[0m' "$*"; } -dim() { printf '\033[2m%s\033[0m' "$*"; } -green() { printf '\033[32m%s\033[0m' "$*"; } -yellow() { printf '\033[33m%s\033[0m' "$*"; } -red() { printf '\033[31m%s\033[0m' "$*"; } -cyan() { printf '\033[36m%s\033[0m' "$*"; } - -log() { echo " $(dim ">>") $*"; } -phase() { echo; echo "$(bold "$*")"; } -log_ok() { echo " $(green "ok") $*"; } -log_skip() { echo " $(dim "skip") $*"; } - -step() { - ((CURRENT_STEP++)) - echo - echo "$(bold "[$CURRENT_STEP/$TOTAL_STEPS]") $1" -} - -# -- Core helpers -------------------------------------------------------------- - -usage() { - cat <<'EOF' -Usage: scripts/release/release-pr.sh [options] - -Options: - --target Release target. If omitted, prompt interactively. - --bump Bump mode. Default: minor. - --yes Skip confirmation prompts. - --dry-run Print actions without making changes/publishes. - --help, -h Show help. - -Versioning: - - App tags: vM.m - - CLI package version: M.m.0 - -Prerequisites: - - Run from a clean working tree on branch main. - - Required tools: git, node, npm, pnpm, git-cliff, gh. - -Examples: - # Fully interactive release wizard - scripts/release/release-pr.sh - - # Non-interactive CLI minor release - scripts/release/release-pr.sh --target cli --bump minor --yes - - # Preview both releases with major bump - scripts/release/release-pr.sh --target both --bump major --dry-run -EOF -} - -die() { - echo "$(red "Error:") $*" >&2 - exit 1 -} - -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -run_cmd() { - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") $*" - return 0 - fi - "$@" -} - -confirm() { - local prompt="$1" - if [[ "$AUTO_YES" -eq 1 ]]; then - return 0 - fi - echo - local answer - read -r -p "$(bold "$prompt") [y/N]: " answer - answer="$(echo "${answer:-}" | tr '[:upper:]' '[:lower:]')" - [[ "$answer" == "y" || "$answer" == "yes" ]] -} - -validate_bump() { - case "$1" in - minor|major) ;; - *) die "Invalid bump '$1'. Use 'minor' or 'major'." ;; - esac -} - -compute_total_steps() { - TOTAL_STEPS=1 # create release PR (always) - [[ "$CLI_SELECTED" -eq 1 ]] && ((TOTAL_STEPS++)) # build CLI - [[ "$APP_SELECTED" -eq 1 ]] && ((TOTAL_STEPS++)) # create app tag - [[ "$CLI_SELECTED" -eq 1 ]] && ((TOTAL_STEPS++)) # publish CLI -} - -release_branch_name() { - if [[ "$CLI_SELECTED" -eq 1 && "$APP_SELECTED" -eq 1 ]]; then - echo "release/app-${NEXT_APP_TAG}-cli-${NEXT_CLI_VERSION}" - elif [[ "$CLI_SELECTED" -eq 1 ]]; then - echo "release/cli-${NEXT_CLI_VERSION}" - else - echo "release/app-${NEXT_APP_TAG}" - fi -} - -# -- Target selection ---------------------------------------------------------- - -parse_target() { - case "$1" in - cli) - CLI_SELECTED=1 - APP_SELECTED=0 - ;; - app) - CLI_SELECTED=0 - APP_SELECTED=1 - ;; - both) - CLI_SELECTED=1 - APP_SELECTED=1 - ;; - *) - die "Invalid target '$1'. Use cli, app, or both." - ;; - esac -} - -select_target_interactive() { - echo - echo "$(bold "What do you want to release?")" - echo " $(cyan "1)") cli $(dim "- npm publish apps/cli")" - echo " $(cyan "2)") app $(dim "- git tag for GitHub Actions release")" - echo " $(cyan "3)") both $(dim "- cli + app together")" - echo - local choice - read -r -p "$(bold "Choice") [1=cli / 2=app / 3=both]: " choice - case "$choice" in - 1|cli) parse_target "cli" ;; - 2|app) parse_target "app" ;; - 3|both) parse_target "both" ;; - *) die "Invalid choice '$choice'. Enter 1, 2, 3, cli, app, or both." ;; - esac - - local selected="" - [[ "$CLI_SELECTED" -eq 1 ]] && selected+="cli " - [[ "$APP_SELECTED" -eq 1 ]] && selected+="app " - log "Selected target: $(bold "$selected")" -} - -# -- Version parsing ----------------------------------------------------------- - -parse_cli_version() { - local version="$1" - if [[ ! "$version" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]]; then - die "CLI version '$version' is not valid. Expected M.m.0 format." - fi - CURRENT_CLI_MAJOR="${BASH_REMATCH[1]}" - CURRENT_CLI_MINOR="${BASH_REMATCH[2]}" - CURRENT_CLI_PATCH="${BASH_REMATCH[4]:-0}" -} - -detect_current_cli_version() { - [[ -f "$CLI_PACKAGE_JSON" ]] || die "Missing $CLI_PACKAGE_JSON" - CURRENT_CLI_VERSION="$(node -p "require('$CLI_PACKAGE_JSON').version")" - parse_cli_version "$CURRENT_CLI_VERSION" - log "Current CLI version: $(bold "$CURRENT_CLI_VERSION") $(dim "(from apps/cli/package.json)")" -} - -detect_current_app_version() { - local tags - tags="$(git -C "$ROOT_DIR" tag --list 'v*')" - - CURRENT_APP_TAG_FOUND=0 - local found=0 - local best_major=0 - local best_minor=0 - local tag - - while IFS= read -r tag; do - [[ -z "$tag" ]] && continue - if [[ "$tag" =~ ^v([0-9]+)\.([0-9]+)$ ]]; then - local major="${BASH_REMATCH[1]}" - local minor="${BASH_REMATCH[2]}" - if [[ "$found" -eq 0 ]] || (( 10#$major > 10#$best_major )) || \ - (( 10#$major == 10#$best_major && 10#$minor > 10#$best_minor )); then - found=1 - best_major="$major" - best_minor="$minor" - fi - fi - done <<<"$tags" - - CURRENT_APP_MAJOR="$best_major" - CURRENT_APP_MINOR="$best_minor" - CURRENT_APP_TAG="v${CURRENT_APP_MAJOR}.${CURRENT_APP_MINOR}" - CURRENT_APP_TAG_FOUND="$found" - - if [[ "$found" -eq 1 ]]; then - log "Current app tag: $(bold "$CURRENT_APP_TAG") $(dim "(latest git tag matching vM.m)")" - else - log "Current app tag: $(dim "none found") $(dim "(will start at v0.1)")" - fi -} - -# -- Bump selection ------------------------------------------------------------ - -next_minor_pair() { - local major="$1" - local minor="$2" - echo "${major}.$((10#$minor + 1))" -} - -next_major_pair() { - local major="$1" - echo "$((10#$major + 1)).0" -} - -choose_bump() { - local subject="$1" - local current="$2" - local next_minor="$3" - local next_major="$4" - - if [[ -n "$DEFAULT_BUMP" ]]; then - validate_bump "$DEFAULT_BUMP" - fi - - if [[ "$AUTO_YES" -eq 1 && -n "$DEFAULT_BUMP" ]]; then - echo "$DEFAULT_BUMP" - return - fi - - if [[ -n "$DEFAULT_BUMP" && "$TARGET" != "" ]]; then - echo "$DEFAULT_BUMP" - return - fi - - echo >&2 - echo "$(bold "$subject version bump") $(dim "(current: $current)")" >&2 - echo " $(cyan "1)") minor -> $(bold "$next_minor") $(dim "(default)")" >&2 - echo " $(cyan "2)") major -> $(bold "$next_major")" >&2 - local choice - read -r -p "$(bold "Bump") [1=minor / 2=major]: " choice - case "${choice:-1}" in - 1|minor) echo "minor" ;; - 2|major) echo "major" ;; - *) die "Invalid bump choice '$choice'. Enter 1, 2, minor, or major." ;; - esac -} - -# -- Version computation ------------------------------------------------------- - -compute_next_versions() { - if [[ "$CLI_SELECTED" -eq 1 ]]; then - local cli_minor_pair cli_major_pair - cli_minor_pair="$(next_minor_pair "$CURRENT_CLI_MAJOR" "$CURRENT_CLI_MINOR")" - cli_major_pair="$(next_major_pair "$CURRENT_CLI_MAJOR")" - - CLI_BUMP="$(choose_bump "CLI" "${CURRENT_CLI_MAJOR}.${CURRENT_CLI_MINOR}.0" \ - "${cli_minor_pair}.0" "${cli_major_pair}.0")" - validate_bump "$CLI_BUMP" - - if [[ "$CLI_BUMP" == "minor" ]]; then - NEXT_CLI_VERSION="${cli_minor_pair}.0" - else - NEXT_CLI_VERSION="${cli_major_pair}.0" - fi - log "CLI bump: $CLI_BUMP -> $(bold "$NEXT_CLI_VERSION")" - fi - - if [[ "$APP_SELECTED" -eq 1 ]]; then - local app_minor_pair app_major_pair - app_minor_pair="$(next_minor_pair "$CURRENT_APP_MAJOR" "$CURRENT_APP_MINOR")" - app_major_pair="$(next_major_pair "$CURRENT_APP_MAJOR")" - - APP_BUMP="$(choose_bump "App" "$CURRENT_APP_TAG" "v${app_minor_pair}" "v${app_major_pair}")" - validate_bump "$APP_BUMP" - - if [[ "$APP_BUMP" == "minor" ]]; then - NEXT_APP_TAG="v${app_minor_pair}" - else - NEXT_APP_TAG="v${app_major_pair}" - fi - log "App bump: $APP_BUMP -> $(bold "$NEXT_APP_TAG")" - fi -} - -# -- Preconditions ------------------------------------------------------------- - -ensure_prerequisites() { - phase "Checking prerequisites" - - for tool in git node npm pnpm git-cliff gh; do - if command_exists "$tool"; then - log_ok "$tool" - else - die "$tool is required but not found in PATH." - fi - done - - CURRENT_BRANCH="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)" - if [[ "$CURRENT_BRANCH" == "main" ]]; then - log_ok "On branch $(bold "main")" - else - die "Release must run from main branch (current: $CURRENT_BRANCH)." - fi - - if [[ -n "$(git -C "$ROOT_DIR" status --porcelain)" ]]; then - die "Working tree is not clean. Commit or stash changes before releasing." - fi - log_ok "Working tree is clean" - - log "Fetching tags from origin..." - run_cmd git -C "$ROOT_DIR" fetch --tags origin - log_ok "Tags fetched" -} - -ensure_npm_auth() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - [[ "$DRY_RUN" -eq 1 ]] && return 0 - - log "Checking npm authentication..." - local npm_user - if npm_user="$(npm whoami 2>/dev/null)"; then - log_ok "Logged in to npm as $(bold "$npm_user")" - else - echo - echo " $(yellow "npm auth required") — you are not logged in to npm." - echo " $(dim "This is needed to publish the CLI package.")" - echo - if [[ "$AUTO_YES" -eq 1 ]]; then - die "npm login required but --yes was specified. Run 'npm login' first." - fi - local answer - read -r -p "$(bold "Run npm login now?") [Y/n]: " answer - answer="$(echo "${answer:-y}" | tr '[:upper:]' '[:lower:]')" - if [[ "$answer" == "y" || "$answer" == "yes" || -z "$answer" ]]; then - npm login - # Verify login succeeded - if npm_user="$(npm whoami 2>/dev/null)"; then - log_ok "Logged in to npm as $(bold "$npm_user")" - else - die "npm login failed. Cannot publish CLI." - fi - else - die "npm login required to publish CLI. Run 'npm login' and try again." - fi - fi -} - -ensure_app_tag_available() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - log "Checking tag $(bold "$NEXT_APP_TAG") is available..." - if git -C "$ROOT_DIR" rev-parse "$NEXT_APP_TAG" >/dev/null 2>&1; then - die "Tag $NEXT_APP_TAG already exists locally." - fi - - if git -C "$ROOT_DIR" ls-remote --tags origin "refs/tags/$NEXT_APP_TAG" | grep -q .; then - die "Tag $NEXT_APP_TAG already exists on origin." - fi - log_ok "Tag $(bold "$NEXT_APP_TAG") is available" -} - -latest_origin_app_tag() { - local refs - if ! refs="$(git -C "$ROOT_DIR" ls-remote --tags origin 'refs/tags/v*')"; then - return 2 - fi - - local found=0 - local best_major=0 - local best_minor=0 - local ref tag - - while read -r _ ref; do - [[ -z "${ref:-}" ]] && continue - tag="${ref#refs/tags/}" - tag="${tag%\^\{\}}" - if [[ "$tag" =~ ^v([0-9]+)\.([0-9]+)$ ]]; then - local major="${BASH_REMATCH[1]}" - local minor="${BASH_REMATCH[2]}" - if [[ "$found" -eq 0 ]] || (( 10#$major > 10#$best_major )) || \ - (( 10#$major == 10#$best_major && 10#$minor > 10#$best_minor )); then - found=1 - best_major="$major" - best_minor="$minor" - fi - fi - done <<<"$refs" - - [[ "$found" -eq 1 ]] || return 1 - echo "v${best_major}.${best_minor}" -} - -ensure_latest_app_tag_present() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - [[ "$DRY_RUN" -eq 1 ]] && return 0 - - log "Verifying latest app tag $(bold "$CURRENT_APP_TAG") is present locally..." - if [[ "$CURRENT_APP_TAG_FOUND" -eq 0 ]]; then - local origin_status - if latest_origin_app_tag >/dev/null; then - origin_status=0 - else - origin_status="$?" - fi - if [[ "$origin_status" -eq 0 ]]; then - die "No local app tag found, but origin has app tags. Fetch origin tags and retry." - fi - if [[ "$origin_status" -ne 1 ]]; then - die "Could not reach origin to verify app tags. Check your connection and retry." - fi - log_ok "No prior app tags on origin or locally" - return 0 - fi - - local origin_app_tag - if ! origin_app_tag="$(latest_origin_app_tag)"; then - die "Could not determine latest app tag from origin. Refusing to generate CHANGELOG.md." - fi - - if [[ "$CURRENT_APP_TAG" != "$origin_app_tag" ]]; then - die "Latest local app tag is $CURRENT_APP_TAG, but origin latest is $origin_app_tag. Refusing to generate CHANGELOG.md." - fi - - if ! git -C "$ROOT_DIR" rev-parse --verify --quiet "refs/tags/$CURRENT_APP_TAG" >/dev/null; then - die "Latest app tag $CURRENT_APP_TAG is missing locally after fetching origin tags. Refusing to generate CHANGELOG.md." - fi - - log_ok "Latest app tag $(bold "$CURRENT_APP_TAG") is present" -} - -# -- Release plan -------------------------------------------------------------- - -print_plan() { - echo - echo "$(bold "=== Release Plan ===")" - echo " branch: $(bold "$CURRENT_BRANCH")" - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " cli: $(bold "$CURRENT_CLI_VERSION") -> $(green "$NEXT_CLI_VERSION") ($CLI_BUMP bump)" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " app tag: $(bold "$CURRENT_APP_TAG") -> $(green "$NEXT_APP_TAG") ($APP_BUMP bump)" - fi - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " mode: $(yellow "DRY RUN") $(dim "(no changes will be made)")" - fi - echo - - local n=0 - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " $(dim "Steps for CLI:")" - echo " $(dim "1.") Bump version in apps/cli/package.json" - echo " $(dim "2.") Install dependencies (pnpm install)" - echo " $(dim "3.") Build CLI (pnpm build)" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " $(dim "Steps for App:")" - echo " $(dim "1.") Generate CHANGELOG.md (git-cliff)" - fi - echo " $(dim "Steps for Release:")" - ((n++)); echo " $(dim "$n.") Create release branch and open PR" - ((n++)); echo " $(dim "$n.") Squash-merge PR into main" - if [[ "$APP_SELECTED" -eq 1 ]]; then - ((n++)); echo " $(dim "$n.") Create and push annotated git tag $NEXT_APP_TAG" - fi - if [[ "$CLI_SELECTED" -eq 1 ]]; then - ((n++)); echo " $(dim "$n.") Publish CLI to npm" - fi - echo -} - -# -- Release actions ----------------------------------------------------------- - -commit_message() { - if [[ "$CLI_SELECTED" -eq 1 && "$APP_SELECTED" -eq 1 ]]; then - echo "release: app $NEXT_APP_TAG, cli $NEXT_CLI_VERSION" - elif [[ "$CLI_SELECTED" -eq 1 ]]; then - echo "release: cli $NEXT_CLI_VERSION" - elif [[ "$APP_SELECTED" -eq 1 ]]; then - echo "release: app $NEXT_APP_TAG" - else - echo "" - fi -} - -apply_cli_release() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - - step "Building CLI release" - - log "Bumping CLI version to $(bold "$NEXT_CLI_VERSION")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") npm version --no-git-tag-version $NEXT_CLI_VERSION" - else - (cd "$ROOT_DIR/apps/cli" && npm version --no-git-tag-version "$NEXT_CLI_VERSION") - fi - log_ok "Version bumped in package.json" - - log "Installing dependencies..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") pnpm install --frozen-lockfile" - else - pnpm -C "$ROOT_DIR/apps" install --frozen-lockfile - fi - log_ok "Dependencies installed" - - log "Building CLI..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") pnpm -C apps/cli build" - else - pnpm -C "$ROOT_DIR/apps/cli" build - fi - log_ok "CLI built" -} - -generate_changelog() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - log "Refreshing origin tags before changelog generation..." - run_cmd git -C "$ROOT_DIR" fetch --tags origin - ensure_latest_app_tag_present - - log "Generating changelog for $(bold "$NEXT_APP_TAG")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") git-cliff --tag $NEXT_APP_TAG -o CHANGELOG.md" - return 0 - fi - git-cliff --tag "$NEXT_APP_TAG" -o "$ROOT_DIR/CHANGELOG.md" - log_ok "CHANGELOG.md updated" -} - -create_release_pr() { - local msg - msg="$(commit_message)" - [[ -n "$msg" ]] || return 0 - - step "Creating release PR" - - RELEASE_BRANCH="$(release_branch_name)" - - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") git checkout -b $RELEASE_BRANCH" - [[ "$CLI_SELECTED" -eq 1 ]] && echo " $(yellow "[dry-run]") git add apps/cli/package.json" - [[ "$APP_SELECTED" -eq 1 ]] && echo " $(yellow "[dry-run]") git add CHANGELOG.md" - echo " $(yellow "[dry-run]") git commit -m \"$msg\"" - echo " $(yellow "[dry-run]") git push -u origin $RELEASE_BRANCH" - echo " $(yellow "[dry-run]") gh pr create --title \"$msg\"" - echo " $(yellow "[dry-run]") gh pr merge --squash --delete-branch" - echo " $(yellow "[dry-run]") git checkout main && git pull origin main" - return 0 - fi - - log "Creating release branch $(bold "$RELEASE_BRANCH")..." - git -C "$ROOT_DIR" checkout -b "$RELEASE_BRANCH" - log_ok "Branch created" - - log "Staging release files..." - [[ "$CLI_SELECTED" -eq 1 ]] && git -C "$ROOT_DIR" add apps/cli/package.json - [[ "$APP_SELECTED" -eq 1 && -f "$ROOT_DIR/CHANGELOG.md" ]] && git -C "$ROOT_DIR" add CHANGELOG.md - - if git -C "$ROOT_DIR" diff --cached --quiet; then - log_skip "No staged changes to commit" - git -C "$ROOT_DIR" checkout main - git -C "$ROOT_DIR" branch -d "$RELEASE_BRANCH" - return 0 - fi - - log "Committing: $(dim "$msg")" - git -C "$ROOT_DIR" commit -m "$msg" - log_ok "Committed" - - log "Pushing release branch..." - git -C "$ROOT_DIR" push -u origin "$RELEASE_BRANCH" - log_ok "Branch pushed" - - local pr_body - pr_body="Automated release PR."$'\n\n'"**Changes:**" - [[ "$CLI_SELECTED" -eq 1 ]] && pr_body+=$'\n'"- CLI: \`$CURRENT_CLI_VERSION\` → \`$NEXT_CLI_VERSION\`" - [[ "$APP_SELECTED" -eq 1 ]] && pr_body+=$'\n'"- App: \`$CURRENT_APP_TAG\` → \`$NEXT_APP_TAG\`" - - log "Creating pull request..." - local pr_url - pr_url="$(gh pr create \ - --title "$msg" \ - --body "$pr_body" \ - --head "$RELEASE_BRANCH" \ - --base main)" - log_ok "PR created: $(bold "$pr_url")" - - log "Merging PR (squash)..." - if gh pr merge "$RELEASE_BRANCH" --squash --delete-branch --subject "$msg" --body ""; then - log_ok "PR merged" - else - echo - echo " $(yellow "Auto-merge failed.") Merge the PR manually:" - echo " $(cyan "$pr_url")" - echo - if [[ "$AUTO_YES" -eq 1 ]]; then - die "Cannot proceed without PR merge in --yes mode. Merge the PR manually and re-run." - fi - read -r -p "$(bold "Press Enter after merging the PR...")" - # Verify PR was merged - local pr_state - pr_state="$(gh pr view "$RELEASE_BRANCH" --json state --jq '.state')" - if [[ "$pr_state" != "MERGED" ]]; then - die "PR is not merged (state: $pr_state). Cannot continue." - fi - log_ok "PR merged" - fi - - log "Switching to main and pulling..." - git -C "$ROOT_DIR" checkout main - git -C "$ROOT_DIR" pull origin main - # Clean up local release branch if it still exists - git -C "$ROOT_DIR" branch -d "$RELEASE_BRANCH" 2>/dev/null || true - log_ok "main is up to date" -} - -create_and_push_app_tag() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - step "Creating app tag" - - log "Creating annotated tag $(bold "$NEXT_APP_TAG")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") git tag -a $NEXT_APP_TAG -m \"release: $NEXT_APP_TAG\"" - echo " $(yellow "[dry-run]") git push origin $NEXT_APP_TAG" - return 0 - fi - - git -C "$ROOT_DIR" tag -a "$NEXT_APP_TAG" -m "release: $NEXT_APP_TAG" - log_ok "Tag created" - - log "Pushing tag to origin..." - git -C "$ROOT_DIR" push origin "$NEXT_APP_TAG" - log_ok "Tag pushed (GitHub Actions release workflow will trigger)" -} - -publish_cli() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - - step "Publishing CLI to npm" - - log "Running npm publish --access public..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") npm publish --access public" - return 0 - fi - (cd "$ROOT_DIR/apps/cli" && npm publish --access public) - log_ok "Published to npm" -} - -# -- Argument parsing ---------------------------------------------------------- - -parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - --target) - [[ $# -ge 2 ]] || die "--target requires a value." - TARGET="$2" - shift 2 - ;; - --bump) - [[ $# -ge 2 ]] || die "--bump requires a value." - DEFAULT_BUMP="$2" - shift 2 - ;; - --yes) - AUTO_YES=1 - shift - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --help|-h) - usage - exit 0 - ;; - *) - die "Unknown argument '$1'." - ;; - esac - done -} - -# -- Main ---------------------------------------------------------------------- - -main() { - parse_args "$@" - validate_bump "$DEFAULT_BUMP" - - echo - echo "$(bold "Kandev Release") $(dim "(PR mode)")" - if [[ "$DRY_RUN" -eq 1 ]]; then - echo "$(yellow " DRY RUN MODE - no changes will be made")" - fi - - ensure_prerequisites - - phase "Selecting target and version" - - if [[ -n "$TARGET" ]]; then - parse_target "$TARGET" - local selected="" - [[ "$CLI_SELECTED" -eq 1 ]] && selected+="cli " - [[ "$APP_SELECTED" -eq 1 ]] && selected+="app " - log "Target: $(bold "$selected") $(dim "(from --target flag)")" - else - # Interactive mode should prompt for target and bump choices. - DEFAULT_BUMP="" - select_target_interactive - fi - - ensure_npm_auth - detect_current_cli_version - detect_current_app_version - compute_next_versions - ensure_latest_app_tag_present - ensure_app_tag_available - compute_total_steps - print_plan - - if ! confirm "Proceed with release?"; then - echo - echo "Release cancelled." - exit 0 - fi - - apply_cli_release - generate_changelog - create_release_pr - create_and_push_app_tag - publish_cli - - echo - echo "$(green "$(bold "Release complete!")")" - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " $(green "cli:") published $NEXT_CLI_VERSION to npm" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " $(green "app:") pushed tag $NEXT_APP_TAG to origin" - fi - echo -} - -main "$@" diff --git a/scripts/release/release.sh b/scripts/release/release.sh deleted file mode 100755 index ce0e75d013..0000000000 --- a/scripts/release/release.sh +++ /dev/null @@ -1,648 +0,0 @@ -#!/usr/bin/env bash -# ----------------------------------------------------------------------------- -# release.sh -# -# Unified release script for: -# - CLI npm release (apps/cli) -# - App runtime release (git tag that triggers GitHub Actions release workflow) -# - Both in one run -# -# Versioning model: -# - App tag format: vM.m -# - CLI npm version format: M.m.0 -# - Default bump mode: minor (major bump is explicit) -# -# High-level flow: -# 1) Validate preconditions (tools, clean tree, main branch, fetch tags) -# 2) Select release target (cli | app | both) -# 3) Auto-detect current versions/tags and compute next versions -# 4) Show a release plan and require confirmation -# 5) Execute release actions: -# - CLI: bump package version, install deps, build, commit/push, npm publish -# - App: create/push release tag -# -# Safety: -# - Use --dry-run to preview every action without mutating repo or publishing. -# - Use --yes for non-interactive confirmation. -# ----------------------------------------------------------------------------- -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -CLI_PACKAGE_JSON="$ROOT_DIR/apps/cli/package.json" - -TARGET="" -DEFAULT_BUMP="minor" -AUTO_YES=0 -DRY_RUN=0 - -CLI_SELECTED=0 -APP_SELECTED=0 - -CURRENT_BRANCH="" -CURRENT_CLI_VERSION="" -CURRENT_CLI_MAJOR=0 -CURRENT_CLI_MINOR=0 -CURRENT_CLI_PATCH=0 -CURRENT_APP_TAG="" -CURRENT_APP_MAJOR=0 -CURRENT_APP_MINOR=0 - -CLI_BUMP="" -APP_BUMP="" -NEXT_CLI_VERSION="" -NEXT_APP_TAG="" - -# -- Formatting helpers ------------------------------------------------------- - -bold() { printf '\033[1m%s\033[0m' "$*"; } -dim() { printf '\033[2m%s\033[0m' "$*"; } -green() { printf '\033[32m%s\033[0m' "$*"; } -yellow() { printf '\033[33m%s\033[0m' "$*"; } -red() { printf '\033[31m%s\033[0m' "$*"; } -cyan() { printf '\033[36m%s\033[0m' "$*"; } - -log() { echo " $(dim ">>") $*"; } -log_step() { echo; echo "$(bold "[$1]") $2"; } -log_ok() { echo " $(green "ok") $*"; } -log_skip() { echo " $(dim "skip") $*"; } - -# -- Core helpers -------------------------------------------------------------- - -usage() { - cat <<'EOF' -Usage: scripts/release/release.sh [options] - -Options: - --target Release target. If omitted, prompt interactively. - --bump Bump mode. Default: minor. - --yes Skip confirmation prompts. - --dry-run Print actions without making changes/publishes. - --help, -h Show help. - -Versioning: - - App tags: vM.m - - CLI package version: M.m.0 - -Prerequisites: - - Run from a clean working tree on branch main. - - Required tools: git, node, npm, pnpm, git-cliff. - -Examples: - # Fully interactive release wizard - scripts/release/release.sh - - # Non-interactive CLI minor release - scripts/release/release.sh --target cli --bump minor --yes - - # Preview both releases with major bump - scripts/release/release.sh --target both --bump major --dry-run -EOF -} - -die() { - echo "$(red "Error:") $*" >&2 - exit 1 -} - -command_exists() { - command -v "$1" >/dev/null 2>&1 -} - -run_cmd() { - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") $*" - return 0 - fi - "$@" -} - -confirm() { - local prompt="$1" - if [[ "$AUTO_YES" -eq 1 ]]; then - return 0 - fi - echo - local answer - read -r -p "$(bold "$prompt") [y/N]: " answer - answer="$(echo "${answer:-}" | tr '[:upper:]' '[:lower:]')" - [[ "$answer" == "y" || "$answer" == "yes" ]] -} - -validate_bump() { - case "$1" in - minor|major) ;; - *) die "Invalid bump '$1'. Use 'minor' or 'major'." ;; - esac -} - -# -- Target selection ---------------------------------------------------------- - -parse_target() { - case "$1" in - cli) - CLI_SELECTED=1 - APP_SELECTED=0 - ;; - app) - CLI_SELECTED=0 - APP_SELECTED=1 - ;; - both) - CLI_SELECTED=1 - APP_SELECTED=1 - ;; - *) - die "Invalid target '$1'. Use cli, app, or both." - ;; - esac -} - -select_target_interactive() { - echo - echo "$(bold "What do you want to release?")" - echo " $(cyan "1)") cli $(dim "- npm publish apps/cli")" - echo " $(cyan "2)") app $(dim "- git tag for GitHub Actions release")" - echo " $(cyan "3)") both $(dim "- cli + app together")" - echo - local choice - read -r -p "$(bold "Choice") [1=cli / 2=app / 3=both]: " choice - case "$choice" in - 1|cli) parse_target "cli" ;; - 2|app) parse_target "app" ;; - 3|both) parse_target "both" ;; - *) die "Invalid choice '$choice'. Enter 1, 2, 3, cli, app, or both." ;; - esac - - local selected="" - [[ "$CLI_SELECTED" -eq 1 ]] && selected+="cli " - [[ "$APP_SELECTED" -eq 1 ]] && selected+="app " - log "Selected target: $(bold "$selected")" -} - -# -- Version parsing ----------------------------------------------------------- - -parse_cli_version() { - local version="$1" - if [[ ! "$version" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]]; then - die "CLI version '$version' is not valid. Expected M.m.0 format." - fi - CURRENT_CLI_MAJOR="${BASH_REMATCH[1]}" - CURRENT_CLI_MINOR="${BASH_REMATCH[2]}" - CURRENT_CLI_PATCH="${BASH_REMATCH[4]:-0}" -} - -detect_current_cli_version() { - [[ -f "$CLI_PACKAGE_JSON" ]] || die "Missing $CLI_PACKAGE_JSON" - CURRENT_CLI_VERSION="$(node -p "require('$CLI_PACKAGE_JSON').version")" - parse_cli_version "$CURRENT_CLI_VERSION" - log "Current CLI version: $(bold "$CURRENT_CLI_VERSION") $(dim "(from apps/cli/package.json)")" -} - -detect_current_app_version() { - local tags - tags="$(git -C "$ROOT_DIR" tag --list 'v*')" - - local found=0 - local best_major=0 - local best_minor=0 - local tag - - while IFS= read -r tag; do - [[ -z "$tag" ]] && continue - if [[ "$tag" =~ ^v([0-9]+)\.([0-9]+)(\.[0-9]+)?$ ]]; then - local major="${BASH_REMATCH[1]}" - local minor="${BASH_REMATCH[2]}" - if [[ "$found" -eq 0 ]] || (( 10#$major > 10#$best_major )) || \ - (( 10#$major == 10#$best_major && 10#$minor > 10#$best_minor )); then - found=1 - best_major="$major" - best_minor="$minor" - fi - fi - done <<<"$tags" - - CURRENT_APP_MAJOR="$best_major" - CURRENT_APP_MINOR="$best_minor" - CURRENT_APP_TAG="v${CURRENT_APP_MAJOR}.${CURRENT_APP_MINOR}" - - if [[ "$found" -eq 1 ]]; then - log "Current app tag: $(bold "$CURRENT_APP_TAG") $(dim "(latest git tag matching vM.m)")" - else - log "Current app tag: $(dim "none found") $(dim "(will start at v0.1)")" - fi -} - -# -- Bump selection ------------------------------------------------------------ - -next_minor_pair() { - local major="$1" - local minor="$2" - echo "${major}.$((10#$minor + 1))" -} - -next_major_pair() { - local major="$1" - echo "$((10#$major + 1)).0" -} - -choose_bump() { - local subject="$1" - local current="$2" - local next_minor="$3" - local next_major="$4" - - if [[ -n "$DEFAULT_BUMP" ]]; then - validate_bump "$DEFAULT_BUMP" - fi - - if [[ "$AUTO_YES" -eq 1 && -n "$DEFAULT_BUMP" ]]; then - echo "$DEFAULT_BUMP" - return - fi - - if [[ -n "$DEFAULT_BUMP" && "$TARGET" != "" ]]; then - echo "$DEFAULT_BUMP" - return - fi - - echo >&2 - echo "$(bold "$subject version bump") $(dim "(current: $current)")" >&2 - echo " $(cyan "1)") minor -> $(bold "$next_minor") $(dim "(default)")" >&2 - echo " $(cyan "2)") major -> $(bold "$next_major")" >&2 - local choice - read -r -p "$(bold "Bump") [1=minor / 2=major]: " choice - case "${choice:-1}" in - 1|minor) echo "minor" ;; - 2|major) echo "major" ;; - *) die "Invalid bump choice '$choice'. Enter 1, 2, minor, or major." ;; - esac -} - -# -- Version computation ------------------------------------------------------- - -compute_next_versions() { - if [[ "$CLI_SELECTED" -eq 1 ]]; then - local cli_minor_pair cli_major_pair - cli_minor_pair="$(next_minor_pair "$CURRENT_CLI_MAJOR" "$CURRENT_CLI_MINOR")" - cli_major_pair="$(next_major_pair "$CURRENT_CLI_MAJOR")" - - CLI_BUMP="$(choose_bump "CLI" "${CURRENT_CLI_MAJOR}.${CURRENT_CLI_MINOR}.0" \ - "${cli_minor_pair}.0" "${cli_major_pair}.0")" - validate_bump "$CLI_BUMP" - - if [[ "$CLI_BUMP" == "minor" ]]; then - NEXT_CLI_VERSION="${cli_minor_pair}.0" - else - NEXT_CLI_VERSION="${cli_major_pair}.0" - fi - log "CLI bump: $CLI_BUMP -> $(bold "$NEXT_CLI_VERSION")" - fi - - if [[ "$APP_SELECTED" -eq 1 ]]; then - local app_minor_pair app_major_pair - app_minor_pair="$(next_minor_pair "$CURRENT_APP_MAJOR" "$CURRENT_APP_MINOR")" - app_major_pair="$(next_major_pair "$CURRENT_APP_MAJOR")" - - APP_BUMP="$(choose_bump "App" "$CURRENT_APP_TAG" "v${app_minor_pair}" "v${app_major_pair}")" - validate_bump "$APP_BUMP" - - if [[ "$APP_BUMP" == "minor" ]]; then - NEXT_APP_TAG="v${app_minor_pair}" - else - NEXT_APP_TAG="v${app_major_pair}" - fi - log "App bump: $APP_BUMP -> $(bold "$NEXT_APP_TAG")" - fi -} - -# -- Preconditions ------------------------------------------------------------- - -ensure_prerequisites() { - log_step "1/5" "Checking prerequisites" - - for tool in git node npm pnpm git-cliff; do - if command_exists "$tool"; then - log_ok "$tool" - else - die "$tool is required but not found in PATH." - fi - done - - CURRENT_BRANCH="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)" - if [[ "$CURRENT_BRANCH" == "main" ]]; then - log_ok "On branch $(bold "main")" - else - die "Release must run from main branch (current: $CURRENT_BRANCH)." - fi - - if [[ -n "$(git -C "$ROOT_DIR" status --porcelain)" ]]; then - die "Working tree is not clean. Commit or stash changes before releasing." - fi - log_ok "Working tree is clean" - - log "Fetching tags from origin..." - run_cmd git -C "$ROOT_DIR" fetch --tags origin - log_ok "Tags fetched" -} - -ensure_npm_auth() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - [[ "$DRY_RUN" -eq 1 ]] && return 0 - - log "Checking npm authentication..." - local npm_user - if npm_user="$(npm whoami 2>/dev/null)"; then - log_ok "Logged in to npm as $(bold "$npm_user")" - else - echo - echo " $(yellow "npm auth required") — you are not logged in to npm." - echo " $(dim "This is needed to publish the CLI package.")" - echo - if [[ "$AUTO_YES" -eq 1 ]]; then - die "npm login required but --yes was specified. Run 'npm login' first." - fi - local answer - read -r -p "$(bold "Run npm login now?") [Y/n]: " answer - answer="$(echo "${answer:-y}" | tr '[:upper:]' '[:lower:]')" - if [[ "$answer" == "y" || "$answer" == "yes" || -z "$answer" ]]; then - npm login - # Verify login succeeded - if npm_user="$(npm whoami 2>/dev/null)"; then - log_ok "Logged in to npm as $(bold "$npm_user")" - else - die "npm login failed. Cannot publish CLI." - fi - else - die "npm login required to publish CLI. Run 'npm login' and try again." - fi - fi -} - -ensure_app_tag_available() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - log "Checking tag $(bold "$NEXT_APP_TAG") is available..." - if git -C "$ROOT_DIR" rev-parse "$NEXT_APP_TAG" >/dev/null 2>&1; then - die "Tag $NEXT_APP_TAG already exists locally." - fi - - if git -C "$ROOT_DIR" ls-remote --tags origin "refs/tags/$NEXT_APP_TAG" | grep -q .; then - die "Tag $NEXT_APP_TAG already exists on origin." - fi - log_ok "Tag $(bold "$NEXT_APP_TAG") is available" -} - -# -- Release plan -------------------------------------------------------------- - -print_plan() { - echo - echo "$(bold "=== Release Plan ===")" - echo " branch: $(bold "$CURRENT_BRANCH")" - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " cli: $(bold "$CURRENT_CLI_VERSION") -> $(green "$NEXT_CLI_VERSION") ($CLI_BUMP bump)" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " app tag: $(bold "$CURRENT_APP_TAG") -> $(green "$NEXT_APP_TAG") ($APP_BUMP bump)" - fi - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " mode: $(yellow "DRY RUN") $(dim "(no changes will be made)")" - fi - echo - - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " $(dim "Steps for CLI:")" - echo " $(dim "1.") Bump version in apps/cli/package.json" - echo " $(dim "2.") Install dependencies (pnpm install)" - echo " $(dim "3.") Build CLI (pnpm build)" - echo " $(dim "4.") Commit and push to main" - echo " $(dim "5.") Publish to npm" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " $(dim "Steps for App:")" - echo " $(dim "1.") Generate CHANGELOG.md (git-cliff)" - echo " $(dim "2.") Commit and push changelog" - echo " $(dim "3.") Create annotated git tag $NEXT_APP_TAG" - echo " $(dim "4.") Push tag to origin (triggers GitHub Actions)" - fi - echo -} - -# -- Release actions ----------------------------------------------------------- - -commit_message() { - if [[ "$CLI_SELECTED" -eq 1 && "$APP_SELECTED" -eq 1 ]]; then - echo "release: app $NEXT_APP_TAG, cli $NEXT_CLI_VERSION" - elif [[ "$CLI_SELECTED" -eq 1 ]]; then - echo "release: cli $NEXT_CLI_VERSION" - elif [[ "$APP_SELECTED" -eq 1 ]]; then - echo "release: app $NEXT_APP_TAG" - else - echo "" - fi -} - -apply_cli_release() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - - log_step "3/5" "Building CLI release" - - log "Bumping CLI version to $(bold "$NEXT_CLI_VERSION")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") npm version --no-git-tag-version $NEXT_CLI_VERSION" - else - (cd "$ROOT_DIR/apps/cli" && npm version --no-git-tag-version "$NEXT_CLI_VERSION") - fi - log_ok "Version bumped in package.json" - - log "Installing dependencies..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") pnpm install --frozen-lockfile" - else - pnpm -C "$ROOT_DIR/apps" install --frozen-lockfile - fi - log_ok "Dependencies installed" - - log "Building CLI..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") pnpm -C apps/cli build" - else - pnpm -C "$ROOT_DIR/apps/cli" build - fi - log_ok "CLI built" -} - -generate_changelog() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - log "Generating changelog for $(bold "$NEXT_APP_TAG")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") git-cliff --tag $NEXT_APP_TAG -o CHANGELOG.md" - return 0 - fi - git-cliff --tag "$NEXT_APP_TAG" -o "$ROOT_DIR/CHANGELOG.md" - log_ok "CHANGELOG.md updated" -} - -commit_and_push_if_needed() { - local msg - msg="$(commit_message)" - [[ -n "$msg" ]] || return 0 - - log_step "4/5" "Committing and pushing" - - if [[ "$DRY_RUN" -eq 1 ]]; then - [[ "$CLI_SELECTED" -eq 1 ]] && echo " $(yellow "[dry-run]") git add apps/cli/package.json" - [[ "$APP_SELECTED" -eq 1 ]] && echo " $(yellow "[dry-run]") git add CHANGELOG.md" - echo " $(yellow "[dry-run]") git commit -m \"$msg\"" - echo " $(yellow "[dry-run]") git push origin $CURRENT_BRANCH" - return 0 - fi - - log "Staging release files..." - [[ "$CLI_SELECTED" -eq 1 ]] && git -C "$ROOT_DIR" add apps/cli/package.json - [[ "$APP_SELECTED" -eq 1 && -f "$ROOT_DIR/CHANGELOG.md" ]] && git -C "$ROOT_DIR" add CHANGELOG.md - if git -C "$ROOT_DIR" diff --cached --quiet; then - log_skip "No staged changes to commit" - return 0 - fi - - log "Committing: $(dim "$msg")" - git -C "$ROOT_DIR" commit -m "$msg" - log_ok "Committed" - - log "Pushing to origin/$CURRENT_BRANCH..." - git -C "$ROOT_DIR" push origin "$CURRENT_BRANCH" - log_ok "Pushed" -} - -create_and_push_app_tag() { - [[ "$APP_SELECTED" -eq 1 ]] || return 0 - - if [[ "$CLI_SELECTED" -eq 0 ]]; then - log_step "3/5" "Creating app tag" - else - log_step "4/5" "Creating app tag" - fi - - log "Creating annotated tag $(bold "$NEXT_APP_TAG")..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") git tag -a $NEXT_APP_TAG -m \"release: $NEXT_APP_TAG\"" - echo " $(yellow "[dry-run]") git push origin $NEXT_APP_TAG" - return 0 - fi - - git -C "$ROOT_DIR" tag -a "$NEXT_APP_TAG" -m "release: $NEXT_APP_TAG" - log_ok "Tag created" - - log "Pushing tag to origin..." - git -C "$ROOT_DIR" push origin "$NEXT_APP_TAG" - log_ok "Tag pushed (GitHub Actions release workflow will trigger)" -} - -publish_cli() { - [[ "$CLI_SELECTED" -eq 1 ]] || return 0 - - log_step "5/5" "Publishing CLI to npm" - - log "Running npm publish --access public..." - if [[ "$DRY_RUN" -eq 1 ]]; then - echo " $(yellow "[dry-run]") npm publish --access public" - return 0 - fi - (cd "$ROOT_DIR/apps/cli" && npm publish --access public) - log_ok "Published to npm" -} - -# -- Argument parsing ---------------------------------------------------------- - -parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - --target) - [[ $# -ge 2 ]] || die "--target requires a value." - TARGET="$2" - shift 2 - ;; - --bump) - [[ $# -ge 2 ]] || die "--bump requires a value." - DEFAULT_BUMP="$2" - shift 2 - ;; - --yes) - AUTO_YES=1 - shift - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --help|-h) - usage - exit 0 - ;; - *) - die "Unknown argument '$1'." - ;; - esac - done -} - -# -- Main ---------------------------------------------------------------------- - -main() { - parse_args "$@" - validate_bump "$DEFAULT_BUMP" - - echo - echo "$(bold "Kandev Release")" - if [[ "$DRY_RUN" -eq 1 ]]; then - echo "$(yellow " DRY RUN MODE - no changes will be made")" - fi - - ensure_prerequisites - - log_step "2/5" "Selecting target and version" - - if [[ -n "$TARGET" ]]; then - parse_target "$TARGET" - local selected="" - [[ "$CLI_SELECTED" -eq 1 ]] && selected+="cli " - [[ "$APP_SELECTED" -eq 1 ]] && selected+="app " - log "Target: $(bold "$selected") $(dim "(from --target flag)")" - else - # Interactive mode should prompt for target and bump choices. - DEFAULT_BUMP="" - select_target_interactive - fi - - ensure_npm_auth - detect_current_cli_version - detect_current_app_version - compute_next_versions - ensure_app_tag_available - print_plan - - if ! confirm "Proceed with release?"; then - echo - echo "Release cancelled." - exit 0 - fi - - apply_cli_release - generate_changelog - commit_and_push_if_needed - create_and_push_app_tag - publish_cli - - echo - echo "$(green "$(bold "Release complete!")")" - if [[ "$CLI_SELECTED" -eq 1 ]]; then - echo " $(green "cli:") published $NEXT_CLI_VERSION to npm" - fi - if [[ "$APP_SELECTED" -eq 1 ]]; then - echo " $(green "app:") pushed tag $NEXT_APP_TAG to origin" - fi - echo -} - -main "$@" diff --git a/scripts/release/update-homebrew-tap.sh b/scripts/release/update-homebrew-tap.sh new file mode 100755 index 0000000000..06a4e653b6 --- /dev/null +++ b/scripts/release/update-homebrew-tap.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# Update the kdlbs/homebrew-kandev tap with a new formula for the given release. +# +# Downloads .sha256 files from the GitHub release to populate the formula, +# generates Formula/kandev.rb, and pushes (or opens a PR). +# +# Usage: +# update-homebrew-tap.sh +# +# Arguments: +# version SemVer string (e.g. 0.17.0) +# tag Git tag (e.g. v0.17.0) +# +# Environment: +# HOMEBREW_TAP_REPO Override tap repo (default: kdlbs/homebrew-kandev). +# HOMEBREW_TAP_DEPLOY_KEY Private SSH key (PEM) for the tap repo. When set, +# clone via SSH and push directly to main. This is +# the default CI path. Deploy keys cannot open PRs. +# HOMEBREW_TAP_PUSH When no deploy key is present, set to "1" to push +# directly via `gh`/HTTPS instead of opening a PR. +set -euo pipefail + +VERSION="${1:?Usage: $0 }" +TAG="${2:?Usage: $0 }" +TAP_REPO="${HOMEBREW_TAP_REPO:-kdlbs/homebrew-kandev}" +PUSH_DIRECT="${HOMEBREW_TAP_PUSH:-0}" + +bold() { printf '\033[1m%s\033[0m' "$*"; } +green() { printf '\033[32m%s\033[0m' "$*"; } +red() { printf '\033[31m%s\033[0m' "$*"; } + +log() { echo " >> $*"; } +log_ok() { echo " $(green "ok") $*"; } + +die() { + echo "$(red "Error:") $*" >&2 + exit 1 +} + +# -- Determine clone strategy: deploy key (SSH) or gh (HTTPS) ----------------- + +CLONE_METHOD="gh" +WORK_DIR="$(mktemp -d)" +TAP_DIR="$WORK_DIR/tap" +SSH_KEY_FILE="" +KNOWN_HOSTS_FILE="" + +cleanup() { + [[ -n "${SSH_KEY_FILE:-}" && -f "$SSH_KEY_FILE" ]] && rm -f "$SSH_KEY_FILE" + [[ -n "${KNOWN_HOSTS_FILE:-}" && -f "$KNOWN_HOSTS_FILE" ]] && rm -f "$KNOWN_HOSTS_FILE" + [[ -n "${WORK_DIR:-}" && -d "$WORK_DIR" ]] && rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +if [[ -n "${HOMEBREW_TAP_DEPLOY_KEY:-}" ]]; then + CLONE_METHOD="ssh" + # Materialize the deploy key + a custom GIT_SSH_COMMAND so it doesn't pollute ~/.ssh + SSH_KEY_FILE="$(mktemp)" + chmod 600 "$SSH_KEY_FILE" + printf '%s\n' "$HOMEBREW_TAP_DEPLOY_KEY" > "$SSH_KEY_FILE" + # Ensure trailing newline (some secret stores strip it; openssh requires it) + if [[ "$(tail -c1 "$SSH_KEY_FILE")" != "" ]]; then + echo "" >> "$SSH_KEY_FILE" + fi + + # Pin GitHub's published SSH host keys so we don't trust whatever key the + # remote presents. Source: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints + KNOWN_HOSTS_FILE="$(mktemp)" + chmod 600 "$KNOWN_HOSTS_FILE" + cat > "$KNOWN_HOSTS_FILE" <<'EOF' +github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl +github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg= +github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk= +EOF + export GIT_SSH_COMMAND="ssh -i $SSH_KEY_FILE -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KNOWN_HOSTS_FILE -o LogLevel=ERROR" + log "Using SSH deploy key for tap auth (pinned github.com host keys)" +fi + +# -- Fetch sha256 values from GitHub release ---------------------------------- + +fetch_sha256() { + local platform="$1" + local sha_file="kandev-${platform}.tar.gz.sha256" + local content + # Download into WORK_DIR so the EXIT trap cleans it up automatically. + content="$(gh release download "$TAG" --pattern "$sha_file" --dir "$WORK_DIR" 2>/dev/null && cat "$WORK_DIR/$sha_file")" || { + die "Failed to download $sha_file from release $TAG." + } + # sha256 files contain: — extract just the hash + echo "$content" | awk '{print $1}' +} + +log "Fetching sha256 values from GitHub release $TAG..." +SHA_MACOS_ARM64="$(fetch_sha256 macos-arm64)" +SHA_MACOS_X64="$(fetch_sha256 macos-x64)" +SHA_LINUX_ARM64="$(fetch_sha256 linux-arm64)" +SHA_LINUX_X64="$(fetch_sha256 linux-x64)" +log_ok "sha256 values retrieved" + +# -- Clone tap repo ----------------------------------------------------------- +# (WORK_DIR and TAP_DIR were created near the top so the EXIT trap covers them.) + +log "Cloning $TAP_REPO..." +if [[ "$CLONE_METHOD" == "ssh" ]]; then + git clone "git@github.com:${TAP_REPO}.git" "$TAP_DIR" +else + gh repo clone "$TAP_REPO" "$TAP_DIR" +fi +log_ok "Tap cloned to $TAP_DIR" + +# -- Generate formula --------------------------------------------------------- + +FORMULA_PATH="$TAP_DIR/Formula/kandev.rb" +mkdir -p "$(dirname "$FORMULA_PATH")" + +GITHUB_BASE="https://github.com/kdlbs/kandev/releases/download/${TAG}" + +cat > "$FORMULA_PATH" < Date: Tue, 5 May 2026 01:06:17 +0100 Subject: [PATCH 03/33] fix(release): restore PR-and-merge pattern (#824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(release): restore PR-and-merge pattern — main is protected by a ruleset The first real run of the new release workflow (run 25349824365) failed at the push step with: remote: error: GH013: Repository rule violations found for refs/heads/main. remote: - Changes must be made through a pull request. remote: ! [remote rejected] main -> main main is protected by a ruleset that requires PRs. Earlier I checked /repos/kdlbs/kandev/branches/main/protection (the classic branch-protection endpoint), which reported "not protected" — but rulesets are a separate, newer mechanism enforced at push time, not surfaced via that API. So the "simplify to direct push" refactor in 53c2e45 was based on a false negative. Restoring the PR-and-merge pattern that the legacy release-pr.sh used: 1. Bump version + regenerate CHANGELOG in working tree (separate step). 2. Create release/ branch, commit + push it. 3. gh pr create + gh pr merge --squash --delete-branch. 4. Pull squashed commit into local main, tag from there, push tag. Restored pull-requests: write on the prepare job (needed for gh pr create). Side note: --atomic in the failed run worked correctly. The tag push was rejected together with main, so there's no orphan tag on origin from the failed attempt. * fix(release): retry tag push + document --auto trade-off Two non-blocking review suggestions on PR #824: 1. Tag push hardened against transient failures. With the PR-and-merge pattern, the merge and the tag push are separate operations. If the tag push fails after the PR merges, main has the bumped commit but no tag — leaving the release in a half-state where downstream jobs never run and the next 'bump=patch' run computes the *next* patch, skipping the originally-intended version. Now: retry once on push failure. If both attempts fail, log the recovery command (manual 'git push origin ') and exit non-zero loudly so the operator knows what to do. 2. Inline comment documenting why we use 'gh pr merge' without --auto. The current main ruleset only requires "PR required" — no status checks — so immediate merge works. If status checks are added to the ruleset later, this code needs to switch to --auto + polling before the tag steps. No behavior change today. * fix(release): delete stale release branch before pushing If a release run fails after `git push -u origin release/` but before `gh pr merge` lands, the remote branch persists. Because main's package.json hasn't been bumped, the next workflow run computes the same NEXT version + same branch name, then fails at the push step with a non-fast-forward rejection (same content, different SHA from the rebuild). Recovery currently requires manually closing the stale PR and deleting the remote branch. Now: delete any stale remote branch with the same name as a no-op prologue. GitHub auto-closes the corresponding stale PR when the head branch disappears, so the subsequent `gh pr create` opens a fresh PR cleanly. Caught by Claude and Greptile (P1). --- .github/workflows/release.yml | 77 +++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 855e754bdc..0c79b9e59d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ name: release # Trigger: GitHub Actions UI → "Run workflow" (workflow_dispatch). # # Flow (one click → everything below): -# 1. prepare — bump version, regen CHANGELOG, commit to main, push tag vX.Y.Z +# 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z # 2. build-web — build Next.js standalone bundle # 3. build-cli — build + esbuild-bundle CLI # 4. build-bundles — assemble bin/ + web/ + cli/ tarballs for 5 platforms @@ -33,7 +33,7 @@ on: - minor - major dry_run: - description: "Dry run (no commit, tag, npm publish, or tap update)" + description: "Dry run (no PR, tag, npm publish, or tap update)" type: boolean default: false @@ -44,10 +44,11 @@ permissions: jobs: prepare: - name: Prepare release (bump version, commit, tag) + name: Prepare release (bump version, PR, tag) runs-on: ubuntu-latest permissions: - contents: write # commit + push to main, push tag + contents: write # push release branch + tag + pull-requests: write # create + merge release PR (required by main ruleset) outputs: version: ${{ steps.compute.outputs.version }} tag: ${{ steps.compute.outputs.tag }} @@ -151,36 +152,76 @@ jobs: exit 1 fi - - name: Bump version, generate CHANGELOG, commit, tag, push + - name: Bump version + generate CHANGELOG (in working tree) if: ${{ !inputs.dry_run }} env: NEXT: ${{ steps.compute.outputs.version }} TAG: ${{ steps.compute.outputs.tag }} run: | set -euo pipefail - git fetch origin main - git checkout main - git reset --hard origin/main - # Bump only the main package version. optionalDependencies stay # pinned to 0.0.0-bootstrap in committed source — publish-npm.sh # rewrites them to the real version in-flight before publishing, # once the matching @kdlbs/runtime-* tarballs exist on npm. (cd apps/cli && npm version --no-git-tag-version "$NEXT") pnpm -C apps install --no-frozen-lockfile - git-cliff --tag "$TAG" -o CHANGELOG.md + - name: Create release PR + squash-merge + tag + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEXT: ${{ steps.compute.outputs.version }} + TAG: ${{ steps.compute.outputs.tag }} + run: | + set -euo pipefail + # main is protected by a ruleset that requires PRs, so we go through + # a release branch + auto-merged PR rather than pushing directly. + BRANCH="release/$TAG" + MSG="release: $NEXT" + + git checkout -b "$BRANCH" git add apps/cli/package.json CHANGELOG.md apps/pnpm-lock.yaml - git commit -m "release: $NEXT" + git commit -m "$MSG" + # If a previous run pushed this branch but failed before the merge + # landed, the remote branch still exists. Delete it so the push + # below is a clean -u origin "$BRANCH" rather than a non-fast-forward + # rejection. Closing the corresponding stale PR (if any) happens + # automatically when GitHub sees the head branch deleted. + git push origin --delete "$BRANCH" 2>/dev/null || true + git push -u origin "$BRANCH" + + gh pr create \ + --title "$MSG" \ + --body "Automated release: \`$NEXT\`" \ + --head "$BRANCH" \ + --base main + # Immediate squash-merge. This relies on main's ruleset requiring + # only "PR required" with no status checks. If status checks are + # added to the ruleset later, this needs to switch to `--auto` + + # polling for merge completion before the tag steps below. + gh pr merge "$BRANCH" --squash --delete-branch --subject "$MSG" --body "" + + # Pull the squashed commit into local main, then tag from it. + git fetch origin main + git checkout main + git reset --hard origin/main git tag -a "$TAG" -m "release: $NEXT" - # Push commit and tag atomically — without --atomic, Git could - # accept the tag push but reject the main update on a non-fast-forward, - # leaving an orphaned tag at the pre-bump commit (and tripping the - # "tag already exists" guard on the next re-run). --atomic makes the - # whole multi-ref push succeed or fail as a unit. - git push --atomic origin main "$TAG" + # Tag push: retry once for transient network blips. If both attempts + # fail, main already has the bumped commit but no tag exists. + # Recover by manually pushing the tag from any clone pointing at the + # squashed merge commit: git push origin "$TAG" + git push origin "$TAG" || { + echo "Tag push failed; retrying in 5s..." >&2 + sleep 5 + git push origin "$TAG" || { + echo "ERROR: Tag push failed twice after PR merge succeeded." >&2 + echo " main is at $NEXT but the tag was not created on origin." >&2 + echo " Recover manually: git push origin $TAG" >&2 + exit 1 + } + } - name: Dry-run summary if: ${{ inputs.dry_run }} @@ -195,7 +236,7 @@ jobs: pnpm -C apps install --no-frozen-lockfile git-cliff --tag "$TAG" -o CHANGELOG.md echo - echo "DRY RUN: Would have committed + tagged $TAG and pushed to origin/main." + echo "DRY RUN: Would have created + merged release PR and tagged $TAG." echo "DRY RUN: apps/cli/package.json bumped to $NEXT (in-runner only)." echo "DRY RUN: CHANGELOG.md regenerated (in-runner only)." From 252c874c3e5194ff3cf84c4c32b4e31ee590dc90 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 00:11:29 +0000 Subject: [PATCH 04/33] release: 0.39.1 --- CHANGELOG.md | 10 ++++++++++ apps/cli/package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6c36b4ad..b0e486fda0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to Kandev. +## 0.39.1 - 2026-05-05 + +### Features + +- brew install + npm sibling channels via OIDC trusted publishing ([#806](https://github.com/kdlbs/kandev/pull/806)) + +### Bug Fixes + +- restore PR-and-merge pattern ([#824](https://github.com/kdlbs/kandev/pull/824)) + ## 0.39 - 2026-05-04 ### Features diff --git a/apps/cli/package.json b/apps/cli/package.json index a545526b7f..d53d7b2a05 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "kandev", - "version": "0.17.0", + "version": "0.39.1", "private": false, "description": "Launcher for Kandev — manage tasks, orchestrate agents, review changes, and ship value", "license": "AGPL-3.0-only", From e8780d54bbad08e4f774f5ce20067e321313fe68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 5 May 2026 01:27:17 +0100 Subject: [PATCH 05/33] fix(release): add repository field for npm provenance, patch CLI perms (#826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(release): add repository field for npm provenance The publish-npm step in release run 25350606233 (v0.39.1) failed for all 6 packages with: npm error code E422 npm error 422 Unprocessable Entity Error verifying sigstore provenance bundle: Failed to validate repository information: package.json: "repository.url" is "", expected to match "https://github.com/kdlbs/kandev" from provenance We added `--provenance` to `npm publish` earlier so the registry attaches a sigstore attestation linking each tarball to its source repo + workflow run. The attestation is signed using info from the OIDC token (which includes the repo). At publish time, npm cross-checks the package.json's `repository.url` against the OIDC repo claim — if the package.json doesn't have one, or it doesn't match, the publish is rejected. Both the runtime template (package-npm-runtime.sh) and the main apps/cli/package.json were missing `repository`. Adding `repository` + `homepage` to both. The runtime template gets it inline via the heredoc; the main package.json gets static fields. This fix unblocks the next release. v0.39.1 itself can't be republished (version conflict on npm if it had landed; on GitHub release it already exists). The next release will be v0.39.2 and will go all the way through. Side state notes: - v0.39.1 git tag + GitHub release exist with all 5 platform tarballs. - Homebrew tap was updated to v0.39.1 — `brew install` works as v0.39.1. - npm has nothing at v0.39.1 (all 6 publishes 422'd). - v0.39.2 will replace the npm side; Homebrew formula will move forward; v0.39.1 GitHub release stays as a historical artifact. * fix(release): re-chmod cli.js after artifact download (lost in zip roundtrip) `brew install kdlbs/kandev/kandev` followed by `kandev` failed with: /opt/homebrew/bin/kandev: line 2: /opt/homebrew/Cellar/kandev/0.39.1/libexec/cli/bin/cli.js: Permission denied cannot execute: Undefined error: 0 Inspected the published v0.39.1 macos-arm64 tarball: -rwxr-xr-x kandev/bin/kandev (755 — executable, correct) -rwxr-xr-x kandev/bin/agentctl (755 — executable, correct) -rw-r--r-- kandev/cli/bin/cli.js (644 — NOT executable) Root cause: `actions/upload-artifact@v4` + `actions/download-artifact@v4` zip-encode the artifact during upload and extract during download. **Executable bits do not survive that round-trip.** The Go binaries (kandev, agentctl) keep their +x because they're built inside the same build-bundles job (no artifact crossing); cli.js is built in build-cli, uploaded as an artifact, downloaded in build-bundles, and emerges as 644. The chmod +x in package-cli.sh runs before upload, so it's effectively erased. Fix: re-apply chmod +x to dist/kandev/cli/bin/cli.js right before tarballing, in the Package bundle step (which runs in build-bundles after the artifact download). Could not have been caught by the Tier 1.1 local smoke test we did before merging — that test cp -R'd the cli/ dir directly without an artifact roundtrip, so the chmod survived. This bug only manifests in CI's matrix builds. Manual recovery for v0.39.1 brew users: chmod +x /opt/homebrew/Cellar/kandev/0.39.1/libexec/cli/bin/cli.js Or wait for v0.39.2 and `brew upgrade kandev`. --- .github/workflows/release.yml | 7 ++++++- apps/cli/package.json | 5 +++++ scripts/release/package-npm-runtime.sh | 8 ++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c79b9e59d..1e7015421a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -369,7 +369,12 @@ jobs: cp apps/backend/bin/agentctl${EXT} dist/kandev/bin/ mkdir -p dist/kandev/web cp -R dist/web/. dist/kandev/web/ - # cli/ was already placed by the build-cli job (downloaded above) + # cli/ was already placed by the build-cli job (downloaded above). + # actions/upload-artifact + download-artifact zip-roundtrip strips the + # executable bit, so re-apply it. The Homebrew formula's wrapper at + # bin/kandev does `exec libexec/cli/bin/cli.js`, which fails with + # "Permission denied" if this isn't +x. + chmod +x dist/kandev/cli/bin/cli.js cd dist tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256" diff --git a/apps/cli/package.json b/apps/cli/package.json index d53d7b2a05..7559ae1c4a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -4,6 +4,11 @@ "private": false, "description": "Launcher for Kandev — manage tasks, orchestrate agents, review changes, and ship value", "license": "AGPL-3.0-only", + "repository": { + "type": "git", + "url": "git+https://github.com/kdlbs/kandev.git" + }, + "homepage": "https://github.com/kdlbs/kandev", "type": "commonjs", "bin": { "kandev": "bin/cli.js" diff --git a/scripts/release/package-npm-runtime.sh b/scripts/release/package-npm-runtime.sh index 2bf4004eb0..10d861055d 100755 --- a/scripts/release/package-npm-runtime.sh +++ b/scripts/release/package-npm-runtime.sh @@ -89,12 +89,20 @@ for platform in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do os_field="${PLATFORM_TO_OS[$platform]}" cpu_field="${PLATFORM_TO_CPU[$platform]}" + # Note: `repository` is required when publishing with `npm publish --provenance`. + # npm's sigstore attestation embeds repo info from the OIDC token and refuses + # to publish if the package.json's repository.url doesn't match. cat > "$pkg_out/package.json" < Date: Tue, 5 May 2026 00:30:49 +0000 Subject: [PATCH 06/33] release: 0.39.2 --- CHANGELOG.md | 6 ++++++ apps/cli/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0e486fda0..84ea65e355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Kandev. +## 0.39.2 - 2026-05-05 + +### Bug Fixes + +- add repository field for npm provenance, patch CLI perms ([#826](https://github.com/kdlbs/kandev/pull/826)) + ## 0.39.1 - 2026-05-05 ### Features diff --git a/apps/cli/package.json b/apps/cli/package.json index 7559ae1c4a..068b9f7aa6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "kandev", - "version": "0.39.1", + "version": "0.39.2", "private": false, "description": "Launcher for Kandev — manage tasks, orchestrate agents, review changes, and ship value", "license": "AGPL-3.0-only", From 83de4793fd11068562468715c530d3b0fb3a0680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 5 May 2026 08:28:09 +0100 Subject: [PATCH 07/33] feat: add Linear issue watchers (#805) --- AGENTS.md | 6 +- apps/backend/cmd/kandev/main.go | 5 +- apps/backend/cmd/kandev/orchestrator.go | 19 + apps/backend/cmd/kandev/services.go | 8 +- .../prompts/linear-issue-watch-default.md | 22 + apps/backend/internal/events/types.go | 5 + apps/backend/internal/linear/handlers.go | 6 + .../internal/linear/handlers_issue_watch.go | 134 ++++ apps/backend/internal/linear/models.go | 81 +++ apps/backend/internal/linear/poller.go | 162 ++++- .../linear/poller_issue_watch_test.go | 213 +++++++ apps/backend/internal/linear/provider.go | 14 +- apps/backend/internal/linear/service.go | 2 + .../internal/linear/service_issue_watch.go | 266 ++++++++ .../linear/service_issue_watch_test.go | 225 +++++++ apps/backend/internal/linear/store.go | 29 + .../internal/linear/store_issue_watch.go | 267 ++++++++ .../internal/linear/store_issue_watch_test.go | 261 ++++++++ .../orchestrator/event_handlers_linear.go | 187 ++++++ .../event_handlers_linear_test.go | 43 ++ apps/backend/internal/orchestrator/service.go | 6 + .../linear/linear-issue-watch-dialog.tsx | 589 ++++++++++++++++++ .../linear/linear-issue-watch-placeholders.ts | 86 +++ .../linear/linear-issue-watch-table.tsx | 187 ++++++ .../linear/linear-issue-watchers-section.tsx | 177 ++++++ .../web/components/linear/linear-settings.tsx | 2 + .../linear/use-linear-issue-watches.ts | 100 +++ apps/web/lib/api/domains/linear-api.test.ts | 84 +++ apps/web/lib/api/domains/linear-api.ts | 65 ++ apps/web/lib/state/default-state.ts | 6 + apps/web/lib/state/slices/index.ts | 7 + .../state/slices/linear/linear-slice.test.ts | 96 +++ .../lib/state/slices/linear/linear-slice.ts | 48 ++ apps/web/lib/state/slices/linear/types.ts | 27 + apps/web/lib/state/store.ts | 21 + apps/web/lib/types/linear.ts | 46 ++ 36 files changed, 3473 insertions(+), 29 deletions(-) create mode 100644 apps/backend/config/prompts/linear-issue-watch-default.md create mode 100644 apps/backend/internal/linear/handlers_issue_watch.go create mode 100644 apps/backend/internal/linear/poller_issue_watch_test.go create mode 100644 apps/backend/internal/linear/service_issue_watch.go create mode 100644 apps/backend/internal/linear/service_issue_watch_test.go create mode 100644 apps/backend/internal/linear/store_issue_watch.go create mode 100644 apps/backend/internal/linear/store_issue_watch_test.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_linear.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_linear_test.go create mode 100644 apps/web/components/linear/linear-issue-watch-dialog.tsx create mode 100644 apps/web/components/linear/linear-issue-watch-placeholders.ts create mode 100644 apps/web/components/linear/linear-issue-watch-table.tsx create mode 100644 apps/web/components/linear/linear-issue-watchers-section.tsx create mode 100644 apps/web/hooks/domains/linear/use-linear-issue-watches.ts create mode 100644 apps/web/lib/state/slices/linear/linear-slice.test.ts create mode 100644 apps/web/lib/state/slices/linear/linear-slice.ts create mode 100644 apps/web/lib/state/slices/linear/types.ts diff --git a/AGENTS.md b/AGENTS.md index 42dcd27818..0ef8c64649 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -362,7 +362,7 @@ Skills use `gh` CLI by default. If a `gh` command fails (not installed, not auth Jira and Linear are the model: per-workspace credentials, a 90s auth-health poller, a settings page with status banner + reconnect CTA, link/import buttons that gate on availability. New integrations should reuse the shared shapes rather than copying either. **Backend** (`apps/backend/internal//`): -- Mirror the package layout: `service.go`, `store.go`, `client.go`, `provider.go`, `handlers.go`, `models.go`, `poller.go`. Expose `Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error)` (add `eventBus bus.EventBus` only if the integration actually publishes events — Linear does not). +- Mirror the package layout: `service.go`, `store.go`, `client.go`, `provider.go`, `handlers.go`, `models.go`, `poller.go`. Expose `Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error)`. Pass `nil` for `eventBus` when the integration doesn't publish events; both Jira and Linear take and use it for issue-watch publishing. - Use `internal/integrations/secretadapter` instead of writing your own upsert wrapper around `secrets.SecretStore`. The adapter satisfies any per-integration `SecretStore` interface shaped as `{Reveal, Set, Delete, Exists}`. - Use `internal/integrations/healthpoll` for the auth-health loop. Implement the `Prober` interface (`ListConfiguredWorkspaces` + `RecordAuthHealth`) on a small adapter and let `healthpoll.New("name", prober, log)` own Start/Stop/ticker. Keep integration-specific loops (JQL polling, webhook reconciliation, etc.) separate, like jira's issue-watch loop. - Wire the service via a per-domain `initService(...)` helper in `cmd/kandev/services.go`, not inline in `provideServices`. @@ -377,7 +377,7 @@ Jira and Linear are the model: per-workspace credentials, a 90s auth-health poll **Where Jira and Linear deliberately diverge:** - Issue model: Jira uses transitions + JQL; Linear uses state IDs + structured filters. Don't merge these schemas — the upstream APIs are genuinely different. -- Event publishing: only Jira accepts `eventBus` and emits `NewJiraIssueEvent` from its issue-watch loop. Linear has no equivalent feature today. +- Watch filter persistence: Jira stores the JQL string verbatim; Linear stores the structured `SearchFilter` as JSON in `filter_json` (Linear has no JQL equivalent). The orchestrator emits `NewJiraIssueEvent` / `NewLinearIssueEvent` respectively and dedups by issue key (Jira) vs identifier (Linear). - Health column extras: Linear's `linear_configs` row carries an `org_slug` captured from successful probes; Jira's row does not. --- @@ -388,4 +388,4 @@ This file is read by AI coding agents (Claude Code via `CLAUDE.md` symlink, Code --- -**Last Updated**: 2026-05-02 +**Last Updated**: 2026-05-03 diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index c1c3461663..f3cc9648f6 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -358,8 +358,11 @@ func startAgentInfrastructure( addCleanup(func() error { jiraPoller.Stop(); return nil }) } - // Start Linear auth-health poller, mirroring the Jira poller. + // Start Linear poller. Mirrors the Jira shape: auth-health probe plus an + // issue-watch loop that runs configured filters and emits + // NewLinearIssueEvent for the orchestrator to turn into tasks. if services.Linear != nil { + orchestratorSvc.SetLinearService(&linearServiceAdapter{svc: services.Linear}) linearPoller := linearpkg.NewPoller(services.Linear, log) linearPoller.Start(ctx) addCleanup(func() error { linearPoller.Stop(); return nil }) diff --git a/apps/backend/cmd/kandev/orchestrator.go b/apps/backend/cmd/kandev/orchestrator.go index 157a719dfa..0db11f75d7 100644 --- a/apps/backend/cmd/kandev/orchestrator.go +++ b/apps/backend/cmd/kandev/orchestrator.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" jirapkg "github.com/kandev/kandev/internal/jira" + linearpkg "github.com/kandev/kandev/internal/linear" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/secrets" @@ -267,6 +268,24 @@ func (a *jiraServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, issueKey) } +// linearServiceAdapter exposes the Linear service's issue-watch dedup methods +// to the orchestrator without leaking the rest of the package surface area. +type linearServiceAdapter struct { + svc *linearpkg.Service +} + +func (a *linearServiceAdapter) ReserveIssueWatchTask(ctx context.Context, watchID, identifier, issueURL string) (bool, error) { + return a.svc.Store().ReserveIssueWatchTask(ctx, watchID, identifier, issueURL) +} + +func (a *linearServiceAdapter) AssignIssueWatchTaskID(ctx context.Context, watchID, identifier, taskID string) error { + return a.svc.Store().AssignIssueWatchTaskID(ctx, watchID, identifier, taskID) +} + +func (a *linearServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, identifier string) error { + return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, identifier) +} + // repoLocalPathUpdater adapts the task service's UpdateRepository to the executor.RepoUpdater interface. type repoLocalPathUpdater struct { svc *taskservice.Service diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 0df0e4f4c7..4261354217 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -86,7 +86,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories githubSvc := initGitHubService(dbPool, eventBus, repos.Secrets, log) jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log) - linearSvc := initLinearService(dbPool, repos.Secrets, log) + linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log) return &Services{ Task: taskSvc, @@ -238,10 +238,8 @@ func initJiraService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secret } // initLinearService wires up the Linear integration. Failures are non-fatal. -// Linear doesn't publish events today so it doesn't take an eventBus — -// adding one would be speculative until a feature actually needs it. -func initLinearService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *logger.Logger) *linear.Service { - svc, _, err := linear.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), log) +func initLinearService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *linear.Service { + svc, _, err := linear.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log) if err != nil { log.Warn("Linear service initialization failed (non-fatal)", zap.Error(err)) } diff --git a/apps/backend/config/prompts/linear-issue-watch-default.md b/apps/backend/config/prompts/linear-issue-watch-default.md new file mode 100644 index 0000000000..451b3a6395 --- /dev/null +++ b/apps/backend/config/prompts/linear-issue-watch-default.md @@ -0,0 +1,22 @@ +You have been assigned a Linear issue to work on. + +**Issue:** {{issue.url}} +**Identifier:** {{issue.identifier}} +**Title:** {{issue.title}} +**Team:** {{issue.team}} +**State:** {{issue.state}} +**Priority:** {{issue.priority}} +**Assignee:** {{issue.assignee}} + +## Description + +{{issue.description}} + +## Instructions + +1. Read the issue description carefully and understand the requirements. +2. Explore the codebase to understand the relevant code and architecture. +3. Implement the changes described in the issue. +4. Write or update tests to cover the changes. +5. Run the test suite to ensure nothing is broken. +6. Commit your changes with a descriptive commit message referencing {{issue.identifier}}. diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 8b8ff9767d..750d6d595a 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -211,6 +211,11 @@ const ( JiraNewIssue = "jira.new_issue" // New issue found matching a Jira issue watch ) +// Event types for Linear integration +const ( + LinearNewIssue = "linear.new_issue" // New issue found matching a Linear issue watch +) + // BuildShellOutputSubject creates a shell output subject for a specific session func BuildShellOutputSubject(sessionID string) string { return ShellOutput + "." + sessionID diff --git a/apps/backend/internal/linear/handlers.go b/apps/backend/internal/linear/handlers.go index b119b775e7..e0ace4bc85 100644 --- a/apps/backend/internal/linear/handlers.go +++ b/apps/backend/internal/linear/handlers.go @@ -39,6 +39,12 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/issues", c.httpSearchIssues) api.GET("/issues/:id", c.httpGetIssue) api.POST("/issues/:id/state", c.httpSetIssueState) + + api.GET("/watches/issue", c.httpListIssueWatches) + api.POST("/watches/issue", c.httpCreateIssueWatch) + api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch) + api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch) + api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch) } // --- HTTP handlers --- diff --git a/apps/backend/internal/linear/handlers_issue_watch.go b/apps/backend/internal/linear/handlers_issue_watch.go new file mode 100644 index 0000000000..2265cbe1a3 --- /dev/null +++ b/apps/backend/internal/linear/handlers_issue_watch.go @@ -0,0 +1,134 @@ +package linear + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// httpListIssueWatches returns watches scoped to one workspace when +// `workspace_id` is supplied, or every watch across all workspaces when it is +// absent (used by the install-wide settings page). +func (c *Controller) httpListIssueWatches(ctx *gin.Context) { + workspaceID := ctx.Query("workspace_id") + var ( + watches []*IssueWatch + err error + ) + if workspaceID == "" { + watches, err = c.service.ListAllIssueWatches(ctx.Request.Context()) + } else { + watches, err = c.service.ListIssueWatches(ctx.Request.Context(), workspaceID) + } + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"watches": watches}) +} + +func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) { + var req CreateIssueWatchRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + w, err := c.service.CreateIssueWatch(ctx.Request.Context(), &req) + if err != nil { + c.writeIssueWatchError(ctx, err) + return + } + ctx.JSON(http.StatusOK, w) +} + +func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + if !c.assertWatchInWorkspace(ctx, id) { + return + } + var req UpdateIssueWatchRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + w, err := c.service.UpdateIssueWatch(ctx.Request.Context(), id, &req) + if err != nil { + c.writeIssueWatchError(ctx, err) + return + } + ctx.JSON(http.StatusOK, w) +} + +func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + if !c.assertWatchInWorkspace(ctx, id) { + return + } + if err := c.service.DeleteIssueWatch(ctx.Request.Context(), id); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +// httpTriggerIssueWatch runs a single immediate poll, ignoring the per-watch +// interval gating. Returns the count of newly-discovered issues so the user +// gets feedback without waiting for the orchestrator to fan tasks out. +func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) { + id := ctx.Param("id") + w, err := c.service.GetIssueWatch(ctx.Request.Context(), id) + if err != nil { + c.writeIssueWatchError(ctx, err) + return + } + if !workspaceMatches(ctx, w.WorkspaceID) { + ctx.JSON(http.StatusNotFound, gin.H{"error": ErrIssueWatchNotFound.Error()}) + return + } + issues, err := c.service.CheckIssueWatch(ctx.Request.Context(), w) + if err != nil { + c.writeClientError(ctx, err) + return + } + for _, issue := range issues { + c.service.publishNewLinearIssueEvent(ctx.Request.Context(), w, issue) + } + ctx.JSON(http.StatusOK, gin.H{"newIssues": len(issues)}) +} + +// assertWatchInWorkspace guards mutation/trigger endpoints against IDOR: the +// caller must supply `?workspace_id=...` matching the watch's workspace. +// Writes the response and returns false on mismatch. +func (c *Controller) assertWatchInWorkspace(ctx *gin.Context, id string) bool { + w, err := c.service.GetIssueWatch(ctx.Request.Context(), id) + if err != nil { + c.writeIssueWatchError(ctx, err) + return false + } + if !workspaceMatches(ctx, w.WorkspaceID) { + ctx.JSON(http.StatusNotFound, gin.H{"error": ErrIssueWatchNotFound.Error()}) + return false + } + return true +} + +// workspaceMatches returns true when the request's `workspace_id` query +// matches the resource's stored workspace. Empty query is rejected so callers +// can't bypass the check by omitting the parameter. +func workspaceMatches(ctx *gin.Context, resourceWorkspace string) bool { + q := ctx.Query("workspace_id") + return q != "" && q == resourceWorkspace +} + +func (c *Controller) writeIssueWatchError(ctx *gin.Context, err error) { + if errors.Is(err, ErrIssueWatchNotFound) { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, ErrInvalidConfig) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.writeClientError(ctx, err) +} diff --git a/apps/backend/internal/linear/models.go b/apps/backend/internal/linear/models.go index 6e21cc53cc..40b68514e2 100644 --- a/apps/backend/internal/linear/models.go +++ b/apps/backend/internal/linear/models.go @@ -135,3 +135,84 @@ const SecretKey = "linear:singleton:token" func LegacySecretKeyForWorkspace(workspaceID string) string { return "linear:" + workspaceID + ":token" } + +// DefaultIssueWatchPollInterval is the polling cadence assigned to a watcher +// when the caller does not specify one. Five minutes balances freshness +// against Linear rate limits when many workspaces have watches configured. +const DefaultIssueWatchPollInterval = 300 + +// IssueWatch configures periodic Linear search-polling. The filter is a +// structured SearchFilter (Linear has no JQL equivalent) persisted as JSON; +// the poller deserialises it back to SearchFilter at the store boundary. +// +// As with Jira, Linear issues have no repository affinity — the target +// workflow step's defaults determine where the resulting task runs. +type IssueWatch struct { + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspaceId" db:"workspace_id"` + WorkflowID string `json:"workflowId" db:"workflow_id"` + WorkflowStepID string `json:"workflowStepId" db:"workflow_step_id"` + Filter SearchFilter `json:"filter"` + AgentProfileID string `json:"agentProfileId" db:"agent_profile_id"` + ExecutorProfileID string `json:"executorProfileId" db:"executor_profile_id"` + Prompt string `json:"prompt" db:"prompt"` + Enabled bool `json:"enabled" db:"enabled"` + PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"` + LastPolledAt *time.Time `json:"lastPolledAt,omitempty" db:"last_polled_at"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// IssueWatchTask deduplicates task creation per (watch, issue) tuple. The +// UNIQUE constraint on (issue_watch_id, issue_identifier) prevents two +// concurrent pollers from racing to create duplicate tasks for the same +// issue. We key on Identifier (e.g. "ENG-123") rather than the GraphQL UUID +// because it's stable, what humans see, and present on every search result. +type IssueWatchTask struct { + ID string `json:"id" db:"id"` + IssueWatchID string `json:"issueWatchId" db:"issue_watch_id"` + IssueIdentifier string `json:"issueIdentifier" db:"issue_identifier"` + IssueURL string `json:"issueUrl" db:"issue_url"` + TaskID string `json:"taskId" db:"task_id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +// NewLinearIssueEvent is published on the bus whenever the poller observes an +// issue matching a watch that has no existing dedup row. The orchestrator +// consumes this to create (and optionally auto-start) a Kandev task. +type NewLinearIssueEvent struct { + IssueWatchID string `json:"issueWatchId"` + WorkspaceID string `json:"workspaceId"` + WorkflowID string `json:"workflowId"` + WorkflowStepID string `json:"workflowStepId"` + AgentProfileID string `json:"agentProfileId"` + ExecutorProfileID string `json:"executorProfileId"` + Prompt string `json:"prompt"` + Issue *LinearIssue `json:"issue"` +} + +// CreateIssueWatchRequest is the payload for POST /api/v1/linear/watches/issue. +type CreateIssueWatchRequest struct { + WorkspaceID string `json:"workspaceId"` + WorkflowID string `json:"workflowId"` + WorkflowStepID string `json:"workflowStepId"` + Filter SearchFilter `json:"filter"` + AgentProfileID string `json:"agentProfileId"` + ExecutorProfileID string `json:"executorProfileId"` + Prompt string `json:"prompt"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + Enabled *bool `json:"enabled,omitempty"` +} + +// UpdateIssueWatchRequest is the payload for PATCH /api/v1/linear/watches/issue/:id. +// All fields are pointers so the caller can omit ones it doesn't want to change. +type UpdateIssueWatchRequest struct { + WorkflowID *string `json:"workflowId,omitempty"` + WorkflowStepID *string `json:"workflowStepId,omitempty"` + Filter *SearchFilter `json:"filter,omitempty"` + AgentProfileID *string `json:"agentProfileId,omitempty"` + ExecutorProfileID *string `json:"executorProfileId,omitempty"` + Prompt *string `json:"prompt,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + PollIntervalSeconds *int `json:"pollIntervalSeconds,omitempty"` +} diff --git a/apps/backend/internal/linear/poller.go b/apps/backend/internal/linear/poller.go index f98d85edb9..a1da701728 100644 --- a/apps/backend/internal/linear/poller.go +++ b/apps/backend/internal/linear/poller.go @@ -2,44 +2,174 @@ package linear import ( "context" + "sync" + "time" + + "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/integrations/healthpoll" ) -// Poller probes the stored Linear credentials of every configured workspace on -// the shared 90s auth-health cadence. The actual loop lives in -// internal/integrations/healthpoll; this type exists so callers can keep the -// familiar `linear.NewPoller(...).Start(ctx)` shape without depending on the -// shared package directly. +// defaultIssuePollTickInterval is how often the issue-watch loop wakes up to +// consider every enabled watcher. The actual GraphQL search is only re-run for +// a watcher when its per-watch `PollIntervalSeconds` has elapsed since +// `LastPolledAt`, so this is the *minimum* granularity, not the *actual* +// cadence. +const defaultIssuePollTickInterval = 60 * time.Second + +// Poller drives two background loops sharing a single Service: +// - auth health: probes stored credentials so the UI can show connect status. +// Delegated to internal/integrations/healthpoll for the loop semantics. +// - issue watches: runs each enabled watcher's filter and emits +// NewLinearIssueEvent for every matching issue the orchestrator hasn't yet +// seen. +// +// Both loops are cancelled together via Stop. type Poller struct { - inner *healthpoll.Poller + service *Service + logger *logger.Logger + auth *healthpoll.Poller + issueInterval time.Duration + issueTickHook func() // tests use this to observe each issue-watch tick. + + mu sync.Mutex + cancelIssueLoop context.CancelFunc + wg sync.WaitGroup + started bool } -// NewPoller returns a poller using the default 90s cadence. Returns nil when -// svc is nil so the caller's nil-check still works after the indirection. +// NewPoller returns a poller using the default cadences. Returns nil when svc +// is nil so the caller's nil-check still works after the indirection. func NewPoller(svc *Service, log *logger.Logger) *Poller { if svc == nil { return nil } - return &Poller{inner: healthpoll.New("linear", svcProber{svc}, log)} + return &Poller{ + service: svc, + logger: log, + auth: healthpoll.New("linear", svcProber{svc}, log), + issueInterval: defaultIssuePollTickInterval, + } } -// Start launches the background loop. Calling Start more than once without -// Stop is a no-op. +// SetIssueTickHook installs a callback fired at the end of each issue-watch +// tick. Production code never sets this; tests use it to wait for a tick +// without sleep-polling. +func (p *Poller) SetIssueTickHook(fn func()) { + p.mu.Lock() + p.issueTickHook = fn + p.mu.Unlock() +} + +// Start launches both background loops. Calling Start more than once without +// Stop is a no-op. A nil receiver is also a no-op. func (p *Poller) Start(ctx context.Context) { - if p == nil || p.inner == nil { + if p == nil { return } - p.inner.Start(ctx) + p.mu.Lock() + defer p.mu.Unlock() + if p.started || p.service == nil { + return + } + p.started = true + issueCtx, cancel := context.WithCancel(ctx) + p.cancelIssueLoop = cancel + p.auth.Start(ctx) + p.wg.Add(1) + go p.issueWatchLoop(issueCtx) } -// Stop cancels the loop and waits for it to drain. +// Stop cancels both loops and waits for them to drain. func (p *Poller) Stop() { - if p == nil || p.inner == nil { + if p == nil { return } - p.inner.Stop() + p.mu.Lock() + if !p.started { + p.mu.Unlock() + return + } + cancel := p.cancelIssueLoop + p.mu.Unlock() + if cancel != nil { + cancel() + } + p.auth.Stop() + p.wg.Wait() + p.mu.Lock() + p.started = false + p.mu.Unlock() +} + +func (p *Poller) issueWatchLoop(ctx context.Context) { + defer p.wg.Done() + ticker := time.NewTicker(p.issueInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.checkIssueWatches(ctx) + p.fireIssueTickHook() + } + } +} + +func (p *Poller) checkIssueWatches(ctx context.Context) { + watches, err := p.service.Store().ListEnabledIssueWatches(ctx) + if err != nil { + p.logger.Warn("linear poller: list enabled issue watches failed", zap.Error(err)) + return + } + if len(watches) == 0 { + return + } + for _, w := range watches { + if ctx.Err() != nil { + return + } + if !isIssueWatchDue(w, time.Now()) { + continue + } + newIssues, err := p.service.CheckIssueWatch(ctx, w) + if err != nil { + p.logger.Debug("linear poller: check issue watch failed", + zap.String("watch_id", w.ID), zap.Error(err)) + continue + } + for _, issue := range newIssues { + p.logger.Info("new linear issue found for watch", + zap.String("watch_id", w.ID), + zap.String("identifier", issue.Identifier), + zap.String("title", issue.Title)) + p.service.publishNewLinearIssueEvent(ctx, w, issue) + } + } +} + +// isIssueWatchDue reports whether enough time has passed since the watch was +// last polled to re-run its search. +func isIssueWatchDue(w *IssueWatch, now time.Time) bool { + if w.LastPolledAt == nil { + return true + } + interval := w.PollIntervalSeconds + if interval <= 0 { + interval = DefaultIssueWatchPollInterval + } + return now.Sub(*w.LastPolledAt) >= time.Duration(interval)*time.Second +} + +func (p *Poller) fireIssueTickHook() { + p.mu.Lock() + hook := p.issueTickHook + p.mu.Unlock() + if hook != nil { + hook() + } } // svcProber adapts *Service to healthpoll.Prober without leaking the shared diff --git a/apps/backend/internal/linear/poller_issue_watch_test.go b/apps/backend/internal/linear/poller_issue_watch_test.go new file mode 100644 index 0000000000..03d8db61c5 --- /dev/null +++ b/apps/backend/internal/linear/poller_issue_watch_test.go @@ -0,0 +1,213 @@ +package linear + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// recordingSubscriber subscribes to LinearNewIssue on the in-memory bus and +// captures payloads so the test can assert on them without touching internals. +type recordingSubscriber struct { + mu sync.Mutex + payload []*NewLinearIssueEvent + done chan struct{} + want int +} + +func newRecordingSubscriber(eb bus.EventBus, want int) (*recordingSubscriber, error) { + r := &recordingSubscriber{done: make(chan struct{}), want: want} + _, err := eb.Subscribe(events.LinearNewIssue, func(_ context.Context, e *bus.Event) error { + r.mu.Lock() + defer r.mu.Unlock() + evt, ok := e.Data.(*NewLinearIssueEvent) + if !ok { + return nil + } + r.payload = append(r.payload, evt) + if r.want > 0 && len(r.payload) == r.want { + close(r.done) + } + return nil + }) + return r, err +} + +func (r *recordingSubscriber) snapshot() []*NewLinearIssueEvent { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*NewLinearIssueEvent, len(r.payload)) + copy(out, r.payload) + return out +} + +func TestPoller_CheckIssueWatches_PublishesNewIssuesOnly(t *testing.T) { + f := newPollerFixture(t) + ctx := context.Background() + f.saveConfig(t, "tok") + + eb := bus.NewMemoryEventBus(logger.Default()) + defer eb.Close() + f.svc.SetEventBus(eb) + + sub, err := newRecordingSubscriber(eb, 2) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + + w := newTestIssueWatch("ws-1") + if err := f.store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create watch: %v", err) + } + + f.client.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) { + return &SearchResult{ + Issues: []LinearIssue{ + {Identifier: "ENG-1", Title: "first", URL: "https://linear.app/x/issue/ENG-1"}, + {Identifier: "ENG-2", Title: "second", URL: "https://linear.app/x/issue/ENG-2"}, + }, + IsLast: true, + }, nil + } + + // First tick: both issues are new, two events publish. + f.poller.checkIssueWatches(ctx) + + <-sub.done + first := sub.snapshot() + if len(first) != 2 { + t.Fatalf("expected 2 events on first tick, got %d", len(first)) + } + idents := []string{first[0].Issue.Identifier, first[1].Issue.Identifier} + if !contains(idents, "ENG-1") || !contains(idents, "ENG-2") { + t.Errorf("expected events for ENG-1 and ENG-2, got %v", idents) + } + if first[0].WorkspaceID != "ws-1" || first[0].WorkflowID != "wf-1" { + t.Errorf("event missing watch context: %+v", first[0]) + } + + // Reserve both identifiers to simulate the orchestrator having created tasks. + for _, id := range []string{"ENG-1", "ENG-2"} { + if _, err := f.store.ReserveIssueWatchTask(ctx, w.ID, id, "https://linear.app/x/issue/"+id); err != nil { + t.Fatalf("reserve %s: %v", id, err) + } + } + + // Second tick: nothing new, no additional events should publish. + f.poller.checkIssueWatches(ctx) + if got := len(sub.snapshot()); got != 2 { + t.Errorf("expected event count to stay at 2 after dedup, got %d", got) + } +} + +func TestPoller_CheckIssueWatches_SkipsDisabled(t *testing.T) { + f := newPollerFixture(t) + ctx := context.Background() + f.saveConfig(t, "tok") + + eb := bus.NewMemoryEventBus(logger.Default()) + defer eb.Close() + f.svc.SetEventBus(eb) + + disabled := newTestIssueWatch("ws-1") + disabled.Enabled = false + if err := f.store.CreateIssueWatch(ctx, disabled); err != nil { + t.Fatalf("create disabled: %v", err) + } + + calls := 0 + f.client.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) { + calls++ + return &SearchResult{Issues: []LinearIssue{{Identifier: "ENG-1"}}, IsLast: true}, nil + } + + f.poller.checkIssueWatches(ctx) + + if calls != 0 { + t.Errorf("expected disabled watch to be skipped (no Linear call), got %d calls", calls) + } +} + +func contains(haystack []string, needle string) bool { + for _, h := range haystack { + if h == needle { + return true + } + } + return false +} + +func TestIsIssueWatchDue(t *testing.T) { + now := time.Now() + stamp := now.Add(-30 * time.Second) + cases := []struct { + name string + watch *IssueWatch + expected bool + }{ + { + name: "never polled is always due", + watch: &IssueWatch{PollIntervalSeconds: 300}, + expected: true, + }, + { + name: "polled less than interval ago is not due", + watch: &IssueWatch{PollIntervalSeconds: 300, LastPolledAt: &stamp}, + expected: false, + }, + { + name: "polled at exactly the interval boundary is due", + watch: &IssueWatch{PollIntervalSeconds: 30, LastPolledAt: &stamp}, + expected: true, + }, + { + name: "zero interval falls back to default and gates accordingly", + watch: &IssueWatch{PollIntervalSeconds: 0, LastPolledAt: &stamp}, + expected: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isIssueWatchDue(tc.watch, now); got != tc.expected { + t.Errorf("expected %v, got %v", tc.expected, got) + } + }) + } +} + +func TestPoller_CheckIssueWatches_RespectsPerWatchInterval(t *testing.T) { + f := newPollerFixture(t) + ctx := context.Background() + f.saveConfig(t, "tok") + + eb := bus.NewMemoryEventBus(logger.Default()) + defer eb.Close() + f.svc.SetEventBus(eb) + + w := newTestIssueWatch("ws-1") + w.PollIntervalSeconds = 300 + if err := f.store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + now := time.Now().UTC() + if err := f.store.UpdateIssueWatchLastPolled(ctx, w.ID, now); err != nil { + t.Fatalf("stamp: %v", err) + } + + calls := 0 + f.client.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) { + calls++ + return &SearchResult{Issues: []LinearIssue{{Identifier: "ENG-1"}}, IsLast: true}, nil + } + + f.poller.checkIssueWatches(ctx) + + if calls != 0 { + t.Errorf("expected gating to skip the Linear search, got %d call(s)", calls) + } +} diff --git a/apps/backend/internal/linear/provider.go b/apps/backend/internal/linear/provider.go index 7eb4e9933f..62c385e62c 100644 --- a/apps/backend/internal/linear/provider.go +++ b/apps/backend/internal/linear/provider.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" ) // mockEnvVar gates the in-memory mock client used in E2E tests. @@ -20,14 +21,16 @@ func MockEnabled() bool { return os.Getenv(mockEnvVar) == "true" } -// Provide builds the Linear service. Cleanup is a no-op today — the service -// holds only in-memory client caches — but the signature mirrors other -// integration providers so callers can register it uniformly. +// Provide builds the Linear service. eventBus may be nil — used in tests and +// during early boot before the bus is ready; the service falls back to a +// no-op publish path. Cleanup is a no-op today — the service holds only +// in-memory client caches — but the signature mirrors other integration +// providers so callers can register it uniformly. // // When KANDEV_MOCK_LINEAR=true, the service is wired to a process-wide // MockClient and the same instance is exposed via Service.MockClient() so the // E2E mock controller can drive it. -func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error) { +func Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error) { store, err := NewStore(writer, reader) if err != nil { return nil, nil, err @@ -42,6 +45,9 @@ func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) ( } svc := NewService(store, secrets, clientFn, log) svc.mockClient = mock + if eventBus != nil { + svc.SetEventBus(eventBus) + } cleanup := func() error { return nil } return svc, cleanup, nil } diff --git a/apps/backend/internal/linear/service.go b/apps/backend/internal/linear/service.go index b42b2df417..25bf49d655 100644 --- a/apps/backend/internal/linear/service.go +++ b/apps/backend/internal/linear/service.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" ) // SecretStore is the subset of the secrets store the service needs. @@ -30,6 +31,7 @@ type Service struct { clientFn ClientFactory client Client // singleton, cleared on config change. probeHook func() + eventBus bus.EventBus // mockClient is non-nil only when Provide built the service with a MockClient // (KANDEV_MOCK_LINEAR=true). Exposed via MockClient() so the e2e control // routes can drive the same instance the clientFn returns. diff --git a/apps/backend/internal/linear/service_issue_watch.go b/apps/backend/internal/linear/service_issue_watch.go new file mode 100644 index 0000000000..f999a14491 --- /dev/null +++ b/apps/backend/internal/linear/service_issue_watch.go @@ -0,0 +1,266 @@ +package linear + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// ErrIssueWatchNotFound is returned when GetIssueWatch's caller looks up an ID +// that doesn't exist. Callers map this to HTTP 404. +var ErrIssueWatchNotFound = errors.New("linear: issue watch not found") + +// SetEventBus wires the bus used to publish NewLinearIssueEvent. Optional: if +// unset the poller still runs but observed issues do not become Kandev tasks. +func (s *Service) SetEventBus(eb bus.EventBus) { + s.mu.Lock() + s.eventBus = eb + s.mu.Unlock() +} + +// CreateIssueWatch validates the request and persists a new watch row. +func (s *Service) CreateIssueWatch(ctx context.Context, req *CreateIssueWatchRequest) (*IssueWatch, error) { + if err := validateIssueWatchCreate(req); err != nil { + return nil, err + } + w := &IssueWatch{ + WorkspaceID: req.WorkspaceID, + WorkflowID: req.WorkflowID, + WorkflowStepID: req.WorkflowStepID, + Filter: normalizeFilter(req.Filter), + AgentProfileID: req.AgentProfileID, + ExecutorProfileID: req.ExecutorProfileID, + Prompt: req.Prompt, + PollIntervalSeconds: req.PollIntervalSeconds, + Enabled: true, + } + if req.Enabled != nil { + w.Enabled = *req.Enabled + } + if err := s.store.CreateIssueWatch(ctx, w); err != nil { + return nil, err + } + return w, nil +} + +// ListIssueWatches returns the watches configured for a workspace. +func (s *Service) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { + return s.store.ListIssueWatches(ctx, workspaceID) +} + +// ListAllIssueWatches returns every watch across all workspaces. +func (s *Service) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + return s.store.ListAllIssueWatches(ctx) +} + +// GetIssueWatch returns a single watch by ID or ErrIssueWatchNotFound. +func (s *Service) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { + w, err := s.store.GetIssueWatch(ctx, id) + if err != nil { + return nil, err + } + if w == nil { + return nil, ErrIssueWatchNotFound + } + return w, nil +} + +// UpdateIssueWatch applies a partial update by patching only the fields the +// caller explicitly set, then persists the result. +func (s *Service) UpdateIssueWatch(ctx context.Context, id string, req *UpdateIssueWatchRequest) (*IssueWatch, error) { + w, err := s.GetIssueWatch(ctx, id) + if err != nil { + return nil, err + } + applyIssueWatchPatch(w, req) + if filterIsEmpty(w.Filter) { + return nil, fmt.Errorf("%w: filter must specify at least one of query, teamKey, stateIds, or assigned", ErrInvalidConfig) + } + if w.WorkflowID == "" || w.WorkflowStepID == "" { + return nil, fmt.Errorf("%w: workflowId and workflowStepId cannot be empty", ErrInvalidConfig) + } + if err := validatePollInterval(w.PollIntervalSeconds); err != nil { + return nil, err + } + if err := s.store.UpdateIssueWatch(ctx, w); err != nil { + return nil, err + } + return w, nil +} + +// DeleteIssueWatch removes the watch and its dedup rows. Idempotent. +func (s *Service) DeleteIssueWatch(ctx context.Context, id string) error { + return s.store.DeleteIssueWatch(ctx, id) +} + +// CheckIssueWatch runs the watch's filter once and returns the issues that +// haven't been turned into tasks yet. last_polled_at is stamped regardless of +// whether the search succeeded — a failing search still counts as "we tried". +// +// Concurrency note: callers must tolerate being handed an issue that gets +// stolen by a concurrent reserver. We query the seen-set and return unseen +// identifiers, but we do NOT insert the dedup row here — that happens in the +// orchestrator's ReserveIssueWatchTask via INSERT OR IGNORE. If the manual +// /trigger endpoint and the poller tick fire for the same watch in quick +// succession, both calls can see the same identifier as unseen before either +// has reserved it. The duplicate publish is harmless (the second reserver +// loses the race and bails) but the goroutine work is wasted. Same pattern as +// the JIRA watcher. +func (s *Service) CheckIssueWatch(ctx context.Context, w *IssueWatch) ([]*LinearIssue, error) { + defer s.stampWatchLastPolled(w.ID) + client, err := s.clientFor(ctx) + if err != nil { + return nil, err + } + res, err := client.SearchIssues(ctx, w.Filter, "", issueWatchSearchPageSize) + if err != nil { + return nil, err + } + seen, err := s.store.ListSeenIssueIdentifiers(ctx, w.ID) + if err != nil { + s.log.Warn("linear: dedup set fetch failed", + zap.String("watch_id", w.ID), zap.Error(err)) + seen = nil + } + out := make([]*LinearIssue, 0, len(res.Issues)) + for i := range res.Issues { + issue := res.Issues[i] + if _, ok := seen[issue.Identifier]; ok { + continue + } + out = append(out, &issue) + } + return out, nil +} + +// stampWatchLastPolled writes the current timestamp using a fresh background +// context with a short write deadline, so a cancelled caller ctx (e.g. shutdown) +// doesn't drop the liveness record. +func (s *Service) stampWatchLastPolled(watchID string) { + ctx, cancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) + defer cancel() + if err := s.store.UpdateIssueWatchLastPolled(ctx, watchID, time.Now().UTC()); err != nil { + s.log.Warn("linear: update last_polled_at failed", + zap.String("watch_id", watchID), zap.Error(err)) + } +} + +// publishNewLinearIssueEvent emits the orchestrator-facing event for one +// freshly-observed issue. No-op when the event bus is not wired (tests, early +// boot). +func (s *Service) publishNewLinearIssueEvent(ctx context.Context, w *IssueWatch, issue *LinearIssue) { + s.mu.Lock() + eb := s.eventBus + s.mu.Unlock() + if eb == nil { + return + } + evt := bus.NewEvent(events.LinearNewIssue, "linear", &NewLinearIssueEvent{ + IssueWatchID: w.ID, + WorkspaceID: w.WorkspaceID, + WorkflowID: w.WorkflowID, + WorkflowStepID: w.WorkflowStepID, + AgentProfileID: w.AgentProfileID, + ExecutorProfileID: w.ExecutorProfileID, + Prompt: w.Prompt, + Issue: issue, + }) + if err := eb.Publish(ctx, events.LinearNewIssue, evt); err != nil { + s.log.Debug("linear: publish new issue event failed", + zap.String("watch_id", w.ID), zap.String("identifier", issue.Identifier), zap.Error(err)) + } +} + +// issueWatchSearchPageSize caps how many issues a single CheckIssueWatch call +// pulls from Linear. Mirrors the Jira watcher's limit so per-tick cost stays +// bounded for very broad filters. +const issueWatchSearchPageSize = 50 + +// MinIssueWatchPollInterval / MaxIssueWatchPollInterval bound the per-watch +// search re-run cadence. +const ( + MinIssueWatchPollInterval = 60 + MaxIssueWatchPollInterval = 3600 +) + +func validateIssueWatchCreate(req *CreateIssueWatchRequest) error { + if req.WorkspaceID == "" { + return fmt.Errorf("%w: workspaceId required", ErrInvalidConfig) + } + if req.WorkflowID == "" || req.WorkflowStepID == "" { + return fmt.Errorf("%w: workflowId and workflowStepId required", ErrInvalidConfig) + } + if filterIsEmpty(normalizeFilter(req.Filter)) { + return fmt.Errorf("%w: filter must specify at least one of query, teamKey, stateIds, or assigned", ErrInvalidConfig) + } + if req.PollIntervalSeconds != 0 { + if err := validatePollInterval(req.PollIntervalSeconds); err != nil { + return err + } + } + return nil +} + +func validatePollInterval(seconds int) error { + if seconds < MinIssueWatchPollInterval || seconds > MaxIssueWatchPollInterval { + return fmt.Errorf("%w: pollIntervalSeconds must be between %d and %d", + ErrInvalidConfig, MinIssueWatchPollInterval, MaxIssueWatchPollInterval) + } + return nil +} + +// normalizeFilter trims string fields and drops empty stateIds entries, so a +// filter that looks empty after normalization fails the at-least-one check +// instead of slipping through with whitespace. +func normalizeFilter(f SearchFilter) SearchFilter { + out := SearchFilter{ + Query: strings.TrimSpace(f.Query), + TeamKey: strings.TrimSpace(f.TeamKey), + Assigned: strings.TrimSpace(f.Assigned), + } + for _, id := range f.StateIDs { + id = strings.TrimSpace(id) + if id != "" { + out.StateIDs = append(out.StateIDs, id) + } + } + return out +} + +func filterIsEmpty(f SearchFilter) bool { + return f.Query == "" && f.TeamKey == "" && f.Assigned == "" && len(f.StateIDs) == 0 +} + +func applyIssueWatchPatch(w *IssueWatch, req *UpdateIssueWatchRequest) { + if req.WorkflowID != nil { + w.WorkflowID = *req.WorkflowID + } + if req.WorkflowStepID != nil { + w.WorkflowStepID = *req.WorkflowStepID + } + if req.Filter != nil { + w.Filter = normalizeFilter(*req.Filter) + } + if req.AgentProfileID != nil { + w.AgentProfileID = *req.AgentProfileID + } + if req.ExecutorProfileID != nil { + w.ExecutorProfileID = *req.ExecutorProfileID + } + if req.Prompt != nil { + w.Prompt = *req.Prompt + } + if req.Enabled != nil { + w.Enabled = *req.Enabled + } + if req.PollIntervalSeconds != nil { + w.PollIntervalSeconds = *req.PollIntervalSeconds + } +} diff --git a/apps/backend/internal/linear/service_issue_watch_test.go b/apps/backend/internal/linear/service_issue_watch_test.go new file mode 100644 index 0000000000..2f15c62d02 --- /dev/null +++ b/apps/backend/internal/linear/service_issue_watch_test.go @@ -0,0 +1,225 @@ +package linear + +import ( + "context" + "errors" + "testing" +) + +// withSearchResults returns a fakeClient that always returns the given issues +// for SearchIssues, ignoring the filter. +func (c *fakeClient) withSearchResults(issues []LinearIssue) *fakeClient { + c.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) { + return &SearchResult{Issues: issues, IsLast: true}, nil + } + return c +} + +func TestService_CreateIssueWatch_DefaultsAndValidation(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + // Empty filter is rejected. + if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + }); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for empty filter, got %v", err) + } + + // Whitespace-only fields are also rejected (normalize then check). + if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: SearchFilter{Query: " ", TeamKey: " ", StateIDs: []string{" ", ""}}, + }); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for whitespace-only filter, got %v", err) + } + + // Happy path assigns ID + defaults Enabled=true. + w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if w.ID == "" { + t.Fatal("expected ID assigned") + } + if !w.Enabled { + t.Error("expected Enabled defaulted to true") + } + if w.Filter.TeamKey != "ENG" { + t.Errorf("filter not persisted: %+v", w.Filter) + } +} + +func TestService_UpdateIssueWatch_PartialPatch(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + Prompt: "original", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Patch only the prompt; everything else must remain. + newPrompt := "updated" + updated, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{ + Prompt: &newPrompt, + }) + if err != nil { + t.Fatalf("update: %v", err) + } + if updated.Prompt != "updated" { + t.Errorf("prompt not patched: %q", updated.Prompt) + } + if updated.Filter.TeamKey != "ENG" || updated.WorkspaceID != created.WorkspaceID { + t.Errorf("unexpected mutation of unset fields: %+v", updated) + } + + // Patching filter to something empty is rejected to keep watch rows valid. + empty := SearchFilter{} + if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{Filter: &empty}); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for empty filter patch, got %v", err) + } +} + +func TestService_CreateIssueWatch_RejectsOutOfRangeInterval(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + for _, n := range []int{1, 30, 59, 3601, 86400} { + _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + PollIntervalSeconds: n, + }) + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for pollIntervalSeconds=%d, got %v", n, err) + } + } + // Zero is allowed (the store coerces to default). + if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf", + WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + }); err != nil { + t.Errorf("expected zero pollIntervalSeconds to be accepted, got %v", err) + } +} + +func TestService_UpdateIssueWatch_RejectsEmptyWorkflowFields(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + empty := "" + for _, req := range []*UpdateIssueWatchRequest{ + {WorkflowID: &empty}, + {WorkflowStepID: &empty}, + } { + if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, req); !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected ErrInvalidConfig for %+v, got %v", req, err) + } + } +} + +func TestService_UpdateIssueWatch_NotFound(t *testing.T) { + f := newSvcFixture(t) + prompt := "x" + _, err := f.svc.UpdateIssueWatch(context.Background(), "ghost", &UpdateIssueWatchRequest{Prompt: &prompt}) + if !errors.Is(err, ErrIssueWatchNotFound) { + t.Errorf("expected ErrIssueWatchNotFound, got %v", err) + } +} + +func TestService_CheckIssueWatch_FiltersAlreadySeen(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAPIKey, Secret: "lin_api", + }); err != nil { + t.Fatalf("set config: %v", err) + } + + f.client.withSearchResults([]LinearIssue{ + {Identifier: "ENG-1", Title: "one", URL: "https://linear.app/x/issue/ENG-1"}, + {Identifier: "ENG-2", Title: "two", URL: "https://linear.app/x/issue/ENG-2"}, + {Identifier: "ENG-3", Title: "three", URL: "https://linear.app/x/issue/ENG-3"}, + }) + + w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + }) + if err != nil { + t.Fatalf("create watch: %v", err) + } + + // Pre-seed ENG-2 as already turned into a task. + if _, err := f.store.ReserveIssueWatchTask(ctx, w.ID, "ENG-2", "https://linear.app/x/issue/ENG-2"); err != nil { + t.Fatalf("seed reservation: %v", err) + } + + got, err := f.svc.CheckIssueWatch(ctx, w) + if err != nil { + t.Fatalf("check: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 unseen issues, got %d", len(got)) + } + for _, i := range got { + if i.Identifier == "ENG-2" { + t.Error("ENG-2 should have been filtered as already seen") + } + } + + refreshed, _ := f.store.GetIssueWatch(ctx, w.ID) + if refreshed.LastPolledAt == nil { + t.Error("expected last_polled_at stamped after check") + } +} + +func TestService_CheckIssueWatch_StampsLastPolledOnError(t *testing.T) { + f := newSvcFixture(t) + ctx := context.Background() + if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{ + AuthMethod: AuthMethodAPIKey, Secret: "lin_api", + }); err != nil { + t.Fatalf("set config: %v", err) + } + f.client.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) { + return nil, errors.New("upstream 500") + } + w, _ := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{ + WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step", + Filter: SearchFilter{TeamKey: "ENG"}, + }) + if _, err := f.svc.CheckIssueWatch(ctx, w); err == nil { + t.Error("expected error from search to surface to caller") + } + refreshed, _ := f.store.GetIssueWatch(ctx, w.ID) + if refreshed.LastPolledAt == nil { + t.Error("expected last_polled_at stamped even on search failure (liveness signal)") + } +} diff --git a/apps/backend/internal/linear/store.go b/apps/backend/internal/linear/store.go index 3ace6e0ed4..0e913e4b5a 100644 --- a/apps/backend/internal/linear/store.go +++ b/apps/backend/internal/linear/store.go @@ -51,6 +51,35 @@ const createTablesSQL = ` created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); + + CREATE TABLE IF NOT EXISTS linear_issue_watches ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + workflow_id TEXT NOT NULL, + workflow_step_id TEXT NOT NULL, + filter_json TEXT NOT NULL DEFAULT '{}', + agent_profile_id TEXT NOT NULL DEFAULT '', + executor_profile_id TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT 1, + poll_interval_seconds INTEGER NOT NULL DEFAULT 300, + last_polled_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_linear_issue_watches_workspace + ON linear_issue_watches(workspace_id); + + CREATE TABLE IF NOT EXISTS linear_issue_watch_tasks ( + id TEXT PRIMARY KEY, + issue_watch_id TEXT NOT NULL, + issue_identifier TEXT NOT NULL, + issue_url TEXT NOT NULL, + task_id TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + UNIQUE(issue_watch_id, issue_identifier), + FOREIGN KEY(issue_watch_id) REFERENCES linear_issue_watches(id) ON DELETE CASCADE + ); ` // singletonID is the synthetic primary key of the (only) row in linear_configs. diff --git a/apps/backend/internal/linear/store_issue_watch.go b/apps/backend/internal/linear/store_issue_watch.go new file mode 100644 index 0000000000..3219704433 --- /dev/null +++ b/apps/backend/internal/linear/store_issue_watch.go @@ -0,0 +1,267 @@ +package linear + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +// issueWatchRow mirrors IssueWatch but stores the filter as a raw JSON string +// the way SQLite holds it. The store marshals/unmarshals at this boundary so +// service callers see a typed SearchFilter and never have to think about JSON. +type issueWatchRow struct { + ID string `db:"id"` + WorkspaceID string `db:"workspace_id"` + WorkflowID string `db:"workflow_id"` + WorkflowStepID string `db:"workflow_step_id"` + FilterJSON string `db:"filter_json"` + AgentProfileID string `db:"agent_profile_id"` + ExecutorProfileID string `db:"executor_profile_id"` + Prompt string `db:"prompt"` + Enabled bool `db:"enabled"` + PollIntervalSeconds int `db:"poll_interval_seconds"` + LastPolledAt *time.Time `db:"last_polled_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (r *issueWatchRow) toIssueWatch() (*IssueWatch, error) { + var filter SearchFilter + if r.FilterJSON != "" { + if err := json.Unmarshal([]byte(r.FilterJSON), &filter); err != nil { + return nil, fmt.Errorf("decode filter: %w", err) + } + } + return &IssueWatch{ + ID: r.ID, + WorkspaceID: r.WorkspaceID, + WorkflowID: r.WorkflowID, + WorkflowStepID: r.WorkflowStepID, + Filter: filter, + AgentProfileID: r.AgentProfileID, + ExecutorProfileID: r.ExecutorProfileID, + Prompt: r.Prompt, + Enabled: r.Enabled, + PollIntervalSeconds: r.PollIntervalSeconds, + LastPolledAt: r.LastPolledAt, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + }, nil +} + +func encodeFilter(f SearchFilter) (string, error) { + b, err := json.Marshal(f) + if err != nil { + return "", fmt.Errorf("encode filter: %w", err) + } + return string(b), nil +} + +const issueWatchColumns = `id, workspace_id, workflow_id, workflow_step_id, filter_json, + agent_profile_id, executor_profile_id, prompt, enabled, + poll_interval_seconds, last_polled_at, created_at, updated_at` + +// CreateIssueWatch persists a new issue watch row. ID and timestamps are +// assigned here so callers can pass a partially-populated struct. +func (s *Store) CreateIssueWatch(ctx context.Context, w *IssueWatch) error { + if w.ID == "" { + w.ID = uuid.New().String() + } + now := time.Now().UTC() + w.CreatedAt = now + w.UpdatedAt = now + if w.PollIntervalSeconds <= 0 { + w.PollIntervalSeconds = DefaultIssueWatchPollInterval + } + filterJSON, err := encodeFilter(w.Filter) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO linear_issue_watches (`+issueWatchColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + w.ID, w.WorkspaceID, w.WorkflowID, w.WorkflowStepID, filterJSON, + w.AgentProfileID, w.ExecutorProfileID, w.Prompt, w.Enabled, + w.PollIntervalSeconds, w.LastPolledAt, w.CreatedAt, w.UpdatedAt) + return err +} + +// GetIssueWatch returns a single watch by ID, or nil when no row matches. +func (s *Store) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { + var row issueWatchRow + err := s.ro.GetContext(ctx, &row, + `SELECT `+issueWatchColumns+` FROM linear_issue_watches WHERE id = ?`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return row.toIssueWatch() +} + +// ListIssueWatches returns all watches configured for a workspace. +func (s *Store) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { + var rows []issueWatchRow + err := s.ro.SelectContext(ctx, &rows, + `SELECT `+issueWatchColumns+` FROM linear_issue_watches + WHERE workspace_id = ? ORDER BY created_at`, workspaceID) + if err != nil { + return nil, err + } + return materializeWatches(rows) +} + +// ListAllIssueWatches returns every watch across all workspaces. +func (s *Store) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + var rows []issueWatchRow + err := s.ro.SelectContext(ctx, &rows, + `SELECT `+issueWatchColumns+` FROM linear_issue_watches ORDER BY workspace_id, created_at`) + if err != nil { + return nil, err + } + return materializeWatches(rows) +} + +// ListEnabledIssueWatches returns every enabled watch across all workspaces, +// used by the poller to decide what to query each tick. +func (s *Store) ListEnabledIssueWatches(ctx context.Context) ([]*IssueWatch, error) { + var rows []issueWatchRow + err := s.ro.SelectContext(ctx, &rows, + `SELECT `+issueWatchColumns+` FROM linear_issue_watches + WHERE enabled = 1 ORDER BY created_at`) + if err != nil { + return nil, err + } + return materializeWatches(rows) +} + +func materializeWatches(rows []issueWatchRow) ([]*IssueWatch, error) { + out := make([]*IssueWatch, 0, len(rows)) + for i := range rows { + w, err := rows[i].toIssueWatch() + if err != nil { + return nil, err + } + out = append(out, w) + } + return out, nil +} + +// UpdateIssueWatch overwrites the mutable fields of an existing watch row. +// updated_at is bumped automatically; last_polled_at is preserved unless the +// caller explicitly sets it. +func (s *Store) UpdateIssueWatch(ctx context.Context, w *IssueWatch) error { + w.UpdatedAt = time.Now().UTC() + if w.PollIntervalSeconds <= 0 { + w.PollIntervalSeconds = DefaultIssueWatchPollInterval + } + filterJSON, err := encodeFilter(w.Filter) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` + UPDATE linear_issue_watches SET workflow_id = ?, workflow_step_id = ?, filter_json = ?, + agent_profile_id = ?, executor_profile_id = ?, prompt = ?, + enabled = ?, poll_interval_seconds = ?, last_polled_at = ?, updated_at = ? + WHERE id = ?`, + w.WorkflowID, w.WorkflowStepID, filterJSON, + w.AgentProfileID, w.ExecutorProfileID, w.Prompt, + w.Enabled, w.PollIntervalSeconds, w.LastPolledAt, w.UpdatedAt, w.ID) + return err +} + +// UpdateIssueWatchLastPolled stamps the last-polled timestamp without touching +// the rest of the row. +func (s *Store) UpdateIssueWatchLastPolled(ctx context.Context, id string, t time.Time) error { + _, err := s.db.ExecContext(ctx, + `UPDATE linear_issue_watches SET last_polled_at = ?, updated_at = ? WHERE id = ?`, + t, time.Now().UTC(), id) + return err +} + +// DeleteIssueWatch removes a watch and its dedup rows in a single transaction. +// The explicit child DELETE guards older databases where foreign_keys may not +// have been enabled at attach time. +func (s *Store) DeleteIssueWatch(ctx context.Context, id string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM linear_issue_watch_tasks WHERE issue_watch_id = ?`, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM linear_issue_watches WHERE id = ?`, id); err != nil { + return err + } + return tx.Commit() +} + +// ReserveIssueWatchTask atomically claims a slot for a (watch, issue) pair via +// INSERT OR IGNORE. Returns true when this caller won the race and should +// proceed to create the task. +func (s *Store) ReserveIssueWatchTask(ctx context.Context, watchID, identifier, issueURL string) (bool, error) { + res, err := s.db.ExecContext(ctx, ` + INSERT OR IGNORE INTO linear_issue_watch_tasks (id, issue_watch_id, issue_identifier, issue_url, task_id, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + uuid.New().String(), watchID, identifier, issueURL, "", time.Now().UTC()) + if err != nil { + return false, err + } + rows, err := res.RowsAffected() + if err != nil { + return false, err + } + return rows == 1, nil +} + +// AssignIssueWatchTaskID stamps the created task ID onto a previously-reserved +// dedup row. +func (s *Store) AssignIssueWatchTaskID(ctx context.Context, watchID, identifier, taskID string) error { + res, err := s.db.ExecContext(ctx, ` + UPDATE linear_issue_watch_tasks SET task_id = ? + WHERE issue_watch_id = ? AND issue_identifier = ?`, + taskID, watchID, identifier) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("assign task ID: reservation row not found for watch=%s issue=%s", watchID, identifier) + } + return nil +} + +// ReleaseIssueWatchTask drops a reservation so the next poll can retry. Used +// when task creation fails after a successful reserve. +func (s *Store) ReleaseIssueWatchTask(ctx context.Context, watchID, identifier string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM linear_issue_watch_tasks WHERE issue_watch_id = ? AND issue_identifier = ?`, + watchID, identifier) + return err +} + +// ListSeenIssueIdentifiers returns the set of issue identifiers already +// reserved against a watch. +func (s *Store) ListSeenIssueIdentifiers(ctx context.Context, watchID string) (map[string]struct{}, error) { + var keys []string + err := s.ro.SelectContext(ctx, &keys, + `SELECT issue_identifier FROM linear_issue_watch_tasks WHERE issue_watch_id = ?`, watchID) + if err != nil { + return nil, err + } + out := make(map[string]struct{}, len(keys)) + for _, k := range keys { + out[k] = struct{}{} + } + return out, nil +} diff --git a/apps/backend/internal/linear/store_issue_watch_test.go b/apps/backend/internal/linear/store_issue_watch_test.go new file mode 100644 index 0000000000..300e55e979 --- /dev/null +++ b/apps/backend/internal/linear/store_issue_watch_test.go @@ -0,0 +1,261 @@ +package linear + +import ( + "context" + "testing" + "time" +) + +func newTestIssueWatch(workspaceID string) *IssueWatch { + return &IssueWatch{ + WorkspaceID: workspaceID, + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + Filter: SearchFilter{TeamKey: "ENG", StateIDs: []string{"state-started"}}, + Prompt: "Investigate {{issue.identifier}}", + Enabled: true, + } +} + +func TestStore_IssueWatch_CreateGet(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if w.ID == "" { + t.Fatal("expected ID assigned") + } + if w.PollIntervalSeconds != DefaultIssueWatchPollInterval { + t.Errorf("expected default poll interval %d, got %d", DefaultIssueWatchPollInterval, w.PollIntervalSeconds) + } + if w.CreatedAt.IsZero() || w.UpdatedAt.IsZero() { + t.Error("expected timestamps assigned") + } + + got, err := store.GetIssueWatch(ctx, w.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected watch, got nil") + } + if got.WorkspaceID != w.WorkspaceID || got.Prompt != w.Prompt { + t.Errorf("round-trip mismatch: %+v vs %+v", got, w) + } + // Filter survives the JSON round-trip. + if got.Filter.TeamKey != "ENG" || len(got.Filter.StateIDs) != 1 || got.Filter.StateIDs[0] != "state-started" { + t.Errorf("filter round-trip failed: %+v", got.Filter) + } +} + +func TestStore_IssueWatch_ListByWorkspace(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w1 := newTestIssueWatch("ws-1") + w1.Filter = SearchFilter{TeamKey: "A"} + w2 := newTestIssueWatch("ws-1") + w2.Filter = SearchFilter{TeamKey: "B"} + w3 := newTestIssueWatch("ws-2") + + for _, w := range []*IssueWatch{w1, w2, w3} { + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + } + + got, err := store.ListIssueWatches(ctx, "ws-1") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 watches for ws-1, got %d", len(got)) + } + for _, w := range got { + if w.WorkspaceID != "ws-1" { + t.Errorf("workspace leaked into list: %s", w.WorkspaceID) + } + } +} + +func TestStore_IssueWatch_ListEnabled(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + enabled := newTestIssueWatch("ws-1") + disabled := newTestIssueWatch("ws-2") + disabled.Enabled = false + for _, w := range []*IssueWatch{enabled, disabled} { + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + } + + got, err := store.ListEnabledIssueWatches(ctx) + if err != nil { + t.Fatalf("list enabled: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected only the enabled watch, got %d", len(got)) + } + if got[0].ID != enabled.ID { + t.Errorf("expected enabled watch returned, got %s", got[0].ID) + } +} + +func TestStore_IssueWatch_Update(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + originalCreated := w.CreatedAt + w.Filter = SearchFilter{Query: "auth bug"} + w.Enabled = false + w.PollIntervalSeconds = 60 + if err := store.UpdateIssueWatch(ctx, w); err != nil { + t.Fatalf("update: %v", err) + } + got, _ := store.GetIssueWatch(ctx, w.ID) + if got.Filter.Query != "auth bug" || got.Enabled || got.PollIntervalSeconds != 60 { + t.Errorf("update did not persist: %+v", got) + } + if !got.CreatedAt.Equal(originalCreated) { + t.Errorf("update must not change created_at: %v vs %v", got.CreatedAt, originalCreated) + } +} + +func TestStore_IssueWatch_LastPolledStamp(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + t1 := time.Now().UTC().Truncate(time.Second) + if err := store.UpdateIssueWatchLastPolled(ctx, w.ID, t1); err != nil { + t.Fatalf("stamp: %v", err) + } + got, _ := store.GetIssueWatch(ctx, w.ID) + if got.LastPolledAt == nil || !got.LastPolledAt.Equal(t1) { + t.Errorf("expected last_polled_at=%v, got %v", t1, got.LastPolledAt) + } +} + +func TestStore_IssueWatch_DeleteCascadesDedupRows(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-1", "https://linear.app/x/issue/ENG-1"); err != nil { + t.Fatalf("reserve: %v", err) + } + + if err := store.DeleteIssueWatch(ctx, w.ID); err != nil { + t.Fatalf("delete: %v", err) + } + gone, _ := store.GetIssueWatch(ctx, w.ID) + if gone != nil { + t.Errorf("expected watch deleted, got %+v", gone) + } + // Re-creating a watch with the same ID and reserving the same identifier + // should succeed without a UNIQUE collision — proves cascade ran. + w2 := newTestIssueWatch("ws-1") + w2.ID = w.ID + if err := store.CreateIssueWatch(ctx, w2); err != nil { + t.Fatalf("recreate watch: %v", err) + } + ok, err := store.ReserveIssueWatchTask(ctx, w2.ID, "ENG-1", "https://linear.app/x/issue/ENG-1") + if err != nil { + t.Fatalf("re-reserve: %v", err) + } + if !ok { + t.Error("expected re-reserve to succeed after cascade delete") + } +} + +func TestStore_IssueWatchTask_ReserveDedup(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + + first, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-7", "https://linear.app/x/issue/ENG-7") + if err != nil { + t.Fatalf("first reserve: %v", err) + } + if !first { + t.Error("expected first reservation to win") + } + + second, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-7", "https://linear.app/x/issue/ENG-7") + if err != nil { + t.Fatalf("second reserve: %v", err) + } + if second { + t.Error("expected second reservation to lose due to UNIQUE constraint") + } + + seen, err := store.ListSeenIssueIdentifiers(ctx, w.ID) + if err != nil { + t.Fatalf("list seen: %v", err) + } + if _, ok := seen["ENG-7"]; !ok { + t.Error("expected ENG-7 in seen set after reservation") + } +} + +func TestStore_IssueWatchTask_AssignTaskID(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-1", "https://linear.app/x/issue/ENG-1"); err != nil { + t.Fatalf("reserve: %v", err) + } + if err := store.AssignIssueWatchTaskID(ctx, w.ID, "ENG-1", "task-abc"); err != nil { + t.Fatalf("assign: %v", err) + } + + if err := store.AssignIssueWatchTaskID(ctx, w.ID, "ENG-NOPE", "task-zzz"); err == nil { + t.Error("expected error for missing reservation, got nil") + } +} + +func TestStore_IssueWatchTask_Release(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + w := newTestIssueWatch("ws-1") + if err := store.CreateIssueWatch(ctx, w); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-1", "https://linear.app/x/issue/ENG-1"); err != nil { + t.Fatalf("reserve: %v", err) + } + if err := store.ReleaseIssueWatchTask(ctx, w.ID, "ENG-1"); err != nil { + t.Fatalf("release: %v", err) + } + again, err := store.ReserveIssueWatchTask(ctx, w.ID, "ENG-1", "https://linear.app/x/issue/ENG-1") + if err != nil { + t.Fatalf("re-reserve: %v", err) + } + if !again { + t.Error("expected reservation to succeed after release") + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_linear.go b/apps/backend/internal/orchestrator/event_handlers_linear.go new file mode 100644 index 0000000000..e0e6e80e7d --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_linear.go @@ -0,0 +1,187 @@ +package orchestrator + +import ( + "context" + "fmt" + "strings" + + "go.uber.org/zap" + + promptcfg "github.com/kandev/kandev/config/prompts" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/linear" +) + +// LinearService is the subset of linear.Service the orchestrator needs to +// deduplicate issue→task mappings. Mirrors the JIRA equivalent. +type LinearService interface { + ReserveIssueWatchTask(ctx context.Context, watchID, identifier, issueURL string) (bool, error) + AssignIssueWatchTaskID(ctx context.Context, watchID, identifier, taskID string) error + ReleaseIssueWatchTask(ctx context.Context, watchID, identifier string) error +} + +// SetLinearService wires the Linear dedup helpers into the orchestrator so +// linear-watch handlers can claim issue slots before creating tasks. +func (s *Service) SetLinearService(l LinearService) { + s.linearService = l +} + +// subscribeLinearEvents wires the Linear event handlers onto the bus. +func (s *Service) subscribeLinearEvents() { + if s.eventBus == nil { + return + } + if _, err := s.eventBus.Subscribe(events.LinearNewIssue, s.handleNewLinearIssue); err != nil { + s.logger.Error("failed to subscribe to linear.new_issue events", zap.Error(err)) + } +} + +// handleNewLinearIssue creates a Kandev task for a freshly-observed Linear issue. +func (s *Service) handleNewLinearIssue(_ context.Context, event *bus.Event) error { + evt, ok := event.Data.(*linear.NewLinearIssueEvent) + if !ok { + return nil + } + if evt.Issue == nil { + return nil + } + s.logger.Info("new linear issue detected from watch", + zap.String("issue_watch_id", evt.IssueWatchID), + zap.String("identifier", evt.Issue.Identifier)) + + if s.issueTaskCreator == nil { + s.logger.Warn("issue task creator not configured, skipping linear task creation") + return nil + } + + // Background context: the bus delivery context may be cancelled before + // task creation finishes. The work is independent of the publisher. + go s.createLinearIssueTask(context.Background(), evt) + return nil +} + +// createLinearIssueTask mirrors createJiraIssueTask intentionally: the dedup- +// reserve → create → assign-or-release lifecycle is the same shape across +// both integrations. Refactoring out the symmetry would couple two +// integration packages we'd rather keep independent (their event payloads, +// metadata keys, and prompt placeholders all differ). +// +//nolint:dupl // intentional symmetry with createJiraIssueTask; see comment above +func (s *Service) createLinearIssueTask(ctx context.Context, evt *linear.NewLinearIssueEvent) { + issue := evt.Issue + if !s.reserveLinearIssue(ctx, evt) { + return + } + + req := &IssueTaskRequest{ + WorkspaceID: evt.WorkspaceID, + WorkflowID: evt.WorkflowID, + WorkflowStepID: evt.WorkflowStepID, + Title: fmt.Sprintf("[%s] %s", issue.Identifier, issue.Title), + Description: interpolateLinearPrompt(evt.Prompt, issue), + Metadata: map[string]interface{}{ + "linear_issue_watch_id": evt.IssueWatchID, + "linear_issue_identifier": issue.Identifier, + "linear_issue_url": issue.URL, + "linear_state": issue.StateName, + "linear_assignee": issue.AssigneeName, + "agent_profile_id": evt.AgentProfileID, + "executor_profile_id": evt.ExecutorProfileID, + }, + } + + task, err := s.issueTaskCreator.CreateIssueTask(ctx, req) + if err != nil { + s.logger.Error("failed to create linear issue task", + zap.String("issue_watch_id", evt.IssueWatchID), + zap.String("identifier", issue.Identifier), + zap.Error(err)) + s.releaseLinearIssue(ctx, evt) + return + } + + s.attachLinearIssueTaskID(ctx, evt, task.ID) + s.logger.Info("created linear issue task", + zap.String("task_id", task.ID), + zap.String("identifier", issue.Identifier)) + + if !s.shouldAutoStartStep(ctx, evt.WorkflowStepID) { + return + } + if _, err := s.StartTask( + ctx, task.ID, evt.AgentProfileID, "", evt.ExecutorProfileID, + 0, task.Description, evt.WorkflowStepID, false, nil, + ); err != nil { + s.logger.Error("failed to auto-start linear issue task", + zap.String("task_id", task.ID), zap.Error(err)) + return + } + s.logger.Info("auto-started linear issue task", + zap.String("task_id", task.ID), + zap.String("identifier", issue.Identifier)) +} + +// reserveLinearIssue claims the dedup slot before task creation. When the +// linear service isn't wired (boot order corner case), proceed anyway — +// better to risk a duplicate task than silently drop the event. +func (s *Service) reserveLinearIssue(ctx context.Context, evt *linear.NewLinearIssueEvent) bool { + if s.linearService == nil { + return true + } + reserved, err := s.linearService.ReserveIssueWatchTask(ctx, evt.IssueWatchID, evt.Issue.Identifier, evt.Issue.URL) + if err != nil { + s.logger.Error("failed to reserve linear issue slot", + zap.String("identifier", evt.Issue.Identifier), zap.Error(err)) + return false + } + if !reserved { + s.logger.Debug("linear issue already reserved by concurrent handler, skipping", + zap.String("identifier", evt.Issue.Identifier)) + } + return reserved +} + +func (s *Service) releaseLinearIssue(ctx context.Context, evt *linear.NewLinearIssueEvent) { + if s.linearService == nil { + return + } + if err := s.linearService.ReleaseIssueWatchTask(ctx, evt.IssueWatchID, evt.Issue.Identifier); err != nil { + s.logger.Warn("failed to release linear issue reservation after task-create failure", + zap.String("identifier", evt.Issue.Identifier), zap.Error(err)) + } +} + +func (s *Service) attachLinearIssueTaskID(ctx context.Context, evt *linear.NewLinearIssueEvent, taskID string) { + if s.linearService == nil { + return + } + if err := s.linearService.AssignIssueWatchTaskID(ctx, evt.IssueWatchID, evt.Issue.Identifier, taskID); err != nil { + s.logger.Error("failed to assign task ID to linear issue reservation", + zap.String("task_id", taskID), + zap.String("identifier", evt.Issue.Identifier), zap.Error(err)) + } +} + +// interpolateLinearPrompt replaces {{issue.*}} placeholders with issue fields. +// When the template is empty (user didn't configure a custom prompt), it falls +// back to the embedded default at config/prompts/linear-issue-watch-default.md +// — same pattern as the github + jira watchers, so prompt content is editable +// without redeploying. +func interpolateLinearPrompt(template string, i *linear.LinearIssue) string { + if strings.TrimSpace(template) == "" { + template = promptcfg.Get("linear-issue-watch-default") + } + r := strings.NewReplacer( + "{{issue.identifier}}", i.Identifier, + "{{issue.title}}", i.Title, + "{{issue.url}}", i.URL, + "{{issue.state}}", i.StateName, + "{{issue.priority}}", i.PriorityLabel, + "{{issue.team}}", i.TeamKey, + "{{issue.assignee}}", i.AssigneeName, + "{{issue.creator}}", i.CreatorName, + "{{issue.description}}", i.Description, + ) + return r.Replace(template) +} diff --git a/apps/backend/internal/orchestrator/event_handlers_linear_test.go b/apps/backend/internal/orchestrator/event_handlers_linear_test.go new file mode 100644 index 0000000000..6a1f83e8ac --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_linear_test.go @@ -0,0 +1,43 @@ +package orchestrator + +import ( + "strings" + "testing" + + "github.com/kandev/kandev/internal/linear" +) + +func TestInterpolateLinearPrompt(t *testing.T) { + issue := &linear.LinearIssue{ + Identifier: "ENG-7", + Title: "Login fails on mobile", + URL: "https://linear.app/acme/issue/ENG-7", + StateName: "In Progress", + PriorityLabel: "High", + TeamKey: "ENG", + AssigneeName: "Alice", + CreatorName: "Bob", + Description: "Tap submit, nothing happens.", + } + + // Empty template falls back to the embedded default; assert on the load- + // bearing fields rather than the full string so editing the markdown + // doesn't break the test. + got := interpolateLinearPrompt("", issue) + if !strings.Contains(got, "ENG-7") { + t.Errorf("default template missing identifier: %q", got) + } + if !strings.Contains(got, "https://linear.app/acme/issue/ENG-7") { + t.Errorf("default template missing URL: %q", got) + } + + // All placeholders interpolate. + got = interpolateLinearPrompt( + "{{issue.identifier}} | {{issue.title}} | {{issue.state}} | {{issue.priority}} | {{issue.team}} | {{issue.assignee}} | {{issue.creator}}", + issue, + ) + want := "ENG-7 | Login fails on mobile | In Progress | High | ENG | Alice | Bob" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 2a78e877b2..ba91807405 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -225,6 +225,9 @@ type Service struct { // Jira service for issue watch dedup operations jiraService JiraService + // Linear service for issue watch dedup operations + linearService LinearService + // Repository resolver for cloning + finding/creating repos for review tasks repositoryResolver RepositoryResolver @@ -662,6 +665,9 @@ func (s *Service) Start(ctx context.Context) error { // Subscribe to JIRA integration events s.subscribeJiraEvents() + // Subscribe to Linear integration events + s.subscribeLinearEvents() + // Subscribe to clarification events (cancel-and-resume flow) s.subscribeClarificationEvents() diff --git a/apps/web/components/linear/linear-issue-watch-dialog.tsx b/apps/web/components/linear/linear-issue-watch-dialog.tsx new file mode 100644 index 0000000000..3d790bb0e4 --- /dev/null +++ b/apps/web/components/linear/linear-issue-watch-dialog.tsx @@ -0,0 +1,589 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { Button } from "@kandev/ui/button"; +import { Switch } from "@kandev/ui/switch"; +import { Label } from "@kandev/ui/label"; +import { Input } from "@kandev/ui/input"; +import { Badge } from "@kandev/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@kandev/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { useWorkflows } from "@/hooks/use-workflows"; +import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps"; +import { listLinearTeams, listLinearStates } from "@/lib/api/domains/linear-api"; +import { + ScriptEditor, + computeEditorHeight, +} from "@/components/settings/profile-edit/script-editor"; +import { + LINEAR_ISSUE_WATCH_PLACEHOLDERS, + DEFAULT_LINEAR_ISSUE_WATCH_PROMPT, +} from "./linear-issue-watch-placeholders"; +import type { + CreateLinearIssueWatchInput, + LinearIssueWatch, + LinearTeam, + LinearWorkflowState, + UpdateLinearIssueWatchInput, +} from "@/lib/types/linear"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + watch: LinearIssueWatch | null; + workspaceId?: string; + onCreate: (req: CreateLinearIssueWatchInput) => Promise; + onUpdate: (id: string, req: UpdateLinearIssueWatchInput) => Promise; +}; + +type FormState = { + workspaceId: string; + query: string; + teamKey: string; + stateIds: string[]; + assigned: string; + workflowId: string; + workflowStepId: string; + agentProfileId: string; + executorProfileId: string; + prompt: string; + enabled: boolean; + pollInterval: number; +}; + +const ASSIGNED_ANY = "__any__"; + +function makeEmptyForm(workspaceId: string): FormState { + return { + workspaceId, + query: "", + teamKey: "", + stateIds: [], + assigned: "", + workflowId: "", + workflowStepId: "", + agentProfileId: "", + executorProfileId: "", + prompt: DEFAULT_LINEAR_ISSUE_WATCH_PROMPT, + enabled: true, + pollInterval: 300, + }; +} + +function formStateFromWatch(w: LinearIssueWatch): FormState { + return { + workspaceId: w.workspaceId, + query: w.filter?.query ?? "", + teamKey: w.filter?.teamKey ?? "", + stateIds: w.filter?.stateIds ?? [], + assigned: w.filter?.assigned ?? "", + workflowId: w.workflowId, + workflowStepId: w.workflowStepId, + agentProfileId: w.agentProfileId, + executorProfileId: w.executorProfileId, + prompt: w.prompt || DEFAULT_LINEAR_ISSUE_WATCH_PROMPT, + enabled: w.enabled, + pollInterval: w.pollIntervalSeconds, + }; +} + +function useFormData(workspaceId: string) { + useSettingsData(true); + useWorkflows(workspaceId, true); + const allWorkflows = useAppStore((s) => s.workflows.items); + const workflows = useMemo(() => allWorkflows.filter((w) => !w.hidden), [allWorkflows]); + const agentProfiles = useAppStore((s) => s.agentProfiles.items); + const executors = useAppStore((s) => s.executors.items); + const allExecutorProfiles = useMemo( + () => executors.filter((e) => e.type !== "local").flatMap((e) => e.profiles ?? []), + [executors], + ); + const filteredAgentProfiles = useMemo( + () => agentProfiles.filter((p) => !p.cli_passthrough), + [agentProfiles], + ); + return { workflows, agentProfiles: filteredAgentProfiles, allExecutorProfiles }; +} + +function SelectField(props: { + label: string; + description?: string; + value: string; + onChange: (v: string) => void; + placeholder: string; + items: { id: string; label: string }[]; + disabled?: boolean; +}) { + return ( +
+ + {props.description &&

{props.description}

} + +
+ ); +} + +function StateMultiSelect({ + states, + loading, + selected, + onToggle, + disabled, +}: { + states: LinearWorkflowState[]; + loading: boolean; + selected: string[]; + onToggle: (id: string) => void; + disabled: boolean; +}) { + if (disabled) { + return ( +

+ Pick a team to choose specific workflow states. +

+ ); + } + if (loading) { + return

Loading states…

; + } + if (states.length === 0) { + return

No workflow states available.

; + } + return ( +
+ {states.map((s) => { + const active = selected.includes(s.id); + return ( + onToggle(s.id)} + > + {s.name} + + ); + })} +
+ ); +} + +// useTeamsAndStates loads the team list once Linear is configured, plus the +// states for the currently-selected team. The states map is keyed by teamKey +// so switching teams renders an empty list without us having to setState in +// an effect — only the lookup expression changes. +function useTeamsAndStates(teamKey: string) { + const [teams, setTeams] = useState([]); + const [statesByTeam, setStatesByTeam] = useState>({}); + // Track which teams we've already kicked off a fetch for so the effect + // doesn't list `statesByTeam` as a dep (which would re-fire whenever any + // team's states load). A ref mutation isn't reactive, so the sole input + // that schedules a fetch is `teamKey`. + const fetchedTeams = useRef>(new Set()); + + useEffect(() => { + let cancelled = false; + listLinearTeams() + .then((res) => { + if (!cancelled) setTeams(res.teams ?? []); + }) + .catch(() => { + if (!cancelled) setTeams([]); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!teamKey || fetchedTeams.current.has(teamKey)) return; + fetchedTeams.current.add(teamKey); + let cancelled = false; + listLinearStates(teamKey) + .then((res) => { + if (!cancelled) setStatesByTeam((prev) => ({ ...prev, [teamKey]: res.states ?? [] })); + }) + .catch(() => { + if (!cancelled) setStatesByTeam((prev) => ({ ...prev, [teamKey]: [] })); + }); + return () => { + cancelled = true; + }; + }, [teamKey]); + + const states = teamKey ? (statesByTeam[teamKey] ?? []) : []; + // "loading" = a team is selected but we haven't received a result yet. + const loadingStates = !!teamKey && statesByTeam[teamKey] === undefined; + return { teams, states, loadingStates }; +} + +function FilterFields({ + form, + setForm, +}: { + form: FormState; + setForm: React.Dispatch>; +}) { + const { teams, states, loadingStates } = useTeamsAndStates(form.teamKey); + const toggleState = useCallback( + (id: string) => + setForm((p) => ({ + ...p, + stateIds: p.stateIds.includes(id) + ? p.stateIds.filter((s) => s !== id) + : [...p.stateIds, id], + })), + [setForm], + ); + + return ( + <> +
+ setForm((p) => ({ ...p, teamKey: v, stateIds: [] }))} + placeholder="(any team)" + items={teams.map((t) => ({ id: t.key, label: `${t.name} (${t.key})` }))} + /> + setForm((p) => ({ ...p, assigned: v === ASSIGNED_ANY ? "" : v }))} + placeholder="(any)" + items={[ + { id: ASSIGNED_ANY, label: "(any)" }, + { id: "me", label: "Me" }, + { id: "unassigned", label: "Unassigned" }, + ]} + /> +
+
+ +

+ Click states to toggle. Empty matches every state on the team. +

+ +
+
+ +

+ Free-text match across title and description (optional). +

+ setForm((p) => ({ ...p, query: e.target.value }))} + placeholder="auth bug" + /> +
+ + ); +} + +function PlaceholdersHelp() { + return ( + + + + + + +

Available placeholders:

+
    + {LINEAR_ISSUE_WATCH_PLACEHOLDERS.map((p) => ( +
  • + {`{{${p.key}}}`}{" "} + {p.description} +
  • + ))} +
+
+
+
+ ); +} + +function PromptField({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( +
+
+ + +
+

+ The prompt sent to the agent for each new issue. Type {"{{"} to insert placeholders. +

+
+ +
+
+ ); +} + +function WorkspacePicker({ + value, + onChange, + disabled, +}: { + value: string; + onChange: (v: string) => void; + disabled?: boolean; +}) { + const workspaces = useAppStore((s) => s.workspaces.items); + return ( + ({ id: w.id, label: w.name }))} + disabled={disabled} + /> + ); +} + +function AutomationFields({ + form, + setForm, +}: { + form: FormState; + setForm: React.Dispatch>; +}) { + const { workflows, agentProfiles, allExecutorProfiles } = useFormData(form.workspaceId); + const { steps, loading: stepsLoading } = useWorkflowSteps(form.workflowId); + return ( + <> +
+ setForm((p) => ({ ...p, workflowId: v, workflowStepId: "" }))} + placeholder="Select workflow" + items={workflows.map((w) => ({ id: w.id, label: w.name }))} + /> + setForm((p) => ({ ...p, workflowStepId: v }))} + placeholder={stepPlaceholder(form.workflowId, stepsLoading, steps.length)} + items={steps.map((s) => ({ id: s.id, label: s.name }))} + disabled={!form.workflowId || stepsLoading || steps.length === 0} + /> +
+
+ setForm((p) => ({ ...p, agentProfileId: v }))} + placeholder="(use step default)" + items={agentProfiles.map((p) => ({ id: p.id, label: p.label }))} + /> + setForm((p) => ({ ...p, executorProfileId: v }))} + placeholder="(use step default)" + items={allExecutorProfiles.map((p) => ({ id: p.id, label: p.name }))} + /> +
+ + ); +} + +function SettingsFields({ + form, + setForm, +}: { + form: FormState; + setForm: React.Dispatch>; +}) { + return ( + <> +
+ +

+ How often to re-run the search. Minimum 60s, maximum 3600s. +

+ setForm((p) => ({ ...p, pollInterval: Number(e.target.value) }))} + min={60} + max={3600} + /> +
+
+
+ +

Pause or resume polling.

+
+ setForm((p) => ({ ...p, enabled: v }))} + className="cursor-pointer" + /> +
+ + ); +} + +function savingLabel(saving: boolean, isEdit: boolean): string { + if (saving) return "Saving…"; + return isEdit ? "Update" : "Create"; +} + +function filterIsEmpty(form: FormState): boolean { + return ( + form.query.trim() === "" && + form.teamKey.trim() === "" && + form.assigned.trim() === "" && + form.stateIds.length === 0 + ); +} + +export function LinearIssueWatchDialog({ + open, + onOpenChange, + watch, + workspaceId, + onCreate, + onUpdate, +}: Props) { + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState(() => makeEmptyForm(workspaceId ?? "")); + + useEffect(() => { + if (watch) { + setForm(formStateFromWatch(watch)); + } else { + setForm(makeEmptyForm(workspaceId ?? activeWorkspaceId ?? "")); + } + }, [watch, open, workspaceId, activeWorkspaceId]); + + const workspaceLocked = !!watch || !!workspaceId; + + const canSave = + !!form.workspaceId && + !filterIsEmpty(form) && + !!form.workflowId && + !!form.workflowStepId && + !!form.prompt.trim(); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const filter = { + query: form.query.trim() || undefined, + teamKey: form.teamKey.trim() || undefined, + stateIds: form.stateIds.length > 0 ? form.stateIds : undefined, + assigned: form.assigned.trim() || undefined, + }; + const payload = { + filter, + workflowId: form.workflowId, + workflowStepId: form.workflowStepId, + agentProfileId: form.agentProfileId, + executorProfileId: form.executorProfileId, + prompt: form.prompt, + enabled: form.enabled, + pollIntervalSeconds: form.pollInterval, + }; + if (watch) { + await onUpdate(watch.id, payload); + } else { + await onCreate({ ...payload, workspaceId: form.workspaceId }); + } + onOpenChange(false); + } catch { + // Error surfaced by caller's toast. + } finally { + setSaving(false); + } + }, [form, watch, onCreate, onUpdate, onOpenChange]); + + return ( + + + + {watch ? "Edit Linear Watcher" : "Create Linear Watcher"} + + Poll Linear with a structured filter and auto-create a Kandev task for each + newly-matching issue. Issues are not bound to a repository — the workflow step's + defaults decide where the task runs. + + +
+ + setForm((p) => ({ ...p, workspaceId: v, workflowId: "", workflowStepId: "" })) + } + disabled={workspaceLocked} + /> + + + setForm((p) => ({ ...p, prompt: v }))} + /> + +
+ + + + +
+
+ ); +} diff --git a/apps/web/components/linear/linear-issue-watch-placeholders.ts b/apps/web/components/linear/linear-issue-watch-placeholders.ts new file mode 100644 index 0000000000..39faf2c10c --- /dev/null +++ b/apps/web/components/linear/linear-issue-watch-placeholders.ts @@ -0,0 +1,86 @@ +import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions"; + +export const LINEAR_ISSUE_WATCH_PLACEHOLDERS: ScriptPlaceholder[] = [ + { + key: "issue.url", + description: "Linear issue URL", + example: "https://linear.app/acme/issue/ENG-7", + executor_types: [], + }, + { + key: "issue.identifier", + description: "Issue identifier", + example: "ENG-7", + executor_types: [], + }, + { + key: "issue.title", + description: "Issue title", + example: "Login fails on mobile", + executor_types: [], + }, + { + key: "issue.team", + description: "Team key", + example: "ENG", + executor_types: [], + }, + { + key: "issue.state", + description: "Workflow state name", + example: "In Progress", + executor_types: [], + }, + { + key: "issue.priority", + description: "Priority label", + example: "High", + executor_types: [], + }, + { + key: "issue.assignee", + description: "Assignee display name", + example: "Alice", + executor_types: [], + }, + { + key: "issue.creator", + description: "Issue creator display name", + example: "Bob", + executor_types: [], + }, + { + key: "issue.description", + description: "Issue description body", + example: "Tap submit, nothing happens.", + executor_types: [], + }, +]; + +// DEFAULT_LINEAR_ISSUE_WATCH_PROMPT mirrors apps/backend/config/prompts/linear-issue-watch-default.md. +// Kept in sync by hand: the UI shows this when the user clears the field, and +// the backend reads the .md when the saved prompt is empty. Diverging would +// surprise the user — they'd see one default in the dialog and another get +// sent to the agent. +export const DEFAULT_LINEAR_ISSUE_WATCH_PROMPT = `You have been assigned a Linear issue to work on. + +**Issue:** {{issue.url}} +**Identifier:** {{issue.identifier}} +**Title:** {{issue.title}} +**Team:** {{issue.team}} +**State:** {{issue.state}} +**Priority:** {{issue.priority}} +**Assignee:** {{issue.assignee}} + +## Description + +{{issue.description}} + +## Instructions + +1. Read the issue description carefully and understand the requirements. +2. Explore the codebase to understand the relevant code and architecture. +3. Implement the changes described in the issue. +4. Write or update tests to cover the changes. +5. Run the test suite to ensure nothing is broken. +6. Commit your changes with a descriptive commit message referencing {{issue.identifier}}.`; diff --git a/apps/web/components/linear/linear-issue-watch-table.tsx b/apps/web/components/linear/linear-issue-watch-table.tsx new file mode 100644 index 0000000000..96de193788 --- /dev/null +++ b/apps/web/components/linear/linear-issue-watch-table.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { IconTrash, IconRefresh, IconPlayerPlay, IconPlayerPause } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Badge } from "@kandev/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; +import type { LinearIssueWatch, LinearSearchFilter } from "@/lib/types/linear"; + +type LinearIssueWatchTableProps = { + watches: LinearIssueWatch[]; + // showWorkspace renders a Workspace column when the table aggregates rows + // from every workspace (install-wide settings page). + showWorkspace?: boolean; + onEdit: (watch: LinearIssueWatch) => void; + onDelete: (id: string) => void; + onTrigger: (id: string) => void; + onToggleEnabled: (watch: LinearIssueWatch) => void; +}; + +function formatLastPolled(dateStr?: string | null): string { + if (!dateStr) return "Never"; + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +// summarizeFilter renders the structured filter as a short tag-style label +// the user can scan at a glance. Falls back to "(any)" when somehow empty — +// the backend rejects empty filters at create/update time, so this is just +// defensive for forward-compat. +function summarizeFilter(filter: LinearSearchFilter | undefined): string { + if (!filter) return "(any)"; + const parts: string[] = []; + if (filter.teamKey) parts.push(`team:${filter.teamKey}`); + if (filter.stateIds && filter.stateIds.length > 0) { + parts.push(`state:${filter.stateIds.length} selected`); + } + if (filter.assigned) parts.push(`assigned:${filter.assigned}`); + if (filter.query) parts.push(`q:"${filter.query}"`); + return parts.length === 0 ? "(any)" : parts.join(" · "); +} + +function WatchActions({ + watch, + onToggleEnabled, + onTrigger, + onDelete, +}: { + watch: LinearIssueWatch; + onToggleEnabled: (watch: LinearIssueWatch) => void; + onTrigger: (id: string) => void; + onDelete: (id: string) => void; +}) { + return ( +
+ + + + + {watch.enabled ? "Pause" : "Enable"} + + + + + + Check now + + + + + + Delete + +
+ ); +} + +export function LinearIssueWatchTable({ + watches, + showWorkspace, + onEdit, + onDelete, + onTrigger, + onToggleEnabled, +}: LinearIssueWatchTableProps) { + const workspaces = useAppStore((s) => s.workspaces.items); + const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; + + if (watches.length === 0) { + return ( +

+ No Linear watchers configured. Create one to auto-create tasks from filtered issues. +

+ ); + } + + return ( + + + + {showWorkspace && Workspace} + Filter + Interval + Last Polled + Status + Actions + + + + {watches.map((watch) => ( + onEdit(watch)}> + {showWorkspace && ( + + {workspaceName(watch.workspaceId)} + + )} + + {summarizeFilter(watch.filter)} + + + {Math.round(watch.pollIntervalSeconds / 60)}m + + + {formatLastPolled(watch.lastPolledAt)} + + + + {watch.enabled ? "Active" : "Paused"} + + + + + + + ))} + +
+ ); +} diff --git a/apps/web/components/linear/linear-issue-watchers-section.tsx b/apps/web/components/linear/linear-issue-watchers-section.tsx new file mode 100644 index 0000000000..8c627cb742 --- /dev/null +++ b/apps/web/components/linear/linear-issue-watchers-section.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { IconBellRinging, IconPlus } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useToast } from "@/components/toast-provider"; +import { useLinearIssueWatches } from "@/hooks/domains/linear/use-linear-issue-watches"; +import { LinearIssueWatchTable } from "./linear-issue-watch-table"; +import { LinearIssueWatchDialog } from "./linear-issue-watch-dialog"; +import type { LinearIssueWatch } from "@/lib/types/linear"; + +// LinearIssueWatchersSection lists watches across every workspace in a single +// flat table on the install-wide settings page. The dialog's create flow asks +// the user to pick the workspace; per-row mutations forward each watch's +// stored workspaceId so the backend's IDOR guard accepts them. +type RawActions = { + create: ReturnType["create"]; + update: ReturnType["update"]; + remove: ReturnType["remove"]; + trigger: ReturnType["trigger"]; +}; + +function useToastedActions({ create, update, remove, trigger }: RawActions) { + const { toast } = useToast(); + + const wrappedCreate = useCallback( + async (req: Parameters[0]) => { + try { + await create(req); + toast({ description: "Watcher created", variant: "success" }); + } catch (err) { + toast({ description: `Create failed: ${String(err)}`, variant: "error" }); + throw err; + } + }, + [create, toast], + ); + + const wrappedUpdate = useCallback( + async (id: string, req: Parameters[1], rowWorkspaceId: string) => { + try { + await update(id, req, rowWorkspaceId); + toast({ description: "Watcher updated", variant: "success" }); + } catch (err) { + toast({ description: `Update failed: ${String(err)}`, variant: "error" }); + throw err; + } + }, + [update, toast], + ); + + const wrappedDelete = useCallback( + async (w: LinearIssueWatch) => { + if (!confirm("Delete this Linear watcher?")) return; + try { + await remove(w.id, w.workspaceId); + toast({ description: "Watcher deleted", variant: "success" }); + } catch (err) { + toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); + } + }, + [remove, toast], + ); + + const wrappedTrigger = useCallback( + async (w: LinearIssueWatch) => { + try { + const res = await trigger(w.id, w.workspaceId); + const n = res?.newIssues ?? 0; + const description = + n > 0 ? `Found ${n} new issue(s) — tasks will appear shortly.` : "No new issues matched."; + toast({ description, variant: "success" }); + } catch (err) { + toast({ description: `Check failed: ${String(err)}`, variant: "error" }); + } + }, + [trigger, toast], + ); + + const toggleEnabled = useCallback( + async (w: LinearIssueWatch) => { + try { + await update(w.id, { enabled: !w.enabled }, w.workspaceId); + } catch (err) { + toast({ description: `Toggle failed: ${String(err)}`, variant: "error" }); + } + }, + [update, toast], + ); + + return { + create: wrappedCreate, + update: wrappedUpdate, + remove: wrappedDelete, + trigger: wrappedTrigger, + toggleEnabled, + }; +} + +export function LinearIssueWatchersSection() { + const { items, loading, create, update, remove, trigger } = useLinearIssueWatches(); + const actions = useToastedActions({ create, update, remove, trigger }); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + + const openCreate = useCallback(() => { + setEditing(null); + setDialogOpen(true); + }, []); + const openEdit = useCallback((w: LinearIssueWatch) => { + setEditing(w); + setDialogOpen(true); + }, []); + + // Adapt the watch-aware actions back to id-keyed callbacks the table expects; + // the table looks up the watch by id when it needs to forward the per-row + // workspaceId to mutations. + const handleDelete = useCallback( + (id: string) => { + const w = items.find((item) => item.id === id); + if (w) actions.remove(w); + }, + [items, actions], + ); + const handleTrigger = useCallback( + (id: string) => { + const w = items.find((item) => item.id === id); + if (w) actions.trigger(w); + }, + [items, actions], + ); + + return ( + } + title="Linear watchers" + description="Poll a Linear filter and auto-create a Kandev task for each newly-matching issue." + action={ + + } + > + + + {loading && items.length === 0 ? ( +

Loading…

+ ) : ( + + )} +
+
+ { + const w = editing; + if (!w) throw new Error("update without editing watch"); + return actions.update(id, req, w.workspaceId); + }} + /> +
+ ); +} diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx index 7ca805f299..d7dcc35cfa 100644 --- a/apps/web/components/linear/linear-settings.tsx +++ b/apps/web/components/linear/linear-settings.tsx @@ -26,6 +26,7 @@ import { listLinearTeams, } from "@/lib/api/domains/linear-api"; import type { LinearConfig, LinearTeam, TestLinearConnectionResult } from "@/lib/types/linear"; +import { LinearIssueWatchersSection } from "./linear-issue-watchers-section"; type FormState = { defaultTeamKey: string; @@ -432,6 +433,7 @@ export function LinearIntegrationPage() { return (
+
); } diff --git a/apps/web/hooks/domains/linear/use-linear-issue-watches.ts b/apps/web/hooks/domains/linear/use-linear-issue-watches.ts new file mode 100644 index 0000000000..85eb5163c0 --- /dev/null +++ b/apps/web/hooks/domains/linear/use-linear-issue-watches.ts @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useCallback, useRef } from "react"; +import { + listLinearIssueWatches, + createLinearIssueWatch, + updateLinearIssueWatch, + deleteLinearIssueWatch, + triggerLinearIssueWatch, +} from "@/lib/api/domains/linear-api"; +import { useAppStore } from "@/components/state-provider"; +import type { CreateLinearIssueWatchInput, UpdateLinearIssueWatchInput } from "@/lib/types/linear"; + +/** + * useLinearIssueWatches owns the Linear-watcher list: + * - workspaceId: string → fetch and operate on watches in one workspace + * - workspaceId: undefined → fetch every watch across all workspaces; the + * caller supplies workspaceId to update/remove/trigger + * calls per-row (those endpoints still validate it + * against the watch's stored workspace as an IDOR guard) + * - workspaceId: null → don't fetch + * + * Mirrors `useJiraIssueWatches`. Workspace changes reset the cached list so + * the user doesn't see the previous workspace's stale rows during the swap. + */ +export function useLinearIssueWatches(workspaceId?: string | null) { + const items = useAppStore((s) => s.linearIssueWatches.items); + const loaded = useAppStore((s) => s.linearIssueWatches.loaded); + const loading = useAppStore((s) => s.linearIssueWatches.loading); + const setWatches = useAppStore((s) => s.setLinearIssueWatches); + const resetWatches = useAppStore((s) => s.resetLinearIssueWatches); + const setLoading = useAppStore((s) => s.setLinearIssueWatchesLoading); + const addWatch = useAppStore((s) => s.addLinearIssueWatch); + const updateWatch = useAppStore((s) => s.updateLinearIssueWatch); + const removeWatch = useAppStore((s) => s.removeLinearIssueWatch); + + const lastScope = useRef(undefined); + const scope: string | null = workspaceId ?? null; + + useEffect(() => { + if (workspaceId === null) return; + if (lastScope.current !== undefined && lastScope.current !== scope) { + resetWatches(); + } + lastScope.current = scope; + }, [workspaceId, scope, resetWatches]); + + useEffect(() => { + if (workspaceId === null || loaded || loading) return; + setLoading(true); + listLinearIssueWatches(workspaceId ?? undefined, { cache: "no-store" }) + .then((res) => setWatches(res ?? [])) + .catch(() => setWatches([])) + .finally(() => setLoading(false)); + }, [workspaceId, loaded, loading, setWatches, setLoading]); + + const create = useCallback( + async (req: CreateLinearIssueWatchInput) => { + const watch = await createLinearIssueWatch(req); + addWatch(watch); + return watch; + }, + [addWatch], + ); + + // Per-row mutations require the row's own workspace_id to satisfy the + // backend's IDOR guard. Callers pass it explicitly when the hook itself + // wasn't bound to a single workspace (the install-wide listing case). + const update = useCallback( + async (id: string, req: UpdateLinearIssueWatchInput, rowWorkspaceId?: string) => { + const ws = rowWorkspaceId ?? workspaceId; + if (!ws) throw new Error("workspaceId required"); + const watch = await updateLinearIssueWatch(ws, id, req); + updateWatch(watch); + return watch; + }, + [workspaceId, updateWatch], + ); + + const remove = useCallback( + async (id: string, rowWorkspaceId?: string) => { + const ws = rowWorkspaceId ?? workspaceId; + if (!ws) throw new Error("workspaceId required"); + await deleteLinearIssueWatch(ws, id); + removeWatch(id); + }, + [workspaceId, removeWatch], + ); + + const trigger = useCallback( + async (id: string, rowWorkspaceId?: string) => { + const ws = rowWorkspaceId ?? workspaceId; + if (!ws) throw new Error("workspaceId required"); + return triggerLinearIssueWatch(ws, id); + }, + [workspaceId], + ); + + return { items, loaded, loading, create, update, remove, trigger }; +} diff --git a/apps/web/lib/api/domains/linear-api.test.ts b/apps/web/lib/api/domains/linear-api.test.ts index f4a3fc6624..e4e409915e 100644 --- a/apps/web/lib/api/domains/linear-api.test.ts +++ b/apps/web/lib/api/domains/linear-api.test.ts @@ -7,15 +7,20 @@ vi.mock("@/lib/config", () => ({ })); import { + createLinearIssueWatch, deleteLinearConfig, + deleteLinearIssueWatch, getLinearConfig, getLinearIssue, + listLinearIssueWatches, listLinearStates, listLinearTeams, searchLinearIssues, setLinearConfig, setLinearIssueState, testLinearConnection, + triggerLinearIssueWatch, + updateLinearIssueWatch, } from "./linear-api"; const BASE = "http://api.test/api/v1/linear"; @@ -173,3 +178,82 @@ describe("setLinearIssueState", () => { expect(JSON.parse(String(init?.body))).toEqual({ stateId: "state-id-123" }); }); }); + +// Issue watches: workspace_id is a query param on list/update/delete/trigger +// but absent on create — exactly the kind of subtle URL-construction +// difference that's worth catching at the API client boundary. +describe("listLinearIssueWatches", () => { + it("hits /watches/issue with no query param when workspaceId omitted", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ watches: [] })); + await listLinearIssueWatches(); + expect(lastCall().url).toBe(`${BASE}/watches/issue`); + }); + + it("appends workspace_id query param when provided", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ watches: [] })); + await listLinearIssueWatches("ws-1"); + expect(lastCall().url).toBe(`${BASE}/watches/issue?workspace_id=ws-1`); + }); + + it("returns an empty array when the response has no watches field", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({})); + await expect(listLinearIssueWatches()).resolves.toEqual([]); + }); +}); + +describe("createLinearIssueWatch", () => { + it("POSTs to /watches/issue without workspace_id in the URL", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "w1" })); + await createLinearIssueWatch({ + workspaceId: "ws-1", + workflowId: "wf-1", + workflowStepId: "step-1", + filter: { teamKey: "ENG" }, + }); + const { url, init } = lastCall(); + expect(url).toBe(`${BASE}/watches/issue`); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + workspaceId: "ws-1", + filter: { teamKey: "ENG" }, + }); + }); +}); + +describe("updateLinearIssueWatch", () => { + it("PATCHes /watches/issue/:id with workspace_id query param", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "w1" })); + await updateLinearIssueWatch("ws-1", "w1", { enabled: false }); + const { url, init } = lastCall(); + expect(url).toBe(`${BASE}/watches/issue/w1?workspace_id=ws-1`); + expect(init?.method).toBe("PATCH"); + expect(JSON.parse(String(init?.body))).toEqual({ enabled: false }); + }); + + it("URL-encodes both id and workspace_id", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "w/1" })); + await updateLinearIssueWatch("ws/space", "w/1", {}); + expect(lastCall().url).toBe(`${BASE}/watches/issue/w%2F1?workspace_id=ws%2Fspace`); + }); +}); + +describe("deleteLinearIssueWatch", () => { + it("issues DELETE on /watches/issue/:id with workspace_id query param", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ deleted: true })); + await deleteLinearIssueWatch("ws-1", "w1"); + const { url, init } = lastCall(); + expect(url).toBe(`${BASE}/watches/issue/w1?workspace_id=ws-1`); + expect(init?.method).toBe("DELETE"); + }); +}); + +describe("triggerLinearIssueWatch", () => { + it("POSTs to /watches/issue/:id/trigger with workspace_id query param", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ newIssues: 3 })); + const res = await triggerLinearIssueWatch("ws-1", "w1"); + const { url, init } = lastCall(); + expect(url).toBe(`${BASE}/watches/issue/w1/trigger?workspace_id=ws-1`); + expect(init?.method).toBe("POST"); + expect(res.newIssues).toBe(3); + }); +}); diff --git a/apps/web/lib/api/domains/linear-api.ts b/apps/web/lib/api/domains/linear-api.ts index 8de4c09e8b..702b488465 100644 --- a/apps/web/lib/api/domains/linear-api.ts +++ b/apps/web/lib/api/domains/linear-api.ts @@ -1,13 +1,16 @@ import { fetchJson, type ApiRequestOptions } from "../client"; import type { + CreateLinearIssueWatchInput, LinearConfig, LinearIssue, + LinearIssueWatch, LinearSearchFilter, LinearSearchResult, LinearTeam, LinearWorkflowState, SetLinearConfigRequest, TestLinearConnectionResult, + UpdateLinearIssueWatchInput, } from "@/lib/types/linear"; // getLinearConfig returns null when the backend responds 204 (no config yet). @@ -90,3 +93,65 @@ export async function setLinearIssueState( }, ); } + +// --- Issue watches --- + +// listLinearIssueWatches fetches watches across all workspaces when +// workspaceId is omitted, or scoped to one workspace when provided. +export async function listLinearIssueWatches(workspaceId?: string, options?: ApiRequestOptions) { + const path = workspaceId + ? `/api/v1/linear/watches/issue?workspace_id=${encodeURIComponent(workspaceId)}` + : `/api/v1/linear/watches/issue`; + const res = await fetchJson<{ watches: LinearIssueWatch[] }>(path, options); + return res.watches ?? []; +} + +export async function createLinearIssueWatch( + payload: CreateLinearIssueWatchInput, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/linear/watches/issue`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, + }); +} + +// All mutation/trigger endpoints require `workspace_id` so the backend can +// reject cross-workspace IDOR. + +export async function updateLinearIssueWatch( + workspaceId: string, + id: string, + payload: UpdateLinearIssueWatchInput, + options?: ApiRequestOptions, +) { + return fetchJson( + `/api/v1/linear/watches/issue/${encodeURIComponent(id)}?workspace_id=${encodeURIComponent(workspaceId)}`, + { + ...options, + init: { ...(options?.init ?? {}), method: "PATCH", body: JSON.stringify(payload) }, + }, + ); +} + +export async function deleteLinearIssueWatch( + workspaceId: string, + id: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ deleted: boolean }>( + `/api/v1/linear/watches/issue/${encodeURIComponent(id)}?workspace_id=${encodeURIComponent(workspaceId)}`, + { ...options, init: { ...(options?.init ?? {}), method: "DELETE" } }, + ); +} + +export async function triggerLinearIssueWatch( + workspaceId: string, + id: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ newIssues: number }>( + `/api/v1/linear/watches/issue/${encodeURIComponent(id)}/trigger?workspace_id=${encodeURIComponent(workspaceId)}`, + { ...options, init: { ...(options?.init ?? {}), method: "POST" } }, + ); +} diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index 327a33681d..8126f74070 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -7,6 +7,7 @@ import { defaultUIState, defaultGitHubState, defaultJiraState, + defaultLinearState, } from "./slices"; export const defaultState = { @@ -64,6 +65,7 @@ export const defaultState = { issueWatches: defaultGitHubState.issueWatches, actionPresets: defaultGitHubState.actionPresets, jiraIssueWatches: defaultJiraState.jiraIssueWatches, + linearIssueWatches: defaultLinearState.linearIssueWatches, previewPanel: defaultUIState.previewPanel, rightPanel: defaultUIState.rightPanel, diffs: defaultUIState.diffs, @@ -148,6 +150,10 @@ export function mergeInitialState(initialState?: Partial): Default issueWatches: { ...defaultState.issueWatches, ...initialState.issueWatches }, actionPresets: { ...defaultState.actionPresets, ...initialState.actionPresets }, jiraIssueWatches: { ...defaultState.jiraIssueWatches, ...initialState.jiraIssueWatches }, + linearIssueWatches: { + ...defaultState.linearIssueWatches, + ...initialState.linearIssueWatches, + }, previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel }, rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel }, diffs: { ...defaultState.diffs, ...initialState.diffs }, diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index 9bae93e8a8..84e93dd633 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -10,6 +10,7 @@ export { export { createUISlice, defaultUIState } from "./ui/ui-slice"; export { createGitHubSlice, defaultGitHubState } from "./github/github-slice"; export { createJiraSlice, defaultJiraState } from "./jira/jira-slice"; +export { createLinearSlice, defaultLinearState } from "./linear/linear-slice"; // Export types export type { KanbanSlice, KanbanSliceState, KanbanSliceActions } from "./kanban/types"; @@ -29,6 +30,12 @@ export type { JiraSliceActions, JiraIssueWatchesState, } from "./jira/types"; +export type { + LinearSlice, + LinearSliceState, + LinearSliceActions, + LinearIssueWatchesState, +} from "./linear/types"; // Re-export commonly used types from each domain export type { diff --git a/apps/web/lib/state/slices/linear/linear-slice.test.ts b/apps/web/lib/state/slices/linear/linear-slice.test.ts new file mode 100644 index 0000000000..85f0b23f37 --- /dev/null +++ b/apps/web/lib/state/slices/linear/linear-slice.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { createLinearSlice } from "./linear-slice"; +import type { LinearSlice } from "./types"; +import type { LinearIssueWatch } from "@/lib/types/linear"; + +function makeStore() { + return create()( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + immer((...a) => ({ ...(createLinearSlice as any)(...a) })), + ); +} + +function watch(id: string, overrides: Partial = {}): LinearIssueWatch { + return { + id, + workspaceId: "ws-1", + workflowId: "wf-1", + workflowStepId: "step-1", + filter: { teamKey: "ENG" }, + agentProfileId: "", + executorProfileId: "", + prompt: "", + enabled: true, + pollIntervalSeconds: 300, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("linear issue-watches slice", () => { + it("starts empty and not loaded", () => { + const store = makeStore(); + const s = store.getState(); + expect(s.linearIssueWatches.items).toEqual([]); + expect(s.linearIssueWatches.loaded).toBe(false); + expect(s.linearIssueWatches.loading).toBe(false); + }); + + it("setLinearIssueWatches replaces items and flips loaded=true", () => { + const store = makeStore(); + store.getState().setLinearIssueWatches([watch("a"), watch("b")]); + const s = store.getState(); + expect(s.linearIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]); + expect(s.linearIssueWatches.loaded).toBe(true); + }); + + it("setLinearIssueWatchesLoading toggles loading independently", () => { + const store = makeStore(); + store.getState().setLinearIssueWatchesLoading(true); + expect(store.getState().linearIssueWatches.loading).toBe(true); + store.getState().setLinearIssueWatchesLoading(false); + expect(store.getState().linearIssueWatches.loading).toBe(false); + }); + + it("addLinearIssueWatch appends a new entry", () => { + const store = makeStore(); + store.getState().setLinearIssueWatches([watch("a")]); + store.getState().addLinearIssueWatch(watch("b")); + expect(store.getState().linearIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]); + }); + + it("updateLinearIssueWatch replaces in place by id; missing id is a no-op", () => { + const store = makeStore(); + store + .getState() + .setLinearIssueWatches([watch("a", { filter: { teamKey: "OLD" } }), watch("b")]); + store.getState().updateLinearIssueWatch(watch("a", { filter: { teamKey: "NEW" } })); + expect(store.getState().linearIssueWatches.items[0].filter.teamKey).toBe("NEW"); + + store.getState().updateLinearIssueWatch(watch("ghost", { filter: { teamKey: "X" } })); + expect(store.getState().linearIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]); + }); + + it("removeLinearIssueWatch filters by id", () => { + const store = makeStore(); + store.getState().setLinearIssueWatches([watch("a"), watch("b"), watch("c")]); + store.getState().removeLinearIssueWatch("b"); + expect(store.getState().linearIssueWatches.items.map((w) => w.id)).toEqual(["a", "c"]); + }); + + it("resetLinearIssueWatches clears items AND loaded so a refetch is triggered", () => { + // The whole point of this action vs. setLinearIssueWatches([]) — an empty + // setWatches keeps loaded=true and would block the fetch effect from + // re-running on workspace switch. + const store = makeStore(); + store.getState().setLinearIssueWatches([watch("a")]); + expect(store.getState().linearIssueWatches.loaded).toBe(true); + + store.getState().resetLinearIssueWatches(); + expect(store.getState().linearIssueWatches.items).toEqual([]); + expect(store.getState().linearIssueWatches.loaded).toBe(false); + }); +}); diff --git a/apps/web/lib/state/slices/linear/linear-slice.ts b/apps/web/lib/state/slices/linear/linear-slice.ts new file mode 100644 index 0000000000..03d6dc1086 --- /dev/null +++ b/apps/web/lib/state/slices/linear/linear-slice.ts @@ -0,0 +1,48 @@ +import type { StateCreator } from "zustand"; +import type { LinearSlice, LinearSliceState } from "./types"; + +export const defaultLinearState: LinearSliceState = { + linearIssueWatches: { items: [], loaded: false, loading: false }, +}; + +type ImmerSet = Parameters< + StateCreator +>[0]; + +export const createLinearSlice: StateCreator< + LinearSlice, + [["zustand/immer", never]], + [], + LinearSlice +> = (set: ImmerSet) => ({ + ...defaultLinearState, + setLinearIssueWatches: (watches) => + set((draft) => { + draft.linearIssueWatches.items = watches; + draft.linearIssueWatches.loaded = true; + }), + setLinearIssueWatchesLoading: (loading) => + set((draft) => { + draft.linearIssueWatches.loading = loading; + }), + addLinearIssueWatch: (watch) => + set((draft) => { + draft.linearIssueWatches.items.push(watch); + }), + updateLinearIssueWatch: (watch) => + set((draft) => { + const idx = draft.linearIssueWatches.items.findIndex((w) => w.id === watch.id); + if (idx >= 0) { + draft.linearIssueWatches.items[idx] = watch; + } + }), + removeLinearIssueWatch: (id) => + set((draft) => { + draft.linearIssueWatches.items = draft.linearIssueWatches.items.filter((w) => w.id !== id); + }), + resetLinearIssueWatches: () => + set((draft) => { + draft.linearIssueWatches.items = []; + draft.linearIssueWatches.loaded = false; + }), +}); diff --git a/apps/web/lib/state/slices/linear/types.ts b/apps/web/lib/state/slices/linear/types.ts new file mode 100644 index 0000000000..4412aa27b7 --- /dev/null +++ b/apps/web/lib/state/slices/linear/types.ts @@ -0,0 +1,27 @@ +import type { LinearIssueWatch } from "@/lib/types/linear"; + +export type LinearIssueWatchesState = { + items: LinearIssueWatch[]; + loaded: boolean; + loading: boolean; +}; + +export type LinearSliceState = { + linearIssueWatches: LinearIssueWatchesState; +}; + +export type LinearSliceActions = { + setLinearIssueWatches: (watches: LinearIssueWatch[]) => void; + setLinearIssueWatchesLoading: (loading: boolean) => void; + addLinearIssueWatch: (watch: LinearIssueWatch) => void; + updateLinearIssueWatch: (watch: LinearIssueWatch) => void; + removeLinearIssueWatch: (id: string) => void; + /** + * Clears items AND `loaded` so the next fetch effect runs again. Distinct + * from `setLinearIssueWatches([])`, which marks the empty list as loaded + * and would prevent a refetch on workspace switch. + */ + resetLinearIssueWatches: () => void; +}; + +export type LinearSlice = LinearSliceState & LinearSliceActions; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index c8944b6f2e..5b968cbd49 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -29,6 +29,7 @@ import { createUISlice, createGitHubSlice, createJiraSlice, + createLinearSlice, defaultKanbanState, defaultWorkspaceState, defaultSettingsState, @@ -37,6 +38,7 @@ import { defaultUIState, defaultGitHubState, defaultJiraState, + defaultLinearState, type WorkspaceState, type WorkflowsState, type ExecutorsState, @@ -139,8 +141,13 @@ export type { JiraSliceState, JiraSliceActions, JiraIssueWatchesState, + LinearSlice, + LinearSliceState, + LinearSliceActions, + LinearIssueWatchesState, } from "./slices"; import type { JiraIssueWatch } from "@/lib/types/jira"; +import type { LinearIssueWatch } from "@/lib/types/linear"; // Combined AppState type export type AppState = { @@ -213,6 +220,9 @@ export type AppState = { // JIRA slice jiraIssueWatches: (typeof defaultJiraState)["jiraIssueWatches"]; + // Linear slice + linearIssueWatches: (typeof defaultLinearState)["linearIssueWatches"]; + // UI slice previewPanel: (typeof defaultUIState)["previewPanel"]; rightPanel: (typeof defaultUIState)["rightPanel"]; @@ -260,6 +270,14 @@ export type AppState = { removeJiraIssueWatch: (id: string) => void; resetJiraIssueWatches: () => void; + // Linear actions + setLinearIssueWatches: (watches: LinearIssueWatch[]) => void; + setLinearIssueWatchesLoading: (loading: boolean) => void; + addLinearIssueWatch: (watch: LinearIssueWatch) => void; + updateLinearIssueWatch: (watch: LinearIssueWatch) => void; + removeLinearIssueWatch: (id: string) => void; + resetLinearIssueWatches: () => void; + // Actions from all slices hydrate: (state: Partial, options?: HydrationOptions) => void; setActiveWorkspace: (workspaceId: string | null) => void; @@ -506,6 +524,8 @@ export function createAppStore(initialState?: Partial) { // eslint-disable-next-line @typescript-eslint/no-explicit-any ...createJiraSlice(set as any, get as any, api as any), // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...createLinearSlice(set as any, get as any, api as any), + // eslint-disable-next-line @typescript-eslint/no-explicit-any ...createUISlice(set as any, get as any, api as any), // Override state with merged initial state kanban: merged.kanban, @@ -558,6 +578,7 @@ export function createAppStore(initialState?: Partial) { issueWatches: merged.issueWatches, actionPresets: merged.actionPresets, jiraIssueWatches: merged.jiraIssueWatches, + linearIssueWatches: merged.linearIssueWatches, previewPanel: merged.previewPanel, rightPanel: merged.rightPanel, diffs: merged.diffs, diff --git a/apps/web/lib/types/linear.ts b/apps/web/lib/types/linear.ts index 12f0792751..73ce0613bd 100644 --- a/apps/web/lib/types/linear.ts +++ b/apps/web/lib/types/linear.ts @@ -89,3 +89,49 @@ export interface LinearSearchResult { isLast: boolean; nextPageToken?: string; } + +/** + * A workspace-scoped Linear poller. The backend re-evaluates the structured + * filter on `pollIntervalSeconds` cadence and creates a Kandev task in the + * configured workflow step for each newly-matching issue. + */ +export interface LinearIssueWatch { + id: string; + workspaceId: string; + workflowId: string; + workflowStepId: string; + filter: LinearSearchFilter; + agentProfileId: string; + executorProfileId: string; + prompt: string; + enabled: boolean; + pollIntervalSeconds: number; + /** Last poll timestamp, or null when the watch has never run. */ + lastPolledAt?: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateLinearIssueWatchInput { + workspaceId: string; + workflowId: string; + workflowStepId: string; + filter: LinearSearchFilter; + agentProfileId?: string; + executorProfileId?: string; + prompt?: string; + pollIntervalSeconds?: number; + enabled?: boolean; +} + +/** Patch shape: every field is optional so the UI can change one knob at a time. */ +export interface UpdateLinearIssueWatchInput { + workflowId?: string; + workflowStepId?: string; + filter?: LinearSearchFilter; + agentProfileId?: string; + executorProfileId?: string; + prompt?: string; + enabled?: boolean; + pollIntervalSeconds?: number; +} From ba32a44cb4e8c75328364e5a82f8b9ee306162bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 5 May 2026 08:29:46 +0100 Subject: [PATCH 08/33] refactor(backend): drop legacy orchestrator WS handlers superseded by session.launch (#803) * refactor(backend): drop legacy orchestrator WS handlers superseded by session.launch The orchestrator exposed six WS handlers (orchestrator.start, orchestrator.trigger, orchestrator.prompt, orchestrator.complete, task.session.resume, task.session.prepare) that all delegated to the unified LaunchSession or are no longer reachable from any client. Drop the handlers, their DTOs, and the dead action constants (orchestrator.pause, orchestrator.resume) that were never registered. Migrate the remaining integration tests to the unified session.launch action. * refactor(backend): drop orphan DTO request types and fix stale doc reference Address PR review feedback: the DTO types TaskSessionStatusRequest, StopTaskRequest, and PermissionRespondRequest had no consumers (each was shadowed by an inline private ws*Request type in the handler). AGENTS.md still referenced the removed task.start action; switch it to session.launch. --------- Co-authored-by: Kandev Agent --- AGENTS.md | 2 +- .../integration/cancel_after_crash_test.go | 4 +- .../integration/orchestrator_advanced_test.go | 143 +----------- .../integration/orchestrator_basic_test.go | 6 +- .../orchestrator_interaction_test.go | 157 +------------ apps/backend/internal/orchestrator/dto/dto.go | 100 -------- .../orchestrator/handlers/handlers.go | 220 ------------------ apps/backend/pkg/websocket/actions.go | 18 +- 8 files changed, 24 insertions(+), 626 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0ef8c64649..2a43ef2e58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,7 +162,7 @@ Client (WS) → Orchestrator → Lifecycle Manager → ExecutorBackend (containe Client (WS) ← Orchestrator ← Lifecycle Manager ←──── stream updates (WS) ──────── agent subprocess ``` -1. Orchestrator receives `task.start` via WS +1. Orchestrator receives `session.launch` via WS 2. Lifecycle Manager creates executor instance (container or process) 3. agentctl starts inside the instance, agent subprocess is configured and started 4. Agent events stream back via WS through the chain diff --git a/apps/backend/internal/integration/cancel_after_crash_test.go b/apps/backend/internal/integration/cancel_after_crash_test.go index 1e8fb283f5..4c2ad4a73e 100644 --- a/apps/backend/internal/integration/cancel_after_crash_test.go +++ b/apps/backend/internal/integration/cancel_after_crash_test.go @@ -31,7 +31,7 @@ func TestOrchestratorCancelAfterAgentCrash_UnsticksSession(t *testing.T) { defer client.Close() // Start the task so a session is created and an execution is registered in the simulator. - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) @@ -110,7 +110,7 @@ func TestOrchestratorCancelWhenAgentHangs_UnsticksSession(t *testing.T) { client := NewOrchestratorWSClient(t, ts.Server.URL) defer client.Close() - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) diff --git a/apps/backend/internal/integration/orchestrator_advanced_test.go b/apps/backend/internal/integration/orchestrator_advanced_test.go index 989a874dc6..76b3c25fc3 100644 --- a/apps/backend/internal/integration/orchestrator_advanced_test.go +++ b/apps/backend/internal/integration/orchestrator_advanced_test.go @@ -30,21 +30,21 @@ func TestOrchestratorTaskPriority(t *testing.T) { defer client.Close() // Start low priority first - _, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + _, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": lowPriorityTask, "agent_profile_id": "augment-agent", }) require.NoError(t, err) // Then high priority - _, err = client.SendRequest("start-2", ws.ActionOrchestratorStart, map[string]interface{}{ + _, err = client.SendRequest("start-2", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": highPriorityTask, "agent_profile_id": "augment-agent", }) require.NoError(t, err) // Then medium priority - _, err = client.SendRequest("start-3", ws.ActionOrchestratorStart, map[string]interface{}{ + _, err = client.SendRequest("start-3", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": medPriorityTask, "agent_profile_id": "augment-agent", }) @@ -54,137 +54,6 @@ func TestOrchestratorTaskPriority(t *testing.T) { assert.Equal(t, 3, ts.AgentManager.GetLaunchCount()) } -func TestOrchestratorTriggerTask(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - resp, err := client.SendRequest("trigger-1", ws.ActionOrchestratorTrigger, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeResponse, resp.Type) - - var payload map[string]interface{} - require.NoError(t, resp.ParsePayload(&payload)) - assert.True(t, payload["success"].(bool)) - assert.Equal(t, taskID, payload["task_id"]) -} - -func TestOrchestratorEndToEndWorkflow(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - workspaceID := createOrchestratorWorkspace(t, client) - - // 1. Create workflow with workflow template - workflowResp, err := client.SendRequest("workflow-1", ws.ActionWorkflowCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "E2E Test Workflow", - "description": "End-to-end test workflow", - "workflow_template_id": "simple", - }) - require.NoError(t, err) - - var workflowPayload map[string]interface{} - require.NoError(t, workflowResp.ParsePayload(&workflowPayload)) - workflowID := workflowPayload["id"].(string) - - // 2. Get first workflow step from workflow - stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{ - "workflow_id": workflowID, - }) - require.NoError(t, err) - - var stepListPayload map[string]interface{} - require.NoError(t, stepResp.ParsePayload(&stepListPayload)) - steps := stepListPayload["steps"].([]interface{}) - require.NotEmpty(t, steps, "workflow should have workflow steps") - workflowStepID := steps[0].(map[string]interface{})["id"].(string) - - // 3. Create task with workflow step - repoResp, err := client.SendRequest("repo-1", ws.ActionRepositoryCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "name": "Test Repo", - "source_type": "local", - "local_path": createTempRepoDir(t), - }) - require.NoError(t, err) - - var repoPayload map[string]interface{} - require.NoError(t, repoResp.ParsePayload(&repoPayload)) - repositoryID := repoPayload["id"].(string) - - taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ - "workspace_id": workspaceID, - "workflow_id": workflowID, - "workflow_step_id": workflowStepID, - "title": "Implement feature X", - "description": "Create a new feature with tests", - "priority": 2, - "repository_id": repositoryID, - "base_branch": "main", - }) - require.NoError(t, err) - - var taskPayload map[string]interface{} - require.NoError(t, taskResp.ParsePayload(&taskPayload)) - taskID := taskPayload["id"].(string) - - // 4. Subscribe to task - _, err = client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - // 5. Start orchestrator - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "test-profile-id", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - assert.True(t, startPayload["success"].(bool)) - agentExecutionID := startPayload["agent_execution_id"].(string) - assert.NotEmpty(t, agentExecutionID) - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID) - - _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - - // 6. Collect notifications during execution - time.Sleep(400 * time.Millisecond) - notifications := client.CollectNotifications(200 * time.Millisecond) - t.Logf("Received %d notifications", len(notifications)) - - // 7. Complete the task - completeResp, err := client.SendRequest("complete-1", ws.ActionOrchestratorComplete, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - - var completePayload map[string]interface{} - require.NoError(t, completeResp.ParsePayload(&completePayload)) - assert.True(t, completePayload["success"].(bool)) - - // 8. Verify final task state - getResp, err := client.SendRequest("get-1", ws.ActionTaskGet, map[string]string{"id": taskID}) - require.NoError(t, err) - - var getPayload map[string]interface{} - require.NoError(t, getResp.ParsePayload(&getPayload)) - assert.Equal(t, "COMPLETED", getPayload["state"].(string)) -} - // TestOrchestratorAgentMessagePersistence validates that agent messages are stored // correctly in the database without missing data or ordering issues. func TestOrchestratorAgentMessagePersistence(t *testing.T) { @@ -223,7 +92,7 @@ func TestOrchestratorAgentMessagePersistence(t *testing.T) { require.NoError(t, err) // Start task execution - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "test-profile-id", }) @@ -462,7 +331,7 @@ func TestOrchestratorAgentMessagePersistenceWithToolCalls(t *testing.T) { require.NoError(t, err) // Start task execution - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "test-profile-id", }) @@ -630,7 +499,7 @@ func TestOrchestratorAgentMessageChunkStreaming(t *testing.T) { _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) require.NoError(t, err) - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "test-profile-id", }) diff --git a/apps/backend/internal/integration/orchestrator_basic_test.go b/apps/backend/internal/integration/orchestrator_basic_test.go index 10720e2597..4c96e9f5a5 100644 --- a/apps/backend/internal/integration/orchestrator_basic_test.go +++ b/apps/backend/internal/integration/orchestrator_basic_test.go @@ -69,7 +69,7 @@ func TestOrchestratorStartTask(t *testing.T) { assert.Equal(t, ws.MessageTypeResponse, subResp.Type) // Start task execution - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + resp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "test-profile-id", }) @@ -111,7 +111,7 @@ func TestOrchestratorStartTaskWithAgentTypeOverride(t *testing.T) { defer client.Close() // Start with different agent profile - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + resp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "override-profile-id", "priority": 3, @@ -134,7 +134,7 @@ func TestOrchestratorStopTask(t *testing.T) { defer client.Close() // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) diff --git a/apps/backend/internal/integration/orchestrator_interaction_test.go b/apps/backend/internal/integration/orchestrator_interaction_test.go index b95157d2c0..bd8686d7da 100644 --- a/apps/backend/internal/integration/orchestrator_interaction_test.go +++ b/apps/backend/internal/integration/orchestrator_interaction_test.go @@ -2,7 +2,6 @@ package integration import ( - "context" "fmt" "sync" "testing" @@ -11,130 +10,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/pkg/acp/protocol" - v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" ) -func TestOrchestratorPromptTask(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Subscribe to task - _, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]string{"task_id": taskID}) - require.NoError(t, err) - - // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - require.True(t, startPayload["success"].(bool)) - sessionID, _ := startPayload["session_id"].(string) - require.NotEmpty(t, sessionID) - - _, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID}) - require.NoError(t, err) - - // Test 1: Verify that prompting while agent is not yet ready is rejected. - // The simulated agent takes ~310ms (50ms launch + 60ms ACP + 200ms execution) before - // publishing AgentReady, so at 100ms the session is still in CREATED state. - time.Sleep(100 * time.Millisecond) - - // Attempt to send prompt while agent is still initializing — should be rejected - promptResp1, err := client.SendRequest("prompt-rejected", ws.ActionOrchestratorPrompt, map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "prompt": "This should be rejected", - }) - require.NoError(t, err) - - // Should receive an error response (type=error with ErrorPayload) - assert.Equal(t, ws.MessageTypeError, promptResp1.Type, "Expected error response when session is not ready") - var errPayload ws.ErrorPayload - require.NoError(t, promptResp1.ParsePayload(&errPayload)) - t.Logf("Prompt rejection: code=%s message=%s", errPayload.Code, errPayload.Message) - assert.NotEmpty(t, errPayload.Message) - - // Test 2: Wait for agent to complete, then send a valid prompt - // Wait for agent to reach COMPLETED or WAITING_FOR_INPUT state - waitForSessionReady := func(timeout time.Duration) bool { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - session, err := ts.TaskRepo.GetTaskSession(context.Background(), sessionID) - if err == nil && (session.State == models.TaskSessionStateWaitingForInput || session.State == models.TaskSessionStateCompleted) { - return true - } - time.Sleep(100 * time.Millisecond) - } - return false - } - - // Wait up to 5 seconds for session to be ready - if waitForSessionReady(5 * time.Second) { - // Now send a valid prompt - promptResp2, err := client.SendRequest("prompt-valid", ws.ActionOrchestratorPrompt, map[string]interface{}{ - "task_id": taskID, - "session_id": sessionID, - "prompt": "Please continue and add error handling", - }) - require.NoError(t, err) - - var promptPayload map[string]interface{} - require.NoError(t, promptResp2.ParsePayload(&promptPayload)) - assert.True(t, promptPayload["success"].(bool), "Prompt should succeed when agent is ready") - } -} - -func TestOrchestratorCompleteTask(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ - "task_id": taskID, - "agent_profile_id": "augment-agent", - }) - require.NoError(t, err) - - var startPayload map[string]interface{} - require.NoError(t, startResp.ParsePayload(&startPayload)) - require.True(t, startPayload["success"].(bool)) - - // Wait for agent to be running - time.Sleep(200 * time.Millisecond) - - // Complete task - completeResp, err := client.SendRequest("complete-1", ws.ActionOrchestratorComplete, map[string]interface{}{ - "task_id": taskID, - }) - require.NoError(t, err) - - var completePayload map[string]interface{} - require.NoError(t, completeResp.ParsePayload(&completePayload)) - assert.True(t, completePayload["success"].(bool)) - - // Verify task state is COMPLETED - task, err := ts.TaskRepo.GetTask(context.Background(), taskID) - require.NoError(t, err) - assert.Equal(t, v1.TaskStateCompleted, task.State) -} - func TestOrchestratorConcurrentTasks(t *testing.T) { ts := NewOrchestratorTestServer(t) defer ts.Close() @@ -157,7 +36,7 @@ func TestOrchestratorConcurrentTasks(t *testing.T) { go func(idx int, tid string) { defer wg.Done() - resp, err := client.SendRequest(fmt.Sprintf("start-%d", idx), ws.ActionOrchestratorStart, map[string]interface{}{ + resp, err := client.SendRequest(fmt.Sprintf("start-%d", idx), ws.ActionSessionLaunch, map[string]interface{}{ "task_id": tid, "agent_profile_id": "augment-agent", }) @@ -256,7 +135,7 @@ func TestOrchestratorACPStreaming(t *testing.T) { require.NoError(t, err) // Start task - startResp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) @@ -302,7 +181,7 @@ func TestOrchestratorMultipleClients(t *testing.T) { require.NoError(t, err) // Client 1 starts the task - startResp, err := client1.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + startResp, err := client1.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) @@ -339,7 +218,7 @@ func TestOrchestratorErrorHandling(t *testing.T) { client := NewOrchestratorWSClient(t, ts.Server.URL) defer client.Close() - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{}) + resp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{}) require.NoError(t, err) assert.Equal(t, ws.MessageTypeError, resp.Type) @@ -357,7 +236,7 @@ func TestOrchestratorErrorHandling(t *testing.T) { client := NewOrchestratorWSClient(t, ts.Server.URL) defer client.Close() - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + resp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": "non-existent-task", "agent_profile_id": "augment-agent", }) @@ -366,28 +245,6 @@ func TestOrchestratorErrorHandling(t *testing.T) { assert.Equal(t, ws.MessageTypeError, resp.Type) }) - t.Run("PromptTaskMissingPrompt", func(t *testing.T) { - ts := NewOrchestratorTestServer(t) - defer ts.Close() - - taskID := ts.CreateTestTask(t, "augment-agent", 2) - - client := NewOrchestratorWSClient(t, ts.Server.URL) - defer client.Close() - - resp, err := client.SendRequest("prompt-1", ws.ActionOrchestratorPrompt, map[string]interface{}{ - "task_id": taskID, - "session_id": "session-1", - }) - require.NoError(t, err) - - assert.Equal(t, ws.MessageTypeError, resp.Type) - - var errPayload ws.ErrorPayload - require.NoError(t, resp.ParsePayload(&errPayload)) - assert.Contains(t, errPayload.Message, "prompt") - }) - t.Run("StopTaskNotRunning", func(t *testing.T) { ts := NewOrchestratorTestServer(t) defer ts.Close() @@ -420,7 +277,7 @@ func TestOrchestratorAgentFailure(t *testing.T) { client := NewOrchestratorWSClient(t, ts.Server.URL) defer client.Close() - resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{ + resp, err := client.SendRequest("start-1", ws.ActionSessionLaunch, map[string]interface{}{ "task_id": taskID, "agent_profile_id": "augment-agent", }) @@ -431,5 +288,5 @@ func TestOrchestratorAgentFailure(t *testing.T) { var errPayload ws.ErrorPayload require.NoError(t, resp.ParsePayload(&errPayload)) - assert.Contains(t, errPayload.Message, "Failed to start task") + assert.Contains(t, errPayload.Message, "Failed to launch session") } diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index 8c7eb8e395..ad69845f77 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -27,65 +27,6 @@ type QueueResponse struct { Total int `json:"total"` } -// TriggerTaskRequest is the payload for orchestrator.trigger -type TriggerTaskRequest struct { - TaskID string `json:"task_id"` -} - -// TriggerTaskResponse is the response for orchestrator.trigger -type TriggerTaskResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - TaskID string `json:"task_id"` -} - -// StartTaskRequest is the payload for orchestrator.start -// If SessionID is provided, the existing session is resumed and prompted with workflow step config. -// If SessionID is not provided, a new session is created (requires AgentProfileID). -type StartTaskRequest struct { - TaskID string `json:"task_id"` - SessionID string `json:"session_id,omitempty"` // If provided, resume this existing session instead of creating new - AgentProfileID string `json:"agent_profile_id,omitempty"` // Required when creating new session (SessionID not provided) - ExecutorID string `json:"executor_id,omitempty"` // Executor to use (determines runtime: local, worktree, local_docker, etc.) - Priority int `json:"priority,omitempty"` - Prompt string `json:"prompt,omitempty"` // Initial prompt to send to the agent - WorkflowStepID string `json:"workflow_step_id,omitempty"` // If provided, prompt prefix/suffix and plan mode are applied from the step -} - -// StartTaskResponse is the response for orchestrator.start -type StartTaskResponse struct { - Success bool `json:"success"` - TaskID string `json:"task_id"` - AgentExecutionID string `json:"agent_execution_id"` - TaskSessionID string `json:"session_id,omitempty"` - State string `json:"state"` - WorktreePath *string `json:"worktree_path,omitempty"` - WorktreeBranch *string `json:"worktree_branch,omitempty"` -} - -// ResumeTaskSessionRequest is the payload for task.session.resume -type ResumeTaskSessionRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` -} - -// ResumeTaskSessionResponse is the response for task.session.resume -type ResumeTaskSessionResponse struct { - Success bool `json:"success"` - TaskID string `json:"task_id"` - AgentExecutionID string `json:"agent_execution_id"` - TaskSessionID string `json:"session_id,omitempty"` - State string `json:"state"` - WorktreePath *string `json:"worktree_path,omitempty"` - WorktreeBranch *string `json:"worktree_branch,omitempty"` -} - -// TaskSessionStatusRequest is the payload for task.session.status -type TaskSessionStatusRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` -} - // TaskSessionStatusResponse is the response for task.session.status type TaskSessionStatusResponse struct { // Session metadata @@ -124,52 +65,11 @@ type TaskSessionStatusResponse struct { Error string `json:"error,omitempty"` } -// StopTaskRequest is the payload for orchestrator.stop -type StopTaskRequest struct { - TaskID string `json:"task_id"` - Reason string `json:"reason,omitempty"` - Force bool `json:"force,omitempty"` -} - // SuccessResponse is a generic success response type SuccessResponse struct { Success bool `json:"success"` } -// PromptTaskRequest is the payload for orchestrator.prompt -type PromptTaskRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` - Prompt string `json:"prompt"` - Model string `json:"model,omitempty"` // Optional: switch to this model before processing prompt - PlanMode bool `json:"plan_mode,omitempty"` // Optional: enable plan mode (analyze before executing) -} - -// PromptTaskResponse is the response for orchestrator.prompt -type PromptTaskResponse struct { - Success bool `json:"success"` - StopReason string `json:"stop_reason"` -} - -// CompleteTaskRequest is the payload for orchestrator.complete -type CompleteTaskRequest struct { - TaskID string `json:"task_id"` -} - -// CompleteTaskResponse is the response for orchestrator.complete -type CompleteTaskResponse struct { - Success bool `json:"success"` - Message string `json:"message"` -} - -// PermissionRespondRequest is the payload for permission.respond -type PermissionRespondRequest struct { - SessionID string `json:"session_id"` - PendingID string `json:"pending_id"` - OptionID string `json:"option_id,omitempty"` - Cancelled bool `json:"cancelled,omitempty"` -} - // PermissionRespondResponse is the response for permission.respond type PermissionRespondResponse struct { Success bool `json:"success"` diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index ff362d9d5f..cbed2ecf6a 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -30,15 +30,9 @@ func NewHandlers(svc *orchestrator.Service, log *logger.Logger) *Handlers { func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionOrchestratorStatus, h.wsGetStatus) d.RegisterFunc(ws.ActionOrchestratorQueue, h.wsGetQueue) - d.RegisterFunc(ws.ActionOrchestratorTrigger, h.wsTriggerTask) - d.RegisterFunc(ws.ActionOrchestratorStart, h.wsStartTask) d.RegisterFunc(ws.ActionOrchestratorStop, h.wsStopTask) - d.RegisterFunc(ws.ActionOrchestratorPrompt, h.wsPromptTask) - d.RegisterFunc(ws.ActionOrchestratorComplete, h.wsCompleteTask) d.RegisterFunc(ws.ActionPermissionRespond, h.wsRespondToPermission) - d.RegisterFunc(ws.ActionTaskSessionResume, h.wsResumeTaskSession) d.RegisterFunc(ws.ActionTaskSessionStatus, h.wsGetTaskSessionStatus) - d.RegisterFunc(ws.ActionTaskSessionPrepare, h.wsPrepareTaskSession) d.RegisterFunc(ws.ActionAgentCancel, h.wsCancelAgent) d.RegisterFunc(ws.ActionSessionLaunch, h.wsLaunchSession) d.RegisterFunc(ws.ActionSessionEnsure, h.wsEnsureSession) @@ -79,28 +73,6 @@ func (h *Handlers) wsGetQueue(ctx context.Context, msg *ws.Message) (*ws.Message return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsTriggerTaskRequest struct { - TaskID string `json:"task_id"` -} - -func (h *Handlers) wsTriggerTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsTriggerTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - - // TriggerTask is a placeholder that just returns success - resp := dto.TriggerTaskResponse{ - Success: true, - Message: "Task triggered", - TaskID: req.TaskID, - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - func (h *Handlers) wsLaunchSession(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req orchestrator.LaunchSessionRequest if err := msg.ParsePayload(&req); err != nil { @@ -208,140 +180,6 @@ func (h *Handlers) wsRecoverSession(ctx context.Context, msg *ws.Message) (*ws.M return ws.NewResponse(msg.ID, msg.Action, resp) } -type wsStartTaskRequest struct { - TaskID string `json:"task_id"` - SessionID string `json:"session_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - ExecutorProfileID string `json:"executor_profile_id,omitempty"` - Priority int `json:"priority,omitempty"` - Prompt string `json:"prompt,omitempty"` - WorkflowStepID string `json:"workflow_step_id,omitempty"` - PlanMode bool `json:"plan_mode,omitempty"` -} - -// wsStartTask delegates to LaunchSession for backward compatibility. -func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsStartTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - if req.SessionID == "" && req.AgentProfileID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required when creating a new session", nil) - } - - launchReq := &orchestrator.LaunchSessionRequest{ - TaskID: req.TaskID, - SessionID: req.SessionID, - AgentProfileID: req.AgentProfileID, - ExecutorID: req.ExecutorID, - ExecutorProfileID: req.ExecutorProfileID, - Priority: req.Priority, - Prompt: req.Prompt, - WorkflowStepID: req.WorkflowStepID, - PlanMode: req.PlanMode, - } - resp, err := h.service.LaunchSession(ctx, launchReq) - if err != nil { - h.logger.Error("failed to start task", zap.String("task_id", req.TaskID), zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start task: "+err.Error(), nil) - } - - // Map to legacy StartTaskResponse format for backward compatibility - legacyResp := dto.StartTaskResponse{ - Success: resp.Success, - TaskID: resp.TaskID, - AgentExecutionID: resp.AgentExecutionID, - TaskSessionID: resp.SessionID, - State: resp.State, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - } - return ws.NewResponse(msg.ID, msg.Action, legacyResp) -} - -type wsResumeTaskSessionRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` -} - -// wsResumeTaskSession delegates to LaunchSession with resume intent for backward compatibility. -func (h *Handlers) wsResumeTaskSession(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsResumeTaskSessionRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - if req.TaskSessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - - launchReq := &orchestrator.LaunchSessionRequest{ - TaskID: req.TaskID, - SessionID: req.TaskSessionID, - Intent: orchestrator.IntentResume, - } - resp, err := h.service.LaunchSession(ctx, launchReq) - if err != nil { - h.logger.Error("failed to resume task session", - zap.String("task_id", req.TaskID), - zap.String("session_id", req.TaskSessionID), - zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to resume task session: "+err.Error(), nil) - } - - // Map to legacy ResumeTaskSessionResponse format - legacyResp := dto.ResumeTaskSessionResponse{ - Success: resp.Success, - TaskID: resp.TaskID, - AgentExecutionID: resp.AgentExecutionID, - TaskSessionID: resp.SessionID, - State: resp.State, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - } - return ws.NewResponse(msg.ID, msg.Action, legacyResp) -} - -type wsPrepareTaskSessionRequest struct { - TaskID string `json:"task_id"` -} - -type wsPrepareTaskSessionResponse struct { - SessionID string `json:"session_id"` -} - -// wsPrepareTaskSession delegates to LaunchSession with prepare intent for backward compatibility. -func (h *Handlers) wsPrepareTaskSession(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsPrepareTaskSessionRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - - launchReq := &orchestrator.LaunchSessionRequest{ - TaskID: req.TaskID, - Intent: orchestrator.IntentPrepare, - LaunchWorkspace: true, - } - resp, err := h.service.LaunchSession(ctx, launchReq) - if err != nil { - h.logger.Error("failed to prepare task session", - zap.String("task_id", req.TaskID), - zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to prepare session: "+err.Error(), nil) - } - - return ws.NewResponse(msg.ID, msg.Action, wsPrepareTaskSessionResponse{SessionID: resp.SessionID}) -} - type wsStopTaskRequest struct { TaskID string `json:"task_id"` Reason string `json:"reason,omitempty"` @@ -368,64 +206,6 @@ func (h *Handlers) wsStopTask(ctx context.Context, msg *ws.Message) (*ws.Message return ws.NewResponse(msg.ID, msg.Action, dto.SuccessResponse{Success: true}) } -type wsPromptTaskRequest struct { - TaskID string `json:"task_id"` - TaskSessionID string `json:"session_id"` - Prompt string `json:"prompt"` - Model string `json:"model,omitempty"` -} - -func (h *Handlers) wsPromptTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsPromptTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - if req.TaskSessionID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) - } - if req.Prompt == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "prompt is required", nil) - } - - result, err := h.service.PromptTask(ctx, req.TaskID, req.TaskSessionID, req.Prompt, req.Model, false, nil, false) - if err != nil { - h.logger.Error("failed to send prompt", zap.String("task_id", req.TaskID), zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to send prompt: "+err.Error(), nil) - } - resp := dto.PromptTaskResponse{ - Success: true, - StopReason: result.StopReason, - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - -type wsCompleteTaskRequest struct { - TaskID string `json:"task_id"` -} - -func (h *Handlers) wsCompleteTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { - var req wsCompleteTaskRequest - if err := msg.ParsePayload(&req); err != nil { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) - } - if req.TaskID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) - } - - if err := h.service.CompleteTask(ctx, req.TaskID); err != nil { - h.logger.Error("failed to complete task", zap.String("task_id", req.TaskID), zap.Error(err)) - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to complete task: "+err.Error(), nil) - } - resp := dto.CompleteTaskResponse{ - Success: true, - Message: "task completed", - } - return ws.NewResponse(msg.ID, msg.Action, resp) -} - type wsPermissionRespondRequest struct { SessionID string `json:"session_id"` PendingID string `json:"pending_id"` diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 78f5aa4f64..ee212f9a34 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -73,10 +73,8 @@ const ( ActionTaskPlanRevisionGet = "task.plan.revision.get" ActionTaskPlanRevert = "task.plan.revert" - ActionTaskSessionList = "task.session.list" - ActionTaskSessionResume = "task.session.resume" - ActionTaskSessionStatus = "task.session.status" - ActionTaskSessionPrepare = "task.session.prepare" + ActionTaskSessionList = "task.session.list" + ActionTaskSessionStatus = "task.session.status" // Unified session launch ActionSessionLaunch = "session.launch" @@ -104,15 +102,9 @@ const ( ActionAgentResize = "agent.resize" // Resize agent PTY (passthrough mode) // Orchestrator actions - ActionOrchestratorStatus = "orchestrator.status" - ActionOrchestratorQueue = "orchestrator.queue" - ActionOrchestratorTrigger = "orchestrator.trigger" - ActionOrchestratorStart = "orchestrator.start" - ActionOrchestratorStop = "orchestrator.stop" - ActionOrchestratorPause = "orchestrator.pause" - ActionOrchestratorResume = "orchestrator.resume" - ActionOrchestratorPrompt = "orchestrator.prompt" - ActionOrchestratorComplete = "orchestrator.complete" + ActionOrchestratorStatus = "orchestrator.status" + ActionOrchestratorQueue = "orchestrator.queue" + ActionOrchestratorStop = "orchestrator.stop" // Message Queue actions ActionMessageQueueAdd = "message.queue.add" From 7e0b46c227bb21c998a8e4da803536438ff3cee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 5 May 2026 09:03:42 +0100 Subject: [PATCH 09/33] feat(slack): cookie-mode integration that triages threads via a utility agent (#775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `, 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 * 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 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.7 --- apps/backend/cmd/kandev/helpers.go | 6 + apps/backend/cmd/kandev/main.go | 33 + apps/backend/cmd/kandev/services.go | 60 ++ apps/backend/cmd/kandev/types.go | 2 + .../internal/agent/hostutility/public.go | 18 +- .../agentctl/server/utility/acp_executor.go | 73 +- .../internal/agentctl/server/utility/types.go | 26 + .../backend/internal/mcp/handlers/handlers.go | 22 +- .../internal/mcp/server/config_handlers.go | 7 + apps/backend/internal/mcp/server/handlers.go | 17 + apps/backend/internal/mcp/server/server.go | 4 +- .../internal/mcp/server/server_test.go | 10 +- apps/backend/internal/slack/client.go | 63 ++ apps/backend/internal/slack/cookie_client.go | 339 ++++++++++ .../internal/slack/cookie_client_test.go | 255 +++++++ apps/backend/internal/slack/handlers.go | 207 ++++++ apps/backend/internal/slack/handlers_test.go | 54 ++ apps/backend/internal/slack/models.go | 132 ++++ apps/backend/internal/slack/poller.go | 52 ++ apps/backend/internal/slack/provider.go | 64 ++ apps/backend/internal/slack/runtime.go | 302 +++++++++ apps/backend/internal/slack/service.go | 359 ++++++++++ apps/backend/internal/slack/store.go | 297 +++++++++ apps/backend/internal/slack/store_test.go | 250 +++++++ apps/backend/internal/slack/trigger.go | 331 +++++++++ apps/backend/internal/slack/trigger_test.go | 629 ++++++++++++++++++ .../internal/utility/service/service.go | 6 + apps/backend/internal/utility/store/sqlite.go | 16 + .../internal/utility/template/engine.go | 12 +- apps/backend/pkg/websocket/actions.go | 9 + apps/web/app/settings/integrations/page.tsx | 8 +- .../app/settings/integrations/slack/page.tsx | 5 + .../settings/settings-app-sidebar.tsx | 2 + apps/web/components/slack/slack-settings.tsx | 597 +++++++++++++++++ .../domains/slack/use-slack-availability.ts | 32 + .../hooks/domains/slack/use-slack-enabled.ts | 11 + apps/web/lib/api/domains/slack-api.ts | 36 + apps/web/lib/types/slack.ts | 41 ++ docs/specs/INDEX.md | 1 + docs/specs/slack-integration/spec.md | 46 ++ 40 files changed, 4419 insertions(+), 15 deletions(-) create mode 100644 apps/backend/internal/slack/client.go create mode 100644 apps/backend/internal/slack/cookie_client.go create mode 100644 apps/backend/internal/slack/cookie_client_test.go create mode 100644 apps/backend/internal/slack/handlers.go create mode 100644 apps/backend/internal/slack/handlers_test.go create mode 100644 apps/backend/internal/slack/models.go create mode 100644 apps/backend/internal/slack/poller.go create mode 100644 apps/backend/internal/slack/provider.go create mode 100644 apps/backend/internal/slack/runtime.go create mode 100644 apps/backend/internal/slack/service.go create mode 100644 apps/backend/internal/slack/store.go create mode 100644 apps/backend/internal/slack/store_test.go create mode 100644 apps/backend/internal/slack/trigger.go create mode 100644 apps/backend/internal/slack/trigger_test.go create mode 100644 apps/web/app/settings/integrations/slack/page.tsx create mode 100644 apps/web/components/slack/slack-settings.tsx create mode 100644 apps/web/hooks/domains/slack/use-slack-availability.ts create mode 100644 apps/web/hooks/domains/slack/use-slack-enabled.ts create mode 100644 apps/web/lib/api/domains/slack-api.ts create mode 100644 apps/web/lib/types/slack.ts create mode 100644 docs/specs/slack-integration/spec.md diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index d00c074c66..dad4ef4c60 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -49,6 +49,7 @@ import ( prompthandlers "github.com/kandev/kandev/internal/prompts/handlers" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/slack" spriteshandlers "github.com/kandev/kandev/internal/sprites" taskhandlers "github.com/kandev/kandev/internal/task/handlers" "github.com/kandev/kandev/internal/task/models" @@ -626,6 +627,11 @@ func registerSecondaryRoutes( p.log.Debug("Registered Linear handlers (HTTP + WebSocket)") } + if p.services.Slack != nil { + slack.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.Slack, p.log) + p.log.Debug("Registered Slack handlers (HTTP + WebSocket)") + } + docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), p.log) p.log.Debug("Registered Docker management handlers (HTTP)") diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index f3cc9648f6..6aff72c903 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -32,6 +32,7 @@ import ( // JIRA integration jirapkg "github.com/kandev/kandev/internal/jira" linearpkg "github.com/kandev/kandev/internal/linear" + slackpkg "github.com/kandev/kandev/internal/slack" // Agent infrastructure "github.com/kandev/kandev/internal/agent/hostutility" @@ -368,6 +369,20 @@ func startAgentInfrastructure( addCleanup(func() error { linearPoller.Stop(); return nil }) } + // Start Slack auth-health poller and the trigger loop. The trigger + // polls each configured workspace every 30s for new `!kandev …` + // messages from the authenticated user and turns them into Kandev + // tasks via taskSvc. + if services.Slack != nil { + slackPoller := slackpkg.NewPoller(services.Slack, log) + slackPoller.Start(ctx) + addCleanup(func() error { slackPoller.Stop(); return nil }) + + slackTrigger := slackpkg.NewTrigger(services.Slack, log) + slackTrigger.Start(ctx) + addCleanup(func() error { slackTrigger.Stop(); return nil }) + } + return startGatewayAndServe(ctx, cfg, log, eventBus, repos, services, agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, addCleanup, runCleanups) } @@ -443,6 +458,24 @@ func startGatewayAndServe( return nil }) + // Wire the Slack agent runner. Slack triage uses the host-utility + // inference path (single-shot ACP subprocess) with the Kandev MCP + // server attached so the agent can call list_workflows_kandev / + // create_task_kandev / etc. mid-prompt. Both deps land here at the + // same time: hostUtilityMgr just bootstrapped above, services.Utility + // was constructed in provideServices. + if services.Slack != nil && services.Utility != nil { + mcpURL := buildKandevMCPURL(cfg.Server.Port) + slackRunner := slackpkg.NewRunner( + services.Utility, + services.User, + slackHostUtilityAdapter{mgr: hostUtilityMgr}, + []slackpkg.MCPDescriptor{{Name: "kandev", URL: mcpURL}}, + log, + ) + services.Slack.SetRunner(slackRunner) + } + if err := orchestratorSvc.Start(ctx); err != nil { log.Error("Failed to start orchestrator", zap.Error(err)) return false diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 4261354217..b12381a40d 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -2,12 +2,15 @@ package main import ( "context" + "fmt" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/discovery" + "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/registry" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" + agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" @@ -19,6 +22,7 @@ import ( "github.com/kandev/kandev/internal/linear" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/slack" taskmodels "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" @@ -87,6 +91,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories githubSvc := initGitHubService(dbPool, eventBus, repos.Secrets, log) jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log) linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log) + slackSvc := initSlackService(dbPool, repos.Secrets, log) return &Services{ Task: taskSvc, @@ -98,6 +103,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories GitHub: githubSvc, Jira: jiraSvc, Linear: linearSvc, + Slack: slackSvc, // Notification service is initialized after gateway is available. Notification: nil, }, agentSettingsController, nil @@ -246,6 +252,60 @@ func initLinearService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secr return svc } +// initSlackService wires up the Slack integration. Failures are non-fatal. +// The agent runner is wired post-construction by main.go once hostutility + +// utility services exist. +func initSlackService(dbPool *db.Pool, secretsStore secrets.SecretStore, log *logger.Logger) *slack.Service { + svc, _, err := slack.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), log) + if err != nil { + log.Warn("Slack service initialization failed (non-fatal)", zap.Error(err)) + } + return svc +} + +// buildKandevMCPURL is the URL passed to the Slack triage agent for the +// Kandev MCP server. The MCP server is mounted on the same port as the rest +// of the backend's HTTP API; this just centralises the path so it stays in +// sync with internal/mcp/server's mount point ("/mcp"). +func buildKandevMCPURL(port int) string { + if port == 0 { + port = portsBackendDefault + } + return fmt.Sprintf("http://localhost:%d/mcp", port) +} + +// portsBackendDefault is the default backend HTTP port. We don't import +// internal/common/ports here to avoid pulling its transitive deps into +// services.go's import graph; the value is duplicated only as a fallback for +// when cfg.Server.Port is left at zero (which shouldn't happen in practice). +const portsBackendDefault = 38429 + +// slackHostUtilityAdapter adapts *hostutility.Manager to slack.HostUtilityRunner. +// The slack package can't import hostutility without a transitive cycle (it +// would need to import agentctl + lifecycle), so we shim through the agentctl +// utility DTO here in the cmd package where both are already imported. +type slackHostUtilityAdapter struct { + mgr *hostutility.Manager +} + +func (a slackHostUtilityAdapter) ExecutePromptWithMCP( + ctx context.Context, + agentType, model, mode, prompt string, + mcpServers []agentctlutil.MCPServerDTO, +) (slack.HostPromptResult, error) { + res, err := a.mgr.ExecutePromptWithMCP(ctx, agentType, model, mode, prompt, mcpServers) + if err != nil { + return slack.HostPromptResult{}, err + } + return slack.HostPromptResult{ + Response: res.Response, + Model: res.Model, + PromptTokens: res.PromptTokens, + ResponseTokens: res.ResponseTokens, + DurationMs: res.DurationMs, + }, nil +} + // workflowProviderAdapter adapts task service to workflow service's WorkflowProvider interface. type workflowProviderAdapter struct { svc *taskservice.Service diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index fee624d0a0..84098f9a35 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -13,6 +13,7 @@ import ( promptservice "github.com/kandev/kandev/internal/prompts/service" promptstore "github.com/kandev/kandev/internal/prompts/store" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/slack" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" @@ -47,4 +48,5 @@ type Services struct { GitHub *github.Service Jira *jira.Service Linear *linear.Service + Slack *slack.Service } diff --git a/apps/backend/internal/agent/hostutility/public.go b/apps/backend/internal/agent/hostutility/public.go index f156223f7d..3c38b244eb 100644 --- a/apps/backend/internal/agent/hostutility/public.go +++ b/apps/backend/internal/agent/hostutility/public.go @@ -56,11 +56,24 @@ func (m *Manager) Refresh(ctx context.Context, agentType string) (AgentCapabilit } // ExecutePrompt runs a sessionless utility prompt against the warm instance -// for the given agent type. The caller picks the model (explicit from the -// utility agent record, user default, or probe cache fallback). +// for the given agent type. Convenience wrapper over ExecutePromptWithMCP +// that opts the call out of MCP tool access — most callers (PR title, commit +// message, etc.) want pure text-in/text-out. func (m *Manager) ExecutePrompt( ctx context.Context, agentType, model, mode, prompt string, +) (*PromptResult, error) { + return m.ExecutePromptWithMCP(ctx, agentType, model, mode, prompt, nil) +} + +// ExecutePromptWithMCP runs a sessionless utility prompt with the given MCP +// servers wired into the agent's session. The agent (Claude Code, codex, +// etc.) can call MCP tools mid-prompt and incorporate the results into its +// final reply. Pass nil for mcpServers to disable MCP for this call. +func (m *Manager) ExecutePromptWithMCP( + ctx context.Context, + agentType, model, mode, prompt string, + mcpServers []agentctlutil.MCPServerDTO, ) (*PromptResult, error) { if prompt == "" { return nil, fmt.Errorf("prompt is required") @@ -83,6 +96,7 @@ func (m *Manager) ExecutePrompt( ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir, }, + MCPServers: mcpServers, } resp, err := inst.client.InferencePrompt(ctx, req) if err != nil { diff --git a/apps/backend/internal/agentctl/server/utility/acp_executor.go b/apps/backend/internal/agentctl/server/utility/acp_executor.go index 34ad017734..039be01ba2 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_executor.go +++ b/apps/backend/internal/agentctl/server/utility/acp_executor.go @@ -85,7 +85,12 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) }() // Execute ACP protocol - response, err := e.executeACPSession(ctx, stdin, stdout, workDir, req.Prompt, req.Model, req.Mode) + mcpServers, dropped := toACPMcpServers(req.MCPServers) + for _, name := range dropped { + e.logger.Warn("ACP inference: dropping unsupported MCP server transport", + zap.String("name", name)) + } + response, err := e.executeACPSession(ctx, stdin, stdout, workDir, req.Prompt, req.Model, req.Mode, mcpServers) if err != nil { return &PromptResponse{ Success: false, @@ -104,7 +109,9 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) // executeACPSession performs the ACP handshake, creates a session, optionally // sets the session model and mode, sends the prompt, and collects the response -// text. +// text. mcpServers, when non-empty, are forwarded to session/new so the agent +// can call MCP tools mid-prompt; an empty slice preserves the legacy "pure +// inference" behaviour. func (e *ACPInferenceExecutor) executeACPSession( ctx context.Context, stdin io.Writer, @@ -113,6 +120,7 @@ func (e *ACPInferenceExecutor) executeACPSession( prompt string, model string, mode string, + mcpServers []acp.McpServer, ) (string, error) { // Collect response text from updates var responseText strings.Builder @@ -149,10 +157,14 @@ func (e *ACPInferenceExecutor) executeACPSession( return "", fmt.Errorf("ACP initialize failed: %w", err) } - // Create new session with empty MCP servers array (required by ACP protocol) + // Create new session. ACP requires McpServers to be a non-nil slice; + // callers without tools pass nil and we substitute an empty array here. + if mcpServers == nil { + mcpServers = []acp.McpServer{} + } sessionResp, err := conn.NewSession(ctx, acp.NewSessionRequest{ Cwd: workDir, - McpServers: []acp.McpServer{}, + McpServers: mcpServers, }) if err != nil { return "", fmt.Errorf("ACP session/new failed: %w", err) @@ -198,6 +210,59 @@ func (e *ACPInferenceExecutor) executeACPSession( return result, nil } +// toACPMcpServers converts the cross-process DTO list into the ACP SDK +// shape. Uses the *Inline variants because the agentcooper fork (vendored +// via go.mod replace) names them that way; the upstream SDK would call them +// McpServerHttp / McpServerSse. Returns nil when there are no entries so +// callers can use the nil-as-empty convention upstream. The second return +// value carries the names of any DTOs we couldn't convert (unsupported +// transport, e.g. stdio) so the caller can surface them in logs rather than +// having them silently disappear from the agent's tool surface. +func toACPMcpServers(in []MCPServerDTO) ([]acp.McpServer, []string) { + if len(in) == 0 { + return nil, nil + } + out := make([]acp.McpServer, 0, len(in)) + var dropped []string + for _, s := range in { + switch strings.ToLower(s.Type) { + case "http": + out = append(out, acp.McpServer{Http: &acp.McpServerHttpInline{ + Name: s.Name, + Type: "http", + Url: s.URL, + Headers: toACPHeaders(s.HeaderKVs), + }}) + case "sse": + out = append(out, acp.McpServer{Sse: &acp.McpServerSseInline{ + Name: s.Name, + Type: "sse", + Url: s.URL, + Headers: toACPHeaders(s.HeaderKVs), + }}) + default: + // Unsupported transport (stdio, or anything else). We don't fail + // the whole inference call on a single bad entry — the agent can + // still run with the entries that did convert — but we surface + // the name so misconfiguration is visible in logs rather than + // silently leaving the agent without tools it expected to have. + dropped = append(dropped, s.Name) + } + } + return out, dropped +} + +func toACPHeaders(in []HTTPHeaderDTO) []acp.HttpHeader { + if len(in) == 0 { + return []acp.HttpHeader{} + } + out := make([]acp.HttpHeader, 0, len(in)) + for _, h := range in { + out = append(out, acp.HttpHeader{Name: h.Name, Value: h.Value}) + } + return out +} + // Probe runs an ephemeral ACP handshake (initialize + session/new) to discover // agent capabilities, auth methods, models, and modes. It does not send a prompt. func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*ProbeResponse, error) { diff --git a/apps/backend/internal/agentctl/server/utility/types.go b/apps/backend/internal/agentctl/server/utility/types.go index 5ec044c427..eb37b64a3c 100644 --- a/apps/backend/internal/agentctl/server/utility/types.go +++ b/apps/backend/internal/agentctl/server/utility/types.go @@ -24,6 +24,32 @@ type PromptRequest struct { // MaxTokens is the maximum tokens for the response (default: 1024). MaxTokens int `json:"max_tokens,omitempty"` + + // MCPServers are the MCP servers to expose to the agent for the duration + // of this single inference call. Forwarded to ACP `session/new` so the + // agent can call tools mid-prompt. Empty (the common case) keeps the + // existing "pure inference, no tools" behaviour for utility-agent + // callers that only need text-in/text-out (PR title generation, commit + // messages, etc.). + MCPServers []MCPServerDTO `json:"mcp_servers,omitempty"` +} + +// MCPServerDTO describes a single MCP server endpoint to wire into the +// agent's session. Mirrors the ACP McpServer shape but kept JSON-flat so it +// crosses the agentctl HTTP boundary cleanly. Today we use HTTP transport +// only (Type="http"); stdio/sse can be added when a caller needs them. +type MCPServerDTO struct { + Name string `json:"name"` + Type string `json:"type"` // "http" | "sse" | "stdio" + URL string `json:"url,omitempty"` // for http/sse + // HeaderKVs is an optional set of HTTP headers (key, value pairs). + HeaderKVs []HTTPHeaderDTO `json:"headers,omitempty"` +} + +// HTTPHeaderDTO is a single HTTP header for HTTP/SSE-transport MCP servers. +type HTTPHeaderDTO struct { + Name string `json:"name"` + Value string `json:"value"` } // ProbeRequest is the request for probing an agent's capabilities. diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 8d976b8072..a9e4b61710 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -144,6 +144,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPListWorkspaces, h.handleListWorkspaces) d.RegisterFunc(ws.ActionMCPListWorkflows, h.handleListWorkflows) d.RegisterFunc(ws.ActionMCPListWorkflowSteps, h.handleListWorkflowSteps) + d.RegisterFunc(ws.ActionMCPListRepositories, h.handleListRepositories) d.RegisterFunc(ws.ActionMCPListTasks, h.handleListTasks) d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask) d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask) @@ -155,7 +156,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan) d.RegisterFunc(ws.ActionMCPDeleteTaskPlan, h.handleDeleteTaskPlan) d.RegisterFunc(ws.ActionMCPClarificationTimeout, h.handleClarificationTimeout) - count := 14 + count := 15 // Config-mode handlers (registered when config deps are set) if h.workflowSvc != nil { @@ -294,6 +295,25 @@ func (h *Handlers) handleListWorkflowSteps(ctx context.Context, msg *ws.Message) }) } +// handleListRepositories lists repositories for a workspace. Exposes the same +// data the kanban "Edit task → Repositories" picker reads, so an MCP-driven +// agent (e.g. the Slack triage runner) can match a request against an actual +// repo instead of guessing or making up an ID. +func (h *Handlers) handleListRepositories(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return h.handleListByField(ctx, msg, "workspace_id", "failed to list repositories", "Failed to list repositories", + func(ctx context.Context, workspaceID string) (any, error) { + repos, err := h.taskSvc.ListRepositories(ctx, workspaceID) + if err != nil { + return nil, err + } + dtos := make([]dto.RepositoryDTO, 0, len(repos)) + for _, r := range repos { + dtos = append(dtos, dto.FromRepository(r)) + } + return dto.ListRepositoriesResponse{Repositories: dtos, Total: len(dtos)}, nil + }) +} + // handleListTasks lists tasks for a workflow. func (h *Handlers) handleListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) { return h.handleListByField(ctx, msg, "workflow_id", "failed to list tasks", "Failed to list tasks", diff --git a/apps/backend/internal/mcp/server/config_handlers.go b/apps/backend/internal/mcp/server/config_handlers.go index 32e27057d7..268c86f13f 100644 --- a/apps/backend/internal/mcp/server/config_handlers.go +++ b/apps/backend/internal/mcp/server/config_handlers.go @@ -26,6 +26,13 @@ func (s *Server) registerConfigWorkflowTools() { ), s.wrapHandler("list_workflows_kandev", s.listWorkflowsHandler()), ) + s.mcpServer.AddTool( + mcp.NewTool("list_repositories_kandev", + mcp.WithDescription("List repositories in a workspace. Use this to find a repository_id to attach to create_task_kandev when the task should target a specific codebase."), + mcp.WithString("workspace_id", mcp.Required(), mcp.Description("The workspace ID")), + ), + s.wrapHandler("list_repositories_kandev", s.listRepositoriesHandler()), + ) s.mcpServer.AddTool( mcp.NewTool("create_workflow_kandev", mcp.WithDescription("Create a new workflow in a workspace."), diff --git a/apps/backend/internal/mcp/server/handlers.go b/apps/backend/internal/mcp/server/handlers.go index b672484336..a7d2267540 100644 --- a/apps/backend/internal/mcp/server/handlers.go +++ b/apps/backend/internal/mcp/server/handlers.go @@ -45,6 +45,23 @@ func (s *Server) listWorkflowsHandler() server.ToolHandlerFunc { } } +func (s *Server) listRepositoriesHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + workspaceID, err := req.RequireString("workspace_id") + if err != nil { + return mcp.NewToolResultError("workspace_id is required"), nil + } + payload := map[string]string{"workspace_id": workspaceID} + // Backend returns {repositories: [...], total: N} + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ws.ActionMCPListRepositories, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(data)), nil + } +} + func (s *Server) listWorkflowStepsHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { workflowID, err := req.RequireString("workflow_id") diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 68d2aa9836..b15bdddbb7 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -276,7 +276,7 @@ func (s *Server) registerTools() { switch s.mode { case ModeConfig: s.registerConfigWorkflowTools() - count += 10 + count += 11 s.registerConfigAgentTools() count += 4 s.registerConfigMcpTools() @@ -294,7 +294,7 @@ func (s *Server) registerTools() { // they can both manage Kandev configuration and spawn new tasks. // No interaction or plan tools (no live session to attach them to). s.registerConfigWorkflowTools() - count += 10 + count += 11 s.registerConfigAgentTools() count += 4 s.registerConfigMcpTools() diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index 64a81c8e77..1d03315933 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -96,6 +96,7 @@ func TestServerModeConfig_RegistersCorrectTools(t *testing.T) { // Config mode should have workflow config tools assert.Contains(t, tools, "list_workspaces_kandev") assert.Contains(t, tools, "list_workflows_kandev") + assert.Contains(t, tools, "list_repositories_kandev") assert.Contains(t, tools, "create_workflow_kandev") assert.Contains(t, tools, "update_workflow_kandev") assert.Contains(t, tools, "delete_workflow_kandev") @@ -207,8 +208,8 @@ func TestServerModeConfig_ToolCount(t *testing.T) { s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig) tools := getRegisteredToolNames(s) - // 10 workflow + 4 agent + 4 mcp + 5 executor + 6 task + 1 interaction = 30 - assert.Equal(t, 30, len(tools)) + // 11 workflow (incl. list_repositories) + 4 agent + 4 mcp + 5 executor + 6 task + 1 interaction = 31 + assert.Equal(t, 31, len(tools)) } func TestServerModeConfig_ToolDescriptions(t *testing.T) { @@ -243,6 +244,7 @@ func TestServerModeExternal_RegistersCorrectTools(t *testing.T) { // External mode includes all config tools assert.Contains(t, tools, "list_workspaces_kandev") + assert.Contains(t, tools, "list_repositories_kandev") assert.Contains(t, tools, "create_workflow_kandev") assert.Contains(t, tools, "list_agents_kandev") assert.Contains(t, tools, "get_mcp_config_kandev") @@ -273,8 +275,8 @@ func TestServerModeExternal_ToolCount(t *testing.T) { s := New(backend, "", "", 0, log, "", true, ModeExternal) tools := getRegisteredToolNames(s) - // 10 workflow + 4 agent + 4 mcp + 5 executor + 6 task + 1 create_task = 30 - assert.Equal(t, 30, len(tools)) + // 11 workflow (incl. list_repositories) + 4 agent + 4 mcp + 5 executor + 6 task + 1 create_task = 31 + assert.Equal(t, 31, len(tools)) } func TestNewExternal_Constructs(t *testing.T) { diff --git a/apps/backend/internal/slack/client.go b/apps/backend/internal/slack/client.go new file mode 100644 index 0000000000..ba4ed6ad60 --- /dev/null +++ b/apps/backend/internal/slack/client.go @@ -0,0 +1,63 @@ +package slack + +import ( + "context" + "errors" + "fmt" +) + +// ErrNotConfigured is returned when a Slack operation is attempted without a +// stored configuration or credentials. Wording is install-wide ("not +// configured", not "workspace not configured") because the WS path surfaces +// this raw string to clients via err.Error() without rewriting; the HTTP +// path overrides with its own friendlier message in writeClientError. +var ErrNotConfigured = errors.New("slack: not configured") + +// Client is the minimal Slack Web API surface this integration needs. The real +// implementation in cookie_client.go authenticates as the user via the xoxc- +// token + d cookie pair; tests substitute a fake. +type Client interface { + // AuthTest hits the `auth.test` endpoint, which is the cheapest call + // that confirms credentials are still valid and returns the + // authenticated user/team identifiers. Used by the health poller and the + // connection-test handler. + AuthTest(ctx context.Context) (*TestConnectionResult, error) + + // SearchMessages runs Slack's `search.messages` with a query string — + // the trigger uses this to find the user's own messages with the + // configured command prefix. Returns a flat list of matches. + SearchMessages(ctx context.Context, query string) ([]SlackMessage, error) + + // ConversationsReplies fetches the full thread for a parent message. + // When threadTS is empty (the message isn't part of a thread), returns + // just the single message anchored at triggerTS — implementations must + // pin to triggerTS, not "the channel's latest message", or concurrent + // activity in the channel between trigger and processing will give the + // agent unrelated context. + ConversationsReplies(ctx context.Context, channelID, threadTS, triggerTS string) ([]SlackMessage, error) + + // ChatGetPermalink resolves a message's canonical Slack URL. + ChatGetPermalink(ctx context.Context, channelID, ts string) (string, error) + + // ChatPostMessage posts a reply (in cookie mode, as the authenticated + // user). When threadTS is non-empty, the message is posted in-thread. + ChatPostMessage(ctx context.Context, channelID, threadTS, text string) error + + // ReactionsAdd adds an emoji reaction to a message. `name` is the bare + // emoji name (no surrounding colons), e.g. "eyes" or "white_check_mark". + // Implementations swallow Slack's `already_reacted` error so the trigger + // can call this idempotently across watermark retries. + ReactionsAdd(ctx context.Context, channelID, ts, name string) error +} + +// APIError captures a Slack API failure. Slack returns 200 with `{"ok": false, +// "error": "..."}` for almost every failure mode, so StatusCode is the HTTP +// code (usually 200) and Message is Slack's `error` string. +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("slack api: status %d: %s", e.StatusCode, e.Message) +} diff --git a/apps/backend/internal/slack/cookie_client.go b/apps/backend/internal/slack/cookie_client.go new file mode 100644 index 0000000000..10b06f73fd --- /dev/null +++ b/apps/backend/internal/slack/cookie_client.go @@ -0,0 +1,339 @@ +package slack + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// SlackAPIBase is the single host for Slack's web API. The same base is used +// regardless of the user's workspace — auth scopes the request. +const SlackAPIBase = "https://slack.com/api" + +const userAgent = "kandev/1.0 (+https://github.com/kdlbs/kandev)" + +// CookieClient implements Client by speaking the same Web API the Slack +// browser app uses. The xoxc- session token goes in `Authorization: Bearer +// ` and the d cookie is sent as a `Cookie` header — Slack rejects the +// token alone for the endpoints we need. +type CookieClient struct { + http *http.Client + endpoint string + token string + cookie string + maxBodySize int64 +} + +// NewCookieClient builds a client. cfg is reserved so the signature matches +// future bot/oauth clients; it isn't read today. +func NewCookieClient(_ *SlackConfig, token, cookie string) *CookieClient { + return &CookieClient{ + http: &http.Client{Timeout: 30 * time.Second}, + endpoint: SlackAPIBase, + token: token, + cookie: cookie, + maxBodySize: 8 << 20, + } +} + +// slackEnvelope captures the `{ok, error}` shape every Slack endpoint returns. +type slackEnvelope struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +// post sends a form-encoded POST to /api/, decodes the JSON envelope, +// and unmarshals the full body into `out` if it isn't nil. Slack expects +// form-encoded bodies (not JSON) for almost every endpoint. +func (c *CookieClient) post(ctx context.Context, method string, params url.Values, out interface{}) error { + if c.token == "" || c.cookie == "" { + return ErrNotConfigured + } + body := strings.NewReader(params.Encode()) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/"+method, body) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Cookie", "d="+c.cookie) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(io.LimitReader(resp.Body, c.maxBodySize)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &APIError{StatusCode: resp.StatusCode, Message: summarizeBody(raw)} + } + var env slackEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return &APIError{StatusCode: resp.StatusCode, Message: "invalid Slack response: " + err.Error()} + } + if !env.OK { + return &APIError{StatusCode: resp.StatusCode, Message: env.Error} + } + if out == nil { + return nil + } + return json.Unmarshal(raw, out) +} + +func summarizeBody(raw []byte) string { + const maxMsg = 500 + s := strings.TrimSpace(string(raw)) + if len(s) > maxMsg { + return s[:maxMsg] + "…" + } + if s == "" { + return "(empty body)" + } + return s +} + +// --- auth.test --- + +type authTestResponse struct { + slackEnvelope + URL string `json:"url"` + Team string `json:"team"` + User string `json:"user"` + TeamID string `json:"team_id"` + UserID string `json:"user_id"` +} + +// AuthTest implements Client. Slack's auth.test takes no parameters and +// returns the authenticated user + team. Used as the cheapest probe. +func (c *CookieClient) AuthTest(ctx context.Context) (*TestConnectionResult, error) { + var resp authTestResponse + if err := c.post(ctx, "auth.test", url.Values{}, &resp); err != nil { + var apiErr *APIError + if asAPIErr(err, &apiErr) { + return &TestConnectionResult{OK: false, Error: apiErr.Message}, nil + } + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return &TestConnectionResult{ + OK: true, + UserID: resp.UserID, + TeamID: resp.TeamID, + TeamName: resp.Team, + URL: resp.URL, + DisplayName: resp.User, + }, nil +} + +// asAPIErr is a small wrapper over errors.As without the import cycle of +// importing errors twice. +func asAPIErr(err error, target **APIError) bool { + for cur := err; cur != nil; { + if e, ok := cur.(*APIError); ok { + *target = e + return true + } + type unwrap interface{ Unwrap() error } + u, ok := cur.(unwrap) + if !ok { + return false + } + cur = u.Unwrap() + } + return false +} + +// --- search.messages --- + +type searchMessagesResponse struct { + slackEnvelope + Messages struct { + Matches []searchMatch `json:"matches"` + } `json:"messages"` +} + +type searchMatch struct { + TS string `json:"ts"` + Text string `json:"text"` + User string `json:"user"` + Username string `json:"username"` + Permalink string `json:"permalink"` + Channel struct { + ID string `json:"id"` + } `json:"channel"` + // Slack's search.messages returns thread context when the matched message + // is inside a thread. We only need the parent ts here — the trigger fetches + // the full thread separately via conversations.replies. + ThreadTS string `json:"thread_ts,omitempty"` +} + +// SearchMessages implements Client. +func (c *CookieClient) SearchMessages(ctx context.Context, query string) ([]SlackMessage, error) { + params := url.Values{} + params.Set("query", query) + params.Set("count", "30") + // Newest first so the trigger sees the most recent matches first; the + // watermark logic in trigger.go skips anything older than last_seen_ts. + params.Set("sort", "timestamp") + params.Set("sort_dir", "desc") + var resp searchMessagesResponse + if err := c.post(ctx, "search.messages", params, &resp); err != nil { + return nil, err + } + out := make([]SlackMessage, 0, len(resp.Messages.Matches)) + for _, m := range resp.Messages.Matches { + out = append(out, SlackMessage{ + TS: m.TS, + ThreadTS: m.ThreadTS, + ChannelID: m.Channel.ID, + UserID: m.User, + UserName: m.Username, + Text: m.Text, + Permalink: m.Permalink, + }) + } + return out, nil +} + +// --- conversations.replies --- + +type conversationsRepliesResponse struct { + slackEnvelope + Messages []replyMessage `json:"messages"` +} + +type replyMessage struct { + TS string `json:"ts"` + ThreadTS string `json:"thread_ts,omitempty"` + User string `json:"user"` + Username string `json:"username"` + Text string `json:"text"` +} + +// ConversationsReplies implements Client. When threadTS is empty, falls back +// to a single-message read pinned to triggerTS — Slack's conversations.history +// with `oldest=ts, latest=ts, inclusive=true, limit=1` returns exactly that +// message regardless of activity in the channel between trigger detection and +// processing. +func (c *CookieClient) ConversationsReplies(ctx context.Context, channelID, threadTS, triggerTS string) ([]SlackMessage, error) { + if channelID == "" { + return nil, fmt.Errorf("channel id required") + } + if threadTS == "" { + return c.historyAt(ctx, channelID, triggerTS) + } + params := url.Values{} + params.Set("channel", channelID) + params.Set("ts", threadTS) + params.Set("limit", "100") + var resp conversationsRepliesResponse + if err := c.post(ctx, "conversations.replies", params, &resp); err != nil { + return nil, err + } + out := make([]SlackMessage, 0, len(resp.Messages)) + for _, m := range resp.Messages { + out = append(out, SlackMessage{ + TS: m.TS, + ThreadTS: m.ThreadTS, + ChannelID: channelID, + UserID: m.User, + UserName: m.Username, + Text: m.Text, + }) + } + return out, nil +} + +// historyAt returns exactly the message at the given timestamp via +// conversations.history with the timestamp window pinned to ts on both sides +// and inclusive=true. Used as the non-threaded fallback for +// ConversationsReplies; an empty ts returns nothing rather than picking up +// whatever just landed in the channel. +func (c *CookieClient) historyAt(ctx context.Context, channelID, ts string) ([]SlackMessage, error) { + if ts == "" { + return nil, nil + } + params := url.Values{} + params.Set("channel", channelID) + params.Set("oldest", ts) + params.Set("latest", ts) + params.Set("inclusive", "true") + params.Set("limit", "1") + var resp conversationsRepliesResponse + if err := c.post(ctx, "conversations.history", params, &resp); err != nil { + return nil, err + } + out := make([]SlackMessage, 0, len(resp.Messages)) + for _, m := range resp.Messages { + out = append(out, SlackMessage{ + TS: m.TS, + ThreadTS: m.ThreadTS, + ChannelID: channelID, + UserID: m.User, + UserName: m.Username, + Text: m.Text, + }) + } + return out, nil +} + +// --- chat.getPermalink --- + +type permalinkResponse struct { + slackEnvelope + Permalink string `json:"permalink"` +} + +// ChatGetPermalink implements Client. +func (c *CookieClient) ChatGetPermalink(ctx context.Context, channelID, ts string) (string, error) { + params := url.Values{} + params.Set("channel", channelID) + params.Set("message_ts", ts) + var resp permalinkResponse + if err := c.post(ctx, "chat.getPermalink", params, &resp); err != nil { + return "", err + } + return resp.Permalink, nil +} + +// --- chat.postMessage --- + +// ChatPostMessage implements Client. Posts as the authenticated user since the +// cookie auth has no bot identity. +func (c *CookieClient) ChatPostMessage(ctx context.Context, channelID, threadTS, text string) error { + params := url.Values{} + params.Set("channel", channelID) + params.Set("text", text) + if threadTS != "" { + params.Set("thread_ts", threadTS) + } + return c.post(ctx, "chat.postMessage", params, nil) +} + +// ReactionsAdd implements Client. Slack returns `already_reacted` (a non-2xx- +// shaped error inside a 2xx envelope) when the reaction is already present; +// we swallow that so the trigger can call this idempotently after a restart +// or watermark-retry without surfacing a misleading "failed" log line. +func (c *CookieClient) ReactionsAdd(ctx context.Context, channelID, ts, name string) error { + params := url.Values{} + params.Set("channel", channelID) + params.Set("timestamp", ts) + params.Set("name", name) + err := c.post(ctx, "reactions.add", params, nil) + if err == nil { + return nil + } + var apiErr *APIError + if asAPIErr(err, &apiErr) && apiErr.Message == "already_reacted" { + return nil + } + return err +} diff --git a/apps/backend/internal/slack/cookie_client_test.go b/apps/backend/internal/slack/cookie_client_test.go new file mode 100644 index 0000000000..672f2bee05 --- /dev/null +++ b/apps/backend/internal/slack/cookie_client_test.go @@ -0,0 +1,255 @@ +package slack + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// newFakeServer returns an httptest.Server whose handler dispatches by URL path +// (the trailing API method name) to the supplied handlers map. +func newFakeServer(t *testing.T, handlers map[string]http.HandlerFunc) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method := strings.TrimPrefix(r.URL.Path, "/") + if h, ok := handlers[method]; ok { + h(w, r) + return + } + http.Error(w, "no handler for "+method, http.StatusNotFound) + })) + t.Cleanup(srv.Close) + return srv +} + +func newFakeClient(srv *httptest.Server) *CookieClient { + c := NewCookieClient(nil, "xoxc-test", "d-test") + c.endpoint = srv.URL + return c +} + +func TestCookieClient_AuthTest_Success(t *testing.T) { + var gotAuth, gotCookie string + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "auth.test": func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotCookie = r.Header.Get("Cookie") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"url":"https://acme.slack.com/","team":"Acme","user":"alice","team_id":"T0001","user_id":"U0001"}`)) + }, + }) + client := newFakeClient(srv) + res, err := client.AuthTest(context.Background()) + if err != nil { + t.Fatalf("AuthTest: %v", err) + } + if !res.OK || res.UserID != "U0001" || res.TeamID != "T0001" { + t.Errorf("unexpected result: %+v", res) + } + if gotAuth != "Bearer xoxc-test" { + t.Errorf("expected bearer auth header, got %q", gotAuth) + } + if gotCookie != "d=d-test" { + t.Errorf("expected d cookie header, got %q", gotCookie) + } +} + +func TestCookieClient_AuthTest_SlackError(t *testing.T) { + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "auth.test": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`)) + }, + }) + client := newFakeClient(srv) + res, err := client.AuthTest(context.Background()) + if err != nil { + t.Fatalf("AuthTest returned err (should surface as result): %v", err) + } + if res.OK { + t.Errorf("expected OK=false, got %+v", res) + } + if !strings.Contains(res.Error, "invalid_auth") { + t.Errorf("expected slack error in result, got %q", res.Error) + } +} + +func TestCookieClient_SearchMessages(t *testing.T) { + var gotQuery string + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "search.messages": func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + gotQuery = r.PostForm.Get("query") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "messages": {"matches": [ + {"ts":"100.000001","text":"!kandev one","user":"U1","permalink":"https://example/1","channel":{"id":"C1"}}, + {"ts":"100.000002","text":"!kandev two","user":"U1","permalink":"https://example/2","channel":{"id":"C1"}} + ]} + }`)) + }, + }) + client := newFakeClient(srv) + msgs, err := client.SearchMessages(context.Background(), `from:U1 "!kandev"`) + if err != nil { + t.Fatalf("SearchMessages: %v", err) + } + if gotQuery != `from:U1 "!kandev"` { + t.Errorf("query passthrough failed, got %q", gotQuery) + } + if len(msgs) != 2 || msgs[0].TS != "100.000001" || msgs[0].ChannelID != "C1" { + t.Errorf("unexpected messages: %+v", msgs) + } +} + +func TestCookieClient_ConversationsReplies(t *testing.T) { + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "conversations.replies": func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if r.PostForm.Get("ts") == "" { + http.Error(w, "ts required", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "ok": true, + "messages": [ + {"ts":"100.0001","user":"U1","text":"first"}, + {"ts":"100.0002","user":"U2","text":"second","thread_ts":"100.0001"} + ] + }`)) + }, + }) + client := newFakeClient(srv) + out, err := client.ConversationsReplies(context.Background(), "C1", "100.0001", "100.0001") + if err != nil { + t.Fatalf("ConversationsReplies: %v", err) + } + if len(out) != 2 || out[0].Text != "first" || out[1].ThreadTS != "100.0001" { + t.Errorf("unexpected thread: %+v", out) + } +} + +func TestCookieClient_ConversationsReplies_PinsHistoryToTriggerTSWhenNoThread(t *testing.T) { + var got struct{ oldest, latest, inclusive, limit string } + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "conversations.history": func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + got.oldest = r.PostForm.Get("oldest") + got.latest = r.PostForm.Get("latest") + got.inclusive = r.PostForm.Get("inclusive") + got.limit = r.PostForm.Get("limit") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"messages":[{"ts":"200.0001","user":"U1","text":"loner"}]}`)) + }, + }) + client := newFakeClient(srv) + out, err := client.ConversationsReplies(context.Background(), "C1", "", "200.0001") + if err != nil { + t.Fatalf("ConversationsReplies: %v", err) + } + // Both window bounds must be the trigger ts and inclusive=true; otherwise + // concurrent activity in the channel would land in the response instead + // of the actual trigger message. + if got.oldest != "200.0001" || got.latest != "200.0001" { + t.Errorf("expected oldest/latest pinned to trigger ts, got %+v", got) + } + if got.inclusive != "true" || got.limit != "1" { + t.Errorf("expected inclusive=true limit=1, got %+v", got) + } + if len(out) != 1 || out[0].Text != "loner" { + t.Errorf("unexpected fallback: %+v", out) + } +} + +func TestCookieClient_ChatPostMessage(t *testing.T) { + var got struct{ channel, text, threadTS string } + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "chat.postMessage": func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + got.channel = r.PostForm.Get("channel") + got.text = r.PostForm.Get("text") + got.threadTS = r.PostForm.Get("thread_ts") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + }, + }) + client := newFakeClient(srv) + if err := client.ChatPostMessage(context.Background(), "C1", "100.0001", "hello"); err != nil { + t.Fatalf("ChatPostMessage: %v", err) + } + if got.channel != "C1" || got.text != "hello" || got.threadTS != "100.0001" { + t.Errorf("payload mismatch: %+v", got) + } +} + +func TestCookieClient_ReactionsAdd(t *testing.T) { + var got struct{ channel, ts, name string } + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "reactions.add": func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + got.channel = r.PostForm.Get("channel") + got.ts = r.PostForm.Get("timestamp") + got.name = r.PostForm.Get("name") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + }, + }) + client := newFakeClient(srv) + if err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes"); err != nil { + t.Fatalf("ReactionsAdd: %v", err) + } + if got.channel != "C1" || got.ts != "100.0001" || got.name != "eyes" { + t.Errorf("payload mismatch: %+v", got) + } +} + +func TestCookieClient_ReactionsAdd_AlreadyReactedSwallowed(t *testing.T) { + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "reactions.add": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":false,"error":"already_reacted"}`)) + }, + }) + client := newFakeClient(srv) + if err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes"); err != nil { + t.Errorf("expected already_reacted to be swallowed, got: %v", err) + } +} + +func TestCookieClient_ReactionsAdd_OtherErrorBubbles(t *testing.T) { + srv := newFakeServer(t, map[string]http.HandlerFunc{ + "reactions.add": func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":false,"error":"channel_not_found"}`)) + }, + }) + client := newFakeClient(srv) + err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes") + if err == nil || !strings.Contains(err.Error(), "channel_not_found") { + t.Errorf("expected channel_not_found error, got %v", err) + } +} + +func TestCookieClient_RejectsEmptyCredentials(t *testing.T) { + // AuthTest absorbs ErrNotConfigured into the returned result rather than + // surfacing it as an error — assert on result.OK explicitly so the test + // catches the guard regression instead of vacuously passing on err==nil. + c := NewCookieClient(nil, "", "") + res, err := c.AuthTest(context.Background()) + if err != nil { + t.Fatalf("AuthTest: %v", err) + } + if res == nil || res.OK { + t.Errorf("expected ok=false when both credentials are empty, got %+v", res) + } + + c2 := NewCookieClient(nil, "xoxc-x", "") + _, err = c2.SearchMessages(context.Background(), "x") + if err == nil { + t.Error("expected error when cookie is missing") + } +} diff --git a/apps/backend/internal/slack/handlers.go b/apps/backend/internal/slack/handlers.go new file mode 100644 index 0000000000..0b9c4acf14 --- /dev/null +++ b/apps/backend/internal/slack/handlers.go @@ -0,0 +1,207 @@ +package slack + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// RegisterRoutes wires the Slack HTTP and WebSocket handlers. +func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) { + ctrl := &Controller{service: svc, logger: log} + ctrl.RegisterHTTPRoutes(router) + registerWSHandlers(dispatcher, svc) +} + +// Controller holds HTTP route handlers for the Slack integration. +type Controller struct { + service *Service + logger *logger.Logger +} + +// RegisterHTTPRoutes attaches the Slack HTTP endpoints to router. +func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { + api := router.Group("/api/v1/slack") + api.GET("/config", c.httpGetConfig) + api.POST("/config", c.httpSetConfig) + api.DELETE("/config", c.httpDeleteConfig) + api.POST("/config/test", c.httpTestConfig) +} + +// --- HTTP handlers --- + +func (c *Controller) httpGetConfig(ctx *gin.Context) { + cfg, err := c.service.GetConfig(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if cfg == nil { + ctx.Status(http.StatusNoContent) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpSetConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + cfg, err := c.service.SetConfig(ctx.Request.Context(), &req) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrInvalidConfig) { + status = http.StatusBadRequest + } + ctx.JSON(status, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, cfg) +} + +func (c *Controller) httpDeleteConfig(ctx *gin.Context) { + if err := c.service.DeleteConfig(ctx.Request.Context()); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (c *Controller) httpTestConfig(ctx *gin.Context) { + var req SetConfigRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + result, err := c.service.TestConnection(ctx.Request.Context(), &req) + if err != nil { + c.writeClientError(ctx, err) + return + } + ctx.JSON(http.StatusOK, result) +} + +// errCodeSlackNotConfigured is the wire-level code surfaced to the UI when +// no Slack credentials are saved. +const errCodeSlackNotConfigured = "SLACK_NOT_CONFIGURED" + +func (c *Controller) writeClientError(ctx *gin.Context, err error) { + if errors.Is(err, ErrNotConfigured) { + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Slack is not configured", + "code": errCodeSlackNotConfigured, + }) + return + } + var apiErr *APIError + if errors.As(err, &apiErr) { + status := http.StatusInternalServerError + switch apiErr.StatusCode { + case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest: + status = apiErr.StatusCode + } + ctx.JSON(status, gin.H{"error": apiErr.Error()}) + return + } + c.logger.Warn("slack handler error", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +} + +// --- WebSocket handlers --- + +func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service) { + dispatcher.RegisterFunc(ws.ActionSlackConfigGet, wsGetConfig(svc)) + dispatcher.RegisterFunc(ws.ActionSlackConfigSet, wsSetConfig(svc)) + dispatcher.RegisterFunc(ws.ActionSlackConfigDelete, wsDeleteConfig(svc)) + dispatcher.RegisterFunc(ws.ActionSlackConfigTest, wsTestConfig(svc)) +} + +func wsReply(msg *ws.Message, payload interface{}) (*ws.Message, error) { + resp, err := ws.NewResponse(msg.ID, msg.Action, payload) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return resp, nil +} + +// wsFail mirrors writeClientError's HTTP-side mapping so WS-driven flows +// surface bad-request / unauthorized / not-found / forbidden distinctly +// instead of collapsing every non-ErrNotConfigured failure into +// INTERNAL_ERROR. +func wsFail(msg *ws.Message, err error) (*ws.Message, error) { + if errors.Is(err, ErrNotConfigured) { + return ws.NewError(msg.ID, msg.Action, errCodeSlackNotConfigured, err.Error(), nil) + } + var apiErr *APIError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case http.StatusBadRequest: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, apiErr.Error(), nil) + case http.StatusUnauthorized: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeUnauthorized, apiErr.Error(), nil) + case http.StatusForbidden: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden, apiErr.Error(), nil) + case http.StatusNotFound: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, apiErr.Error(), nil) + } + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) +} + +func wsGetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + cfg, err := svc.GetConfig(ctx) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"config": cfg}) + } +} + +func wsSetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req SetConfigRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + cfg, err := svc.SetConfig(ctx, &req) + if err != nil { + if errors.Is(err, ErrInvalidConfig) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil) + } + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"config": cfg}) + } +} + +func wsDeleteConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + if err := svc.DeleteConfig(ctx); err != nil { + return wsFail(msg, err) + } + return wsReply(msg, gin.H{"deleted": true}) + } +} + +func wsTestConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req SetConfigRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + result, err := svc.TestConnection(ctx, &req) + if err != nil { + return wsFail(msg, err) + } + return wsReply(msg, result) + } +} diff --git a/apps/backend/internal/slack/handlers_test.go b/apps/backend/internal/slack/handlers_test.go new file mode 100644 index 0000000000..82068a5fba --- /dev/null +++ b/apps/backend/internal/slack/handlers_test.go @@ -0,0 +1,54 @@ +package slack + +import ( + "encoding/json" + "errors" + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +// TestWsFail_MapsAPIErrorCodes locks the wire-level error codes wsFail +// produces for each input variant. wsFail mirrors writeClientError's HTTP +// mapping so WS clients see the same distinct errors (bad-request, +// unauthorized, forbidden, not-found, internal) instead of the previous +// "everything → INTERNAL_ERROR" collapse, and a regression here would put +// the WS path silently out of sync with the HTTP path again. +func TestWsFail_MapsAPIErrorCodes(t *testing.T) { + msg := &ws.Message{ID: "1", Action: ws.ActionSlackConfigTest} + + cases := []struct { + name string + err error + wantCode string + }{ + {"ErrNotConfigured → SLACK_NOT_CONFIGURED", ErrNotConfigured, errCodeSlackNotConfigured}, + {"APIError 400 → BAD_REQUEST", &APIError{StatusCode: 400, Message: "bad"}, ws.ErrorCodeBadRequest}, + {"APIError 401 → UNAUTHORIZED", &APIError{StatusCode: 401, Message: "unauth"}, ws.ErrorCodeUnauthorized}, + {"APIError 403 → FORBIDDEN", &APIError{StatusCode: 403, Message: "forbidden"}, ws.ErrorCodeForbidden}, + {"APIError 404 → NOT_FOUND", &APIError{StatusCode: 404, Message: "notfound"}, ws.ErrorCodeNotFound}, + {"APIError 500 → INTERNAL_ERROR", &APIError{StatusCode: 500, Message: "server"}, ws.ErrorCodeInternalError}, + {"plain error → INTERNAL_ERROR", errors.New("transient"), ws.ErrorCodeInternalError}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + resp, err := wsFail(msg, c.err) + if err != nil { + t.Fatalf("wsFail returned error: %v", err) + } + if resp == nil { + t.Fatal("wsFail returned nil response") + } + var payload ws.ErrorPayload + if jsonErr := json.Unmarshal(resp.Payload, &payload); jsonErr != nil { + t.Fatalf("decode payload: %v (raw=%s)", jsonErr, string(resp.Payload)) + } + if payload.Code != c.wantCode { + t.Errorf("code mismatch: got %q want %q", payload.Code, c.wantCode) + } + if payload.Message == "" { + t.Error("expected non-empty message in error payload") + } + }) + } +} diff --git a/apps/backend/internal/slack/models.go b/apps/backend/internal/slack/models.go new file mode 100644 index 0000000000..22cdc3193f --- /dev/null +++ b/apps/backend/internal/slack/models.go @@ -0,0 +1,132 @@ +// Package slack implements the Slack integration: install-wide +// configuration storage, an HTTP client that authenticates as the user via +// the unofficial browser-session credentials (xoxc- token + d cookie), and a +// polling trigger that picks up `!kandev ` messages and turns +// them into Kandev tasks by handing them to a utility agent that has access +// to Kandev MCP tools. +// +// Slack credentials and triage settings are install-wide singletons (one +// Slack account per Kandev install) — the agent picks the destination Kandev +// workspace per-message via `list_workspaces_kandev`. Only "browser session" +// auth is supported in this v1 slice — bot tokens and user-OAuth modes are +// scoped out per docs/specs/slack-integration/spec.md. +package slack + +import "time" + +// AuthMethodCookie is the only auth method this slice supports: the xoxc- +// session token plus the `d` cookie that Slack's web client uses. +const AuthMethodCookie = "cookie" + +// DefaultCommandPrefix is the marker the trigger looks for at the start of a +// user's own message. Picked because it can't collide with Slack's own slash +// commands (which start with `/`) and is short enough to type. +const DefaultCommandPrefix = "!kandev" + +// DefaultPollIntervalSeconds is the cadence for checking Slack for new +// `!kandev …` matches. 30s is a comfortable middle ground: fast enough that +// captures feel responsive, slow enough to stay well inside Slack's +// `search.messages` rate limit (Tier 2 ~20/min). +const DefaultPollIntervalSeconds = 30 + +// MinPollIntervalSeconds is the floor users can set in the UI. Below 5s the +// `search.messages` cost dominates and a single match's utility-agent run +// takes longer than the polling interval anyway. +const MinPollIntervalSeconds = 5 + +// MaxPollIntervalSeconds caps the upper end of the slider. 10 minutes is +// long enough for "very low traffic" and short enough that a stuck/expired +// session shows up in the auth-status banner promptly. +const MaxPollIntervalSeconds = 600 + +// SlackConfig is the install-wide Slack integration configuration. The xoxc +// token and the d cookie live in the encrypted secret store under SecretKeyToken +// and SecretKeyCookie respectively. +type SlackConfig struct { + AuthMethod string `json:"authMethod" db:"auth_method"` + CommandPrefix string `json:"commandPrefix" db:"command_prefix"` + // UtilityAgentID points at a row in `utility_agents`. The agent is + // invoked for each Slack match with Kandev MCP wired in so it can call + // list_workflows_kandev, create_task_kandev, etc. Required — the trigger + // skips matches when this is empty. + UtilityAgentID string `json:"utilityAgentId" db:"utility_agent_id"` + PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"` + // SlackTeamID and SlackUserID are captured from the most recent successful + // probe so the trigger can scope `search.messages` to the user's own + // authorship without an extra round-trip per poll. + SlackTeamID string `json:"slackTeamId,omitempty" db:"slack_team_id"` + SlackUserID string `json:"slackUserId,omitempty" db:"slack_user_id"` + // LastSeenTS is the highest Slack message timestamp the trigger has + // already processed. + LastSeenTS string `json:"lastSeenTs,omitempty" db:"last_seen_ts"` + HasToken bool `json:"hasToken" db:"-"` + HasCookie bool `json:"hasCookie" db:"-"` + // LastCheckedAt / LastOk / LastError are written by the auth-health poller. + LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty" db:"last_checked_at"` + LastOk bool `json:"lastOk" db:"last_ok"` + LastError string `json:"lastError,omitempty" db:"last_error"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +// SetConfigRequest is the payload sent by the UI. Empty Token / Cookie on +// update keeps the existing stored value; non-empty replaces it. +type SetConfigRequest struct { + AuthMethod string `json:"authMethod"` + CommandPrefix string `json:"commandPrefix"` + UtilityAgentID string `json:"utilityAgentId"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + Token string `json:"token"` + Cookie string `json:"cookie"` +} + +// TestConnectionResult mirrors the Linear shape so the frontend can render +// success/failure with the same component for both integrations. +type TestConnectionResult struct { + OK bool `json:"ok"` + UserID string `json:"userId,omitempty"` + TeamID string `json:"teamId,omitempty"` + TeamName string `json:"teamName,omitempty"` + URL string `json:"url,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Error string `json:"error,omitempty"` +} + +// SlackMessage is the minimal shape we extract from Slack's web API for both +// the trigger search and the thread fetch. +type SlackMessage struct { + TS string `json:"ts"` + ThreadTS string `json:"threadTs,omitempty"` + ChannelID string `json:"channelId"` + UserID string `json:"userId,omitempty"` + UserName string `json:"userName,omitempty"` + Text string `json:"text"` + Permalink string `json:"permalink,omitempty"` +} + +// ThreadContext bundles a triggering message with the surrounding thread the +// trigger fetched for it. +type ThreadContext struct { + Trigger SlackMessage `json:"trigger"` + Messages []SlackMessage `json:"messages"` +} + +// SecretKeyToken is the secret-store key for the install-wide xoxc- token. +const SecretKeyToken = "slack:singleton:token" + +// SecretKeyCookie is the secret-store key for the install-wide `d` cookie. +// Stored separately from the token so each can be rotated/replaced +// independently — Slack rotates the d cookie much more often than the token. +const SecretKeyCookie = "slack:singleton:cookie" + +// LegacySecretKeyForToken returns the pre-singleton per-workspace token key, +// kept so the provider can promote an existing per-workspace token onto +// SecretKeyToken during the schema migration. +func LegacySecretKeyForToken(workspaceID string) string { + return "slack:" + workspaceID + ":token" +} + +// LegacySecretKeyForCookie returns the pre-singleton per-workspace cookie key. +func LegacySecretKeyForCookie(workspaceID string) string { + return "slack:" + workspaceID + ":cookie" +} diff --git a/apps/backend/internal/slack/poller.go b/apps/backend/internal/slack/poller.go new file mode 100644 index 0000000000..c1bb167d00 --- /dev/null +++ b/apps/backend/internal/slack/poller.go @@ -0,0 +1,52 @@ +package slack + +import ( + "context" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/integrations/healthpoll" +) + +// Poller probes the stored Slack credentials on the shared 90s auth-health +// cadence, mirroring Linear/Jira. The actual loop lives in +// internal/integrations/healthpoll; this wrapper exists so callers can write +// the familiar `slack.NewPoller(...).Start(ctx)` shape. +type Poller struct { + inner *healthpoll.Poller +} + +// NewPoller returns a poller using the default 90s cadence. Returns nil when +// svc is nil so the caller's nil-check still works. +func NewPoller(svc *Service, log *logger.Logger) *Poller { + if svc == nil { + return nil + } + return &Poller{inner: healthpoll.New("slack", svcProber{svc}, log)} +} + +// Start launches the background loop. +func (p *Poller) Start(ctx context.Context) { + if p == nil || p.inner == nil { + return + } + p.inner.Start(ctx) +} + +// Stop cancels the loop and waits for it to drain. +func (p *Poller) Stop() { + if p == nil || p.inner == nil { + return + } + p.inner.Stop() +} + +// svcProber adapts *Service to healthpoll.Prober. +type svcProber struct{ svc *Service } + +func (s svcProber) HasConfig(ctx context.Context) (bool, error) { + return s.svc.Store().HasConfig(ctx) +} + +func (s svcProber) RecordAuthHealth(ctx context.Context) { + s.svc.RecordAuthHealth(ctx) +} diff --git a/apps/backend/internal/slack/provider.go b/apps/backend/internal/slack/provider.go new file mode 100644 index 0000000000..b8f6dd8596 --- /dev/null +++ b/apps/backend/internal/slack/provider.go @@ -0,0 +1,64 @@ +package slack + +import ( + "context" + "time" + + "github.com/jmoiron/sqlx" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Provide builds the Slack service. Cleanup is a no-op — the service holds +// only an in-memory client cache. Mirrors the other integration providers so +// callers register it uniformly. +// +// The agent runner is wired post-construction via SetRunner because the +// host-utility manager is built later in the backend's startup sequence. +func Provide(writer, reader *sqlx.DB, secrets SecretStore, log *logger.Logger) (*Service, func() error, error) { + store, err := NewStore(writer, reader) + if err != nil { + return nil, nil, err + } + migrateLegacySecrets(store, secrets, log) + svc := NewService(store, secrets, nil, DefaultClientFactory, log) + cleanup := func() error { return nil } + return svc, cleanup, nil +} + +// migrateLegacySecrets copies the per-workspace token + cookie from the +// pre-singleton secret keys onto the new install-wide keys, then deletes the +// legacy entries. Best-effort: any error is logged and ignored — the worst +// case is the user re-pastes their credentials in the settings UI. +func migrateLegacySecrets(store *Store, secrets SecretStore, log *logger.Logger) { + source := store.MigratedFromWorkspace() + if source == "" || secrets == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + migrateOne(ctx, secrets, LegacySecretKeyForToken(source), SecretKeyToken, "Slack token", log) + migrateOne(ctx, secrets, LegacySecretKeyForCookie(source), SecretKeyCookie, "Slack d cookie", log) +} + +func migrateOne(ctx context.Context, secrets SecretStore, legacyKey, newKey, displayName string, log *logger.Logger) { + // If a fresh value already exists at the new key (someone migrated + // manually, or this codepath ran on a previous boot), don't clobber it. + if exists, err := secrets.Exists(ctx, newKey); err == nil && exists { + return + } + value, err := secrets.Reveal(ctx, legacyKey) + if err != nil || value == "" { + return + } + if err := secrets.Set(ctx, newKey, displayName, value); err != nil { + log.Warn("slack: legacy secret migration failed", + zap.String("key", legacyKey), zap.Error(err)) + return + } + if err := secrets.Delete(ctx, legacyKey); err != nil { + log.Warn("slack: legacy secret cleanup failed", + zap.String("key", legacyKey), zap.Error(err)) + } +} diff --git a/apps/backend/internal/slack/runtime.go b/apps/backend/internal/slack/runtime.go new file mode 100644 index 0000000000..f97dbdb48f --- /dev/null +++ b/apps/backend/internal/slack/runtime.go @@ -0,0 +1,302 @@ +package slack + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.uber.org/zap" + + agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" + "github.com/kandev/kandev/internal/common/logger" + utilitymodels "github.com/kandev/kandev/internal/utility/models" + utilityservice "github.com/kandev/kandev/internal/utility/service" + "github.com/kandev/kandev/internal/utility/template" +) + +// agentSystemPrompt is the persona used when a utility agent's prompt template +// is empty (or doesn't reference any Slack-specific variables). It tells the +// agent it's acting as a triage assistant, names the MCP tools it has, and +// asks for a concise final reply suitable for posting back to Slack. +// +// When the user provides a custom prompt that references Slack template +// variables (e.g. {{SlackInstruction}}, {{SlackThread}}), the template wins +// and we don't append anything — the user is in full control. +const defaultSlackSystemPrompt = `You are a Kandev triage assistant. A user just sent a request from Slack — an instruction plus the surrounding thread for context. Your job: + +1. Use the Kandev MCP tools available to you (list_workspaces_kandev, list_workflows_kandev, list_workflow_steps_kandev, list_repositories_kandev, create_task_kandev, list_agents_kandev, list_executor_profiles_kandev) to figure out which Kandev workspace + workflow + column + repository the request belongs in. **Always call list_workspaces_kandev first** — Slack is install-wide, so you must pick the destination Kandev workspace from the list rather than assume one. Then list_repositories_kandev for that workspace before deciding which repo to attach — never guess a repository_id. +2. Create a Kandev task that captures the request, with a clear title and a description that gives the future agent everything it needs (the user's instruction and any thread context that matters). +3. Reply briefly with what you did — the task title, the workspace it landed in, and a short rationale. Your reply will be posted back into Slack as your final message, so write naturally for a human reader. + +Don't ask the user follow-up questions in the reply — your reply is posted in-thread without a chance for them to respond mid-run. If something is ambiguous, pick the most likely option and explain your choice.` + +// HostUtilityRunner is the slice of hostutility.Manager we depend on. +type HostUtilityRunner interface { + ExecutePromptWithMCP( + ctx context.Context, + agentType, model, mode, prompt string, + mcpServers []agentctlutil.MCPServerDTO, + ) (HostPromptResult, error) +} + +// HostPromptResult mirrors hostutility.PromptResult — duplicated here so the +// slack package doesn't import hostutility (which has heavy transitive deps). +type HostPromptResult struct { + Response string + Model string + PromptTokens int + ResponseTokens int + DurationMs int +} + +// UtilityRegistry is the slice of utility.Service the runner needs: enough to +// look up an agent and prepare a resolved prompt request that handles the +// "empty agent_id/model falls back to user defaults" path the rest of Kandev +// already implements. +type UtilityRegistry interface { + GetAgentByID(ctx context.Context, id string) (*utilitymodels.UtilityAgent, error) + PreparePromptRequest( + ctx context.Context, + utilityID string, + tmplCtx *template.Context, + defaults *utilityservice.DefaultUtilitySettings, + sessionless bool, + ) (*utilityservice.PromptRequest, error) +} + +// UserDefaults is the slice of user.Service the runner uses to resolve +// per-user default agent/model when the chosen utility agent leaves them +// empty (the normal state for built-in agents). +type UserDefaults interface { + GetDefaultUtilitySettings(ctx context.Context) (agentID, model string, err error) +} + +// MCPDescriptor describes a single MCP server to wire into the agent's +// session. Mirrors the agentctlutil.MCPServerDTO shape. +type MCPDescriptor struct { + Name string + URL string +} + +// Runner runs the utility agent for a Slack match. +type Runner struct { + utility UtilityRegistry + userDefaults UserDefaults + host HostUtilityRunner + mcpServers []MCPDescriptor + log *logger.Logger +} + +// NewRunner builds a runner. +func NewRunner(utility UtilityRegistry, userDefaults UserDefaults, host HostUtilityRunner, mcpServers []MCPDescriptor, log *logger.Logger) *Runner { + return &Runner{utility: utility, userDefaults: userDefaults, host: host, mcpServers: mcpServers, log: log} +} + +// ErrNoUtilityAgent is returned when the workspace's Slack config doesn't +// reference a utility agent. +var ErrNoUtilityAgent = errors.New("slack: no utility agent configured") + +// RunForMatch implements AgentRunner. Slack is install-wide singleton, so +// there's no workspace_id to pass — the agent picks the destination Kandev +// workspace itself via list_workspaces_kandev. +func (r *Runner) RunForMatch( + ctx context.Context, + cfg *SlackConfig, + msg SlackMessage, + instruction, permalink string, + thread []SlackMessage, +) (string, error) { + if cfg.UtilityAgentID == "" { + return "", ErrNoUtilityAgent + } + agent, err := r.utility.GetAgentByID(ctx, cfg.UtilityAgentID) + if err != nil { + return "", fmt.Errorf("load utility agent: %w", err) + } + + defaults := r.resolveDefaults(ctx) + tmplCtx := r.buildTemplateContext(msg, instruction, permalink, thread) + + prepared, err := r.utility.PreparePromptRequest(ctx, cfg.UtilityAgentID, tmplCtx, defaults, true) + if err != nil { + return "", fmt.Errorf("prepare prompt: %w", err) + } + if prepared.AgentCLI == "" { + return "", fmt.Errorf("no agent_id resolved for utility agent %q (set one on the agent or pick a default in /settings/utility-agents)", agent.Name) + } + + prompt := r.composePrompt(agent.Prompt, prepared.ResolvedPrompt, msg, instruction, permalink, thread) + mcpDTOs := r.mcpDTOs() + + r.log.Info("slack: invoking utility agent", + zap.String("utility_agent", agent.Name), + zap.String("agent_cli", prepared.AgentCLI), + zap.String("model", prepared.Model), + zap.Int("mcp_servers", len(mcpDTOs)), + zap.String("trigger_ts", msg.TS)) + + res, err := r.host.ExecutePromptWithMCP(ctx, prepared.AgentCLI, prepared.Model, "", prompt, mcpDTOs) + if err != nil { + return "", fmt.Errorf("execute utility prompt: %w", err) + } + reply := strings.TrimSpace(res.Response) + if reply == "" { + r.log.Warn("slack: agent returned empty response", + zap.String("trigger_ts", msg.TS)) + reply = "(Kandev triage agent finished without producing text — check the kanban board.)" + } + return reply, nil +} + +// resolveDefaults reads the user-default agent/model. A read failure here +// just means we'll surface a clearer "no agent_id resolved" error later +// rather than crashing the whole flow on a settings-DB blip. +func (r *Runner) resolveDefaults(ctx context.Context) *utilityservice.DefaultUtilitySettings { + if r.userDefaults == nil { + return nil + } + agentID, model, err := r.userDefaults.GetDefaultUtilitySettings(ctx) + if err != nil { + r.log.Warn("slack: read default utility settings failed", zap.Error(err)) + return nil + } + return &utilityservice.DefaultUtilitySettings{AgentID: agentID, Model: model} +} + +// buildTemplateContext maps Slack data into the existing template Context's +// Custom map so users can reference {{SlackInstruction}}, {{SlackThread}}, etc. +// in their utility agent's prompt template. UserPrompt is set too so prompts +// shared with other utility agents (which use {{UserPrompt}}) work without +// modification. +func (r *Runner) buildTemplateContext(trigger SlackMessage, instruction, permalink string, thread []SlackMessage) *template.Context { + return &template.Context{ + UserPrompt: instruction, + Custom: map[string]string{ + "SlackInstruction": instruction, + "SlackThread": formatThread(thread), + "SlackPermalink": permalink, + "SlackUser": senderLabel(trigger), + "SlackChannelID": trigger.ChannelID, + "SlackTS": trigger.TS, + }, + } +} + +// slackTemplateVars are the placeholders authored Slack-triage templates +// reference. Detecting any of these (not just "the resolver substituted +// anything") in the raw template is what tells us the user designed the +// prompt for Slack rather than reusing a generic utility-agent template +// that happens to use {{UserPrompt}}. +var slackTemplateVars = []string{ + "{{SlackInstruction}}", + "{{SlackThread}}", + "{{SlackPermalink}}", + "{{SlackUser}}", + "{{SlackChannelID}}", + "{{SlackTS}}", +} + +func referencesSlackVars(rawTemplate string) bool { + for _, v := range slackTemplateVars { + if strings.Contains(rawTemplate, v) { + return true + } + } + return false +} + +// composePrompt picks between the resolved template (when the user authored +// one and it references at least one Slack-specific placeholder) and the +// legacy "default system prompt + appended Slack context block" layout. +// +// We test for Slack-specific placeholders explicitly, not just "the resolver +// substituted anything" — a generic utility-agent prompt that uses +// {{UserPrompt}} would otherwise hijack the Slack-owned path and the agent +// would lose the MCP-tool guidance baked into the default system prompt. +// When the raw template has no Slack vars, we fall back so users with empty +// or generic templates still get a working triage prompt. +func (r *Runner) composePrompt(rawTemplate, resolved string, trigger SlackMessage, instruction, permalink string, thread []SlackMessage) string { + tmpl := strings.TrimSpace(rawTemplate) + resolvedTrim := strings.TrimSpace(resolved) + if tmpl != "" && referencesSlackVars(rawTemplate) { + return resolvedTrim + } + system := resolvedTrim + if system == "" { + system = defaultSlackSystemPrompt + } + return appendSlackContextBlock(system, trigger, instruction, permalink, thread) +} + +func appendSlackContextBlock(system string, trigger SlackMessage, instruction, permalink string, thread []SlackMessage) string { + var b strings.Builder + b.WriteString(system) + b.WriteString("\n\n--- Slack context ---\n") + if permalink != "" { + b.WriteString("Slack thread: ") + b.WriteString(permalink) + b.WriteString("\n") + } + if instruction != "" { + b.WriteString("Request from ") + b.WriteString(senderLabel(trigger)) + b.WriteString(":\n") + b.WriteString(instruction) + b.WriteString("\n") + } + if len(thread) > 0 { + b.WriteString("\nThread context:\n") + b.WriteString(formatThread(thread)) + } + return strings.TrimRight(b.String(), "\n") +} + +func formatThread(thread []SlackMessage) string { + if len(thread) == 0 { + return "" + } + var b strings.Builder + for _, m := range thread { + who := m.UserName + if who == "" { + who = m.UserID + } + if who == "" { + who = "(unknown)" + } + b.WriteString(who) + b.WriteString(" (") + b.WriteString(m.TS) + b.WriteString("): ") + b.WriteString(strings.TrimSpace(m.Text)) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} + +func (r *Runner) mcpDTOs() []agentctlutil.MCPServerDTO { + if len(r.mcpServers) == 0 { + return nil + } + out := make([]agentctlutil.MCPServerDTO, 0, len(r.mcpServers)) + for _, m := range r.mcpServers { + out = append(out, agentctlutil.MCPServerDTO{ + Name: m.Name, + Type: "http", + URL: m.URL, + }) + } + return out +} + +func senderLabel(m SlackMessage) string { + switch { + case m.UserName != "": + return "@" + m.UserName + case m.UserID != "": + return "<@" + m.UserID + ">" + default: + return "the user" + } +} diff --git a/apps/backend/internal/slack/service.go b/apps/backend/internal/slack/service.go new file mode 100644 index 0000000000..5fc580886e --- /dev/null +++ b/apps/backend/internal/slack/service.go @@ -0,0 +1,359 @@ +package slack + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// SecretStore is the subset of secrets the service needs. +type SecretStore interface { + Reveal(ctx context.Context, id string) (string, error) + Set(ctx context.Context, id, name, value string) error + Delete(ctx context.Context, id string) error + Exists(ctx context.Context, id string) (bool, error) +} + +// Service orchestrates Slack config storage, the cached client, the +// auth-health probe, and the utility-agent run that turns a matched Slack +// message into a Kandev task. Slack is install-wide (one Slack user/team per +// Kandev install); the agent picks the destination Kandev workspace per +// message via MCP. +type Service struct { + store *Store + secrets SecretStore + runner AgentRunner + log *logger.Logger + + mu sync.Mutex + clientFn ClientFactory + client Client // singleton, cleared on config change. + probeHook func() +} + +// AgentRunner runs the configured utility agent for a Slack match. Defined as +// an interface so tests can inject a fake without spinning up agentctl. +type AgentRunner interface { + RunForMatch(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) +} + +// ClientFactory builds a Client from a config + the (token, cookie) pair. +type ClientFactory func(cfg *SlackConfig, token, cookie string) Client + +// DefaultClientFactory returns a real CookieClient. +func DefaultClientFactory(cfg *SlackConfig, token, cookie string) Client { + return NewCookieClient(cfg, token, cookie) +} + +// NewService wires the service. Pass nil for clientFn to use the default. +// runner may be nil — when nil, matched Slack messages are logged but no +// agent runs (useful in tests and during partial backend init). +func NewService( + store *Store, + secrets SecretStore, + runner AgentRunner, + clientFn ClientFactory, + log *logger.Logger, +) *Service { + if clientFn == nil { + clientFn = DefaultClientFactory + } + return &Service{ + store: store, + secrets: secrets, + runner: runner, + log: log, + clientFn: clientFn, + } +} + +// SetRunner wires the agent runner after construction. main.go calls this +// once the host-utility manager + utility service are available. +func (s *Service) SetRunner(r AgentRunner) { + s.mu.Lock() + defer s.mu.Unlock() + s.runner = r +} + +// Runner returns the wired runner (or nil). +func (s *Service) Runner() AgentRunner { + s.mu.Lock() + defer s.mu.Unlock() + return s.runner +} + +// GetConfig returns the singleton config enriched with HasToken/HasCookie. +func (s *Service) GetConfig(ctx context.Context) (*SlackConfig, error) { + cfg, err := s.store.GetConfig(ctx) + if err != nil || cfg == nil { + return cfg, err + } + if s.secrets == nil { + return cfg, nil + } + cfg.HasToken = s.secretExists(ctx, SecretKeyToken, "token") + cfg.HasCookie = s.secretExists(ctx, SecretKeyCookie, "cookie") + return cfg, nil +} + +func (s *Service) secretExists(ctx context.Context, id, kind string) bool { + exists, err := s.secrets.Exists(ctx, id) + if err != nil { + s.log.Warn("slack: secret exists check failed", + zap.String("kind", kind), zap.Error(err)) + } + return exists +} + +// ErrInvalidConfig is returned by SetConfig when the request fails validation. +var ErrInvalidConfig = errors.New("slack: invalid configuration") + +func (s *Service) persistSecrets(ctx context.Context, req *SetConfigRequest) error { + if s.secrets == nil { + return nil + } + if req.Token != "" { + if err := s.secrets.Set(ctx, SecretKeyToken, "Slack token", req.Token); err != nil { + return fmt.Errorf("store slack token: %w", err) + } + } + if req.Cookie != "" { + if err := s.secrets.Set(ctx, SecretKeyCookie, "Slack d cookie", req.Cookie); err != nil { + return fmt.Errorf("store slack cookie: %w", err) + } + } + return nil +} + +// SetConfig is upsert. Empty Token/Cookie on update keeps the existing values. +func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*SlackConfig, error) { + if err := validateConfigRequest(req); err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error()) + } + cfg := &SlackConfig{ + AuthMethod: req.AuthMethod, + CommandPrefix: req.CommandPrefix, + UtilityAgentID: req.UtilityAgentID, + PollIntervalSeconds: req.PollIntervalSeconds, + } + if err := s.store.UpsertConfig(ctx, cfg); err != nil { + return nil, fmt.Errorf("upsert slack config: %w", err) + } + if err := s.persistSecrets(ctx, req); err != nil { + return nil, err + } + s.invalidateClient() + go func() { + s.RecordAuthHealth(context.Background()) + }() + return s.GetConfig(ctx) +} + +// DeleteConfig removes both the row and the stored secrets. +func (s *Service) DeleteConfig(ctx context.Context) error { + if err := s.store.DeleteConfig(ctx); err != nil { + return err + } + if s.secrets != nil { + s.deleteSecret(ctx, SecretKeyToken, "token") + s.deleteSecret(ctx, SecretKeyCookie, "cookie") + } + s.invalidateClient() + return nil +} + +func (s *Service) deleteSecret(ctx context.Context, id, kind string) { + if err := s.secrets.Delete(ctx, id); err != nil { + s.log.Warn("slack: secret delete failed", + zap.String("kind", kind), zap.Error(err)) + } +} + +// TestConnection validates credentials either inline (from a fresh request) +// or from the stored secrets. +func (s *Service) TestConnection(ctx context.Context, req *SetConfigRequest) (*TestConnectionResult, error) { + cfg, token, cookie, err := s.resolveCredentials(ctx, req) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + client := s.clientFn(cfg, token, cookie) + return client.AuthTest(ctx) +} + +// ProbeAuth validates the stored credentials. +func (s *Service) ProbeAuth(ctx context.Context) (*TestConnectionResult, error) { + client, err := s.clientFor(ctx) + if err != nil { + return &TestConnectionResult{OK: false, Error: err.Error()}, nil + } + return client.AuthTest(ctx) +} + +// Store exposes the underlying store so background workers can persist state. +func (s *Service) Store() *Store { + return s.store +} + +// authProbeTimeout caps a single auth-health probe. +const authProbeTimeout = 15 * time.Second + +// authHealthWriteTimeout bounds the DB write that persists the probe outcome. +const authHealthWriteTimeout = 5 * time.Second + +// SetProbeHook installs a callback fired at the end of each RecordAuthHealth. +func (s *Service) SetProbeHook(fn func()) { + s.mu.Lock() + defer s.mu.Unlock() + s.probeHook = fn +} + +// RecordAuthHealth probes credentials and writes the outcome onto the row. +func (s *Service) RecordAuthHealth(ctx context.Context) { + probeCtx, cancel := context.WithTimeout(ctx, authProbeTimeout) + defer cancel() + res, err := s.ProbeAuth(probeCtx) + ok := err == nil && res != nil && res.OK + errMsg := "" + switch { + case err != nil: + errMsg = err.Error() + case res != nil && !res.OK: + errMsg = res.Error + } + teamID, userID := "", "" + if res != nil && ok { + teamID = res.TeamID + userID = res.UserID + } + writeCtx, writeCancel := context.WithTimeout(context.Background(), authHealthWriteTimeout) + defer writeCancel() + if updateErr := s.store.UpdateAuthHealth(writeCtx, ok, errMsg, teamID, userID, time.Now().UTC()); updateErr != nil { + s.log.Warn("slack: update auth health failed", zap.Error(updateErr)) + } + if !ok { + s.invalidateClient() + } + s.mu.Lock() + hook := s.probeHook + s.mu.Unlock() + if hook != nil { + hook() + } +} + +// Client exposes the cached client to the trigger and runtime. +func (s *Service) Client(ctx context.Context) (Client, error) { + return s.clientFor(ctx) +} + +func (s *Service) clientFor(ctx context.Context) (Client, error) { + s.mu.Lock() + if s.client != nil { + c := s.client + s.mu.Unlock() + return c, nil + } + s.mu.Unlock() + + cfg, err := s.store.GetConfig(ctx) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, ErrNotConfigured + } + token, cookie, err := s.revealSecrets(ctx) + if err != nil { + return nil, err + } + if token == "" || cookie == "" { + return nil, ErrNotConfigured + } + client := s.clientFn(cfg, token, cookie) + s.mu.Lock() + defer s.mu.Unlock() + if s.client != nil { + return s.client, nil + } + s.client = client + return client, nil +} + +func (s *Service) revealSecrets(ctx context.Context) (string, string, error) { + if s.secrets == nil { + return "", "", nil + } + token, err := s.secrets.Reveal(ctx, SecretKeyToken) + if err != nil { + return "", "", fmt.Errorf("read slack token: %w", err) + } + cookie, err := s.secrets.Reveal(ctx, SecretKeyCookie) + if err != nil { + return "", "", fmt.Errorf("read slack cookie: %w", err) + } + return token, cookie, nil +} + +func (s *Service) invalidateClient() { + s.mu.Lock() + s.client = nil + s.mu.Unlock() +} + +func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*SlackConfig, string, string, error) { + cfg := &SlackConfig{AuthMethod: req.AuthMethod} + token, cookie := req.Token, req.Cookie + if token != "" && cookie != "" { + return cfg, token, cookie, nil + } + if s.secrets == nil { + return nil, "", "", errors.New("no secret store configured") + } + if token == "" { + stored, err := s.secrets.Reveal(ctx, SecretKeyToken) + if err != nil { + s.log.Warn("slack: token reveal failed", zap.Error(err)) + return nil, "", "", fmt.Errorf("read slack token: %w", err) + } + token = stored + } + if cookie == "" { + stored, err := s.secrets.Reveal(ctx, SecretKeyCookie) + if err != nil { + s.log.Warn("slack: cookie reveal failed", zap.Error(err)) + return nil, "", "", fmt.Errorf("read slack cookie: %w", err) + } + cookie = stored + } + if token == "" || cookie == "" { + return nil, "", "", errors.New("token and cookie required — paste both to test") + } + return cfg, token, cookie, nil +} + +func validateConfigRequest(req *SetConfigRequest) error { + if req.AuthMethod == "" { + req.AuthMethod = AuthMethodCookie + } + if req.AuthMethod != AuthMethodCookie { + return fmt.Errorf("unknown auth method: %q", req.AuthMethod) + } + req.CommandPrefix = strings.TrimSpace(req.CommandPrefix) + if req.CommandPrefix == "" { + req.CommandPrefix = DefaultCommandPrefix + } + if req.PollIntervalSeconds == 0 { + req.PollIntervalSeconds = DefaultPollIntervalSeconds + } + if req.PollIntervalSeconds < MinPollIntervalSeconds || req.PollIntervalSeconds > MaxPollIntervalSeconds { + return fmt.Errorf("pollIntervalSeconds must be between %d and %d", MinPollIntervalSeconds, MaxPollIntervalSeconds) + } + return nil +} diff --git a/apps/backend/internal/slack/store.go b/apps/backend/internal/slack/store.go new file mode 100644 index 0000000000..9cd5b2e23b --- /dev/null +++ b/apps/backend/internal/slack/store.go @@ -0,0 +1,297 @@ +package slack + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +// Store persists the install-wide Slack configuration. Secret values (xoxc +// token, d cookie) live in the shared encrypted secrets store, not here. +type Store struct { + db *sqlx.DB + ro *sqlx.DB + + // migratedFromWorkspace records the workspace_id of the row that was + // promoted into the singleton during initSchema. Provider reads this to + // migrate the per-workspace secrets to the new global keys. Empty when + // no migration ran. + migratedFromWorkspace string +} + +// NewStore creates a new Store and initializes the schema if needed. +func NewStore(writer, reader *sqlx.DB) (*Store, error) { + s := &Store{db: writer, ro: reader} + if err := s.initSchema(); err != nil { + return nil, fmt.Errorf("slack schema init: %w", err) + } + return s, nil +} + +// MigratedFromWorkspace returns the workspace_id of the row promoted to the +// singleton during the per-workspace → singleton schema migration, or "" when +// no migration ran. +func (s *Store) MigratedFromWorkspace() string { + return s.migratedFromWorkspace +} + +const createTablesSQL = ` + CREATE TABLE IF NOT EXISTS slack_configs ( + id TEXT PRIMARY KEY CHECK(id = 'singleton'), + auth_method TEXT NOT NULL, + command_prefix TEXT NOT NULL DEFAULT '', + utility_agent_id TEXT NOT NULL DEFAULT '', + poll_interval_seconds INTEGER NOT NULL DEFAULT 30, + slack_team_id TEXT NOT NULL DEFAULT '', + slack_user_id TEXT NOT NULL DEFAULT '', + last_seen_ts TEXT NOT NULL DEFAULT '', + last_checked_at DATETIME, + last_ok INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); +` + +// singletonID is the synthetic primary key of the (only) row in slack_configs. +const singletonID = "singleton" + +func (s *Store) initSchema() error { + if err := s.migrateLegacyPerWorkspaceTable(); err != nil { + return err + } + if _, err := s.db.Exec(createTablesSQL); err != nil { + return err + } + return nil +} + +// migrateLegacyPerWorkspaceTable detects the pre-singleton schema (where +// slack_configs was keyed by workspace_id) and rewrites it into the singleton +// shape. Picks the most-recently-updated row and records the source +// workspace_id so the provider can migrate the secrets. +func (s *Store) migrateLegacyPerWorkspaceTable() error { + cols, err := s.tableColumns() + if err != nil { + return err + } + if len(cols) == 0 { + return nil + } + if _, hasWorkspace := cols["workspace_id"]; !hasWorkspace { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // Build the SELECT against only columns present in this database — the + // per-workspace shape evolved across patches (utility_agent_id, + // poll_interval_seconds, the slack_team/slack_user/last_seen trio, and + // the auth-health trio were all added incrementally), so a deployment + // upgrading from any earlier intermediate would crash on a SELECT that + // assumed every column. Mirrors the Linear/Jira fix. + selectCols := "workspace_id, auth_method" + selectCols += pickCol(cols, "command_prefix", "''") + selectCols += pickCol(cols, "utility_agent_id", "''") + selectCols += pickCol(cols, "poll_interval_seconds", "30") + selectCols += pickCol(cols, "slack_team_id", "''") + selectCols += pickCol(cols, "slack_user_id", "''") + selectCols += pickCol(cols, "last_seen_ts", "''") + selectCols += pickCol(cols, "last_checked_at", "NULL") + selectCols += pickCol(cols, "last_ok", "0") + selectCols += pickCol(cols, "last_error", "''") + selectCols += ", created_at, updated_at" + + var sourceWorkspace, authMethod, commandPrefix, utilityAgentID sql.NullString + var pollIntervalSeconds sql.NullInt64 + var slackTeamID, slackUserID, lastSeenTS, lastError sql.NullString + var lastCheckedAt sql.NullTime + var lastOk sql.NullInt64 + var createdAt, updatedAt sql.NullTime + row := tx.QueryRow(`SELECT ` + selectCols + ` FROM slack_configs ORDER BY updated_at DESC, workspace_id DESC LIMIT 1`) + switch err := row.Scan(&sourceWorkspace, &authMethod, + &commandPrefix, &utilityAgentID, &pollIntervalSeconds, + &slackTeamID, &slackUserID, &lastSeenTS, + &lastCheckedAt, &lastOk, &lastError, + &createdAt, &updatedAt); { + case errors.Is(err, sql.ErrNoRows): + if _, err := tx.Exec(`DROP TABLE slack_configs`); err != nil { + return err + } + return tx.Commit() + case err != nil: + return err + } + if _, err := tx.Exec(`DROP TABLE slack_configs`); err != nil { + return err + } + if _, err := tx.Exec(createTablesSQL); err != nil { + return err + } + pollSeconds := int(pollIntervalSeconds.Int64) + if pollSeconds == 0 { + pollSeconds = DefaultPollIntervalSeconds + } + if _, err := tx.Exec(` + INSERT INTO slack_configs ( + id, auth_method, command_prefix, utility_agent_id, poll_interval_seconds, + slack_team_id, slack_user_id, last_seen_ts, + last_checked_at, last_ok, last_error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + singletonID, authMethod.String, commandPrefix.String, utilityAgentID.String, pollSeconds, + slackTeamID.String, slackUserID.String, lastSeenTS.String, + nullableTime(lastCheckedAt), lastOk.Int64, lastError.String, + nullableTime(createdAt), nullableTime(updatedAt)); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + s.migratedFromWorkspace = sourceWorkspace.String + return nil +} + +// pickCol returns ", " if the column is present in the legacy schema +// or ", AS " otherwise. Lets the migration SELECT defaults +// for columns that didn't exist in earlier intermediate schemas. +func pickCol(cols map[string]struct{}, name, fallback string) string { + if _, ok := cols[name]; ok { + return ", " + name + } + return ", " + fallback + " AS " + name +} + +func nullableTime(t sql.NullTime) interface{} { + if !t.Valid { + return nil + } + return t.Time +} + +// tableColumns returns the column-name set for slack_configs. SQLite doesn't +// support parameterised identifiers in PRAGMA, and the only table this +// migration logic ever inspects is slack_configs, so the table name is +// inlined as a literal. +func (s *Store) tableColumns() (map[string]struct{}, error) { + rows, err := s.db.Query(`PRAGMA table_info(slack_configs)`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + cols := make(map[string]struct{}) + for rows.Next() { + var ( + cid int + name string + ctype string + notnull int + dflt sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return nil, err + } + cols[name] = struct{}{} + } + return cols, rows.Err() +} + +const selectConfigColumns = `auth_method, command_prefix, utility_agent_id, + poll_interval_seconds, + slack_team_id, slack_user_id, last_seen_ts, + last_checked_at, last_ok, last_error, created_at, updated_at` + +// GetConfig returns the singleton Slack config, or nil when no row exists. +func (s *Store) GetConfig(ctx context.Context) (*SlackConfig, error) { + var cfg SlackConfig + err := s.ro.GetContext(ctx, &cfg, + `SELECT `+selectConfigColumns+` FROM slack_configs WHERE id = ?`, singletonID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// UpsertConfig inserts or updates the singleton row. Health columns +// (last_*) and watermark columns (last_seen_ts, slack_team_id, slack_user_id) +// are owned by the poller / trigger and aren't touched here. +func (s *Store) UpsertConfig(ctx context.Context, cfg *SlackConfig) error { + now := time.Now().UTC() + if cfg.CreatedAt.IsZero() { + cfg.CreatedAt = now + } + cfg.UpdatedAt = now + _, err := s.db.ExecContext(ctx, ` + INSERT INTO slack_configs ( + id, auth_method, + command_prefix, utility_agent_id, poll_interval_seconds, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + auth_method = excluded.auth_method, + command_prefix = excluded.command_prefix, + utility_agent_id = excluded.utility_agent_id, + poll_interval_seconds = excluded.poll_interval_seconds, + updated_at = excluded.updated_at`, + singletonID, cfg.AuthMethod, + cfg.CommandPrefix, cfg.UtilityAgentID, cfg.PollIntervalSeconds, + cfg.CreatedAt, cfg.UpdatedAt) + return err +} + +// DeleteConfig removes the singleton row. Secrets must be cleared separately. +func (s *Store) DeleteConfig(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM slack_configs WHERE id = ?`, singletonID) + return err +} + +// HasConfig reports whether the singleton row exists. Used by the auth-health +// poller to decide whether to probe at all. +func (s *Store) HasConfig(ctx context.Context) (bool, error) { + var present int + err := s.ro.GetContext(ctx, &present, + `SELECT COUNT(*) FROM slack_configs WHERE id = ?`, singletonID) + if err != nil { + return false, err + } + return present > 0, nil +} + +// UpdateAuthHealth records the result of a credential probe and (when +// supplied) the user/team identifiers captured during the same probe so the +// trigger can scope its searches without an extra round-trip. +func (s *Store) UpdateAuthHealth(ctx context.Context, ok bool, errMsg, teamID, userID string, checkedAt time.Time) error { + if teamID != "" && userID != "" { + _, err := s.db.ExecContext(ctx, ` + UPDATE slack_configs + SET last_checked_at = ?, last_ok = ?, last_error = ?, + slack_team_id = ?, slack_user_id = ? + WHERE id = ?`, + checkedAt, ok, errMsg, teamID, userID, singletonID) + return err + } + _, err := s.db.ExecContext(ctx, ` + UPDATE slack_configs + SET last_checked_at = ?, last_ok = ?, last_error = ? + WHERE id = ?`, + checkedAt, ok, errMsg, singletonID) + return err +} + +// UpdateLastSeenTS advances the trigger's watermark. +func (s *Store) UpdateLastSeenTS(ctx context.Context, ts string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE slack_configs SET last_seen_ts = ? WHERE id = ?`, + ts, singletonID) + return err +} diff --git a/apps/backend/internal/slack/store_test.go b/apps/backend/internal/slack/store_test.go new file mode 100644 index 0000000000..8bd3176991 --- /dev/null +++ b/apps/backend/internal/slack/store_test.go @@ -0,0 +1,250 @@ +package slack + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + raw, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + raw.SetMaxIdleConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func TestStore_UpsertGetDelete(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + cfg := &SlackConfig{ + AuthMethod: AuthMethodCookie, + CommandPrefix: "!kandev", + UtilityAgentID: "ua-1", + PollIntervalSeconds: 30, + } + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("upsert: %v", err) + } + + got, err := store.GetConfig(ctx) + if err != nil { + t.Fatalf("get: %v", err) + } + if got == nil { + t.Fatal("expected config, got nil") + } + if got.UtilityAgentID != "ua-1" || got.CommandPrefix != "!kandev" || got.PollIntervalSeconds != 30 { + t.Errorf("round-trip mismatch: %+v", got) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Error("timestamps not set") + } + + cfg.UtilityAgentID = "ua-2" + cfg.CommandPrefix = "!todo" + if err := store.UpsertConfig(ctx, cfg); err != nil { + t.Fatalf("update upsert: %v", err) + } + got2, _ := store.GetConfig(ctx) + if got2.UtilityAgentID != "ua-2" || got2.CommandPrefix != "!todo" { + t.Errorf("expected updates, got %+v", got2) + } + + if err := store.DeleteConfig(ctx); err != nil { + t.Fatalf("delete: %v", err) + } + gone, err := store.GetConfig(ctx) + if err != nil { + t.Fatalf("get after delete: %v", err) + } + if gone != nil { + t.Errorf("expected nil after delete, got %+v", gone) + } +} + +func TestStore_GetConfig_Missing(t *testing.T) { + store := newTestStore(t) + cfg, err := store.GetConfig(context.Background()) + if err != nil { + t.Fatalf("get: %v", err) + } + if cfg != nil { + t.Errorf("expected nil for missing config, got %+v", cfg) + } +} + +func TestStore_HasConfig(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + has, err := store.HasConfig(ctx) + if err != nil || has { + t.Fatalf("expected no config, got has=%v err=%v", has, err) + } + _ = store.UpsertConfig(ctx, &SlackConfig{ + AuthMethod: AuthMethodCookie, + UtilityAgentID: "ua", + PollIntervalSeconds: 30, + }) + has, err = store.HasConfig(ctx) + if err != nil || !has { + t.Fatalf("expected has=true after upsert, got has=%v err=%v", has, err) + } +} + +func TestStore_UpdateAuthHealth(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + _ = store.UpsertConfig(ctx, &SlackConfig{ + AuthMethod: AuthMethodCookie, + UtilityAgentID: "ua", + PollIntervalSeconds: 30, + }) + + now := time.Now().UTC() + if err := store.UpdateAuthHealth(ctx, true, "", "T0001", "U0001", now); err != nil { + t.Fatalf("update auth health: %v", err) + } + got, _ := store.GetConfig(ctx) + if !got.LastOk || got.SlackTeamID != "T0001" || got.SlackUserID != "U0001" { + t.Errorf("expected health to be recorded, got %+v", got) + } + + if err := store.UpdateAuthHealth(ctx, false, "expired", "", "", now.Add(time.Minute)); err != nil { + t.Fatalf("update auth health 2: %v", err) + } + got2, _ := store.GetConfig(ctx) + if got2.LastOk { + t.Error("expected lastOk false") + } + if got2.LastError != "expired" { + t.Errorf("expected error message, got %q", got2.LastError) + } + if got2.SlackTeamID != "T0001" || got2.SlackUserID != "U0001" { + t.Errorf("expected captured ids preserved across failure, got team=%q user=%q", + got2.SlackTeamID, got2.SlackUserID) + } +} + +func TestStore_UpdateLastSeenTS(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + _ = store.UpsertConfig(ctx, &SlackConfig{ + AuthMethod: AuthMethodCookie, + UtilityAgentID: "ua", + PollIntervalSeconds: 30, + }) + if err := store.UpdateLastSeenTS(ctx, "1714659000.000100"); err != nil { + t.Fatalf("update ts: %v", err) + } + got, _ := store.GetConfig(ctx) + if got.LastSeenTS != "1714659000.000100" { + t.Errorf("expected ts persisted, got %q", got.LastSeenTS) + } +} + +func TestStore_LegacyPerWorkspaceMigration(t *testing.T) { + // Simulate a deployment running the pre-singleton schema: a slack_configs + // table keyed by workspace_id, with the most-recently-updated row to be + // promoted into the singleton on upgrade. + raw, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + raw.SetMaxOpenConns(1) + raw.SetMaxIdleConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + + if _, err := db.Exec(` + CREATE TABLE slack_configs ( + workspace_id TEXT PRIMARY KEY, + auth_method TEXT NOT NULL, + command_prefix TEXT NOT NULL DEFAULT '', + utility_agent_id TEXT NOT NULL DEFAULT '', + poll_interval_seconds INTEGER NOT NULL DEFAULT 30, + slack_team_id TEXT NOT NULL DEFAULT '', + slack_user_id TEXT NOT NULL DEFAULT '', + last_seen_ts TEXT NOT NULL DEFAULT '', + last_checked_at DATETIME, + last_ok INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`); err != nil { + t.Fatalf("create legacy table: %v", err) + } + now := time.Now().UTC() + older := now.Add(-time.Hour) + if _, err := db.Exec(` + INSERT INTO slack_configs (workspace_id, auth_method, command_prefix, utility_agent_id, + poll_interval_seconds, slack_team_id, slack_user_id, last_seen_ts, + last_checked_at, last_ok, last_error, created_at, updated_at) + VALUES + ('ws-old', 'cookie', '!kandev', 'ua-old', 30, 'T-old', 'U-old', '100.0', ?, 1, '', ?, ?), + ('ws-new', 'cookie', '!kandev', 'ua-new', 60, 'T-new', 'U-new', '200.0', ?, 1, '', ?, ?)`, + older, older, older, now, now, now); err != nil { + t.Fatalf("seed legacy rows: %v", err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store: %v", err) + } + if got := store.MigratedFromWorkspace(); got != "ws-new" { + t.Errorf("expected migration to promote ws-new (most recently updated), got %q", got) + } + cfg, err := store.GetConfig(context.Background()) + if err != nil || cfg == nil { + t.Fatalf("get post-migration: cfg=%v err=%v", cfg, err) + } + if cfg.UtilityAgentID != "ua-new" || cfg.CommandPrefix != "!kandev" || cfg.PollIntervalSeconds != 60 { + t.Errorf("expected ws-new fields preserved, got %+v", cfg) + } + if cfg.SlackTeamID != "T-new" || cfg.SlackUserID != "U-new" || cfg.LastSeenTS != "200.0" { + t.Errorf("expected ws-new runtime state preserved, got %+v", cfg) + } +} + +func TestStore_LegacyEmptyTableMigration(t *testing.T) { + // A deployment that had the legacy table created but never inserted a + // row should still upgrade cleanly without crashing on the empty SELECT. + raw, _ := sql.Open("sqlite3", ":memory:") + raw.SetMaxOpenConns(1) + db := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + if _, err := db.Exec(` + CREATE TABLE slack_configs ( + workspace_id TEXT PRIMARY KEY, + auth_method TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`); err != nil { + t.Fatalf("create empty legacy: %v", err) + } + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("new store on empty legacy: %v", err) + } + if store.MigratedFromWorkspace() != "" { + t.Error("expected no migration when legacy table is empty") + } + cfg, _ := store.GetConfig(context.Background()) + if cfg != nil { + t.Errorf("expected no config post empty-legacy upgrade, got %+v", cfg) + } +} diff --git a/apps/backend/internal/slack/trigger.go b/apps/backend/internal/slack/trigger.go new file mode 100644 index 0000000000..32ebc51783 --- /dev/null +++ b/apps/backend/internal/slack/trigger.go @@ -0,0 +1,331 @@ +package slack + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// DefaultBaseInterval is how often the trigger's base loop wakes up to check +// whether a scan is due. The actual scan cadence comes from +// SlackConfig.PollIntervalSeconds; this just bounds how soon a freshly-due +// scan gets noticed. +const DefaultBaseInterval = 5 * time.Second + +// AcknowledgeReaction is the emoji we react with on a matched message before +// the agent runs. +const AcknowledgeReaction = "eyes" + +// Trigger drives the polling loop that turns matched Slack messages into +// utility-agent runs. The loop is install-wide singleton — there's at most +// one Slack-search-per-PollIntervalSeconds running, regardless of how many +// Kandev workspaces exist. +type Trigger struct { + svc *Service + log *logger.Logger + interval time.Duration + + mu sync.Mutex + cancel context.CancelFunc + wg sync.WaitGroup + started bool + + // matchHook fires after each successfully-handled match. Tests use it + // to synchronise on processing without sleep-polling. + matchHook func(msg SlackMessage, reply string) + + // lastScannedAt tracks when we last ran a scan so the configured + // PollIntervalSeconds is honoured exactly. In-memory by design — backend + // restart triggers an immediate scan, which is fine. + scannedMu sync.Mutex + lastScannedAt time.Time +} + +// NewTrigger returns a Trigger using the default base ticker. +func NewTrigger(svc *Service, log *logger.Logger) *Trigger { + if svc == nil { + return nil + } + return &Trigger{svc: svc, log: log, interval: DefaultBaseInterval} +} + +// SetInterval overrides the base-ticker cadence. Must be called before Start. +func (t *Trigger) SetInterval(d time.Duration) { + t.mu.Lock() + t.interval = d + t.mu.Unlock() +} + +// SetMatchHook installs a callback fired after each successfully-handled +// match. +func (t *Trigger) SetMatchHook(fn func(msg SlackMessage, reply string)) { + t.mu.Lock() + t.matchHook = fn + t.mu.Unlock() +} + +// Start launches the loop. +func (t *Trigger) Start(ctx context.Context) { + if t == nil { + return + } + t.mu.Lock() + defer t.mu.Unlock() + if t.started { + return + } + t.started = true + ctx, t.cancel = context.WithCancel(ctx) + t.wg.Add(1) + go t.loop(ctx) + t.log.Info("slack trigger started") +} + +// Stop cancels the loop and waits for it to drain. +func (t *Trigger) Stop() { + if t == nil { + return + } + t.mu.Lock() + if !t.started { + t.mu.Unlock() + return + } + cancel := t.cancel + t.mu.Unlock() + if cancel != nil { + cancel() + } + t.wg.Wait() + t.mu.Lock() + t.started = false + t.mu.Unlock() + t.log.Info("slack trigger stopped") +} + +func (t *Trigger) loop(ctx context.Context) { + defer t.wg.Done() + t.tick(ctx) + ticker := time.NewTicker(t.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + t.tick(ctx) + } + } +} + +// tick scans Slack once if the configured cadence has elapsed. +func (t *Trigger) tick(ctx context.Context) { + if t.svc.Runner() == nil { + return + } + cfg, err := t.svc.Store().GetConfig(ctx) + if err != nil || cfg == nil { + return + } + if !cfg.LastOk || cfg.SlackUserID == "" { + return + } + if cfg.UtilityAgentID == "" { + return + } + if !t.dueForScan(cfg.PollIntervalSeconds) { + return + } + prefix := cfg.CommandPrefix + if prefix == "" { + prefix = DefaultCommandPrefix + } + client, err := t.svc.Client(ctx) + if err != nil { + return + } + t.markScanned() + query := fmt.Sprintf("from:<@%s> %q", cfg.SlackUserID, prefix) + matches, err := client.SearchMessages(ctx, query) + if err != nil { + t.log.Warn("slack trigger: search failed", zap.Error(err)) + return + } + t.processMatches(ctx, cfg, prefix, matches, client) +} + +func (t *Trigger) processMatches(ctx context.Context, cfg *SlackConfig, prefix string, matches []SlackMessage, client Client) { + fresh := newMatchesAfter(matches, cfg.LastSeenTS, prefix) + if len(fresh) == 0 { + return + } + highest := cfg.LastSeenTS + for _, m := range fresh { + if ctx.Err() != nil { + return + } + reply, err := t.handleOne(ctx, cfg, prefix, m, client) + if err != nil { + t.log.Warn("slack trigger: handle match failed", + zap.String("ts", m.TS), zap.Error(err)) + if errors.Is(err, ErrNoUtilityAgent) { + if compareTS(m.TS, highest) > 0 { + highest = m.TS + } + continue + } + // Recoverable failure: stop the loop here so the watermark stays + // at the last consecutive success. See the original commit for + // why this is a `break` (not `return`) — the post-loop + // UpdateLastSeenTS still needs to persist prior successes. + break + } + if compareTS(m.TS, highest) > 0 { + highest = m.TS + } + t.fireHook(m, reply) + } + if highest != cfg.LastSeenTS { + if err := t.svc.Store().UpdateLastSeenTS(ctx, highest); err != nil { + t.log.Warn("slack trigger: advance watermark failed", zap.Error(err)) + } + } +} + +// handleOne runs the per-match flow: ack reaction, fetch thread, run the +// utility agent, post the reply in-thread. +func (t *Trigger) handleOne(ctx context.Context, cfg *SlackConfig, prefix string, msg SlackMessage, client Client) (string, error) { + if err := client.ReactionsAdd(ctx, msg.ChannelID, msg.TS, AcknowledgeReaction); err != nil { + t.log.Warn("slack trigger: ack reaction failed", + zap.String("ts", msg.TS), zap.Error(err)) + } + + thread, err := client.ConversationsReplies(ctx, msg.ChannelID, msg.ThreadTS, msg.TS) + if err != nil { + return "", fmt.Errorf("fetch thread: %w", err) + } + permalink := msg.Permalink + if permalink == "" { + permalink, _ = client.ChatGetPermalink(ctx, msg.ChannelID, msg.TS) + } + instruction := stripPrefix(msg.Text, prefix) + + runner := t.svc.Runner() + if runner == nil { + return "", errors.New("agent runner not configured") + } + reply, err := runner.RunForMatch(ctx, cfg, msg, instruction, permalink, thread) + if err != nil { + return "", err + } + t.replyInThread(ctx, client, msg, reply) + return reply, nil +} + +func (t *Trigger) replyInThread(ctx context.Context, client Client, msg SlackMessage, body string) { + threadTS := msg.ThreadTS + if threadTS == "" { + threadTS = msg.TS + } + if err := client.ChatPostMessage(ctx, msg.ChannelID, threadTS, body); err != nil { + t.log.Warn("slack trigger: reply post failed", + zap.String("ts", msg.TS), zap.Error(err)) + } +} + +func (t *Trigger) fireHook(msg SlackMessage, reply string) { + t.mu.Lock() + hook := t.matchHook + t.mu.Unlock() + if hook != nil { + hook(msg, reply) + } +} + +// dueForScan reports whether the configured interval has elapsed since the +// last scan. A zero/never timestamp counts as due. +func (t *Trigger) dueForScan(intervalSeconds int) bool { + if intervalSeconds < MinPollIntervalSeconds { + intervalSeconds = DefaultPollIntervalSeconds + } + t.scannedMu.Lock() + defer t.scannedMu.Unlock() + if t.lastScannedAt.IsZero() { + return true + } + return time.Since(t.lastScannedAt) >= time.Duration(intervalSeconds)*time.Second +} + +func (t *Trigger) markScanned() { + t.scannedMu.Lock() + t.lastScannedAt = time.Now() + t.scannedMu.Unlock() +} + +func newMatchesAfter(matches []SlackMessage, watermark, prefix string) []SlackMessage { + out := make([]SlackMessage, 0, len(matches)) + for _, m := range matches { + if compareTS(m.TS, watermark) <= 0 { + continue + } + if !startsWithPrefix(m.Text, prefix) { + continue + } + out = append(out, m) + } + sort.SliceStable(out, func(i, j int) bool { + return compareTS(out[i].TS, out[j].TS) < 0 + }) + return out +} + +func startsWithPrefix(text, prefix string) bool { + t := strings.TrimSpace(text) + t = strings.TrimPrefix(t, "> ") + t = strings.TrimSpace(t) + if !strings.HasPrefix(strings.ToLower(t), strings.ToLower(prefix)) { + return false + } + rest := t[len(prefix):] + if rest == "" { + return true + } + r := rest[0] + return r == ' ' || r == '\t' || r == '\n' || r == ':' || r == ',' +} + +func stripPrefix(text, prefix string) string { + t := strings.TrimSpace(text) + t = strings.TrimPrefix(t, "> ") + t = strings.TrimSpace(t) + if len(t) >= len(prefix) && strings.EqualFold(t[:len(prefix)], prefix) { + t = t[len(prefix):] + } + t = strings.TrimSpace(t) + t = strings.TrimLeft(t, ":, ") + return strings.TrimSpace(t) +} + +func compareTS(a, b string) int { + if a == b { + return 0 + } + if a == "" { + return -1 + } + if b == "" { + return 1 + } + if a < b { + return -1 + } + return 1 +} diff --git a/apps/backend/internal/slack/trigger_test.go b/apps/backend/internal/slack/trigger_test.go new file mode 100644 index 0000000000..8db683b73b --- /dev/null +++ b/apps/backend/internal/slack/trigger_test.go @@ -0,0 +1,629 @@ +package slack + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" + "github.com/kandev/kandev/internal/common/logger" + utilitymodels "github.com/kandev/kandev/internal/utility/models" + utilityservice "github.com/kandev/kandev/internal/utility/service" + "github.com/kandev/kandev/internal/utility/template" +) + +// --- prefix / parser tests --- + +func TestStartsWithPrefix(t *testing.T) { + cases := []struct { + text string + prefix string + want bool + }{ + {"!kandev fix login bug", "!kandev", true}, + {" !kandev fix login bug", "!kandev", true}, + {"!kandev: do the thing", "!kandev", true}, + {"!kandev", "!kandev", true}, + {"!kandevops handle this", "!kandev", false}, + {"hey !kandev fix this", "!kandev", false}, + {"> !kandev quoted reply", "!kandev", true}, + {"!KANDEV case insensitive", "!kandev", true}, + {"", "!kandev", false}, + } + for _, c := range cases { + if got := startsWithPrefix(c.text, c.prefix); got != c.want { + t.Errorf("startsWithPrefix(%q, %q) = %v, want %v", c.text, c.prefix, got, c.want) + } + } +} + +func TestStripPrefix(t *testing.T) { + cases := []struct { + text, prefix, want string + }{ + {"!kandev fix login bug", "!kandev", "fix login bug"}, + {" !kandev fix login bug ", "!kandev", "fix login bug"}, + {"!kandev: handle this", "!kandev", "handle this"}, + {"!kandev,please", "!kandev", "please"}, + {"> !kandev quoted reply", "!kandev", "quoted reply"}, + {"!kandev", "!kandev", ""}, + } + for _, c := range cases { + if got := stripPrefix(c.text, c.prefix); got != c.want { + t.Errorf("stripPrefix(%q, %q) = %q, want %q", c.text, c.prefix, got, c.want) + } + } +} + +func TestNewMatchesAfter(t *testing.T) { + matches := []SlackMessage{ + {TS: "100.000005", Text: "!kandev five"}, + {TS: "100.000003", Text: "!kandev three"}, + {TS: "100.000004", Text: "!kandevops noop"}, + {TS: "100.000002", Text: "!kandev two"}, + } + got := newMatchesAfter(matches, "100.000003", "!kandev") + if len(got) != 1 || got[0].TS != "100.000005" { + t.Errorf("expected only the freshest matching message, got %+v", got) + } +} + +func TestNewMatchesAfter_OrdersOldestFirst(t *testing.T) { + matches := []SlackMessage{ + {TS: "100.000010", Text: "!kandev ten"}, + {TS: "100.000005", Text: "!kandev five"}, + {TS: "100.000007", Text: "!kandev seven"}, + } + got := newMatchesAfter(matches, "", "!kandev") + if len(got) != 3 { + t.Fatalf("expected 3, got %d", len(got)) + } + if got[0].TS != "100.000005" || got[1].TS != "100.000007" || got[2].TS != "100.000010" { + t.Errorf("expected oldest-first order, got %v", []string{got[0].TS, got[1].TS, got[2].TS}) + } +} + +func TestCompareTS(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"", "", 0}, + {"", "100.0", -1}, + {"100.0", "", 1}, + {"100.0", "100.0", 0}, + {"100.0", "200.0", -1}, + {"200.0", "100.0", 1}, + } + for _, c := range cases { + if got := compareTS(c.a, c.b); got != c.want { + t.Errorf("compareTS(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +// --- prompt composition --- + +func TestComposePrompt_DefaultSystemWhenEmptyTemplate(t *testing.T) { + r := &Runner{} + thread := []SlackMessage{{TS: "100", UserName: "alice", Text: "thread message"}} + prompt := r.composePrompt("", "", + SlackMessage{UserName: "alice"}, "do thing", "https://slack/p100", thread) + if !strings.Contains(prompt, "Kandev triage assistant") { + t.Error("expected default system prompt when template is empty") + } + if !strings.Contains(prompt, "https://slack/p100") { + t.Error("expected permalink in appended block") + } + if !strings.Contains(prompt, "do thing") { + t.Error("expected instruction in appended block") + } +} + +func TestComposePrompt_TemplateOwnsLayoutWhenSubstituted(t *testing.T) { + r := &Runner{} + rawTemplate := "Custom: {{SlackInstruction}} for {{KandevWorkspaceID}}" + resolved := "Custom: do thing for ws-1" + prompt := r.composePrompt(rawTemplate, resolved, SlackMessage{}, "do thing", "", nil) + if prompt != "Custom: do thing for ws-1" { + t.Errorf("expected the resolved template only, got: %q", prompt) + } +} + +func TestComposePrompt_FallsBackWhenTemplateHasNoVars(t *testing.T) { + // Template with no Slack-specific vars → resolved == raw (after trim) → + // fall back so the agent still gets the Slack context block appended. + r := &Runner{} + template := "Be concise." + prompt := r.composePrompt(template, template, + SlackMessage{UserName: "alice"}, "do x", "https://slack/p", nil) + if !strings.Contains(prompt, "Be concise.") { + t.Error("expected user template at top") + } + if !strings.Contains(prompt, "Slack thread:") && !strings.Contains(prompt, "Request from") { + t.Error("expected Slack context block to be appended for non-templated systems") + } +} + +// --- runner end-to-end --- + +type fakeUtility struct { + agents map[string]*utilitymodels.UtilityAgent +} + +func (f fakeUtility) GetAgentByID(ctx context.Context, id string) (*utilitymodels.UtilityAgent, error) { + a, ok := f.agents[id] + if !ok { + return nil, errors.New("not found") + } + return a, nil +} + +func (f fakeUtility) PreparePromptRequest( + ctx context.Context, + utilityID string, + tmplCtx *template.Context, + defaults *utilityservice.DefaultUtilitySettings, + sessionless bool, +) (*utilityservice.PromptRequest, error) { + a, ok := f.agents[utilityID] + if !ok { + return nil, errors.New("not found") + } + // Resolve via the real engine so Custom vars actually substitute. + resolved, err := template.NewEngine().ResolveWithOptions(a.Prompt, tmplCtx, template.ResolveOptions{MissingAsEmpty: sessionless}) + if err != nil { + return nil, err + } + agentCLI, model := a.AgentID, a.Model + if agentCLI == "" && defaults != nil { + agentCLI = defaults.AgentID + } + if model == "" && defaults != nil { + model = defaults.Model + } + return &utilityservice.PromptRequest{ + UtilityID: utilityID, + ResolvedPrompt: resolved, + AgentCLI: agentCLI, + Model: model, + }, nil +} + +type fakeUserDefaults struct { + agentID, model string + err error +} + +func (f fakeUserDefaults) GetDefaultUtilitySettings(ctx context.Context) (string, string, error) { + return f.agentID, f.model, f.err +} + +type fakeHostRunner struct { + mu sync.Mutex + calls []hostCall + response string + err error +} + +type hostCall struct { + agentType, model, mode, prompt string + mcpServers []agentctlutil.MCPServerDTO +} + +func (f *fakeHostRunner) ExecutePromptWithMCP( + ctx context.Context, + agentType, model, mode, prompt string, + mcpServers []agentctlutil.MCPServerDTO, +) (HostPromptResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, hostCall{agentType, model, mode, prompt, mcpServers}) + if f.err != nil { + return HostPromptResult{}, f.err + } + return HostPromptResult{Response: f.response, Model: model, DurationMs: 1}, nil +} + +func TestRunner_RunForMatch_HappyPath(t *testing.T) { + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": { + ID: "ua-1", Name: "Slack triage", AgentID: "claude-acp", + Model: "claude-haiku-4-5", Enabled: true, + Prompt: "You are a Slack triage helper.", + }, + }} + host := &fakeHostRunner{response: "Created task 'Fix checkout bug' in workspace ws-1."} + mcp := []MCPDescriptor{{Name: "kandev", URL: "http://localhost:38429/mcp"}} + r := NewRunner(utility, fakeUserDefaults{}, host, mcp, logger.Default()) + + cfg := &SlackConfig{UtilityAgentID: "ua-1"} + msg := SlackMessage{TS: "100.0001", ChannelID: "C1", UserID: "U1", UserName: "alice", Text: "!kandev fix checkout"} + thread := []SlackMessage{msg} + + reply, err := r.RunForMatch(context.Background(), cfg, msg, "fix checkout", "https://example/p100", thread) + if err != nil { + t.Fatalf("RunForMatch: %v", err) + } + if !strings.Contains(reply, "Created task") { + t.Errorf("unexpected reply: %q", reply) + } + if len(host.calls) != 1 { + t.Fatalf("expected one host call, got %d", len(host.calls)) + } + call := host.calls[0] + if call.agentType != "claude-acp" || call.model != "claude-haiku-4-5" { + t.Errorf("expected agent_id+model passthrough, got %+v", call) + } + if !strings.Contains(call.prompt, "You are a Slack triage helper.") { + t.Error("expected utility agent's custom system prompt to be used") + } + if !strings.Contains(call.prompt, "fix checkout") { + t.Error("expected user instruction in prompt") + } + if len(call.mcpServers) != 1 || call.mcpServers[0].Name != "kandev" || call.mcpServers[0].URL != "http://localhost:38429/mcp" { + t.Errorf("expected kandev MCP wired, got %+v", call.mcpServers) + } + if call.mcpServers[0].Type != "http" { + t.Errorf("expected http transport, got %q", call.mcpServers[0].Type) + } +} + +func TestRunner_RunForMatch_NoUtilityAgent(t *testing.T) { + r := NewRunner(fakeUtility{}, fakeUserDefaults{}, &fakeHostRunner{}, nil, logger.Default()) + cfg := &SlackConfig{} + _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{}, "x", "", nil) + if !errors.Is(err, ErrNoUtilityAgent) { + t.Errorf("expected ErrNoUtilityAgent, got %v", err) + } +} + +func TestRunner_RunForMatch_DisabledBuiltinFallsBackToDefaults(t *testing.T) { + // Built-in utility agents ship with enabled=false and empty agent_id / + // model — they're meant to use the user's default agent + model. The + // runner shouldn't reject this configuration; PreparePromptRequest + // substitutes the defaults at resolution time, matching how the rest of + // Kandev runs utility agents. + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "builtin-x": {ID: "builtin-x", Name: "x", Builtin: true, Enabled: false, Prompt: ""}, + }} + host := &fakeHostRunner{response: "ok"} + r := NewRunner(utility, fakeUserDefaults{agentID: "claude-acp", model: "claude-haiku-4-5"}, + host, nil, logger.Default()) + cfg := &SlackConfig{UtilityAgentID: "builtin-x"} + if _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil); err != nil { + t.Fatalf("expected disabled built-in to run with user defaults, got: %v", err) + } + if len(host.calls) != 1 || host.calls[0].agentType != "claude-acp" || host.calls[0].model != "claude-haiku-4-5" { + t.Errorf("expected default agent_id+model used, got %+v", host.calls) + } +} + +func TestRunner_RunForMatch_NoAgentResolvedErrors(t *testing.T) { + // When the chosen agent has no agent_id and there are no user defaults + // either, we can't pick an inference subprocess to spawn — surface a + // clear error rather than silently failing. + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": {ID: "ua-1", Name: "no-config", Builtin: false, Enabled: false}, + }} + r := NewRunner(utility, fakeUserDefaults{}, &fakeHostRunner{}, nil, logger.Default()) + cfg := &SlackConfig{UtilityAgentID: "ua-1"} + _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{}, "x", "", nil) + if err == nil || !strings.Contains(err.Error(), "no agent_id resolved") { + t.Errorf("expected 'no agent_id resolved' error, got %v", err) + } +} + +func TestRunner_RunForMatch_EmptyResponseFallsBack(t *testing.T) { + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true}, + }} + host := &fakeHostRunner{response: ""} + r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()) + cfg := &SlackConfig{UtilityAgentID: "ua-1"} + reply, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil) + if err != nil { + t.Fatalf("expected success with fallback reply, got error: %v", err) + } + if reply == "" { + t.Error("expected fallback reply text, got empty string") + } +} + +func TestRunner_RunForMatch_HostErrorBubbles(t *testing.T) { + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true}, + }} + host := &fakeHostRunner{err: errors.New("subprocess crashed")} + r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()) + cfg := &SlackConfig{UtilityAgentID: "ua-1"} + _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil) + if err == nil || !strings.Contains(err.Error(), "subprocess crashed") { + t.Errorf("expected host error to bubble, got %v", err) + } +} + +// --- trigger end-to-end with fakes --- + +type fakeClient struct { + mu sync.Mutex + thread []SlackMessage + permalink string + posted []postRecord + reactions []reactionRecord +} + +type postRecord struct{ channel, threadTS, text string } +type reactionRecord struct{ channel, ts, name string } + +func (f *fakeClient) AuthTest(ctx context.Context) (*TestConnectionResult, error) { + return &TestConnectionResult{OK: true, UserID: "U1", TeamID: "T1"}, nil +} +func (f *fakeClient) SearchMessages(ctx context.Context, query string) ([]SlackMessage, error) { + return nil, nil +} +func (f *fakeClient) ConversationsReplies(ctx context.Context, channelID, threadTS, triggerTS string) ([]SlackMessage, error) { + return f.thread, nil +} +func (f *fakeClient) ChatGetPermalink(ctx context.Context, channelID, ts string) (string, error) { + return f.permalink, nil +} +func (f *fakeClient) ChatPostMessage(ctx context.Context, channelID, threadTS, text string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.posted = append(f.posted, postRecord{channelID, threadTS, text}) + return nil +} +func (f *fakeClient) ReactionsAdd(ctx context.Context, channelID, ts, name string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.reactions = append(f.reactions, reactionRecord{channelID, ts, name}) + return nil +} + +func TestTrigger_HandleOne_PostsAgentReplyInThread(t *testing.T) { + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "claude-haiku-4-5", Enabled: true}, + }} + host := &fakeHostRunner{response: "Created task X."} + + store := newTestStore(t) + factory := func(_ *SlackConfig, _ string, _ string) Client { + return &fakeClient{thread: []SlackMessage{{TS: "100.0001", Text: "!kandev x"}}} + } + svc := NewService(store, nil, NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()), factory, logger.Default()) + ctx := context.Background() + _ = svc.Store().UpsertConfig(ctx, &SlackConfig{ + AuthMethod: AuthMethodCookie, + UtilityAgentID: "ua-1", CommandPrefix: "!kandev", + }) + + client := &fakeClient{thread: []SlackMessage{{TS: "100.0001", Text: "!kandev fix it"}}, permalink: "https://example/p100"} + svc.client = client + + cfg, _ := svc.Store().GetConfig(ctx) + trig := NewTrigger(svc, logger.Default()) + reply, err := trig.handleOne(ctx, cfg, "!kandev", SlackMessage{ + TS: "100.0001", ChannelID: "C1", Text: "!kandev fix it", + }, client) + if err != nil { + t.Fatalf("handleOne: %v", err) + } + if reply != "Created task X." { + t.Errorf("expected agent reply passthrough, got %q", reply) + } + if len(client.posted) != 1 { + t.Fatalf("expected one Slack reply, got %d", len(client.posted)) + } + if client.posted[0].text != "Created task X." || client.posted[0].channel != "C1" { + t.Errorf("unexpected slack post: %+v", client.posted[0]) + } + if client.posted[0].threadTS != "100.0001" { + t.Errorf("expected thread anchor on the trigger ts, got %q", client.posted[0].threadTS) + } + if len(client.reactions) != 1 { + t.Fatalf("expected one ack reaction, got %d", len(client.reactions)) + } + r := client.reactions[0] + if r.channel != "C1" || r.ts != "100.0001" || r.name != AcknowledgeReaction { + t.Errorf("unexpected reaction: %+v", r) + } +} + +// --- helper assertions --- + +func TestRunner_PromptIncludesAllSlackContext(t *testing.T) { + // Sanity: run a single match and inspect the prompt the host saw to + // confirm permalink + workspace + thread all made it through. Belt and + // braces against future refactors of buildAgentPrompt. + utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{ + "ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true}, + }} + host := &fakeHostRunner{response: "ok"} + r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()) + cfg := &SlackConfig{UtilityAgentID: "ua-1"} + msg := SlackMessage{TS: "200", UserName: "bob", Text: "!kandev investigate"} + thread := []SlackMessage{msg, {TS: "199", UserName: "alice", Text: "saw a 500 on /api/foo"}} + if _, err := r.RunForMatch(context.Background(), cfg, msg, "investigate", "https://slack/p200", thread); err != nil { + t.Fatalf("RunForMatch: %v", err) + } + prompt := host.calls[0].prompt + for _, want := range []string{"https://slack/p200", "investigate", "saw a 500", "@bob"} { + if !strings.Contains(prompt, want) { + t.Errorf("expected %q in prompt; got:\n%s", want, prompt) + } + } +} + +// --- watermark advance under partial failure --- + +// failingRunner fails handleOne the Nth time it's called (1-indexed). +type failingRunner struct { + mu sync.Mutex + calls int + failOn int + fakeErr error +} + +func (f *failingRunner) RunForMatch(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.calls == f.failOn { + return "", f.fakeErr + } + return "ok " + msg.TS, nil +} + +// TestTrigger_ProcessMatches_BreaksOnTransientError exercises the regression +// the previous "continue on error" loop introduced: in a batch [A, B, C] +// where B fails with a transient error and C would succeed, the watermark +// must advance only past A — not past C — so the next tick re-processes B +// and C. This is the core promise of the !kandev capture flow ("no message +// silently dropped") and easy to break with a refactor that reverts to +// `continue`. +func TestTrigger_ProcessMatches_BreaksOnTransientError(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + _ = store.UpsertConfig(ctx, &SlackConfig{ + + AuthMethod: AuthMethodCookie, + UtilityAgentID: "ua-1", + CommandPrefix: "!kandev", + }) + cfg, _ := store.GetConfig(ctx) + + runner := &failingRunner{failOn: 2, fakeErr: errors.New("transient agent failure")} + factory := func(_ *SlackConfig, _ string, _ string) Client { + return &fakeClient{} + } + svc := NewService(store, nil, runner, factory, logger.Default()) + client := &fakeClient{thread: []SlackMessage{{TS: "100.000001", Text: "!kandev a"}}} + svc.client = client + + trig := NewTrigger(svc, logger.Default()) + matches := []SlackMessage{ + {TS: "100.000001", ChannelID: "C1", Text: "!kandev a"}, + {TS: "100.000002", ChannelID: "C1", Text: "!kandev b"}, + {TS: "100.000003", ChannelID: "C1", Text: "!kandev c"}, + } + trig.processMatches(ctx, cfg, "!kandev", matches, client) + + got, _ := svc.Store().GetConfig(ctx) + if got.LastSeenTS != "100.000001" { + t.Errorf("expected watermark to advance only past A (the consecutive prefix of successes), got %q", got.LastSeenTS) + } + if runner.calls != 2 { + t.Errorf("expected loop to break after first failure (2 calls: A success, B fail); got %d calls", runner.calls) + } +} + +// TestTrigger_ProcessMatches_NoUtilityAgentSkipsWithoutBreak verifies the +// other branch of the failure logic: ErrNoUtilityAgent is unrecoverable, so +// processing continues to subsequent matches and the watermark spans all +// observed messages. Previously this was the only configured behaviour and +// is the only one a production user can hit until they pick an agent. +func TestTrigger_ProcessMatches_NoUtilityAgentSkipsWithoutBreak(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + _ = store.UpsertConfig(ctx, &SlackConfig{ + + AuthMethod: AuthMethodCookie, + CommandPrefix: "!kandev", + // Note: no UtilityAgentID — runner will return ErrNoUtilityAgent. + }) + cfg, _ := store.GetConfig(ctx) + + // Track invocations directly so we can assert the loop *didn't* break + // on ErrNoUtilityAgent (only transient errors should break the loop). + var calls int + var callsMu sync.Mutex + noAgentRunner := agentRunnerFunc(func(ctx context.Context, _ *SlackConfig, msg SlackMessage, _, _ string, _ []SlackMessage) (string, error) { + callsMu.Lock() + calls++ + callsMu.Unlock() + return "", ErrNoUtilityAgent + }) + + factory := func(_ *SlackConfig, _ string, _ string) Client { return &fakeClient{} } + svc := NewService(store, nil, noAgentRunner, factory, logger.Default()) + svc.client = &fakeClient{} + + trig := NewTrigger(svc, logger.Default()) + matches := []SlackMessage{ + {TS: "100.000001", ChannelID: "C1", Text: "!kandev a"}, + {TS: "100.000002", ChannelID: "C1", Text: "!kandev b"}, + } + trig.processMatches(ctx, cfg, "!kandev", matches, &fakeClient{}) + + got, _ := svc.Store().GetConfig(ctx) + if got.LastSeenTS != "100.000002" { + t.Errorf("expected watermark to advance past *all* matches under ErrNoUtilityAgent, got %q", got.LastSeenTS) + } + callsMu.Lock() + finalCalls := calls + callsMu.Unlock() + if finalCalls != 2 { + t.Errorf("expected runner to be invoked for every match (no break), got %d calls", finalCalls) + } +} + +// agentRunnerFunc adapts a function to the AgentRunner interface for tests. +type agentRunnerFunc func(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) + +func (f agentRunnerFunc) RunForMatch(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) { + return f(ctx, cfg, msg, instruction, permalink, thread) +} + +// TestComposePrompt_GenericTemplateGetsResolvedSubstitution verifies the +// follow-up fix to the composePrompt heuristic: when a template uses only +// non-Slack placeholders (e.g. {{UserPrompt}}), the fallback path uses the +// *resolved* template text rather than the raw template, so the agent sees +// the substituted value instead of the literal {{UserPrompt}} placeholder. +func TestComposePrompt_GenericTemplateGetsResolvedSubstitution(t *testing.T) { + r := &Runner{} + rawTemplate := "Be brief. Task: {{UserPrompt}}" + resolved := "Be brief. Task: fix checkout" + prompt := r.composePrompt(rawTemplate, resolved, + SlackMessage{UserName: "alice"}, "fix checkout", "https://slack/p", nil) + if !strings.Contains(prompt, "Task: fix checkout") { + t.Errorf("expected resolved {{UserPrompt}} in fallback path; got:\n%s", prompt) + } + if strings.Contains(prompt, "{{UserPrompt}}") { + t.Errorf("expected no literal placeholder to leak through; got:\n%s", prompt) + } +} + +// --- install-wide cadence --- + +func TestTrigger_DueForScan(t *testing.T) { + trig := &Trigger{} + if !trig.dueForScan(30) { + t.Error("expected fresh trigger to be due immediately") + } + trig.markScanned() + if trig.dueForScan(30) { + t.Error("expected trigger to not be due immediately after scan") + } + + // Backdate the marker to simulate enough elapsed time. Using a direct + // field write here rather than time.Sleep keeps the test deterministic. + trig.scannedMu.Lock() + trig.lastScannedAt = time.Now().Add(-31 * time.Second) + trig.scannedMu.Unlock() + if !trig.dueForScan(30) { + t.Error("expected trigger to be due again after 31s with 30s interval") + } + + // Bogus interval (below floor) falls back to the default — guards against + // a config row with 0 (or a corrupted row) holding the loop hostage. + trig.scannedMu.Lock() + trig.lastScannedAt = time.Now() + trig.scannedMu.Unlock() + if trig.dueForScan(0) { + t.Error("expected zero interval to behave as default 30s, not as 'always due'") + } +} diff --git a/apps/backend/internal/utility/service/service.go b/apps/backend/internal/utility/service/service.go index eac9d69828..75ea71bc6a 100644 --- a/apps/backend/internal/utility/service/service.go +++ b/apps/backend/internal/utility/service/service.go @@ -80,6 +80,12 @@ func (s *Service) CreateAgent(ctx context.Context, name, description, prompt, ag AgentID: agentID, Model: model, Builtin: false, + // Custom agents validate agent_id + model above, so they're always + // configured and ready to run at creation time. The DB's NOT NULL + // column persists the field value (not the schema default), so we + // have to set it here — otherwise new custom agents land disabled + // and never appear as runnable in pickers like the Slack triage one. + Enabled: true, } if err := s.repo.CreateAgent(ctx, agent); err != nil { diff --git a/apps/backend/internal/utility/store/sqlite.go b/apps/backend/internal/utility/store/sqlite.go index ea95950524..112950f043 100644 --- a/apps/backend/internal/utility/store/sqlite.go +++ b/apps/backend/internal/utility/store/sqlite.go @@ -83,6 +83,22 @@ func (r *sqliteRepository) initSchema() error { // Add enabled column if it doesn't exist (migration for existing DBs) _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`) + // Heal pre-existing custom agents that were created with enabled=0 due + // to a bug in CreateAgent (the Enabled field on the model defaulted to + // the zero value, so the INSERT wrote 0 even though the DB schema's + // own DEFAULT was 1). Built-ins are skipped — they have their own + // "enabled=false means fall back to user defaults" semantics. + // + // Restricted to rows whose updated_at == created_at — i.e. agents that + // have never been modified since INSERT. UpdateAgent always bumps + // updated_at, so a user who deliberately disables an agent post-fix + // has updated_at > created_at and is left alone. Without this guard + // every restart would silently re-enable any user-disabled custom agent. + _, _ = r.db.Exec(`UPDATE utility_agents SET enabled = 1 + WHERE builtin = 0 AND enabled = 0 + AND agent_id <> '' AND model <> '' + AND updated_at = created_at`) + // Seed built-in agents if err := r.seedBuiltinAgents(); err != nil { return fmt.Errorf("failed to seed built-in agents: %w", err) diff --git a/apps/backend/internal/utility/template/engine.go b/apps/backend/internal/utility/template/engine.go index 997e671f88..adbc87c382 100644 --- a/apps/backend/internal/utility/template/engine.go +++ b/apps/backend/internal/utility/template/engine.go @@ -106,7 +106,11 @@ func (e *Engine) buildTemplateData(ctx *Context) map[string]string { } // AvailableVariables returns the list of available template variables. +// Integration-specific variables (Slack, future webhooks, etc.) are listed +// in their own category so the UI can group them and users can tell which +// ones make sense in a given utility-agent context. func (e *Engine) AvailableVariables() []VariableInfo { + const categorySlack = "slack" return []VariableInfo{ {Name: "GitDiff", Description: "Output of git diff --staged or git diff", Example: "diff --git a/main.go...", Category: "git"}, {Name: "CommitLog", Description: "Git log for the current branch", Example: "abc123 Fix bug in handler", Category: "git"}, @@ -120,6 +124,12 @@ func (e *Engine) AvailableVariables() []VariableInfo { {Name: "WorkspacePath", Description: "Path to the workspace/worktree", Example: "/home/user/project", Category: "session"}, {Name: "UserPrompt", Description: "User's original prompt text", Example: "Fix the login bug", Category: "input"}, {Name: "ConversationHistory", Description: "Formatted conversation transcript", Example: "User: Fix the bug\nAgent: I'll look into...", Category: "session"}, + {Name: "SlackInstruction", Description: "User's instruction (the !kandev message minus the prefix)", Example: "investigate the safari login bug", Category: categorySlack}, + {Name: "SlackThread", Description: "Surrounding Slack thread, one line per message", Example: "alice (1714658000.0): bug repros every Monday\nbob (1714659000.0): seeing it on safari only", Category: categorySlack}, + {Name: "SlackPermalink", Description: "Canonical URL of the triggering Slack message", Example: "https://acme.slack.com/archives/C1/p1714659000000100", Category: categorySlack}, + {Name: "SlackUser", Description: "Sender of the !kandev message (@name or user id)", Example: "@alice", Category: categorySlack}, + {Name: "SlackChannelID", Description: "Slack channel id of the trigger message", Example: "C0123ABCD", Category: categorySlack}, + {Name: "SlackTS", Description: "Slack timestamp of the trigger message", Example: "1714659000.000100", Category: categorySlack}, } } @@ -128,5 +138,5 @@ type VariableInfo struct { Name string `json:"name"` Description string `json:"description"` Example string `json:"example"` - Category string `json:"category"` // "git", "task", "session" + Category string `json:"category"` // "git", "task", "session", "input", "slack" } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index ee212f9a34..ea83c8ff23 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -303,6 +303,7 @@ const ( ActionMCPListWorkspaces = "mcp.list_workspaces" ActionMCPListWorkflows = "mcp.list_workflows" ActionMCPListWorkflowSteps = "mcp.list_workflow_steps" + ActionMCPListRepositories = "mcp.list_repositories" ActionMCPListTasks = "mcp.list_tasks" ActionMCPCreateTask = "mcp.create_task" ActionMCPUpdateTask = "mcp.update_task" @@ -407,6 +408,14 @@ const ( ActionLinearTeamsList = "linear.teams.list" ) +// Slack integration actions +const ( + ActionSlackConfigGet = "slack.config.get" + ActionSlackConfigSet = "slack.config.set" + ActionSlackConfigDelete = "slack.config.delete" + ActionSlackConfigTest = "slack.config.test" +) + // Error codes const ( ErrorCodeBadRequest = "BAD_REQUEST" diff --git a/apps/web/app/settings/integrations/page.tsx b/apps/web/app/settings/integrations/page.tsx index 7b705ae57d..c6b3c121fc 100644 --- a/apps/web/app/settings/integrations/page.tsx +++ b/apps/web/app/settings/integrations/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { IconBrandGithub, IconHexagon, IconTicket } from "@tabler/icons-react"; +import { IconBrandGithub, IconBrandSlack, IconHexagon, IconTicket } from "@tabler/icons-react"; import { Card, CardContent } from "@kandev/ui/card"; const INTEGRATIONS = [ @@ -21,6 +21,12 @@ const INTEGRATIONS = [ description: "Personal API key and team defaults.", Icon: IconHexagon, }, + { + href: "/settings/integrations/slack", + label: "Slack", + description: "Browser-session credentials and !kandev triage agent.", + Icon: IconBrandSlack, + }, ]; export default function IntegrationsIndexPage() { diff --git a/apps/web/app/settings/integrations/slack/page.tsx b/apps/web/app/settings/integrations/slack/page.tsx new file mode 100644 index 0000000000..b4f8a43924 --- /dev/null +++ b/apps/web/app/settings/integrations/slack/page.tsx @@ -0,0 +1,5 @@ +import { SlackIntegrationPage } from "@/components/slack/slack-settings"; + +export default function IntegrationsSlackPage() { + return ; +} diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index d08f0f2906..ad012afdac 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -13,6 +13,7 @@ import { IconKey, IconMessageCircle, IconBrandGithub, + IconBrandSlack, IconHexagon, IconSparkles, IconWand, @@ -152,6 +153,7 @@ function IntegrationsSidebarSection({ pathname }: { pathname: string }) { { href: "/settings/integrations/github", label: "GitHub", Icon: IconBrandGithub }, { href: "/settings/integrations/jira", label: "Jira", Icon: IconTicket }, { href: "/settings/integrations/linear", label: "Linear", Icon: IconHexagon }, + { href: "/settings/integrations/slack", label: "Slack", Icon: IconBrandSlack }, ]; return ( diff --git a/apps/web/components/slack/slack-settings.tsx b/apps/web/components/slack/slack-settings.tsx new file mode 100644 index 0000000000..2988793b26 --- /dev/null +++ b/apps/web/components/slack/slack-settings.tsx @@ -0,0 +1,597 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { IconBrandSlack } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Separator } from "@kandev/ui/separator"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Switch } from "@kandev/ui/switch"; +import { useToast } from "@/components/toast-provider"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { useSlackEnabled } from "@/hooks/domains/slack/use-slack-enabled"; +import { + IntegrationAuthStatusBanner, + type IntegrationAuthHealth, +} from "@/components/integrations/auth-status-banner"; +import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability"; +import { + getSlackConfig, + setSlackConfig, + deleteSlackConfig, + testSlackConnection, +} from "@/lib/api/domains/slack-api"; +import { listUtilityAgents, type UtilityAgent } from "@/lib/api/domains/utility-api"; +import type { SlackConfig, TestSlackConnectionResult } from "@/lib/types/slack"; + +const DEFAULT_PREFIX = "!kandev"; +const DEFAULT_POLL_INTERVAL_SECONDS = 30; +const MIN_POLL_INTERVAL_SECONDS = 5; +const MAX_POLL_INTERVAL_SECONDS = 600; + +type FormState = { + utilityAgentId: string; + commandPrefix: string; + pollIntervalSeconds: number; + token: string; + cookie: string; +}; + +const emptyForm: FormState = { + utilityAgentId: "", + commandPrefix: DEFAULT_PREFIX, + pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS, + token: "", + cookie: "", +}; + +function configToForm(cfg: SlackConfig | null): FormState { + if (!cfg) return emptyForm; + return { + utilityAgentId: cfg.utilityAgentId, + commandPrefix: cfg.commandPrefix || DEFAULT_PREFIX, + pollIntervalSeconds: cfg.pollIntervalSeconds || DEFAULT_POLL_INTERVAL_SECONDS, + token: "", + cookie: "", + }; +} + +function configToHealth(config: SlackConfig | null): IntegrationAuthHealth | null { + if (!config?.hasToken || !config.hasCookie) return null; + if (!config.lastCheckedAt) return { ok: false, error: "", checkedAt: null }; + return { + ok: !!config.lastOk, + error: config.lastError ?? "", + checkedAt: new Date(config.lastCheckedAt), + }; +} + +function saveLabel(saving: boolean, hasConfig: boolean): string { + if (saving) return "Saving..."; + return hasConfig ? "Update" : "Save"; +} + +type SecretFieldsProps = { + form: FormState; + loading: boolean; + hasSavedToken: boolean; + hasSavedCookie: boolean; + update: (key: K, value: FormState[K]) => void; +}; + +function SecretFields({ form, loading, hasSavedToken, hasSavedCookie, update }: SecretFieldsProps) { + return ( +
+
+ + update("token", e.target.value)} + disabled={loading} + /> +
+
+ + update("cookie", e.target.value)} + disabled={loading} + /> +

+ Open Slack in your browser, copy the value of the `d` cookie and the `xoxc-` token from + local storage. Both are required. +

+
+
+ ); +} + +type UtilityAgentPickerProps = { + form: FormState; + loading: boolean; + agents: UtilityAgent[]; + loadingAgents: boolean; + update: (key: K, value: FormState[K]) => void; +}; + +function utilityAgentPlaceholder(agents: UtilityAgent[], loading: boolean): string { + if (loading) return "Loading…"; + if (agents.length === 0) return "Create one in Settings → Utility agents"; + return "Choose a utility agent"; +} + +function isAgentSelectable(a: UtilityAgent): boolean { + if (a.builtin) return true; + return a.enabled && !!a.agent_id && !!a.model; +} + +function modelSuffix(a: UtilityAgent): string { + if (a.model) return ` (${a.model})`; + if (a.builtin) return " (uses default model)"; + return ""; +} + +function utilityAgentLabel(a: UtilityAgent): string { + const base = `${a.name}${modelSuffix(a)}`; + if (a.builtin) return base; + if (!a.enabled) return `${base} — disabled`; + if (!a.agent_id || !a.model) return `${base} — not configured`; + return base; +} + +function UtilityAgentPicker({ + form, + loading, + agents, + loadingAgents, + update, +}: UtilityAgentPickerProps) { + return ( +
+ + +

+ The utility agent that interprets each Slack message and creates the Kandev task. It runs + with Kandev MCP tools wired in (list_workspaces_kandev, create_task_kandev, …) so it picks + the destination Kandev workspace + workflow + repo from context. Built-in agents use your + default model from{" "} + + Settings → Utility agents + + . +

+

+ Custom prompts can reference {"{{SlackInstruction}}"},{" "} + {"{{SlackThread}}"}, {"{{SlackPermalink}}"},{" "} + {"{{SlackUser}}"}, {"{{SlackChannelID}}"}, and{" "} + {"{{SlackTS}}"}. When at least one is used, your template owns the full prompt; + otherwise the default Slack-triage system prompt is prepended automatically. +

+
+ ); +} + +function PrefixField({ + form, + loading, + update, +}: { + form: FormState; + loading: boolean; + update: (key: K, value: FormState[K]) => void; +}) { + return ( +
+ + update("commandPrefix", e.target.value)} + disabled={loading} + /> +

+ Messages you write in Slack starting with this prefix become Kandev tasks. Default:{" "} + {DEFAULT_PREFIX} <instruction>. +

+
+ ); +} + +function PollIntervalField({ + form, + loading, + update, +}: { + form: FormState; + loading: boolean; + update: (key: K, value: FormState[K]) => void; +}) { + return ( +
+ + { + const n = Number(e.target.value); + update("pollIntervalSeconds", Number.isFinite(n) ? n : DEFAULT_POLL_INTERVAL_SECONDS); + }} + disabled={loading} + /> +

+ How often Slack is checked for new {form.commandPrefix || DEFAULT_PREFIX}{" "} + messages. Lower = more responsive, higher = fewer Slack API calls. Range:{" "} + {MIN_POLL_INTERVAL_SECONDS}–{MAX_POLL_INTERVAL_SECONDS}s. Default:{" "} + {DEFAULT_POLL_INTERVAL_SECONDS}s. +

+
+ ); +} + +function TestResultAlert({ result }: { result: TestSlackConnectionResult | null }) { + if (!result) return null; + const teamSuffix = result.teamName ? ` (${result.teamName})` : ""; + return ( + + + {result.ok + ? `Connected as ${result.displayName || result.userId}${teamSuffix}` + : `Failed: ${result.error}`} + + + ); +} + +function UnsupportedWarning() { + return ( + + + Browser session auth (unsupported): Slack rotates session cookies often, so + you may need to reconnect when authentication expires. Bot installs and user OAuth are on + the roadmap. + + + ); +} + +type ActionBarProps = { + saving: boolean; + testing: boolean; + deleting: boolean; + loading: boolean; + hasConfig: boolean; + disableSave: boolean; + disableTest: boolean; + onTest: () => void; + onSave: () => void; + onDelete: () => void; +}; + +function ActionBar({ + saving, + testing, + deleting, + loading, + hasConfig, + disableSave, + disableTest, + onTest, + onSave, + onDelete, +}: ActionBarProps) { + return ( +
+ + + {hasConfig && ( + + )} +
+ ); +} + +function useUtilityAgentsLoader() { + const [agents, setAgents] = useState(null); + useEffect(() => { + let cancelled = false; + listUtilityAgents({ cache: "no-store" }) + .then((res) => { + if (!cancelled) setAgents(res.agents ?? []); + }) + .catch(() => { + if (!cancelled) setAgents([]); + }); + return () => { + cancelled = true; + }; + }, []); + return { agents: agents ?? [], loadingAgents: agents === null }; +} + +type SettingsActionsArgs = { + form: FormState; + setConfig: (cfg: SlackConfig | null) => void; + setForm: (form: FormState) => void; + setTestResult: (r: TestSlackConnectionResult | null) => void; +}; + +function useSettingsActions({ form, setConfig, setForm, setTestResult }: SettingsActionsArgs) { + const { toast } = useToast(); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [deleting, setDeleting] = useState(false); + + const handleTest = useCallback(async () => { + setTesting(true); + setTestResult(null); + try { + const res = await testSlackConnection({ + authMethod: "cookie", + utilityAgentId: form.utilityAgentId, + commandPrefix: form.commandPrefix, + pollIntervalSeconds: form.pollIntervalSeconds, + token: form.token || undefined, + cookie: form.cookie || undefined, + }); + setTestResult(res); + } catch (err) { + setTestResult({ ok: false, error: String(err) }); + } finally { + setTesting(false); + } + }, [form, setTestResult]); + + const handleSave = useCallback(async () => { + setSaving(true); + try { + const saved = await setSlackConfig({ + authMethod: "cookie", + utilityAgentId: form.utilityAgentId, + commandPrefix: form.commandPrefix, + pollIntervalSeconds: form.pollIntervalSeconds, + token: form.token || undefined, + cookie: form.cookie || undefined, + }); + setConfig(saved); + setForm(configToForm(saved)); + setTestResult(null); + toast({ description: "Slack configuration saved", variant: "success" }); + } catch (err) { + toast({ description: `Save failed: ${String(err)}`, variant: "error" }); + } finally { + setSaving(false); + } + }, [form, toast, setConfig, setForm, setTestResult]); + + const handleDelete = useCallback(async () => { + if (!confirm("Remove Slack configuration?")) return; + setDeleting(true); + try { + await deleteSlackConfig(); + setConfig(null); + setForm(emptyForm); + setTestResult(null); + toast({ description: "Slack configuration removed", variant: "success" }); + } catch (err) { + toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); + } finally { + setDeleting(false); + } + }, [toast, setConfig, setForm, setTestResult]); + + return { saving, testing, deleting, handleTest, handleSave, handleDelete }; +} + +function useSlackSettings() { + const { toast } = useToast(); + const [config, setConfig] = useState(null); + const [form, setForm] = useState(emptyForm); + const [loading, setLoading] = useState(true); + const [testResult, setTestResult] = useState(null); + const health = configToHealth(config); + const { agents, loadingAgents } = useUtilityAgentsLoader(); + + const load = useCallback(async () => { + setLoading(true); + try { + const cfg = await getSlackConfig(); + setConfig(cfg); + setForm(configToForm(cfg)); + } catch (err) { + toast({ description: `Failed to load Slack config: ${String(err)}`, variant: "error" }); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + void load(); + }, [load]); + + // Background refresh so the auth-health banner picks up new probe results. + useEffect(() => { + const id = setInterval(() => { + getSlackConfig() + .then((cfg) => setConfig(cfg)) + .catch(() => { + /* transient failures are fine — next tick retries */ + }); + }, INTEGRATION_STATUS_REFRESH_MS); + return () => clearInterval(id); + }, []); + + const update = useCallback( + (key: K, value: FormState[K]) => + setForm((prev) => ({ ...prev, [key]: value })), + [], + ); + + const { saving, testing, deleting, handleTest, handleSave, handleDelete } = useSettingsActions({ + form, + setConfig, + setForm, + setTestResult, + }); + + return { + config, + form, + loading, + saving, + testing, + deleting, + testResult, + health, + agents, + loadingAgents, + update, + handleTest, + handleSave, + handleDelete, + }; +} + +function EnabledPill() { + const { enabled, setEnabled } = useSlackEnabled(); + return ( +
+ + +
+ ); +} + +export function SlackConnectionSection() { + const s = useSlackSettings(); + const missingSecrets = + (!s.config?.hasToken && !s.form.token) || (!s.config?.hasCookie && !s.form.cookie); + const missingAgent = !s.form.utilityAgentId; + const disableSave = s.saving || missingSecrets || missingAgent; + const disableTest = missingSecrets; + + return ( + } + title="Slack integration" + description="Capture Slack threads as Kandev tasks. Type !kandev in any thread you can see and the configured utility agent picks the workspace/workflow/repo and creates the task." + action={} + > + + + + + + + + + + + + + + + + ); +} + +export function SlackIntegrationPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/hooks/domains/slack/use-slack-availability.ts b/apps/web/hooks/domains/slack/use-slack-availability.ts new file mode 100644 index 0000000000..4625bfef71 --- /dev/null +++ b/apps/web/hooks/domains/slack/use-slack-availability.ts @@ -0,0 +1,32 @@ +"use client"; + +import { getSlackConfig } from "@/lib/api/domains/slack-api"; +import { + useIntegrationAuthed, + useIntegrationAvailable, + type IntegrationConfigStatus, +} from "../integrations/use-integration-availability"; +import { useSlackEnabled } from "./use-slack-enabled"; + +// Slack stores two secrets (token + cookie) instead of one — the shared +// availability hook only checks `hasSecret`, so adapt by reporting authed +// when *both* halves are present. +async function fetchSlackStatus(): Promise { + const cfg = await getSlackConfig(); + if (!cfg) return null; + return { + hasSecret: cfg.hasToken && cfg.hasCookie, + lastOk: cfg.lastOk, + }; +} + +export function useSlackAuthed(): boolean { + return useIntegrationAuthed(fetchSlackStatus); +} + +export function useSlackAvailable(): boolean { + return useIntegrationAvailable({ + useEnabled: useSlackEnabled, + fetchConfig: fetchSlackStatus, + }); +} diff --git a/apps/web/hooks/domains/slack/use-slack-enabled.ts b/apps/web/hooks/domains/slack/use-slack-enabled.ts new file mode 100644 index 0000000000..a88b29bb4c --- /dev/null +++ b/apps/web/hooks/domains/slack/use-slack-enabled.ts @@ -0,0 +1,11 @@ +"use client"; + +import { useIntegrationEnabled } from "../integrations/use-integration-enabled"; + +const STORAGE_KEY = "kandev:slack:enabled:v1"; +const LEGACY_KEY_PREFIX = "kandev:slack:enabled:"; +const SYNC_EVENT = "kandev:slack:enabled-changed"; + +export function useSlackEnabled() { + return useIntegrationEnabled(STORAGE_KEY, LEGACY_KEY_PREFIX, SYNC_EVENT); +} diff --git a/apps/web/lib/api/domains/slack-api.ts b/apps/web/lib/api/domains/slack-api.ts new file mode 100644 index 0000000000..3dc5c36584 --- /dev/null +++ b/apps/web/lib/api/domains/slack-api.ts @@ -0,0 +1,36 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { + SetSlackConfigRequest, + SlackConfig, + TestSlackConnectionResult, +} from "@/lib/types/slack"; + +// getSlackConfig returns null when the backend responds 204 (no config yet). +export async function getSlackConfig(options?: ApiRequestOptions): Promise { + const res = await fetchJson(`/api/v1/slack/config`, options); + return res ?? null; +} + +export async function setSlackConfig(payload: SetSlackConfigRequest, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/slack/config`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, + }); +} + +export async function deleteSlackConfig(options?: ApiRequestOptions) { + return fetchJson<{ deleted: boolean }>(`/api/v1/slack/config`, { + ...options, + init: { ...(options?.init ?? {}), method: "DELETE" }, + }); +} + +export async function testSlackConnection( + payload: SetSlackConfigRequest, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/slack/config/test`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify(payload) }, + }); +} diff --git a/apps/web/lib/types/slack.ts b/apps/web/lib/types/slack.ts new file mode 100644 index 0000000000..f660eaebff --- /dev/null +++ b/apps/web/lib/types/slack.ts @@ -0,0 +1,41 @@ +export type SlackAuthMethod = "cookie"; + +export interface SlackConfig { + authMethod: SlackAuthMethod; + commandPrefix: string; + /** ID of a utility agent (`utility_agents.id`) the trigger invokes for each match. */ + utilityAgentId: string; + /** Polling cadence in seconds. Bounded server-side to [5, 600]. */ + pollIntervalSeconds: number; + slackTeamId?: string; + slackUserId?: string; + lastSeenTs?: string; + hasToken: boolean; + hasCookie: boolean; + lastCheckedAt?: string; + lastOk: boolean; + lastError?: string; + createdAt: string; + updatedAt: string; +} + +export interface SetSlackConfigRequest { + authMethod: SlackAuthMethod; + commandPrefix?: string; + utilityAgentId: string; + pollIntervalSeconds?: number; + /** Empty on update keeps the saved value. */ + token?: string; + /** Empty on update keeps the saved value. */ + cookie?: string; +} + +export interface TestSlackConnectionResult { + ok: boolean; + userId?: string; + teamId?: string; + teamName?: string; + url?: string; + displayName?: string; + error?: string; +} diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 758f008172..320e01c83f 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -8,3 +8,4 @@ See `.agents/skills/spec/SKILL.md` for the workflow. |------|--------|---------|----| | [external-mcp](external-mcp/spec.md) | draft | 2026-04-28 | — | | [improve-kandev](./improve-kandev/spec.md) | draft | 2026-04-29 | — | +| [slack-integration](./slack-integration/spec.md) | draft | 2026-05-02 | — | diff --git a/docs/specs/slack-integration/spec.md b/docs/specs/slack-integration/spec.md new file mode 100644 index 0000000000..88e7dbef2c --- /dev/null +++ b/docs/specs/slack-integration/spec.md @@ -0,0 +1,46 @@ +--- +status: shipped +created: 2026-05-02 +owner: tbd +--- + +# Slack Integration + +## Why + +Users live in Slack and surface task-worthy work there: bug reports in `#support`, feature ideas in DMs, alerts in `#oncall`. Today they have to context-switch to Kandev to capture each one, which loses the original message link and discourages capture in the moment. Teams on locked-down Enterprise Slack tenants cannot install bots, so a bot-only integration would shut them out. + +## What (v1, shipped) + +- Install-wide Slack credentials (one Slack user/team per Kandev install), modeled on the Jira/Linear integrations: settings page with auth-status banner, reconnect CTA, and 90s auth-health polling. +- Single auth mode: **Browser session** — user pastes the `xoxc-` token + `d` cookie pair extracted from their browser; works on locked-down Enterprise Slack tenants where bot installs are not possible. +- Single task-creation trigger: **`!kandev` thread triage** — the trigger polls the connected user's own messages for `!kandev `, hands the surrounding thread context to a configured utility agent (with the Kandev MCP server attached), and posts the agent's reply back in-thread. The agent picks the destination Kandev workspace/workflow/repo via MCP tools. +- An 👀 reaction is added to the triggering message on detection so the user can confirm it was picked up. +- Token storage reuses the shared `secretadapter`; no Slack credentials live in plaintext on disk. + +## Scenarios + +- **GIVEN** a user has pasted their `xoxc-`/`d` cookie pair in settings, **WHEN** they type `!kandev fix the safari login bug` in any Slack channel or DM, **THEN** the trigger reacts with 👀, the configured utility agent picks a workspace/workflow/repo via the Kandev MCP, creates a task, and posts the result back as a threaded reply. +- **GIVEN** a Slack credential is connected, **WHEN** the stored token becomes invalid (revoked, rotated, expired), **THEN** the auth-health poller flips the install to "Reconnect required" within 90 seconds and `RecordAuthHealth` invalidates the cached client so the next probe rebuilds it from current secrets. +- **GIVEN** a `!kandev` message has already been processed (its TS is at or below the watermark), **WHEN** the next poll picks it up, **THEN** it is filtered out and no duplicate task is created. + +## Out of scope (v1) + +- Bot install (`xoxb-` token) and User OAuth (`xoxp-` token) auth modes. +- Reaction trigger (`:kandev:` emoji), `/kandev ` slash command, and "Create Kandev task" message-shortcut entry. +- Posting Kandev task updates back into Slack beyond the initial in-thread confirmation (no live status mirroring). +- Slack as a chat surface for talking to a running agent (no `@kandev <prompt>` runtime, no streaming agent output to Slack). +- Multi-Slack-workspace fan-out within one Kandev install (one Slack user/team per install). +- Routing tasks to specific workflows based on channel or keyword — the utility agent picks the workflow via MCP. +- Importing historical Slack messages. + +## Future scope + +- Bot-install auth mode with the full trigger set (reaction emoji, slash command, message shortcut) for teams whose admins are willing to install the Kandev Slack app. +- User OAuth auth mode for users who want a properly-scoped token without admin involvement. +- Multi-Slack-workspace fan-out for Enterprise Grid tenants. + +## Open questions + +- Does the **browser session** mode cover Slack Enterprise Grid org-level tokens, or only single-workspace sessions? (Affects whether Enterprise users get one connection or one per workspace.) +- Should the slash command and message shortcut be available in **User OAuth** mode via a per-user "personal app" install, or restricted to bot mode only? From 3e676e3bb215775d061b7521afb9396d033f4718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 5 May 2026 13:55:46 +0100 Subject: [PATCH 10/33] feat(web): double-click tab to toggle maximize (#823) * feat(web): double-click tab to toggle maximize Adds a dblclick gesture on dockview tabs that toggles maximize/restore on the containing group, mirroring the existing maximize button. Preview tabs keep their promote-to-pin shortcut: first dblclick pins, then dblclick maximizes. Sidebar group is excluded. * fix(web): add cursor-pointer to tab wrappers for consistency * fix(web): add cursor-pointer to plan-tab wrapper * fix(web): make tab dblclick maximize resilient to layout rebuild Refactor the dblclick-to-maximize handler so it reads the panel's group ID and store state at call time instead of from a render-time closure, and guards against firing while the dockview store is mid-layout-restore. This addresses oscillation when double-clicking a session tab on a task that has multiple sessions: the maximize call now stops event propagation, respects isRestoringLayout, and always uses the live api.group.id, so it remains correct even after dockview rebuilds groups via fromJSON. --- .../task/__tests__/use-tab-maximize.test.ts | 106 ++++++++++++++++++ apps/web/components/task/changes-tab.tsx | 7 +- .../task/dockview-desktop-layout.tsx | 11 +- apps/web/components/task/plan-tab.test.tsx | 17 +++ apps/web/components/task/plan-tab.tsx | 8 +- apps/web/components/task/preview-tab.test.tsx | 94 ++++++++++++++++ apps/web/components/task/preview-tab.tsx | 27 ++++- apps/web/components/task/session-tab.tsx | 5 +- apps/web/components/task/tab-context-menu.tsx | 7 +- apps/web/components/task/use-tab-maximize.ts | 37 ++++++ 10 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 apps/web/components/task/__tests__/use-tab-maximize.test.ts create mode 100644 apps/web/components/task/preview-tab.test.tsx create mode 100644 apps/web/components/task/use-tab-maximize.ts diff --git a/apps/web/components/task/__tests__/use-tab-maximize.test.ts b/apps/web/components/task/__tests__/use-tab-maximize.test.ts new file mode 100644 index 0000000000..6619665048 --- /dev/null +++ b/apps/web/components/task/__tests__/use-tab-maximize.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; + +const SIDEBAR_GROUP_ID = "sidebar-group"; + +const mockMaximizeGroup = vi.fn(); +const mockExitMaximizedLayout = vi.fn(); + +const storeState: { + preMaximizeLayout: object | null; + sidebarGroupId: string; + isRestoringLayout: boolean; + maximizeGroup: typeof mockMaximizeGroup; + exitMaximizedLayout: typeof mockExitMaximizedLayout; +} = { + preMaximizeLayout: null, + sidebarGroupId: SIDEBAR_GROUP_ID, + isRestoringLayout: false, + maximizeGroup: mockMaximizeGroup, + exitMaximizedLayout: mockExitMaximizedLayout, +}; + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: Object.assign( + (selector: (state: typeof storeState) => unknown) => selector(storeState), + { getState: () => storeState }, + ), +})); + +import { useTabMaximizeOnDoubleClick } from "../use-tab-maximize"; + +function makeApi(groupId: string | undefined) { + return { group: groupId === undefined ? undefined : { id: groupId } } as Parameters< + typeof useTabMaximizeOnDoubleClick + >[0]; +} + +function makeEvent() { + return { + stopPropagation: vi.fn(), + preventDefault: vi.fn(), + } as unknown as React.MouseEvent; +} + +describe("useTabMaximizeOnDoubleClick", () => { + beforeEach(() => { + mockMaximizeGroup.mockClear(); + mockExitMaximizedLayout.mockClear(); + storeState.preMaximizeLayout = null; + storeState.sidebarGroupId = SIDEBAR_GROUP_ID; + storeState.isRestoringLayout = false; + }); + + it("calls maximizeGroup when not currently maximized", () => { + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi("group-a"))); + result.current(makeEvent()); + expect(mockMaximizeGroup).toHaveBeenCalledWith("group-a"); + expect(mockExitMaximizedLayout).not.toHaveBeenCalled(); + }); + + it("calls exitMaximizedLayout when currently maximized", () => { + storeState.preMaximizeLayout = { columns: [] }; + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi("group-a"))); + result.current(makeEvent()); + expect(mockExitMaximizedLayout).toHaveBeenCalledTimes(1); + expect(mockMaximizeGroup).not.toHaveBeenCalled(); + }); + + it("no-ops when groupId is the sidebar group", () => { + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi(SIDEBAR_GROUP_ID))); + result.current(makeEvent()); + expect(mockMaximizeGroup).not.toHaveBeenCalled(); + expect(mockExitMaximizedLayout).not.toHaveBeenCalled(); + }); + + it("no-ops while a layout restore is in progress", () => { + storeState.isRestoringLayout = true; + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi("group-a"))); + result.current(makeEvent()); + expect(mockMaximizeGroup).not.toHaveBeenCalled(); + expect(mockExitMaximizedLayout).not.toHaveBeenCalled(); + }); + + it("no-ops when api.group is missing", () => { + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi(undefined))); + result.current(makeEvent()); + expect(mockMaximizeGroup).not.toHaveBeenCalled(); + }); + + it("reads groupId fresh at call time, not from render-time closure", () => { + const api = makeApi("group-a"); + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(api)); + // Simulate dockview reassigning the panel's group after layout rebuild. + (api.group as { id: string }).id = "group-b"; + result.current(makeEvent()); + expect(mockMaximizeGroup).toHaveBeenCalledWith("group-b"); + }); + + it("stops the event from propagating to the underlying tab", () => { + const event = makeEvent(); + const { result } = renderHook(() => useTabMaximizeOnDoubleClick(makeApi("group-a"))); + result.current(event); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/task/changes-tab.tsx b/apps/web/components/task/changes-tab.tsx index 226ec31a68..4dd00d236c 100644 --- a/apps/web/components/task/changes-tab.tsx +++ b/apps/web/components/task/changes-tab.tsx @@ -13,6 +13,7 @@ import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-sta import { useSessionCommits } from "@/hooks/domains/session/use-session-commits"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { cn } from "@kandev/ui/lib/utils"; +import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; /** Auto-activate the changes panel only when it lives in the right sidebar. */ function autoActivateChangesPanel(): void { @@ -35,6 +36,7 @@ function autoActivateChangesPanel(): void { */ export function ChangesTab(props: IDockviewPanelHeaderProps) { const { api, containerApi } = props; + const onDoubleClick = useTabMaximizeOnDoubleClick(api); const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); @@ -118,7 +120,10 @@ export function ChangesTab(props: IDockviewPanelHeaderProps) { return ( <ContextMenu> - <ContextMenuTrigger className="flex h-full items-center cursor-pointer"> + <ContextMenuTrigger + className="flex h-full items-center cursor-pointer select-none" + onDoubleClick={onDoubleClick} + > <div className={cn("relative", isFlashing && "animate-changes-flash")}> <DockviewDefaultTab {...props} /> {badgeCount > 0 && ( diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index fa5d302aee..457f4eebbd 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -42,6 +42,7 @@ import { ChangesTab } from "./changes-tab"; import { PlanTab } from "./plan-tab"; import { PreviewFileTab, PreviewDiffTab, PreviewCommitTab, PinnedDefaultTab } from "./preview-tab"; import { SessionTab } from "./session-tab"; +import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; import { setupSessionTabSync, setupChatPanelSafetyNet, @@ -152,7 +153,15 @@ const components: Record<string, React.FunctionComponent<IDockviewPanelProps>> = // --- TAB COMPONENTS --- function PermanentTab(props: IDockviewPanelHeaderProps) { - return <DockviewDefaultTab {...props} hideClose />; + const onDoubleClick = useTabMaximizeOnDoubleClick(props.api); + return ( + <div + className="flex h-full items-center cursor-pointer select-none" + onDoubleClick={onDoubleClick} + > + <DockviewDefaultTab {...props} hideClose /> + </div> + ); } /** Sync the user's default saved layout from settings into the dockview store. */ diff --git a/apps/web/components/task/plan-tab.test.tsx b/apps/web/components/task/plan-tab.test.tsx index 420856413d..75878e1045 100644 --- a/apps/web/components/task/plan-tab.test.tsx +++ b/apps/web/components/task/plan-tab.test.tsx @@ -27,6 +27,21 @@ vi.mock("dockview-react", () => ({ DockviewDefaultTab: () => null, })); +const mockDockviewState = { + preMaximizeLayout: null, + sidebarGroupId: "sidebar-group", + isRestoringLayout: false, + maximizeGroup: vi.fn(), + exitMaximizedLayout: vi.fn(), +}; + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: Object.assign( + (selector: (state: typeof mockDockviewState) => unknown) => selector(mockDockviewState), + { getState: () => mockDockviewState }, + ), +})); + import { PlanTab } from "./plan-tab"; const TS = "2026-04-20T00:00:00Z"; @@ -46,12 +61,14 @@ function agentPlan(updated_at = TS): TaskPlan { type TabApi = { isActive: boolean; + group: { id: string }; onDidActiveChange: (listener: (e: { isActive: boolean }) => void) => { dispose: () => void }; }; function makeApi(isActive: boolean): TabApi { return { isActive, + group: { id: "group-a" }, onDidActiveChange: () => ({ dispose: () => {} }), }; } diff --git a/apps/web/components/task/plan-tab.tsx b/apps/web/components/task/plan-tab.tsx index c2b4146ea4..3ef1081220 100644 --- a/apps/web/components/task/plan-tab.tsx +++ b/apps/web/components/task/plan-tab.tsx @@ -3,6 +3,7 @@ import { useEffect, useLayoutEffect } from "react"; import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react"; import { useAppStore } from "@/components/state-provider"; +import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; /** * Custom tab component for the Plan panel. @@ -11,6 +12,7 @@ import { useAppStore } from "@/components/state-provider"; */ export function PlanTab(props: IDockviewPanelHeaderProps) { const { api } = props; + const onDoubleClick = useTabMaximizeOnDoubleClick(api); const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); const plan = useAppStore((s) => (activeTaskId ? s.taskPlans.byTaskId[activeTaskId] : null)); @@ -39,7 +41,11 @@ export function PlanTab(props: IDockviewPanelHeaderProps) { const hasUnseen = plan?.created_by === "agent" && lastSeen !== plan.updated_at; return ( - <div data-testid="plan-tab" className="relative"> + <div + data-testid="plan-tab" + className="relative cursor-pointer select-none" + onDoubleClick={onDoubleClick} + > <DockviewDefaultTab {...props} /> {hasUnseen && ( <span diff --git a/apps/web/components/task/preview-tab.test.tsx b/apps/web/components/task/preview-tab.test.tsx new file mode 100644 index 0000000000..a3cdcc09c3 --- /dev/null +++ b/apps/web/components/task/preview-tab.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, fireEvent, screen, cleanup } from "@testing-library/react"; + +const mockPromote = vi.fn(); +const mockMaximizeGroup = vi.fn(); +const mockExitMaximizedLayout = vi.fn(); + +const storeState = { + promotePreviewToPinned: mockPromote, + preMaximizeLayout: null as object | null, + sidebarGroupId: "sidebar-group", + isRestoringLayout: false, + maximizeGroup: mockMaximizeGroup, + exitMaximizedLayout: mockExitMaximizedLayout, +}; + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: Object.assign( + (selector: (state: typeof storeState) => unknown) => selector(storeState), + { getState: () => storeState }, + ), +})); + +vi.mock("dockview-react", () => ({ + DockviewDefaultTab: () => null, +})); + +vi.mock("@kandev/ui/context-menu", () => ({ + ContextMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>, + ContextMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>, + ContextMenuContent: () => null, + ContextMenuItem: () => null, + ContextMenuSeparator: () => null, +})); + +import { PreviewFileTab } from "./preview-tab"; + +const PREVIEW_TAB_TID = "preview-tab-file-editor"; + +type TabApi = { + id: string; + group: { id: string }; +}; + +function makeProps(promoted: boolean) { + const api: TabApi = { id: "panel-1", group: { id: "group-a" } }; + const containerApi = { getPanel: () => undefined, removePanel: () => {} }; + return { + api, + containerApi, + params: { promoted }, + } as unknown as React.ComponentProps<typeof PreviewFileTab>; +} + +describe("PreviewTab dblclick sequencing", () => { + beforeEach(() => { + mockPromote.mockClear(); + mockMaximizeGroup.mockClear(); + mockExitMaximizedLayout.mockClear(); + storeState.preMaximizeLayout = null; + storeState.isRestoringLayout = false; + }); + afterEach(() => cleanup()); + + it("first dblclick on unpinned preview promotes to pinned (no maximize)", () => { + render(<PreviewFileTab {...makeProps(false)} />); + fireEvent.doubleClick(screen.getByTestId(PREVIEW_TAB_TID)); + expect(mockPromote).toHaveBeenCalledWith("file-editor"); + expect(mockMaximizeGroup).not.toHaveBeenCalled(); + }); + + it("dblclick on pinned preview maximizes (no further promote)", () => { + render(<PreviewFileTab {...makeProps(true)} />); + fireEvent.doubleClick(screen.getByTestId(PREVIEW_TAB_TID)); + expect(mockPromote).not.toHaveBeenCalled(); + expect(mockMaximizeGroup).toHaveBeenCalledWith("group-a"); + }); + + it("dblclick on pinned preview while maximized restores layout", () => { + storeState.preMaximizeLayout = { columns: [] }; + render(<PreviewFileTab {...makeProps(true)} />); + fireEvent.doubleClick(screen.getByTestId(PREVIEW_TAB_TID)); + expect(mockExitMaximizedLayout).toHaveBeenCalledTimes(1); + expect(mockMaximizeGroup).not.toHaveBeenCalled(); + }); + + it("ignores dblclick while a layout restore is in progress", () => { + storeState.isRestoringLayout = true; + render(<PreviewFileTab {...makeProps(true)} />); + fireEvent.doubleClick(screen.getByTestId(PREVIEW_TAB_TID)); + expect(mockMaximizeGroup).not.toHaveBeenCalled(); + expect(mockExitMaximizedLayout).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/task/preview-tab.tsx b/apps/web/components/task/preview-tab.tsx index b7e5fc1fa5..d36d17bf9e 100644 --- a/apps/web/components/task/preview-tab.tsx +++ b/apps/web/components/task/preview-tab.tsx @@ -12,6 +12,7 @@ import { import { cn } from "@/lib/utils"; import { useDockviewStore } from "@/lib/state/dockview-store"; import type { PreviewType } from "@/lib/state/dockview-panel-actions"; +import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; /** * Middle-click to close any tab (preview or pinned). @@ -61,11 +62,19 @@ function PreviewTab(props: IDockviewPanelHeaderProps & { type: PreviewType }) { const promote = useDockviewStore((s) => s.promotePreviewToPinned); const onMouseDown = useMiddleClickClose(api, containerApi); const { handleClose, handleCloseOthers } = useTabContextActions(api, containerApi); + const handleMaximizeDblClick = useTabMaximizeOnDoubleClick(api); const isPromoted = (props.params as Record<string, unknown> | undefined)?.promoted === true; - const onDoubleClick = useCallback(() => { - if (!isPromoted) promote(type); - }, [promote, type, isPromoted]); + const onDoubleClick = useCallback( + (event: React.MouseEvent) => { + if (!isPromoted) { + promote(type); + return; + } + handleMaximizeDblClick(event); + }, + [promote, type, isPromoted, handleMaximizeDblClick], + ); const handleKeepOpen = useCallback(() => { promote(type); @@ -75,7 +84,10 @@ function PreviewTab(props: IDockviewPanelHeaderProps & { type: PreviewType }) { <ContextMenu> <ContextMenuTrigger asChild> <div - className={cn("flex h-full items-center cursor-pointer", !isPromoted && "italic")} + className={cn( + "flex h-full items-center cursor-pointer select-none", + !isPromoted && "italic", + )} onMouseDown={onMouseDown} onDoubleClick={onDoubleClick} title={isPromoted ? undefined : "Double-click to keep this tab open"} @@ -122,11 +134,16 @@ export function PinnedDefaultTab(props: IDockviewPanelHeaderProps) { const { api, containerApi } = props; const onMouseDown = useMiddleClickClose(api, containerApi); const { handleClose, handleCloseOthers } = useTabContextActions(api, containerApi); + const onDoubleClick = useTabMaximizeOnDoubleClick(api); return ( <ContextMenu> <ContextMenuTrigger asChild> - <div className="flex h-full items-center cursor-pointer" onMouseDown={onMouseDown}> + <div + className="flex h-full items-center cursor-pointer select-none" + onMouseDown={onMouseDown} + onDoubleClick={onDoubleClick} + > <DockviewDefaultTab {...props} /> </div> </ContextMenuTrigger> diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index 7a2e5a8bbe..b8b1be84f3 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -25,6 +25,7 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; import type { TaskSessionState } from "@/lib/types/http"; +import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; function isStoppable(s: TaskSessionState) { return s === "RUNNING" || s === "STARTING" || s === "WAITING_FOR_INPUT"; @@ -283,6 +284,7 @@ export function SessionTab(props: IDockviewPanelHeaderProps) { const { isPrimary, sessionState, taskId, agentLabel, agentName, sessionNumber, sessionCount } = useSessionTabState(sessionId); const actions = useSessionTabActions(sessionId, taskId, api, containerApi); + const onDoubleClick = useTabMaximizeOnDoubleClick(api); const [confirmDelete, setConfirmDelete] = useState(false); const [isActive, setIsActive] = useState(api.isActive); @@ -301,8 +303,9 @@ export function SessionTab(props: IDockviewPanelHeaderProps) { <> <ContextMenu> <ContextMenuTrigger - className="flex h-full items-center cursor-pointer" + className="flex h-full items-center cursor-pointer select-none" data-testid={sessionId ? `session-tab-${sessionId}` : undefined} + onDoubleClick={onDoubleClick} > <div className="flex items-center"> {isPrimary && showMultiSessionBadges && ( diff --git a/apps/web/components/task/tab-context-menu.tsx b/apps/web/components/task/tab-context-menu.tsx index d933a99ebb..2537610a93 100644 --- a/apps/web/components/task/tab-context-menu.tsx +++ b/apps/web/components/task/tab-context-menu.tsx @@ -8,6 +8,7 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@kandev/ui/context-menu"; +import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; /** An item in the tab right-click context menu. * Items are ephemeral — not serialized to the saved layout. */ @@ -27,6 +28,7 @@ export type TabContextMenuParams = { * props.params.contextMenuItems. */ export function ContextMenuTab(props: IDockviewPanelHeaderProps) { const { api, containerApi } = props; + const onDoubleClick = useTabMaximizeOnDoubleClick(api); const handleCloseOthers = useCallback(() => { const toClose = api.group.panels.filter( @@ -40,7 +42,10 @@ export function ContextMenuTab(props: IDockviewPanelHeaderProps) { return ( <ContextMenu> - <ContextMenuTrigger className="flex h-full items-center"> + <ContextMenuTrigger + className="flex h-full items-center cursor-pointer select-none" + onDoubleClick={onDoubleClick} + > <DockviewDefaultTab {...props} /> </ContextMenuTrigger> <ContextMenuContent> diff --git a/apps/web/components/task/use-tab-maximize.ts b/apps/web/components/task/use-tab-maximize.ts new file mode 100644 index 0000000000..513a1b6f5f --- /dev/null +++ b/apps/web/components/task/use-tab-maximize.ts @@ -0,0 +1,37 @@ +"use client"; + +import { useCallback } from "react"; +import type { DockviewPanelApi } from "dockview-react"; +import { useDockviewStore } from "@/lib/state/dockview-store"; + +/** + * Returns an `onDoubleClick` handler that toggles the maximize state for the + * dockview group containing the given panel. No-ops for the sidebar group + * (which has no maximize button either) and while the dockview store is in + * the middle of restoring a layout (guards against rapid double-firing on + * back-to-back dblclicks during the maximize/restore animation frame). + * + * Reads `api.group.id` and store state at call time rather than render time + * so the toggle remains correct after dockview rebuilds groups via fromJSON. + */ +export function useTabMaximizeOnDoubleClick( + api: Pick<DockviewPanelApi, "group">, +): (event: React.MouseEvent) => void { + return useCallback( + (event) => { + event.stopPropagation(); + event.preventDefault(); + const groupId = api.group?.id; + if (!groupId) return; + const state = useDockviewStore.getState(); + if (state.isRestoringLayout) return; + if (groupId === state.sidebarGroupId) return; + if (state.preMaximizeLayout) { + state.exitMaximizedLayout(); + } else { + state.maximizeGroup(groupId); + } + }, + [api], + ); +} From c64e8356121993816ba620f0bc0c78cefa0f2422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 5 May 2026 14:13:43 +0100 Subject: [PATCH 11/33] 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 --- .../orchestrator/event_handlers_workflow.go | 26 +- .../event_handlers_workflow_moved_test.go | 43 +- apps/backend/internal/task/handlers/errors.go | 25 + .../task/handlers/task_http_handlers.go | 35 +- .../task/handlers/task_http_handlers_test.go | 133 ++++ .../internal/task/service/service_events.go | 9 +- .../internal/task/service/service_workflow.go | 131 +++- .../task/service/service_workflow_test.go | 339 ++++++++++ apps/web/components/kanban-board-grid.tsx | 3 +- apps/web/components/kanban-board.tsx | 35 +- apps/web/components/kanban-card-content.tsx | 384 +++++++++++ .../components/kanban-card-context-menu.tsx | 29 + .../web/components/kanban-card-menu-items.tsx | 436 +++++++++++++ apps/web/components/kanban-card-preview.tsx | 41 ++ apps/web/components/kanban-card.tsx | 610 +++++------------- apps/web/components/kanban-column.tsx | 1 + .../kanban/swimlane-kanban-content.tsx | 3 +- .../task/task-move-context-menu.tsx | 230 +++++++ .../components/task/task-session-sidebar.tsx | 14 +- apps/web/components/task/task-switcher.tsx | 104 ++- apps/web/e2e/pages/kanban-page.ts | 52 ++ apps/web/e2e/pages/session-page.ts | 23 + .../kanban/cross-workflow-task-batch.spec.ts | 101 +++ .../kanban/cross-workflow-task-move.spec.ts | 167 +++++ .../task/sidebar-send-to-workflow.spec.ts | 56 ++ apps/web/hooks/use-task-workflow-move.test.ts | 98 +++ apps/web/hooks/use-task-workflow-move.ts | 51 ++ apps/web/lib/api/domains/kanban-api.ts | 10 + apps/web/lib/types/backend.ts | 1 + apps/web/lib/ws/handlers/tasks.test.ts | 32 + apps/web/lib/ws/handlers/tasks.ts | 13 +- 31 files changed, 2663 insertions(+), 572 deletions(-) create mode 100644 apps/backend/internal/task/service/service_workflow_test.go create mode 100644 apps/web/components/kanban-card-content.tsx create mode 100644 apps/web/components/kanban-card-context-menu.tsx create mode 100644 apps/web/components/kanban-card-menu-items.tsx create mode 100644 apps/web/components/kanban-card-preview.tsx create mode 100644 apps/web/components/task/task-move-context-menu.tsx create mode 100644 apps/web/e2e/tests/kanban/cross-workflow-task-batch.spec.ts create mode 100644 apps/web/e2e/tests/kanban/cross-workflow-task-move.spec.ts create mode 100644 apps/web/e2e/tests/task/sidebar-send-to-workflow.spec.ts create mode 100644 apps/web/hooks/use-task-workflow-move.test.ts create mode 100644 apps/web/hooks/use-task-workflow-move.ts diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 0e86592bbc..be663c0061 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -255,7 +255,10 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s zap.Bool("trigger_on_enter", triggerOnEnter)) if triggerOnEnter { - s.finalizeStepEnter(ctx, taskID, sessionID, targetStep, task.Description) + // Automated transitions always clear review: the agent just completed + // a turn, so any pending review from a prior step is stale regardless + // of whether the new step has auto_start_agent. + s.finalizeStepEnter(ctx, taskID, sessionID, targetStep, task.Description, true) } else { // on_turn_start transitions: user is about to send a message, no on_enter needed. // However, we still need to switch the agent profile if the target step requires @@ -396,17 +399,20 @@ func (s *Service) processStepExitAndEnter(ctx context.Context, taskID string, se return } - s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription) + clearReview := targetStep.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent) + s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription, clearReview) } -// finalizeStepEnter clears review status, reloads the session, and processes on_enter -// actions for the target step. Shared by executeStepTransition and processStepExitAndEnter. -func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string) { - // Clear review status when moving to a new step - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { - s.logger.Warn("failed to clear session review status", - zap.String("session_id", sessionID), - zap.Error(err)) +// finalizeStepEnter optionally clears review status, reloads the session, and +// processes on_enter actions for the target step. Shared by executeStepTransition +// and processStepExitAndEnter. +func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string, clearReview bool) { + if clearReview { + if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { + s.logger.Warn("failed to clear session review status", + zap.String("session_id", sessionID), + zap.Error(err)) + } } // Reload session after on_exit may have changed metadata diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go index 252aabcb7e..497cd8879b 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go @@ -206,7 +206,7 @@ func TestHandleTaskMovedWithSession(t *testing.T) { }) }) - t.Run("clears review status on step transition", func(t *testing.T) { + t.Run("preserves review status on manual move to non-auto-start step", func(t *testing.T) { synctest.Test(t, func(t *testing.T) { repo := setupTestRepo(t) seedSession(t, repo, "t1", "s1", "step1") @@ -235,9 +235,48 @@ func TestHandleTaskMovedWithSession(t *testing.T) { synctest.Wait() + updated, _ := repo.GetTaskSession(ctx, "s1") + if updated.ReviewStatus == nil || *updated.ReviewStatus != "pending" { + t.Fatalf("expected pending review status to be preserved, got %#v", updated.ReviewStatus) + } + }) + }) + + t.Run("clears review status on manual move to auto-start step", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + _ = repo.UpdateSessionReviewStatus(ctx, "s1", "pending") + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + Events: wfmodels.StepEvents{}, + } + stepGetter.steps["step2"] = &wfmodels.WorkflowStep{ + ID: "step2", WorkflowID: "wf1", Name: "Step 2", Position: 1, + Events: wfmodels.StepEvents{ + OnEnter: []wfmodels.OnEnterAction{ + {Type: wfmodels.OnEnterAutoStartAgent}, + }, + }, + } + + svc := createTestService(repo, stepGetter, newMockTaskRepo()) + svc.handleTaskMovedWithSession(ctx, watcher.TaskMovedEventData{ + TaskID: "t1", + SessionID: "s1", + FromStepID: "step1", + ToStepID: "step2", + TaskDescription: "test task", + }) + + synctest.Wait() + updated, _ := repo.GetTaskSession(ctx, "s1") if updated.ReviewStatus != nil && *updated.ReviewStatus != "" { - t.Errorf("expected review status to be cleared, got %q", *updated.ReviewStatus) + t.Fatalf("expected review status to be cleared, got %q", *updated.ReviewStatus) } }) }) diff --git a/apps/backend/internal/task/handlers/errors.go b/apps/backend/internal/task/handlers/errors.go index 701368427c..ff3f2d5a31 100644 --- a/apps/backend/internal/task/handlers/errors.go +++ b/apps/backend/internal/task/handlers/errors.go @@ -22,6 +22,20 @@ func handleNotFound(c *gin.Context, log *logger.Logger, err error, fallback stri c.JSON(http.StatusInternalServerError, gin.H{"error": "request failed"}) } +func handleSelectedMoveError(c *gin.Context, log *logger.Logger, err error) { + switch { + case isNotFound(err): + c.JSON(http.StatusNotFound, gin.H{"error": "task or workflow not found"}) + case isMoveConflict(err): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case isValidationError(err): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + log.Error("task move failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "task move failed"}) + } +} + func isNotFound(err error) bool { if err == nil { return false @@ -29,6 +43,17 @@ func isNotFound(err error) bool { return strings.Contains(strings.ToLower(err.Error()), "not found") } +func isMoveConflict(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "active session") || + strings.Contains(msg, "archived tasks cannot be moved") || + strings.Contains(msg, "different workspace") || + strings.Contains(msg, "does not belong to target workflow") +} + func isValidationError(err error) bool { if err == nil { return false diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 94d01ca72a..3e6be083a5 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -299,10 +299,11 @@ func (h *TaskHandlers) httpGetStepTaskCount(c *gin.Context) { } type httpBulkMoveTasksRequest struct { - SourceWorkflowID string `json:"source_workflow_id"` - SourceStepID string `json:"source_step_id,omitempty"` - TargetWorkflowID string `json:"target_workflow_id"` - TargetStepID string `json:"target_step_id"` + SourceWorkflowID string `json:"source_workflow_id"` + SourceStepID string `json:"source_step_id,omitempty"` + TargetWorkflowID string `json:"target_workflow_id"` + TargetStepID string `json:"target_step_id"` + TaskIDs []string `json:"task_ids,omitempty"` } func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) { @@ -311,7 +312,15 @@ func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } - if body.SourceWorkflowID == "" || body.TargetWorkflowID == "" || body.TargetStepID == "" { + if body.TargetWorkflowID == "" || body.TargetStepID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "target_workflow_id and target_step_id are required"}) + return + } + if len(body.TaskIDs) > 0 { + h.httpBulkMoveSelectedTasks(c, body) + return + } + if body.SourceWorkflowID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "source_workflow_id, target_workflow_id, and target_step_id are required"}) return } @@ -328,6 +337,20 @@ func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) { c.JSON(http.StatusOK, dto.BulkMoveTasksResponse{MovedCount: result.MovedCount}) } +func (h *TaskHandlers) httpBulkMoveSelectedTasks(c *gin.Context, body httpBulkMoveTasksRequest) { + result, err := h.service.BulkMoveSelectedTasks( + c.Request.Context(), + body.TaskIDs, + body.TargetWorkflowID, + body.TargetStepID, + ) + if err != nil { + handleSelectedMoveError(c, h.logger, err) + return + } + c.JSON(http.StatusOK, dto.BulkMoveTasksResponse{MovedCount: result.MovedCount}) +} + type httpTaskRepositoryInput struct { RepositoryID string `json:"repository_id"` BaseBranch string `json:"base_branch"` @@ -850,7 +873,7 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) { body.WorkflowID, body.WorkflowStepID, body.Position, ) if err != nil { - handleNotFound(c, h.logger, err, "task not moved") + handleSelectedMoveError(c, h.logger, err) return } diff --git a/apps/backend/internal/task/handlers/task_http_handlers_test.go b/apps/backend/internal/task/handlers/task_http_handlers_test.go index c5d80fcdf6..70f3becdfb 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -2,14 +2,21 @@ package handlers import ( "context" + "errors" + "net/http" + "net/http/httptest" "strings" "sync" "testing" + "time" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" ) func newTestLogger(t *testing.T) *logger.Logger { @@ -25,6 +32,132 @@ func newTestLogger(t *testing.T) *logger.Logger { return log } +func TestHandleSelectedMoveError(t *testing.T) { + gin.SetMode(gin.TestMode) + log := newTestLogger(t) + + tests := []struct { + name string + err error + want int + wantBodyContains string + }{ + { + name: "not found", + err: errors.New("task not found: task-1"), + want: http.StatusNotFound, + }, + { + name: "move conflict", + err: errors.New("task task-1 cannot be moved: task has an active session (running)"), + want: http.StatusConflict, + }, + { + name: "bad request validation", + err: errors.New("invalid workflow id"), + want: http.StatusBadRequest, + }, + { + name: "internal", + err: errors.New("failed to count target workflow step tasks: database is locked"), + want: http.StatusInternalServerError, + wantBodyContains: "task move failed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + + handleSelectedMoveError(c, log, tc.err) + + assert.Equal(t, tc.want, rec.Code) + if tc.wantBodyContains != "" { + assert.Contains(t, rec.Body.String(), tc.wantBodyContains) + } + }) + } +} + +type moveTaskConflictRepo struct { + mockRepository + task *models.Task + sessions []*models.TaskSession +} + +func (m *moveTaskConflictRepo) GetTask(ctx context.Context, id string) (*models.Task, error) { + return m.task, nil +} + +func (m *moveTaskConflictRepo) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { + return m.sessions, nil +} + +func TestHTTPMoveTaskMapsMoveConflict(t *testing.T) { + gin.SetMode(gin.TestMode) + log := newTestLogger(t) + archivedAt := time.Now().UTC() + + tests := []struct { + name string + task *models.Task + sessions []*models.TaskSession + }{ + { + name: "archived task", + task: &models.Task{ + ID: "task-archived", + WorkspaceID: "workspace-1", + WorkflowID: "wf-source", + WorkflowStepID: "step-source", + ArchivedAt: &archivedAt, + }, + }, + { + name: "active session", + task: &models.Task{ + ID: "task-running", + WorkspaceID: "workspace-1", + WorkflowID: "wf-source", + WorkflowStepID: "step-source", + }, + sessions: []*models.TaskSession{{ + ID: "session-running", + TaskID: "task-running", + State: models.TaskSessionStateRunning, + }}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + repo := &moveTaskConflictRepo{task: tc.task, sessions: tc.sessions} + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := &TaskHandlers{service: svc, logger: log} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Params = gin.Params{{Key: "id", Value: tc.task.ID}} + c.Request = httptest.NewRequest(http.MethodPost, "/tasks/"+tc.task.ID+"/move", strings.NewReader(`{ + "workflow_id": "wf-target", + "workflow_step_id": "step-target", + "position": 0 + }`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.httpMoveTask(c) + + assert.Equal(t, http.StatusConflict, rec.Code) + }) + } +} + func TestResolveFreshBranchName(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 768741a0b0..969d8d795a 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -22,7 +22,7 @@ func (s *Service) PublishTaskUpdated(ctx context.Context, task *models.Task) { } // publishTaskEvent publishes task events to the event bus -func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) { +func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState, oldWorkflowIDs ...string) { if s.eventBus == nil { return } @@ -66,6 +66,9 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * data["old_state"] = string(*oldState) data["new_state"] = string(task.State) } + if len(oldWorkflowIDs) > 0 && oldWorkflowIDs[0] != "" && oldWorkflowIDs[0] != task.WorkflowID { + data["old_workflow_id"] = oldWorkflowIDs[0] + } event := bus.NewEvent(eventType, "task-service", data) if err := s.eventBus.Publish(ctx, eventType, event); err != nil { @@ -154,12 +157,14 @@ func serializeTaskRepositories(repos []*models.TaskRepository) []map[string]inte // publishTaskMovedEvent publishes a task.moved event so the orchestrator can process // on_exit/on_enter actions for the new workflow step. -func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, fromStepID, toStepID, sessionID string) { +func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, fromWorkflowID, fromStepID, toStepID, sessionID string) { if s.eventBus == nil { return } data := map[string]interface{}{ "task_id": task.ID, + "from_workflow_id": fromWorkflowID, + "to_workflow_id": task.WorkflowID, "from_step_id": fromStepID, "to_step_id": toStepID, "session_id": sessionID, diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index 8ac42517b6..cc2ee0ff53 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -228,14 +228,14 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo return nil, err } - if err := s.checkMoveTaskApproval(ctx, id, task.WorkflowStepID, workflowStepID); err != nil { + if err := s.validateTaskMove(ctx, task, workflowID, workflowStepID); err != nil { return nil, err } + oldWorkflowID := task.WorkflowID oldStepID := task.WorkflowStepID stepChanged := oldStepID != workflowStepID - oldState := task.State task.WorkflowID = workflowID task.WorkflowStepID = workflowStepID task.Position = position @@ -252,16 +252,11 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo sessionID = activeSession.ID } - // Publish state_changed event if state changed, otherwise just updated - if oldState != task.State { - s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState) - } else { - s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) - } + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil, oldWorkflowID) // Publish task.moved event so the orchestrator can process on_exit/on_enter actions if stepChanged { - s.publishTaskMovedEvent(ctx, task, oldStepID, workflowStepID, sessionID) + s.publishTaskMovedEvent(ctx, task, oldWorkflowID, oldStepID, workflowStepID, sessionID) } s.logger.Info("task moved", @@ -288,22 +283,51 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo return result, nil } -// checkMoveTaskApproval returns an error when the task's primary session has a pending -// review and the caller is trying to move it to a different step. -func (s *Service) checkMoveTaskApproval(ctx context.Context, taskID, currentStepID, targetStepID string) error { - if currentStepID == targetStepID { +func (s *Service) validateTaskMove(ctx context.Context, task *models.Task, workflowID, workflowStepID string) error { + if task.ArchivedAt != nil { + return fmt.Errorf("archived tasks cannot be moved") + } + if err := s.validateMoveSessions(ctx, task.ID); err != nil { + return err + } + targetWorkflow, err := s.workflows.GetWorkflow(ctx, workflowID) + if err != nil { + return fmt.Errorf("failed to get target workflow: %w", err) + } + if targetWorkflow.WorkspaceID != task.WorkspaceID { + return fmt.Errorf("target workflow is in a different workspace") + } + if s.workflowStepGetter == nil { return nil } - primarySession, err := s.sessions.GetPrimarySessionByTaskID(ctx, taskID) - if err != nil || primarySession == nil { - return nil + targetStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) + if err != nil { + return fmt.Errorf("failed to get target workflow step: %w", err) } - if primarySession.ReviewStatus != nil && *primarySession.ReviewStatus == "pending" { - return fmt.Errorf("task is pending approval - use Approve button to proceed or send a message to request changes") + if targetStep.WorkflowID != workflowID { + return fmt.Errorf("target workflow step does not belong to target workflow") } return nil } +func (s *Service) validateMoveSessions(ctx context.Context, taskID string) error { + sessions, err := s.sessions.ListTaskSessions(ctx, taskID) + if err != nil { + return fmt.Errorf("failed to list task sessions: %w", err) + } + for _, session := range sessions { + if isSessionMoveBlocked(session.State) { + return fmt.Errorf("task has an active session (%s)", session.State) + } + } + return nil +} + +func isSessionMoveBlocked(state models.TaskSessionState) bool { + return state == models.TaskSessionStateStarting || + state == models.TaskSessionStateRunning +} + // resolvePrimaryOrActiveSession returns the primary session if it is in an active // state, otherwise falls back to the most recently started active session. func (s *Service) resolvePrimaryOrActiveSession(ctx context.Context, taskID string) *models.TaskSession { @@ -340,6 +364,74 @@ type BulkMoveTasksResult struct { MovedCount int } +// BulkMoveSelectedTasks moves an explicit task list to a target workflow step. +// The list order is treated as the visible UI order; tasks already in the +// target step are skipped. Validation reads tasks one at a time because the UI +// sends small selected batches; the move is not transactional if task state +// changes between pre-validation and an individual MoveTask call. +func (s *Service) BulkMoveSelectedTasks(ctx context.Context, taskIDs []string, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) { + ids := uniqueTaskIDs(taskIDs) + if len(ids) == 0 { + return &BulkMoveTasksResult{MovedCount: 0}, nil + } + + tasks, err := s.validateSelectedMoveBatch(ctx, ids, targetWorkflowID, targetStepID) + if err != nil { + return nil, err + } + + nextPosition, err := s.tasks.CountTasksByWorkflowStep(ctx, targetStepID) + if err != nil { + return nil, fmt.Errorf("failed to count target workflow step tasks: %w", err) + } + + movedCount := 0 + for _, task := range tasks { + if task.WorkflowID == targetWorkflowID && task.WorkflowStepID == targetStepID { + continue + } + if _, err := s.MoveTask(ctx, task.ID, targetWorkflowID, targetStepID, nextPosition+movedCount); err != nil { + return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err) + } + movedCount++ + } + + return &BulkMoveTasksResult{MovedCount: movedCount}, nil +} + +func uniqueTaskIDs(taskIDs []string) []string { + seen := make(map[string]struct{}, len(taskIDs)) + result := make([]string, 0, len(taskIDs)) + for _, id := range taskIDs { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +func (s *Service) validateSelectedMoveBatch(ctx context.Context, taskIDs []string, targetWorkflowID, targetStepID string) ([]*models.Task, error) { + tasks := make([]*models.Task, 0, len(taskIDs)) + for _, id := range taskIDs { + task, err := s.tasks.GetTask(ctx, id) + if err != nil { + return nil, err + } + if task.WorkflowID != targetWorkflowID || task.WorkflowStepID != targetStepID { + if err := s.validateTaskMove(ctx, task, targetWorkflowID, targetStepID); err != nil { + return nil, fmt.Errorf("task %s cannot be moved: %w", id, err) + } + } + tasks = append(tasks, task) + } + return tasks, nil +} + // BulkMoveTasks moves all tasks from a source workflow/step to a target workflow/step. // If sourceStepID is empty, all tasks in the source workflow are moved. func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceStepID, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) { @@ -361,6 +453,7 @@ func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceSte now := time.Now().UTC() for i, task := range tasks { + oldWorkflowID := task.WorkflowID task.WorkflowID = targetWorkflowID task.WorkflowStepID = targetStepID task.Position = i @@ -373,7 +466,7 @@ func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceSte return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err) } - s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil, oldWorkflowID) } s.logger.Info("bulk moved tasks", diff --git a/apps/backend/internal/task/service/service_workflow_test.go b/apps/backend/internal/task/service/service_workflow_test.go new file mode 100644 index 0000000000..87b571bf81 --- /dev/null +++ b/apps/backend/internal/task/service/service_workflow_test.go @@ -0,0 +1,339 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeWorkflowStepGetter struct { + steps map[string]*wfmodels.WorkflowStep +} + +func (f *fakeWorkflowStepGetter) GetStep(_ context.Context, stepID string) (*wfmodels.WorkflowStep, error) { + if step, ok := f.steps[stepID]; ok { + return step, nil + } + return nil, errStepNotFoundForTest +} + +func (f *fakeWorkflowStepGetter) GetNextStepByPosition(_ context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) { + for _, step := range f.steps { + if step.WorkflowID == workflowID && step.Position == currentPosition+1 { + return step, nil + } + } + return nil, nil +} + +type testStepNotFound struct{} + +func (testStepNotFound) Error() string { return "step not found" } + +var errStepNotFoundForTest = testStepNotFound{} + +func TestService_MoveTaskRejectsInvalidWorkflowTargets(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + + tests := []struct { + name string + taskID string + targetWF string + targetSt string + }{ + { + name: "step belongs to another workflow", + taskID: "task-invalid-step", + targetWF: "wf-source", + targetSt: "step-target", + }, + { + name: "workflow belongs to another workspace", + taskID: "task-other-workspace", + targetWF: "wf-other-workspace", + targetSt: "step-other-workspace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + createMoveTask(t, ctx, repo, tt.taskID, "wf-source", "step-source", nil) + + _, err := svc.MoveTask(ctx, tt.taskID, tt.targetWF, tt.targetSt, 0) + if err == nil { + t.Fatalf("expected move to be rejected") + } + + task, err := repo.GetTask(ctx, tt.taskID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if task.WorkflowID != "wf-source" || task.WorkflowStepID != "step-source" { + t.Fatalf("task moved despite validation error: workflow=%s step=%s", task.WorkflowID, task.WorkflowStepID) + } + }) + } +} + +func TestService_MoveTaskAllowsPendingReviewWhenSessionIdle(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-pending-review", "wf-source", "step-source", nil) + pending := "pending" + createMoveSession(t, ctx, repo, "session-pending-review", "task-pending-review", models.TaskSessionStateWaitingForInput, &pending) + + moved, err := svc.MoveTask(ctx, "task-pending-review", "wf-source", "step-review-target", 0) + if err != nil { + t.Fatalf("pending review on idle session should not block manual move: %v", err) + } + if moved.Task.WorkflowStepID != "step-review-target" { + t.Fatalf("expected step-review-target, got %s", moved.Task.WorkflowStepID) + } +} + +func TestService_MoveTaskRejectsRunningSession(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-running", "wf-source", "step-source", nil) + createMoveSession(t, ctx, repo, "session-running", "task-running", models.TaskSessionStateRunning, nil) + + _, err := svc.MoveTask(ctx, "task-running", "wf-source", "step-review-target", 0) + if err == nil { + t.Fatalf("expected running session move to be rejected") + } + + task, err := repo.GetTask(ctx, "task-running") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if task.WorkflowStepID != "step-source" { + t.Fatalf("task moved despite running session: %s", task.WorkflowStepID) + } +} + +func TestService_MoveTaskRejectsArchivedTask(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + now := time.Now().UTC() + createMoveTask(t, ctx, repo, "task-archived", "wf-source", "step-source", &now) + + _, err := svc.MoveTask(ctx, "task-archived", "wf-source", "step-review-target", 0) + if err == nil { + t.Fatalf("expected archived task move to be rejected") + } +} + +func TestService_MoveTaskMovedEventIncludesSourceWorkflow(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-cross-workflow", "wf-source", "step-source", nil) + eventBus.ClearEvents() + + _, err := svc.MoveTask(ctx, "task-cross-workflow", "wf-target", "step-target", 0) + if err != nil { + t.Fatalf("MoveTask: %v", err) + } + + updatedEvent := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskUpdated) + updatedData, ok := updatedEvent.Data.(map[string]interface{}) + if !ok { + t.Fatalf("updated event data type = %T, want map[string]interface{}", updatedEvent.Data) + } + if got := updatedData["old_workflow_id"]; got != "wf-source" { + t.Fatalf("old_workflow_id = %v, want wf-source", got) + } + + event := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskMoved) + data, ok := event.Data.(map[string]interface{}) + if !ok { + t.Fatalf("event data type = %T, want map[string]interface{}", event.Data) + } + if got := data["from_workflow_id"]; got != "wf-source" { + t.Fatalf("from_workflow_id = %v, want wf-source", got) + } + if got := data["to_workflow_id"]; got != "wf-target" { + t.Fatalf("to_workflow_id = %v, want wf-target", got) + } +} + +func TestService_BulkMoveTasksUpdatedEventIncludesSourceWorkflow(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + createMoveTask(t, ctx, repo, "task-bulk-cross-workflow", "wf-source", "step-source", nil) + eventBus.ClearEvents() + + _, err := svc.BulkMoveTasks(ctx, "wf-source", "", "wf-target", "step-target") + if err != nil { + t.Fatalf("BulkMoveTasks: %v", err) + } + + updatedEvent := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskUpdated) + updatedData, ok := updatedEvent.Data.(map[string]interface{}) + if !ok { + t.Fatalf("updated event data type = %T, want map[string]interface{}", updatedEvent.Data) + } + if got := updatedData["old_workflow_id"]; got != "wf-source" { + t.Fatalf("old_workflow_id = %v, want wf-source", got) + } +} + +func TestService_BulkMoveSelectedTasksValidatesBatchBeforeMoving(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-batch-ok", "wf-source", "step-source", nil) + createMoveTask(t, ctx, repo, "task-batch-running", "wf-source", "step-source", nil) + createMoveSession(t, ctx, repo, "session-batch-running", "task-batch-running", models.TaskSessionStateRunning, nil) + + _, err := svc.BulkMoveSelectedTasks(ctx, []string{"task-batch-ok", "task-batch-running"}, "wf-target", "step-target") + if err == nil { + t.Fatalf("expected selected batch move to be rejected") + } + + for _, id := range []string{"task-batch-ok", "task-batch-running"} { + task, err := repo.GetTask(ctx, id) + if err != nil { + t.Fatalf("GetTask(%s): %v", id, err) + } + if task.WorkflowID != "wf-source" || task.WorkflowStepID != "step-source" { + t.Fatalf("%s moved despite rejected batch: workflow=%s step=%s", id, task.WorkflowID, task.WorkflowStepID) + } + } +} + +func TestService_BulkMoveSelectedTasksSkipsCurrentTargetAndAppendsInOrder(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-target-existing", "wf-target", "step-target", nil) + createMoveTask(t, ctx, repo, "task-source-a", "wf-source", "step-source", nil) + createMoveTask(t, ctx, repo, "task-target-already", "wf-target", "step-target", nil) + createMoveTask(t, ctx, repo, "task-source-b", "wf-source", "step-source", nil) + eventBus.ClearEvents() + + result, err := svc.BulkMoveSelectedTasks( + ctx, + []string{"task-source-a", "task-target-already", "task-source-b"}, + "wf-target", + "step-target", + ) + if err != nil { + t.Fatalf("BulkMoveSelectedTasks: %v", err) + } + if result.MovedCount != 2 { + t.Fatalf("MovedCount = %d, want 2", result.MovedCount) + } + + sourceA, err := repo.GetTask(ctx, "task-source-a") + if err != nil { + t.Fatalf("GetTask(task-source-a): %v", err) + } + sourceB, err := repo.GetTask(ctx, "task-source-b") + if err != nil { + t.Fatalf("GetTask(task-source-b): %v", err) + } + if sourceA.Position != 2 || sourceB.Position != 3 { + t.Fatalf("positions = (%d, %d), want (2, 3)", sourceA.Position, sourceB.Position) + } + + movedEvents := 0 + for _, event := range eventBus.GetPublishedEvents() { + if event.Type == events.TaskMoved { + movedEvents++ + } + } + if movedEvents != 2 { + t.Fatalf("task.moved events = %d, want 2", movedEvents) + } +} + +func seedMoveWorkflows(t *testing.T, ctx context.Context, repo interface { + CreateWorkspace(context.Context, *models.Workspace) error + CreateWorkflow(context.Context, *models.Workflow) error +}) { + t.Helper() + must(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"})) + must(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"})) + must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-source", WorkspaceID: "ws-1", Name: "Source"})) + must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-target", WorkspaceID: "ws-1", Name: "Target"})) + must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-other-workspace", WorkspaceID: "ws-2", Name: "Other"})) +} + +func seedMoveSteps(svc *Service) { + svc.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: map[string]*wfmodels.WorkflowStep{ + "step-source": {ID: "step-source", WorkflowID: "wf-source", Name: "Source", Position: 0}, + "step-review-target": {ID: "step-review-target", WorkflowID: "wf-source", Name: "Review", Position: 1}, + "step-target": {ID: "step-target", WorkflowID: "wf-target", Name: "Target", Position: 0}, + "step-other-workspace": {ID: "step-other-workspace", WorkflowID: "wf-other-workspace", Name: "Other", Position: 0}, + }}) +} + +func createMoveTask(t *testing.T, ctx context.Context, repo interface { + CreateTask(context.Context, *models.Task) error + ArchiveTask(context.Context, string) error +}, id, workflowID, stepID string, archivedAt *time.Time) { + t.Helper() + must(t, repo.CreateTask(ctx, &models.Task{ + ID: id, + WorkspaceID: "ws-1", + WorkflowID: workflowID, + WorkflowStepID: stepID, + Title: id, + State: v1.TaskStateTODO, + ArchivedAt: archivedAt, + })) + if archivedAt != nil { + must(t, repo.ArchiveTask(ctx, id)) + } +} + +func createMoveSession(t *testing.T, ctx context.Context, repo interface { + CreateTaskSession(context.Context, *models.TaskSession) error +}, id, taskID string, state models.TaskSessionState, reviewStatus *string) { + t.Helper() + must(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: id, + TaskID: taskID, + State: state, + IsPrimary: true, + ReviewStatus: reviewStatus, + })) +} + +func findPublishedEvent(t *testing.T, published []*bus.Event, eventType string) *bus.Event { + t.Helper() + for _, event := range published { + if event.Type == eventType { + return event + } + } + t.Fatalf("event %s not published; got %d events", eventType, len(published)) + return nil +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index 3b6444a716..04b8209c25 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -12,7 +12,8 @@ import { useSensors, } from "@dnd-kit/core"; import { KanbanColumn, WorkflowStep } from "./kanban-column"; -import { KanbanCardPreview, Task } from "./kanban-card"; +import { Task } from "./kanban-card"; +import { KanbanCardPreview } from "./kanban-card-preview"; import { MobileColumnTabs } from "./kanban/mobile-column-tabs"; import { SwipeableColumns } from "./kanban/swipeable-columns"; import { MobileDropTargets } from "./kanban/mobile-drop-targets"; diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 8945e31a8f..2e658d1dd7 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -69,14 +69,11 @@ function useWorkflowSelection({ (workflow: WorkflowsState["items"][number]) => workflow.workspaceId === workspaceId, ); - const hasSettingsWorkflow = - settings.workflowId && - workspaceWorkflows.some( - (workflow: WorkflowsState["items"][number]) => workflow.id === settings.workflowId, - ); - const fallbackWorkflowId = workspaceWorkflows.length === 1 ? workspaceWorkflows[0].id : null; - const desiredWorkflowId = - (hasSettingsWorkflow ? settings.workflowId : fallbackWorkflowId) ?? null; + const desiredWorkflowId = resolveDesiredWorkflowId({ + activeWorkflowId: workflowsState.activeId, + settingsWorkflowId: settings.workflowId, + workspaceWorkflows, + }); setActiveWorkflow(desiredWorkflowId); if (!desiredWorkflowId) { store.getState().hydrate({ @@ -94,6 +91,28 @@ function useWorkflowSelection({ ]); } +function resolveDesiredWorkflowId({ + activeWorkflowId, + settingsWorkflowId, + workspaceWorkflows, +}: { + activeWorkflowId?: string | null; + settingsWorkflowId?: string | null; + workspaceWorkflows: WorkflowsState["items"]; +}): string | null { + const visibleWorkflows = workspaceWorkflows.filter((workflow) => !workflow.hidden); + if (activeWorkflowId && visibleWorkflows.some((workflow) => workflow.id === activeWorkflowId)) { + return activeWorkflowId; + } + if ( + settingsWorkflowId && + visibleWorkflows.some((workflow) => workflow.id === settingsWorkflowId) + ) { + return settingsWorkflowId; + } + return visibleWorkflows[0]?.id ?? null; +} + function useMoveErrorState(router: ReturnType<typeof useRouter>) { const [moveError, setMoveError] = useState<MoveTaskError | null>(null); diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx new file mode 100644 index 0000000000..f58ae17c24 --- /dev/null +++ b/apps/web/components/kanban-card-content.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useState } from "react"; +import { CSS, type Transform } from "@dnd-kit/utilities"; +import type { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core"; +import { IconAlertCircle, IconArrowsMaximize, IconDots, IconSubtask } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Checkbox } from "@kandev/ui/checkbox"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { PRTaskIcon } from "@/components/github/pr-task-icon"; +import { + KanbanCardDropdownMenuItems, + type KanbanCardMenuEntry, +} from "@/components/kanban-card-menu-items"; +import { useAppStore } from "@/components/state-provider"; +import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; +import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification"; +import { getTaskStateIcon, shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons"; +import { cn } from "@/lib/utils"; +import { needsAction } from "@/lib/utils/needs-action"; +import type { Task } from "@/components/kanban-card"; + +type KanbanCardActionProps = { + task: Task; + showMaximizeButton?: boolean; + onOpenFullPage?: (task: Task) => void; + menuEntries: KanbanCardMenuEntry[]; + isDeleting?: boolean; + isArchiving?: boolean; +}; + +type DraggableCardState = { + attributes: DraggableAttributes; + listeners: DraggableSyntheticListeners; + setNodeRef: (element: HTMLElement | null) => void; + transform: Transform | null; + isDragging: boolean; +}; + +export type KanbanCardShellProps = KanbanCardActionProps & + DraggableCardState & { + repositoryNames?: string[]; + isSelected?: boolean; + isMultiSelectMode?: boolean; + isPreviewed: boolean; + onClick: () => void; + onCheckboxClick: (e: React.MouseEvent) => void; + }; + +const REPO_CHIPS_VISIBLE = 2; + +function RepoChipRow({ repoNames }: { repoNames: string[] }) { + if (repoNames.length === 0) return null; + const visible = repoNames.slice(0, REPO_CHIPS_VISIBLE); + const overflow = repoNames.slice(REPO_CHIPS_VISIBLE); + const row = ( + <div className="mb-1 flex items-center gap-1 min-w-0 overflow-hidden"> + {visible.map((name) => ( + <span + key={name} + className="shrink-0 rounded-sm bg-muted/60 px-1 py-px text-[9px] font-medium text-muted-foreground leading-tight max-w-[8rem] truncate" + > + {name} + </span> + ))} + {overflow.length > 0 && ( + <span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium text-muted-foreground/80"> + +{overflow.length} + </span> + )} + </div> + ); + if (repoNames.length <= 1) return row; + return ( + <Tooltip> + <TooltipTrigger asChild>{row}</TooltipTrigger> + <TooltipContent side="top" align="start"> + <div className="flex flex-col gap-0.5 text-xs"> + {repoNames.map((name) => ( + <span key={name}>{name}</span> + ))} + </div> + </TooltipContent> + </Tooltip> + ); +} + +export function KanbanCardBody({ + task, + repoNames, + actions, +}: { + task: Task; + repoNames: string[]; + actions?: React.ReactNode; +}) { + return ( + <> + <div className="flex items-start justify-between gap-2"> + <div className="min-w-0 flex-1"> + <RepoChipRow repoNames={repoNames} /> + <div className="flex items-center gap-1 min-w-0"> + <p + data-testid="task-card-title" + className="text-sm font-medium leading-tight line-clamp-1 min-w-0" + > + {task.title} + </p> + <PRTaskIcon taskId={task.id} /> + </div> + </div> + {task.isRemoteExecutor && ( + <RemoteCloudTooltip + taskId={task.id} + sessionId={task.primarySessionId ?? null} + fallbackName={task.primaryExecutorName ?? task.primaryExecutorType} + /> + )} + {actions} + </div> + {task.description && ( + <p className="text-xs text-muted-foreground mt-1 leading-tight line-clamp-1"> + {task.description} + </p> + )} + <KanbanCardBadges task={task} /> + </> + ); +} + +function KanbanCardBadges({ task }: { task: Task }) { + const parentTitle = useAppStore((s) => { + if (!task.parentTaskId) return null; + return s.kanban.tasks.find((t) => t.id === task.parentTaskId)?.title ?? null; + }); + + const showRow = + (task.sessionCount && task.sessionCount > 1) || + task.reviewStatus === "changes_requested" || + task.reviewStatus === "pending" || + task.parentTaskId; + + if (!showRow) return null; + + return ( + <div className="flex flex-wrap items-center justify-end gap-2 mt-1 min-w-0"> + {task.parentTaskId && ( + <Badge variant="outline" className="text-xs h-5 gap-1 max-w-[160px] min-w-0"> + <IconSubtask className="h-3 w-3 shrink-0" /> + <span className="truncate">{parentTitle ?? "Subtask"}</span> + </Badge> + )} + {task.sessionCount && task.sessionCount > 1 && ( + <Badge variant="secondary" className="text-xs h-5"> + {task.sessionCount} sessions + </Badge> + )} + {task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && ( + <div className="flex items-center gap-1 text-amber-700 dark:text-amber-600"> + <IconAlertCircle className="h-3.5 w-3.5" /> + <span className="text-[10px] font-medium">Approval Required</span> + </div> + )} + {task.reviewStatus === "changes_requested" && ( + <Badge + variant="outline" + className="border-amber-500 text-amber-600 bg-amber-50 dark:bg-amber-950/50 text-xs h-5" + > + Changes Requested + </Badge> + )} + </div> + ); +} + +function KanbanCardActions({ + task, + showMaximizeButton, + onOpenFullPage, + menuEntries, + isDeleting, + isArchiving, +}: KanbanCardActionProps) { + const [menuOpen, setMenuOpen] = useState(false); + const effectiveMenuOpen = menuOpen || Boolean(isDeleting) || Boolean(isArchiving); + const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId); + const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarificationRequest); + const statusIcon = getTaskStateIcon(task.state, "h-4 w-4", hasPendingClarificationRequest); + const hasKnownSession = + Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0); + + return ( + <div className="flex items-center gap-2"> + {(task.state === "IN_PROGRESS" || task.state === "SCHEDULING" || showQuestionIcon) && + statusIcon} + {showMaximizeButton && onOpenFullPage && hasKnownSession && ( + <button + type="button" + className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm p-1 -m-1 transition-colors cursor-pointer" + onClick={(event) => { + event.stopPropagation(); + onOpenFullPage(task); + }} + onPointerDown={(event) => event.stopPropagation()} + aria-label="Open full page" + title="Open full page" + > + <IconArrowsMaximize className="h-4 w-4" /> + </button> + )} + <KanbanCardMenu + task={task} + effectiveMenuOpen={effectiveMenuOpen} + setMenuOpen={setMenuOpen} + isDeleting={isDeleting} + isArchiving={isArchiving} + menuEntries={menuEntries} + /> + </div> + ); +} + +type KanbanCardMenuProps = KanbanCardActionProps & { + effectiveMenuOpen: boolean; + setMenuOpen: (open: boolean) => void; +}; + +function KanbanCardMenu(props: KanbanCardMenuProps) { + const { effectiveMenuOpen, setMenuOpen, isDeleting, isArchiving } = props; + const { menuEntries } = props; + const isProcessing = isDeleting || isArchiving; + + return ( + <DropdownMenu + open={effectiveMenuOpen} + onOpenChange={(open) => { + if (!open && isProcessing) return; + setMenuOpen(open); + }} + > + <DropdownMenuTrigger asChild> + <button + type="button" + className="text-muted-foreground hover:text-foreground hover:bg-muted rounded-sm p-1 -m-1 transition-colors cursor-pointer" + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + aria-label="More options" + > + <IconDots className="h-4 w-4" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <KanbanCardDropdownMenuItems entries={menuEntries} /> + </DropdownMenuContent> + </DropdownMenu> + ); +} + +function KanbanCardCheckbox({ + taskId, + taskTitle, + isSelected, + onCheckboxClick, +}: { + taskId: string; + taskTitle: string; + isSelected?: boolean; + onCheckboxClick: (e: React.MouseEvent) => void; +}) { + return ( + <div + className="mt-0.5 shrink-0" + onClick={onCheckboxClick} + onPointerDown={(e) => e.stopPropagation()} + data-testid={`task-select-checkbox-${taskId}`} + > + <Checkbox + checked={!!isSelected} + aria-label={`Select task ${taskTitle}`} + className="cursor-pointer border-muted-foreground/50" + /> + </div> + ); +} + +function KanbanCardActionSlot({ + isMultiSelectMode, + task, + showMaximizeButton, + onOpenFullPage, + menuEntries, + isDeleting, + isArchiving, +}: KanbanCardActionProps & { isMultiSelectMode?: boolean }) { + if (isMultiSelectMode) return null; + return ( + <KanbanCardActions + task={task} + showMaximizeButton={showMaximizeButton} + onOpenFullPage={onOpenFullPage} + menuEntries={menuEntries} + isDeleting={isDeleting} + isArchiving={isArchiving} + /> + ); +} + +export function KanbanCardShell({ + task, + repositoryNames, + attributes, + listeners, + setNodeRef, + transform, + isDragging, + isPreviewed, + isSelected, + isMultiSelectMode, + showMaximizeButton, + isDeleting, + isArchiving, + onClick, + onCheckboxClick, + onOpenFullPage, + menuEntries, +}: KanbanCardShellProps) { + const showCheckbox = isMultiSelectMode || !!isSelected; + const style = { + transform: CSS.Translate.toString(transform), + transition: "none", + willChange: isDragging ? "transform" : undefined, + }; + + return ( + <Card + size="sm" + ref={setNodeRef} + style={style} + data-testid={`task-card-${task.id}`} + className={cn( + "group max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0", + needsAction(task) && !isSelected && "border-l-2 border-l-amber-500", + isDragging && "opacity-50 z-50", + isSelected && "ring-1 ring-primary/60 border-primary/60", + isPreviewed && !isSelected && "ring-2 ring-primary border-primary", + )} + onClick={onClick} + {...(!isMultiSelectMode ? listeners : {})} + {...(!isMultiSelectMode ? attributes : {})} + > + <CardContent className="px-2 py-1"> + <div className="flex items-start gap-1.5"> + {showCheckbox && ( + <KanbanCardCheckbox + taskId={task.id} + taskTitle={task.title} + isSelected={isSelected} + onCheckboxClick={onCheckboxClick} + /> + )} + <div className="min-w-0 flex-1"> + <KanbanCardBody + task={task} + repoNames={repositoryNames ?? []} + actions={ + <KanbanCardActionSlot + isMultiSelectMode={isMultiSelectMode} + task={task} + showMaximizeButton={showMaximizeButton} + onOpenFullPage={onOpenFullPage} + menuEntries={menuEntries} + isDeleting={isDeleting} + isArchiving={isArchiving} + /> + } + /> + </div> + </div> + </CardContent> + </Card> + ); +} diff --git a/apps/web/components/kanban-card-context-menu.tsx b/apps/web/components/kanban-card-context-menu.tsx new file mode 100644 index 0000000000..ca1928e9e5 --- /dev/null +++ b/apps/web/components/kanban-card-context-menu.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@kandev/ui/context-menu"; +import { + KanbanCardContextMenuItems, + type KanbanCardMenuEntry, +} from "@/components/kanban-card-menu-items"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; + +export function KanbanCardContextMenu({ + entries, + children, +}: { + entries: KanbanCardMenuEntry[]; + children: React.ReactNode; +}) { + const { isDesktop } = useResponsiveBreakpoint(); + + if (!isDesktop) return children; + + return ( + <ContextMenu> + <ContextMenuTrigger className="block">{children}</ContextMenuTrigger> + <ContextMenuContent className="w-56"> + <KanbanCardContextMenuItems entries={entries} /> + </ContextMenuContent> + </ContextMenu> + ); +} diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx new file mode 100644 index 0000000000..1c42863264 --- /dev/null +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -0,0 +1,436 @@ +"use client"; + +import { useMemo, type ReactNode } from "react"; +import { + IconArchive, + IconArrowRight, + IconLoader, + IconLogicBuffer, + IconPencil, + IconTrash, +} from "@tabler/icons-react"; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from "@kandev/ui/context-menu"; +import { + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@kandev/ui/dropdown-menu"; +import { useAppStore } from "@/components/state-provider"; +import type { WorkflowStep } from "@/components/kanban-card"; +import { + stepHasAutoStart, + type TaskMoveStep, + type TaskMoveWorkflow, +} from "@/components/task/task-move-context-menu"; +import { cn } from "@/lib/utils"; + +type ItemEntry = { + kind: "item"; + key: string; + label: ReactNode; + icon?: ReactNode; + leading?: ReactNode; + trailing?: ReactNode; + disabled?: boolean; + destructive?: boolean; + testId?: string; + onSelect?: () => void; +}; + +type SeparatorEntry = { kind: "separator"; key: string }; + +type SubmenuEntry = { + kind: "submenu"; + key: string; + label: ReactNode; + icon?: ReactNode; + disabled?: boolean; + testId?: string; + className?: string; + children: KanbanCardMenuEntry[]; +}; + +export type KanbanCardMenuEntry = ItemEntry | SeparatorEntry | SubmenuEntry; + +export type KanbanCardMoveTargets = { + currentWorkflowId: string | null; + workflowItems: TaskMoveWorkflow[]; + stepsByWorkflowId: Record<string, TaskMoveStep[]>; +}; + +type BuildKanbanCardMenuEntriesArgs = { + currentWorkflowId?: string | null; + currentStepId?: string | null; + workflows: TaskMoveWorkflow[]; + stepsByWorkflowId: Record<string, TaskMoveStep[]>; + disabled?: boolean; + isDeleting?: boolean; + isArchiving?: boolean; + onEdit?: () => void; + onArchive?: () => void; + onDelete?: () => void; + onMoveToStep?: (stepId: string) => void; + onSendToWorkflow?: (workflowId: string, stepId: string) => void; +}; + +function StepBadges({ step, isCurrent }: { step: TaskMoveStep; isCurrent: boolean }) { + const hasAutoStart = stepHasAutoStart(step); + if (!isCurrent && !hasAutoStart) return null; + + return ( + <span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground"> + {isCurrent && <span data-testid={`task-context-step-current-${step.id}`}>Current</span>} + {hasAutoStart && ( + <span data-testid={`task-context-step-autostart-${step.id}`}>Auto-start</span> + )} + </span> + ); +} + +function buildStepEntry( + step: TaskMoveStep, + currentStepId: string | null | undefined, + onSelect: (stepId: string) => void, +): KanbanCardMenuEntry { + const isCurrent = step.id === currentStepId; + return { + kind: "item", + key: `step-${step.id}`, + testId: `task-context-step-${step.id}`, + disabled: isCurrent, + leading: <span className={cn("block h-2 w-2 rounded-full shrink-0", step.color ?? "")} />, + label: <span className="flex-1 truncate">{step.title}</span>, + trailing: <StepBadges step={step} isCurrent={isCurrent} />, + onSelect: () => { + if (!isCurrent) onSelect(step.id); + }, + }; +} + +function buildMoveToCurrentWorkflowSubmenu({ + steps, + currentStepId, + disabled, + onMoveToStep, +}: { + steps: TaskMoveStep[]; + currentStepId?: string | null; + disabled?: boolean; + onMoveToStep?: (stepId: string) => void; +}): KanbanCardMenuEntry | null { + if (!onMoveToStep || steps.length <= 1) return null; + return { + kind: "submenu", + key: "move-to", + testId: "task-context-move-to", + icon: <IconArrowRight className="mr-2 h-4 w-4" />, + label: "Move to", + disabled, + className: "w-48", + children: steps.map((step) => buildStepEntry(step, currentStepId, onMoveToStep)), + }; +} + +function buildWorkflowTargetEntry({ + workflow, + steps, + disabled, + onSendToWorkflow, +}: { + workflow: TaskMoveWorkflow; + steps: TaskMoveStep[]; + disabled?: boolean; + onSendToWorkflow?: (workflowId: string, stepId: string) => void; +}): KanbanCardMenuEntry { + if (steps.length === 0 || !onSendToWorkflow) { + return { + kind: "item", + key: `workflow-${workflow.id}`, + testId: `task-context-workflow-${workflow.id}`, + disabled: true, + label: <span className="flex-1 truncate">{workflow.name}</span>, + trailing: ( + <span data-testid="task-context-disabled-reason" className="ml-2 text-[10px]"> + No steps + </span> + ), + }; + } + + return { + kind: "submenu", + key: `workflow-${workflow.id}`, + testId: `task-context-workflow-${workflow.id}`, + label: <span className="truncate">{workflow.name}</span>, + disabled, + className: "w-48", + children: steps.map((step) => + buildStepEntry(step, null, (stepId) => onSendToWorkflow(workflow.id, stepId)), + ), + }; +} + +function buildSendToWorkflowSubmenu({ + currentWorkflowId, + workflows, + stepsByWorkflowId, + disabled, + onSendToWorkflow, +}: { + currentWorkflowId?: string | null; + workflows: TaskMoveWorkflow[]; + stepsByWorkflowId: Record<string, TaskMoveStep[]>; + disabled?: boolean; + onSendToWorkflow?: (workflowId: string, stepId: string) => void; +}): KanbanCardMenuEntry | null { + const targets = workflows.filter((workflow) => workflow.id !== currentWorkflowId); + if (!onSendToWorkflow || !currentWorkflowId || targets.length === 0) return null; + return { + kind: "submenu", + key: "send-to-workflow", + testId: "task-context-send-to-workflow", + icon: <IconLogicBuffer className="mr-2 h-4 w-4" />, + label: "Send to workflow", + disabled, + className: "w-56", + children: targets.map((workflow) => + buildWorkflowTargetEntry({ + workflow, + steps: stepsByWorkflowId[workflow.id] ?? [], + disabled, + onSendToWorkflow, + }), + ), + }; +} + +export function buildKanbanCardMenuEntries({ + currentWorkflowId, + currentStepId, + workflows, + stepsByWorkflowId, + disabled, + isDeleting, + isArchiving, + onEdit, + onArchive, + onDelete, + onMoveToStep, + onSendToWorkflow, +}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] { + const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden); + const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : []; + const isProcessing = Boolean(disabled || isDeleting || isArchiving); + const entries: KanbanCardMenuEntry[] = [ + { + kind: "item", + key: "edit", + icon: <IconPencil className="mr-2 h-4 w-4" />, + label: "Edit", + disabled: isProcessing || !onEdit, + onSelect: onEdit, + }, + ]; + + const moveToEntry = buildMoveToCurrentWorkflowSubmenu({ + steps: currentSteps, + currentStepId, + disabled: isProcessing, + onMoveToStep, + }); + if (moveToEntry) entries.push(moveToEntry); + + const sendToEntry = buildSendToWorkflowSubmenu({ + currentWorkflowId, + workflows: visibleWorkflows, + stepsByWorkflowId, + disabled: isProcessing, + onSendToWorkflow, + }); + if (sendToEntry) entries.push(sendToEntry); + + entries.push({ + kind: "item", + key: "archive", + icon: isArchiving ? ( + <IconLoader className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <IconArchive className="mr-2 h-4 w-4" /> + ), + label: "Archive", + disabled: isProcessing || !onArchive, + onSelect: onArchive, + }); + + entries.push({ kind: "separator", key: "delete-separator" }); + entries.push({ + kind: "item", + key: "delete", + icon: isDeleting ? ( + <IconLoader className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <IconTrash className="mr-2 h-4 w-4" /> + ), + label: "Delete", + destructive: true, + disabled: isProcessing || !onDelete, + onSelect: onDelete, + }); + + return entries; +} + +export function useKanbanCardMoveTargets( + taskId: string, + steps?: WorkflowStep[], +): KanbanCardMoveTargets { + const workflows = useAppStore((state) => state.workflows.items); + const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); + + const currentWorkflowId = useMemo(() => { + for (const [workflowId, snapshot] of Object.entries(snapshots)) { + if (snapshot.tasks.some((task) => task.id === taskId)) return workflowId; + } + return null; + }, [snapshots, taskId]); + + const workflowItems = useMemo<TaskMoveWorkflow[]>(() => { + const current = workflows.find((workflow) => workflow.id === currentWorkflowId); + return workflows + .filter((workflow) => workflow.workspaceId === current?.workspaceId && !workflow.hidden) + .map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden })); + }, [workflows, currentWorkflowId]); + + const stepsByWorkflowId = useMemo<Record<string, TaskMoveStep[]>>(() => { + const result: Record<string, TaskMoveStep[]> = {}; + for (const [workflowId, snapshot] of Object.entries(snapshots)) { + result[workflowId] = snapshot.steps + .slice() + .sort((a, b) => a.position - b.position) + .map((step) => ({ + id: step.id, + title: step.title, + color: step.color, + events: step.events, + })); + } + if (currentWorkflowId && steps) { + result[currentWorkflowId] = steps.map((step) => ({ + id: step.id, + title: step.title, + color: step.color, + events: step.events, + })); + } + return result; + }, [snapshots, currentWorkflowId, steps]); + + return { currentWorkflowId, workflowItems, stepsByWorkflowId }; +} + +function ContextEntry({ entry }: { entry: KanbanCardMenuEntry }) { + if (entry.kind === "separator") return <ContextMenuSeparator />; + if (entry.kind === "submenu") { + return ( + <ContextMenuSub> + <ContextMenuSubTrigger data-testid={entry.testId} disabled={entry.disabled}> + {entry.icon} + {entry.label} + </ContextMenuSubTrigger> + <ContextMenuSubContent className={entry.className}> + {entry.children.map((child) => ( + <ContextEntry key={child.key} entry={child} /> + ))} + </ContextMenuSubContent> + </ContextMenuSub> + ); + } + + return ( + <ContextMenuItem + data-testid={entry.testId} + disabled={entry.disabled} + className={entry.destructive ? "text-destructive focus:text-destructive" : undefined} + onSelect={() => { + if (!entry.disabled) entry.onSelect?.(); + }} + > + {entry.icon} + {entry.leading} + {entry.label} + {entry.trailing} + </ContextMenuItem> + ); +} + +function DropdownEntry({ entry }: { entry: KanbanCardMenuEntry }) { + if (entry.kind === "separator") return <DropdownMenuSeparator />; + if (entry.kind === "submenu") { + return ( + <DropdownMenuSub> + <DropdownMenuSubTrigger + data-testid={entry.testId} + disabled={entry.disabled} + onClick={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + {entry.icon} + {entry.label} + </DropdownMenuSubTrigger> + <DropdownMenuPortal> + <DropdownMenuSubContent className={entry.className}> + {entry.children.map((child) => ( + <DropdownEntry key={child.key} entry={child} /> + ))} + </DropdownMenuSubContent> + </DropdownMenuPortal> + </DropdownMenuSub> + ); + } + + return ( + <DropdownMenuItem + data-testid={entry.testId} + disabled={entry.disabled} + className={entry.destructive ? "text-destructive focus:text-destructive" : undefined} + onSelect={(event) => { + event.stopPropagation(); + if (!entry.disabled) entry.onSelect?.(); + }} + > + {entry.icon} + {entry.leading} + {entry.label} + {entry.trailing} + </DropdownMenuItem> + ); +} + +export function KanbanCardContextMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) { + return ( + <> + {entries.map((entry) => ( + <ContextEntry key={entry.key} entry={entry} /> + ))} + </> + ); +} + +export function KanbanCardDropdownMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) { + return ( + <> + {entries.map((entry) => ( + <DropdownEntry key={entry.key} entry={entry} /> + ))} + </> + ); +} diff --git a/apps/web/components/kanban-card-preview.tsx b/apps/web/components/kanban-card-preview.tsx new file mode 100644 index 0000000000..395e4f39c7 --- /dev/null +++ b/apps/web/components/kanban-card-preview.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useMemo } from "react"; +import { Card, CardContent } from "@kandev/ui/card"; +import { KanbanCardBody } from "@/components/kanban-card-content"; +import { resolveTaskRepositoryNames, type Task } from "@/components/kanban-card"; +import { useAppStore } from "@/components/state-provider"; +import type { Repository } from "@/lib/types/http"; + +function KanbanCardPreviewLayout({ + task, + repositoryNames, +}: { + task: Task; + repositoryNames: string[]; +}) { + return ( + <Card + size="sm" + className="w-full py-0 cursor-grabbing shadow-lg ring-0 pointer-events-none border border-border" + > + <CardContent className="px-2 py-1"> + <KanbanCardBody task={task} repoNames={repositoryNames} /> + </CardContent> + </Card> + ); +} + +export function KanbanCardPreview({ task }: { task: Task }) { + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const repositoryNames = useMemo( + () => + resolveTaskRepositoryNames( + task, + Object.values(repositoriesByWorkspace).flat() as Repository[], + ), + [repositoriesByWorkspace, task], + ); + + return <KanbanCardPreviewLayout task={task} repositoryNames={repositoryNames} />; +} diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 2f6dd0801c..b983dbe798 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -1,40 +1,19 @@ "use client"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useDraggable } from "@dnd-kit/core"; -import { CSS } from "@dnd-kit/utilities"; +import { KanbanCardContextMenu } from "@/components/kanban-card-context-menu"; +import { KanbanCardShell } from "@/components/kanban-card-content"; import { - IconDots, - IconArrowsMaximize, - IconLoader, - IconAlertCircle, - IconSubtask, -} from "@tabler/icons-react"; -import { Checkbox } from "@kandev/ui/checkbox"; -import { Card, CardContent } from "@kandev/ui/card"; -import { Badge } from "@kandev/ui/badge"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, - DropdownMenuPortal, - DropdownMenuSeparator, -} from "@kandev/ui/dropdown-menu"; -import type { TaskState, Repository } from "@/lib/types/http"; -import { cn, getRepositoryDisplayName } from "@/lib/utils"; -import { getTaskStateIcon, shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons"; -import { needsAction } from "@/lib/utils/needs-action"; + buildKanbanCardMenuEntries, + useKanbanCardMoveTargets, +} from "@/components/kanban-card-menu-items"; import { useAppStore } from "@/components/state-provider"; -import { PRTaskIcon } from "@/components/github/pr-task-icon"; -import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; -import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification"; +import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; +import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; +import { getRepositoryDisplayName } from "@/lib/utils"; +import type { Repository, TaskState } from "@/lib/types/http"; export interface Task { id: string; @@ -46,7 +25,6 @@ export interface Task { repositoryId?: string; /** All repositories linked to the task; used to render a "+N" chip for multi-repo. */ repositories?: Array<{ id: string; repository_id: string; position: number }>; - // Workflow fields sessionCount?: number | null; primarySessionId?: string | null; reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null; @@ -63,6 +41,12 @@ export interface WorkflowStep { id: string; title: string; color: string; + events?: { + on_enter?: Array<{ type: string; config?: Record<string, unknown> }>; + on_turn_start?: Array<{ type: string; config?: Record<string, unknown> }>; + on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>; + on_exit?: Array<{ type: string; config?: Record<string, unknown> }>; + }; } interface KanbanCardProps { @@ -80,383 +64,100 @@ interface KanbanCardProps { isDeleting?: boolean; isArchiving?: boolean; isSelected?: boolean; + selectedIds?: Set<string>; onToggleSelect?: (taskId: string) => void; isMultiSelectMode?: boolean; } -const REPO_CHIPS_VISIBLE = 2; - -function RepoChipRow({ repoNames }: { repoNames: string[] }) { - if (repoNames.length === 0) return null; - const visible = repoNames.slice(0, REPO_CHIPS_VISIBLE); - const overflow = repoNames.slice(REPO_CHIPS_VISIBLE); - const row = ( - <div className="mb-1 flex items-center gap-1 min-w-0 overflow-hidden"> - {visible.map((name) => ( - <span - key={name} - className="shrink-0 rounded-sm bg-muted/60 px-1 py-px text-[9px] font-medium text-muted-foreground leading-tight max-w-[8rem] truncate" - > - {name} - </span> - ))} - {overflow.length > 0 && ( - <span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium text-muted-foreground/80"> - +{overflow.length} - </span> - )} - </div> - ); - if (repoNames.length <= 1) return row; - return ( - <Tooltip> - <TooltipTrigger asChild>{row}</TooltipTrigger> - <TooltipContent side="top" align="start"> - <div className="flex flex-col gap-0.5 text-xs"> - {repoNames.map((name) => ( - <span key={name}>{name}</span> - ))} - </div> - </TooltipContent> - </Tooltip> - ); -} - -function KanbanCardBody({ +function useKanbanCardMenus({ task, - repoNames, - actions, -}: { - task: Task; - repoNames: string[]; - actions?: React.ReactNode; -}) { - return ( - <> - <div className="flex items-start justify-between gap-2"> - <div className="min-w-0 flex-1"> - <RepoChipRow repoNames={repoNames} /> - <div className="flex items-center gap-1 min-w-0"> - <p - data-testid="task-card-title" - className="text-sm font-medium leading-tight line-clamp-1 min-w-0" - > - {task.title} - </p> - <PRTaskIcon taskId={task.id} /> - </div> - </div> - {task.isRemoteExecutor && ( - <RemoteCloudTooltip - taskId={task.id} - sessionId={task.primarySessionId ?? null} - fallbackName={task.primaryExecutorName ?? task.primaryExecutorType} - /> - )} - {actions} - </div> - {task.description && ( - <p className="text-xs text-muted-foreground mt-1 leading-tight line-clamp-1"> - {task.description} - </p> - )} - <KanbanCardBadges task={task} /> - </> - ); -} - -function KanbanCardBadges({ task }: { task: Task }) { - const parentTitle = useAppStore((s) => { - if (!task.parentTaskId) return null; - return s.kanban.tasks.find((t) => t.id === task.parentTaskId)?.title ?? null; - }); - - const showRow = - (task.sessionCount && task.sessionCount > 1) || - task.reviewStatus === "changes_requested" || - task.reviewStatus === "pending" || - task.parentTaskId; - - if (!showRow) return null; - - return ( - <div className="flex flex-wrap items-center justify-end gap-2 mt-1 min-w-0"> - {task.parentTaskId && ( - <Badge variant="outline" className="text-xs h-5 gap-1 max-w-[160px] min-w-0"> - <IconSubtask className="h-3 w-3 shrink-0" /> - <span className="truncate">{parentTitle ?? "Subtask"}</span> - </Badge> - )} - {task.sessionCount && task.sessionCount > 1 && ( - <Badge variant="secondary" className="text-xs h-5"> - {task.sessionCount} sessions - </Badge> - )} - {task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && ( - <div className="flex items-center gap-1 text-amber-700 dark:text-amber-600"> - <IconAlertCircle className="h-3.5 w-3.5" /> - <span className="text-[10px] font-medium">Approval Required</span> - </div> - )} - {task.reviewStatus === "changes_requested" && ( - <Badge - variant="outline" - className="border-amber-500 text-amber-600 bg-amber-50 dark:bg-amber-950/50 text-xs h-5" - > - Changes Requested - </Badge> - )} - </div> - ); -} - -function KanbanCardLayout({ - task, - repositoryNames, - className, -}: KanbanCardProps & { className?: string }) { - return ( - <Card size="sm" className={cn("w-full py-0", className)}> - <CardContent className="px-2 py-1"> - <KanbanCardBody task={task} repoNames={repositoryNames ?? []} /> - </CardContent> - </Card> - ); -} - -function KanbanCardActions({ - task, - showMaximizeButton, - onOpenFullPage, + steps, + isDeleting, + isArchiving, + isSelected, + selectedIds, onEdit, onDelete, onArchive, onMove, - steps, - isDeleting, - isArchiving, }: Pick< KanbanCardProps, | "task" - | "showMaximizeButton" - | "onOpenFullPage" + | "steps" + | "isDeleting" + | "isArchiving" + | "isSelected" + | "selectedIds" | "onEdit" | "onDelete" | "onArchive" | "onMove" - | "steps" - | "isDeleting" - | "isArchiving" >) { - const [menuOpen, setMenuOpen] = useState(false); - const effectiveMenuOpen = menuOpen || Boolean(isDeleting) || Boolean(isArchiving); - const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId); - const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarificationRequest); - const statusIcon = getTaskStateIcon(task.state, "h-4 w-4", hasPendingClarificationRequest); - const hasKnownSession = - Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0); - - return ( - <div className="flex items-center gap-2"> - {(task.state === "IN_PROGRESS" || task.state === "SCHEDULING" || showQuestionIcon) && - statusIcon} - {showMaximizeButton && onOpenFullPage && hasKnownSession && ( - <button - type="button" - className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm p-1 -m-1 transition-colors cursor-pointer" - onClick={(event) => { - event.stopPropagation(); - onOpenFullPage(task); - }} - onPointerDown={(event) => event.stopPropagation()} - aria-label="Open full page" - title="Open full page" - > - <IconArrowsMaximize className="h-4 w-4" /> - </button> - )} - <KanbanCardMenu - task={task} - effectiveMenuOpen={effectiveMenuOpen} - setMenuOpen={setMenuOpen} - isDeleting={isDeleting} - isArchiving={isArchiving} - onEdit={onEdit} - onDelete={onDelete} - onArchive={onArchive} - onMove={onMove} - steps={steps} - /> - </div> - ); -} - -function MoveToSubmenu({ - task, - steps, - disabled, - onMove, -}: { - task: Task; - steps: WorkflowStep[]; - disabled?: boolean; - onMove: (task: Task, targetStepId: string) => void; -}) { - return ( - <DropdownMenuSub> - <DropdownMenuSubTrigger - disabled={disabled} - onClick={(event) => event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - > - Move to - </DropdownMenuSubTrigger> - <DropdownMenuPortal> - <DropdownMenuSubContent> - {steps - .filter((col) => col.id !== task.workflowStepId) - .map((col) => ( - <DropdownMenuItem - key={col.id} - onClick={(event) => { - event.stopPropagation(); - onMove(task, col.id); - }} - > - <div className={cn("w-2 h-2 rounded-full mr-2", col.color)} /> - {col.title} - </DropdownMenuItem> - ))} - </DropdownMenuSubContent> - </DropdownMenuPortal> - </DropdownMenuSub> - ); -} - -type KanbanCardMenuProps = { - task: Task; - effectiveMenuOpen: boolean; - setMenuOpen: (open: boolean) => void; - isDeleting?: boolean; - isArchiving?: boolean; - onEdit?: (task: Task) => void; - onDelete?: (task: Task) => void; - onArchive?: (task: Task) => void; - onMove?: (task: Task, targetStepId: string) => void; - steps?: WorkflowStep[]; -}; - -function KanbanCardMenu(props: KanbanCardMenuProps) { - const { task, effectiveMenuOpen, setMenuOpen, isDeleting, isArchiving } = props; - const { onEdit, onDelete, onArchive, onMove, steps } = props; + const moveTargets = useKanbanCardMoveTargets(task.id, steps); + const moveTasks = useTaskWorkflowMove(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); - const isProcessing = isDeleting || isArchiving; + const disabled = Boolean(isDeleting || isArchiving); - return ( - <> - <DropdownMenu - open={effectiveMenuOpen} - onOpenChange={(open) => { - if (!open && isProcessing) return; - setMenuOpen(open); - }} - > - <DropdownMenuTrigger asChild> - <button - type="button" - className="text-muted-foreground hover:text-foreground hover:bg-muted rounded-sm p-1 -m-1 transition-colors cursor-pointer" - onClick={(e) => e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - aria-label="More options" - > - <IconDots className="h-4 w-4" /> - </button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - disabled={isProcessing} - onClick={(e) => { - e.stopPropagation(); - onEdit?.(task); - }} - > - Edit - </DropdownMenuItem> - {steps && steps.length > 1 && onMove && ( - <MoveToSubmenu task={task} steps={steps} disabled={isProcessing} onMove={onMove} /> - )} - {onArchive && ( - <DropdownMenuItem - disabled={isProcessing} - onClick={(e) => { - e.stopPropagation(); - setShowArchiveConfirm(true); - }} - > - {isArchiving ? <IconLoader className="mr-2 h-4 w-4 animate-spin" /> : null} - Archive - </DropdownMenuItem> - )} - <DropdownMenuSeparator /> - <DropdownMenuItem - disabled={isProcessing} - className="text-destructive focus:text-destructive" - onClick={(e) => { - e.stopPropagation(); - setShowDeleteConfirm(true); - }} - > - Delete - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - <TaskDeleteConfirmDialog - open={showDeleteConfirm} - onOpenChange={setShowDeleteConfirm} - taskTitle={task.title} - isDeleting={isDeleting} - onConfirm={() => onDelete?.(task)} - /> - <TaskArchiveConfirmDialog - open={showArchiveConfirm} - onOpenChange={setShowArchiveConfirm} - taskTitle={task.title} - isArchiving={isArchiving} - onConfirm={() => { - onArchive?.(task); - setShowArchiveConfirm(false); - }} - /> - </> - ); -} + const runMoveTasks = (taskIds: string[], workflowId: string, stepId: string) => { + void moveTasks(taskIds, workflowId, stepId).catch(() => { + // useTaskWorkflowMove already shows the failure toast. + }); + }; -function KanbanCardCheckbox({ - taskId, - taskTitle, - isSelected, - onCheckboxClick, -}: { - taskId: string; - taskTitle: string; - isSelected?: boolean; - onCheckboxClick: (e: React.MouseEvent) => void; -}) { - return ( - <div - className="mt-0.5 shrink-0" - onClick={onCheckboxClick} - onPointerDown={(e) => e.stopPropagation()} - data-testid={`task-select-checkbox-${taskId}`} - > - <Checkbox - checked={!!isSelected} - aria-label={`Select task ${taskTitle}`} - className="cursor-pointer border-muted-foreground/50" - /> - </div> - ); + const moveToStepFromDropdown = (stepId: string) => { + if (onMove) { + onMove(task, stepId); + return; + } + if (moveTargets.currentWorkflowId) { + runMoveTasks([task.id], moveTargets.currentWorkflowId, stepId); + } + }; + + const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id]; + const moveSelectedToStep = (stepId: string) => { + if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) { + onMove(task, stepId); + return; + } + if (!moveTargets.currentWorkflowId) return; + runMoveTasks(selectedTaskIds, moveTargets.currentWorkflowId, stepId); + }; + + const menuBase = { + currentWorkflowId: moveTargets.currentWorkflowId, + currentStepId: task.workflowStepId, + workflows: moveTargets.workflowItems, + stepsByWorkflowId: moveTargets.stepsByWorkflowId, + disabled, + isDeleting, + isArchiving, + onEdit: onEdit ? () => onEdit(task) : undefined, + onArchive: onArchive ? () => setShowArchiveConfirm(true) : undefined, + onDelete: onDelete ? () => setShowDeleteConfirm(true) : undefined, + }; + + return { + dropdownMenuEntries: buildKanbanCardMenuEntries({ + ...menuBase, + onMoveToStep: moveToStepFromDropdown, + onSendToWorkflow: (workflowId, stepId) => { + runMoveTasks([task.id], workflowId, stepId); + }, + }), + contextMenuEntries: buildKanbanCardMenuEntries({ + ...menuBase, + onMoveToStep: moveSelectedToStep, + onSendToWorkflow: (workflowId, stepId) => { + runMoveTasks(selectedTaskIds, workflowId, stepId); + }, + }), + showDeleteConfirm, + setShowDeleteConfirm, + showArchiveConfirm, + setShowArchiveConfirm, + }; } export function KanbanCard({ @@ -473,6 +174,7 @@ export function KanbanCard({ isDeleting, isArchiving, isSelected, + selectedIds, onToggleSelect, isMultiSelectMode, }: KanbanCardProps) { @@ -480,14 +182,26 @@ export function KanbanCard({ id: task.id, disabled: isMultiSelectMode, }); - const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id); - - const style = { - transform: CSS.Translate.toString(transform), - transition: "none", - willChange: isDragging ? "transform" : undefined, - }; + const { + dropdownMenuEntries, + contextMenuEntries, + showDeleteConfirm, + setShowDeleteConfirm, + showArchiveConfirm, + setShowArchiveConfirm, + } = useKanbanCardMenus({ + task, + steps, + isDeleting, + isArchiving, + isSelected, + selectedIds, + onEdit, + onDelete, + onArchive, + onMove, + }); const handleClick = () => { if (isMultiSelectMode) { @@ -502,76 +216,44 @@ export function KanbanCard({ onToggleSelect?.(task.id); }; - const showCheckbox = isMultiSelectMode || !!isSelected; - return ( - <Card - size="sm" - ref={setNodeRef} - style={style} - data-testid={`task-card-${task.id}`} - className={cn( - "group max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0", - needsAction(task) && !isSelected && "border-l-2 border-l-amber-500", - isDragging && "opacity-50 z-50", - isSelected && "ring-1 ring-primary/60 border-primary/60", - isPreviewed && !isSelected && "ring-2 ring-primary border-primary", - )} - onClick={handleClick} - {...(!isMultiSelectMode ? listeners : {})} - {...(!isMultiSelectMode ? attributes : {})} - > - <CardContent className="px-2 py-1"> - <div className="flex items-start gap-1.5"> - {showCheckbox && ( - <KanbanCardCheckbox - taskId={task.id} - taskTitle={task.title} - isSelected={isSelected} - onCheckboxClick={handleCheckboxClick} - /> - )} - <div className="min-w-0 flex-1"> - <KanbanCardBody - task={task} - repoNames={repositoryNames ?? []} - actions={ - !isMultiSelectMode ? ( - <KanbanCardActions - task={task} - showMaximizeButton={showMaximizeButton} - onOpenFullPage={onOpenFullPage} - onEdit={onEdit} - onDelete={onDelete} - onArchive={onArchive} - onMove={onMove} - steps={steps} - isDeleting={isDeleting} - isArchiving={isArchiving} - /> - ) : undefined - } - /> - </div> - </div> - </CardContent> - </Card> - ); -} - -export function KanbanCardPreview({ task }: KanbanCardProps) { - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); - const repositoryNames = useMemo( - () => resolveTaskRepositoryNames(task, Object.values(repositoriesByWorkspace).flat()), - [repositoriesByWorkspace, task], - ); - - return ( - <KanbanCardLayout - task={task} - repositoryNames={repositoryNames} - className="cursor-grabbing shadow-lg ring-0 pointer-events-none border border-border" - /> + <> + <KanbanCardContextMenu entries={contextMenuEntries}> + <KanbanCardShell + task={task} + repositoryNames={repositoryNames} + attributes={attributes} + listeners={listeners} + setNodeRef={setNodeRef} + transform={transform} + isDragging={isDragging} + isPreviewed={isPreviewed} + isSelected={isSelected} + isMultiSelectMode={isMultiSelectMode} + showMaximizeButton={showMaximizeButton} + isDeleting={isDeleting} + isArchiving={isArchiving} + menuEntries={dropdownMenuEntries} + onClick={handleClick} + onCheckboxClick={handleCheckboxClick} + onOpenFullPage={onOpenFullPage} + /> + </KanbanCardContextMenu> + <TaskDeleteConfirmDialog + open={showDeleteConfirm} + onOpenChange={setShowDeleteConfirm} + taskTitle={task.title} + isDeleting={isDeleting} + onConfirm={() => onDelete?.(task)} + /> + <TaskArchiveConfirmDialog + open={showArchiveConfirm} + onOpenChange={setShowArchiveConfirm} + taskTitle={task.title} + isArchiving={isArchiving} + onConfirm={() => onArchive?.(task)} + /> + </> ); } diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index ed2c794656..96e203e87c 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -107,6 +107,7 @@ export function KanbanColumn({ isDeleting={deletingTaskId === task.id} isArchiving={archivingTaskId === task.id} isSelected={selectedIds?.has(task.id)} + selectedIds={selectedIds} onToggleSelect={onToggleSelect} isMultiSelectMode={isMultiSelectMode} /> diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index 9ec114b1f7..eabc9e904b 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -12,7 +12,8 @@ import { useSensors, } from "@dnd-kit/core"; import { KanbanColumn } from "@/components/kanban-column"; -import { KanbanCardPreview, type Task } from "@/components/kanban-card"; +import { type Task } from "@/components/kanban-card"; +import { KanbanCardPreview } from "@/components/kanban-card-preview"; import type { WorkflowStep } from "@/components/kanban-column"; import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; import { useTaskActions } from "@/hooks/use-task-actions"; diff --git a/apps/web/components/task/task-move-context-menu.tsx b/apps/web/components/task/task-move-context-menu.tsx new file mode 100644 index 0000000000..52278792ca --- /dev/null +++ b/apps/web/components/task/task-move-context-menu.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { IconArrowRight, IconLogicBuffer } from "@tabler/icons-react"; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from "@kandev/ui/context-menu"; +import { cn } from "@/lib/utils"; + +export type TaskMoveStep = { + id: string; + title: string; + color?: string | null; + events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> }; +}; + +export type TaskMoveWorkflow = { + id: string; + name: string; + hidden?: boolean; +}; + +type TaskMoveContextMenuItemsProps = { + currentWorkflowId?: string | null; + currentStepId?: string | null; + workflows: TaskMoveWorkflow[]; + stepsByWorkflowId: Record<string, TaskMoveStep[]>; + disabled?: boolean; + showSeparator?: boolean; + onMoveToStep?: (stepId: string) => void; + onSendToWorkflow?: (workflowId: string, stepId: string) => void; +}; + +export function stepHasAutoStart(step: TaskMoveStep) { + return step.events?.on_enter?.some((action) => action.type === "auto_start_agent") ?? false; +} + +function StepMenuItem({ + step, + currentStepId, + onSelect, +}: { + step: TaskMoveStep; + currentStepId?: string | null; + onSelect: (stepId: string) => void; +}) { + const isCurrent = step.id === currentStepId; + const hasAutoStart = stepHasAutoStart(step); + return ( + <ContextMenuItem + key={step.id} + data-testid={`task-context-step-${step.id}`} + disabled={isCurrent} + onSelect={(event) => { + event.preventDefault(); + if (!isCurrent) onSelect(step.id); + }} + > + <span className={cn("block h-2 w-2 rounded-full shrink-0", step.color ?? "")} /> + <span className="flex-1 truncate">{step.title}</span> + {(isCurrent || hasAutoStart) && ( + <span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground"> + {isCurrent && <span data-testid={`task-context-step-current-${step.id}`}>Current</span>} + {hasAutoStart && ( + <span data-testid={`task-context-step-autostart-${step.id}`}>Auto-start</span> + )} + </span> + )} + </ContextMenuItem> + ); +} + +function MoveToCurrentWorkflowSubmenu({ + steps, + currentStepId, + disabled, + onMoveToStep, +}: { + steps: TaskMoveStep[]; + currentStepId?: string | null; + disabled?: boolean; + onMoveToStep?: (stepId: string) => void; +}) { + if (!onMoveToStep || steps.length <= 1) return null; + return ( + <ContextMenuSub> + <ContextMenuSubTrigger data-testid="task-context-move-to" disabled={disabled}> + <IconArrowRight className="mr-2 h-4 w-4" /> + Move to + </ContextMenuSubTrigger> + <ContextMenuSubContent className="w-48"> + {steps.map((step) => ( + <StepMenuItem + key={step.id} + step={step} + currentStepId={currentStepId} + onSelect={onMoveToStep} + /> + ))} + </ContextMenuSubContent> + </ContextMenuSub> + ); +} + +function WorkflowTargetItem({ + workflow, + steps, + disabled, + onSendToWorkflow, +}: { + workflow: TaskMoveWorkflow; + steps: TaskMoveStep[]; + disabled?: boolean; + onSendToWorkflow?: (workflowId: string, stepId: string) => void; +}) { + if (steps.length === 0 || !onSendToWorkflow) { + return ( + <ContextMenuItem + data-testid={`task-context-workflow-${workflow.id}`} + disabled + aria-disabled="true" + > + <span className="flex-1 truncate">{workflow.name}</span> + <span data-testid="task-context-disabled-reason" className="ml-2 text-[10px]"> + No steps + </span> + </ContextMenuItem> + ); + } + + return ( + <ContextMenuSub> + <ContextMenuSubTrigger + data-testid={`task-context-workflow-${workflow.id}`} + disabled={disabled} + > + <span className="truncate">{workflow.name}</span> + </ContextMenuSubTrigger> + <ContextMenuSubContent className="w-48"> + {steps.map((step) => ( + <StepMenuItem + key={step.id} + step={step} + onSelect={(stepId) => onSendToWorkflow(workflow.id, stepId)} + /> + ))} + </ContextMenuSubContent> + </ContextMenuSub> + ); +} + +function SendToWorkflowSubmenu({ + currentWorkflowId, + workflows, + stepsByWorkflowId, + disabled, + onSendToWorkflow, +}: { + currentWorkflowId?: string | null; + workflows: TaskMoveWorkflow[]; + stepsByWorkflowId: Record<string, TaskMoveStep[]>; + disabled?: boolean; + onSendToWorkflow?: (workflowId: string, stepId: string) => void; +}) { + const targets = workflows.filter((workflow) => workflow.id !== currentWorkflowId); + if (!onSendToWorkflow || !currentWorkflowId || targets.length === 0) return null; + return ( + <ContextMenuSub> + <ContextMenuSubTrigger data-testid="task-context-send-to-workflow" disabled={disabled}> + <IconLogicBuffer className="mr-2 h-4 w-4" /> + Send to workflow + </ContextMenuSubTrigger> + <ContextMenuSubContent className="w-56"> + {targets.map((workflow) => ( + <WorkflowTargetItem + key={workflow.id} + workflow={workflow} + steps={stepsByWorkflowId[workflow.id] ?? []} + disabled={disabled} + onSendToWorkflow={onSendToWorkflow} + /> + ))} + </ContextMenuSubContent> + </ContextMenuSub> + ); +} + +export function TaskMoveContextMenuItems({ + currentWorkflowId, + currentStepId, + workflows, + stepsByWorkflowId, + disabled, + showSeparator = true, + onMoveToStep, + onSendToWorkflow, +}: TaskMoveContextMenuItemsProps) { + const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden); + const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : []; + const hasSameWorkflowMove = Boolean(onMoveToStep && currentSteps.length > 1); + const hasCrossWorkflowMove = Boolean( + onSendToWorkflow && + currentWorkflowId && + visibleWorkflows.some((workflow) => workflow.id !== currentWorkflowId), + ); + + if (!hasSameWorkflowMove && !hasCrossWorkflowMove) return null; + + return ( + <> + {showSeparator && <ContextMenuSeparator />} + <MoveToCurrentWorkflowSubmenu + steps={currentSteps} + currentStepId={currentStepId} + disabled={disabled} + onMoveToStep={onMoveToStep} + /> + <SendToWorkflowSubmenu + currentWorkflowId={currentWorkflowId} + workflows={visibleWorkflows} + stepsByWorkflowId={stepsByWorkflowId} + disabled={disabled} + onSendToWorkflow={onSendToWorkflow} + /> + </> + ); +} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index a74430ec8a..29525c6106 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -180,7 +180,13 @@ type TaskSessionSidebarProps = { workflowId: string | null; }; -type StepInfo = { id: string; title: string; color: string; position: number }; +type StepInfo = { + id: string; + title: string; + color: string; + position: number; + events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> }; +}; type SidebarItem = Omit<ReturnType<typeof toSidebarItem>, "workflowId"> & { workflowId?: string }; function buildArchivedItem(s: ReturnType<typeof useArchivedTaskState>): SidebarItem { @@ -221,6 +227,7 @@ function useSidebarData(workspaceId: string | null) { const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId); const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId); const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); + const workflows = useAppStore((state) => state.workflows.items); const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading); const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); const taskPRsByTaskId = useAppStore((state) => state.taskPRs.byTaskId); @@ -308,6 +315,9 @@ function useSidebarData(workspaceId: string | null) { isLoadingWorkflow, tasksWithRepositories, primarySessionIds, + workflows: workflows + .filter((workflow) => !workspaceId || workflow.workspaceId === workspaceId) + .map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden })), }; } @@ -521,6 +531,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ activeTaskId, selectedTaskId, stepsByWorkflowId, + workflows, isLoadingWorkflow, tasksWithRepositories, primarySessionIds, @@ -569,6 +580,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ <PanelBody className="space-y-4 p-0"> <TaskSwitcher grouped={grouped} + workflows={workflows} stepsByWorkflowId={stepsByWorkflowId} activeTaskId={activeTaskId} selectedTaskId={selectedTaskId} diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 0598d07336..49587402bb 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -3,7 +3,6 @@ import { cloneElement, isValidElement, memo, useState } from "react"; import { IconChevronDown, - IconArrowRight, IconPencil, IconCopy, IconArchive, @@ -15,15 +14,17 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, ContextMenuTrigger, } from "@kandev/ui/context-menu"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; import { cn } from "@/lib/utils"; import { TaskItem } from "./task-item"; import type { GroupedSidebarList, SidebarGroup } from "@/lib/sidebar/apply-view"; +import { + TaskMoveContextMenuItems, + type TaskMoveWorkflow, +} from "@/components/task/task-move-context-menu"; +import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; export type TaskSwitcherItem = { id: string; @@ -54,10 +55,16 @@ export type TaskSwitcherItem = { issueInfo?: { url: string; number: number }; }; -type StepDef = { id: string; title: string; color?: string }; +type StepDef = { + id: string; + title: string; + color?: string; + events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> }; +}; type TaskSwitcherProps = { grouped: GroupedSidebarList; + workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record<string, StepDef[]>; activeTaskId: string | null; selectedTaskId: string | null; @@ -132,6 +139,7 @@ type TaskRowProps = { task: TaskSwitcherItem; isSubTask?: boolean; subtaskToggle?: SubtaskToggleInfo; + workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record<string, StepDef[]>; activeTaskId: string | null; selectedTaskId: string | null; @@ -147,6 +155,7 @@ function TaskRow({ task, isSubTask, subtaskToggle, + workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, @@ -162,6 +171,8 @@ function TaskRow({ return ( <TaskItemWithContextMenu task={task} + workflows={workflows} + stepsByWorkflowId={stepsByWorkflowId} steps={taskSteps} onRenameTask={onRenameTask} onArchiveTask={onArchiveTask} @@ -200,6 +211,7 @@ function TaskRow({ function GroupSection({ group, subTasksByParentId, + workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, @@ -217,6 +229,7 @@ function GroupSection({ }: { group: SidebarGroup; subTasksByParentId: Map<string, TaskSwitcherItem[]>; + workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record<string, StepDef[]>; activeTaskId: string | null; selectedTaskId: string | null; @@ -238,6 +251,7 @@ function GroupSection({ ); const collapsedSubs = new Set(collapsedSubtaskParentIds ?? []); const rowProps = { + workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, @@ -287,6 +301,8 @@ function GroupSection({ function TaskItemWithContextMenu({ task, + workflows, + stepsByWorkflowId, steps, children, onRenameTask, @@ -296,6 +312,8 @@ function TaskItemWithContextMenu({ isDeleting, }: { task: TaskSwitcherItem; + workflows?: TaskMoveWorkflow[]; + stepsByWorkflowId?: Record<string, StepDef[]>; steps?: StepDef[]; children: React.ReactElement<{ menuOpen?: boolean }>; onRenameTask?: (taskId: string, currentTitle: string) => void; @@ -306,7 +324,12 @@ function TaskItemWithContextMenu({ }) { const [contextOpen, setContextOpen] = useState(false); const [menuKey, setMenuKey] = useState(0); + const moveTasks = useTaskWorkflowMove(); const menuOpen = contextOpen || isDeleting === true; + const closeMenu = () => { + setContextOpen(false); + setMenuKey((k) => k + 1); + }; return ( <ContextMenu key={menuKey} onOpenChange={setContextOpen}> @@ -330,15 +353,26 @@ function TaskItemWithContextMenu({ Archive </ContextMenuItem> )} - {onMoveToStep && task.workflowId && steps && steps.length > 0 && ( - <MoveToStepSubmenu - steps={steps} + {task.workflowId && ( + <TaskMoveContextMenuItems + currentWorkflowId={task.workflowId} currentStepId={task.workflowStepId} - disabled={isDeleting} - onSelect={(stepId) => { - setContextOpen(false); - setMenuKey((k) => k + 1); - onMoveToStep(task.id, task.workflowId!, stepId); + workflows={workflows ?? []} + stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [task.workflowId]: steps } : {})} + disabled={isDeleting || task.isArchived} + onMoveToStep={ + onMoveToStep + ? (stepId) => { + closeMenu(); + onMoveToStep(task.id, task.workflowId!, stepId); + } + : undefined + } + onSendToWorkflow={(workflowId, stepId) => { + closeMenu(); + void moveTasks([task.id], workflowId, stepId).catch(() => { + // useTaskWorkflowMove already shows the failure toast. + }); }} /> )} @@ -364,48 +398,6 @@ function TaskItemWithContextMenu({ ); } -function MoveToStepSubmenu({ - steps, - currentStepId, - disabled, - onSelect, -}: { - steps: StepDef[]; - currentStepId?: string; - disabled?: boolean; - onSelect: (stepId: string) => void; -}) { - return ( - <> - <ContextMenuSeparator /> - <ContextMenuSub> - <ContextMenuSubTrigger disabled={disabled}> - <IconArrowRight className="mr-2 h-4 w-4" /> - Move to - </ContextMenuSubTrigger> - <ContextMenuSubContent className="w-44"> - {steps.map((step) => ( - <ContextMenuItem - key={step.id} - disabled={step.id === currentStepId} - onSelect={(e) => { - e.preventDefault(); - onSelect(step.id); - }} - > - <span className={cn("block h-2 w-2 rounded-full shrink-0", step.color)} /> - <span className="flex-1 truncate">{step.title}</span> - {step.id === currentStepId && ( - <span className="ml-auto text-[10px] text-muted-foreground">Current</span> - )} - </ContextMenuItem> - ))} - </ContextMenuSubContent> - </ContextMenuSub> - </> - ); -} - function cloneWithMenuOpen( children: React.ReactElement<{ menuOpen?: boolean }>, menuOpen: boolean, @@ -416,6 +408,7 @@ function cloneWithMenuOpen( export const TaskSwitcher = memo(function TaskSwitcher({ grouped, + workflows, stepsByWorkflowId, activeTaskId, selectedTaskId, @@ -450,6 +443,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ key={group.key} group={group} subTasksByParentId={grouped.subTasksByParentId} + workflows={workflows} stepsByWorkflowId={stepsByWorkflowId} activeTaskId={activeTaskId} selectedTaskId={selectedTaskId} diff --git a/apps/web/e2e/pages/kanban-page.ts b/apps/web/e2e/pages/kanban-page.ts index 3b1291c06c..58e702804d 100644 --- a/apps/web/e2e/pages/kanban-page.ts +++ b/apps/web/e2e/pages/kanban-page.ts @@ -59,6 +59,58 @@ export class KanbanPage { }); } + contextMoveTo(): Locator { + return this.page.getByTestId("task-context-move-to"); + } + + contextSendToWorkflow(): Locator { + return this.page.getByTestId("task-context-send-to-workflow"); + } + + contextWorkflow(workflowId: string): Locator { + return this.page.getByTestId(`task-context-workflow-${workflowId}`); + } + + contextStep(stepId: string): Locator { + return this.page.getByTestId(`task-context-step-${stepId}`); + } + + contextAutoStartStep(stepId: string): Locator { + return this.page.getByTestId(`task-context-step-autostart-${stepId}`); + } + + async openTaskContextMenu(taskId: string) { + const card = this.taskCard(taskId); + await card.waitFor({ state: "visible" }); + await card.click({ button: "right" }); + } + + async openTaskActionsMenu(taskId: string) { + const card = this.taskCard(taskId); + await card.waitFor({ state: "visible" }); + await card.getByLabel("More options").click(); + } + + async moveTaskWithinWorkflow(taskId: string, stepId: string) { + await this.openTaskContextMenu(taskId); + await this.contextMoveTo().hover(); + await this.contextStep(stepId).click(); + } + + async sendTaskToWorkflow(taskId: string, workflowId: string, stepId: string) { + await this.openTaskContextMenu(taskId); + await this.contextSendToWorkflow().hover(); + await this.contextWorkflow(workflowId).hover(); + await this.contextStep(stepId).click(); + } + + async sendTaskToWorkflowFromActions(taskId: string, workflowId: string, stepId: string) { + await this.openTaskActionsMenu(taskId); + await this.contextSendToWorkflow().hover(); + await this.contextWorkflow(workflowId).hover(); + await this.contextStep(stepId).click(); + } + async enableMultiSelect() { await this.multiSelectToggle.first().waitFor({ state: "visible" }); const isEnabled = await this.page diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 5af7bffaec..022e8b6edb 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -150,6 +150,29 @@ export class SessionPage { return this.sidebar.getByText(title, { exact: false }); } + sidebarTaskItem(title: string): Locator { + return this.sidebar.getByTestId("sidebar-task-item").filter({ + has: this.page.getByText(title, { exact: false }), + }); + } + + async openSidebarTaskContextMenu(title: string): Promise<void> { + const taskRow = this.sidebarTaskItem(title).first(); + await taskRow.waitFor({ state: "visible" }); + await taskRow.click({ button: "right" }); + } + + async sendSidebarTaskToWorkflow( + title: string, + workflowId: string, + stepId: string, + ): Promise<void> { + await this.openSidebarTaskContextMenu(title); + await this.page.getByTestId("task-context-send-to-workflow").hover(); + await this.page.getByTestId(`task-context-workflow-${workflowId}`).hover(); + await this.page.getByTestId(`task-context-step-${stepId}`).click(); + } + /** * Sidebar state indicator — returns the first icon matching the given state label. * Accepts "Turn Finished" (review/completed), "Running" (in-progress), or "Backlog". diff --git a/apps/web/e2e/tests/kanban/cross-workflow-task-batch.spec.ts b/apps/web/e2e/tests/kanban/cross-workflow-task-batch.spec.ts new file mode 100644 index 0000000000..8b01931b6a --- /dev/null +++ b/apps/web/e2e/tests/kanban/cross-workflow-task-batch.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; + +test.describe("Cross-workflow selected batch moves", () => { + test("right-clicking a selected task sends the selected batch", async ({ + testPage, + apiClient, + seedData, + }) => { + const targetWorkflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Batch Target Workflow", + ); + const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Batch Incoming", 0); + const [first, second, unselected] = await Promise.all([ + apiClient.createTask(seedData.workspaceId, "Batch Move First", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + apiClient.createTask(seedData.workspaceId, "Batch Move Second", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + apiClient.createTask(seedData.workspaceId, "Batch Move Unselected", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + ]); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.selectTask(first.id); + await kanban.selectTask(second.id); + await expect(kanban.multiSelectToolbar).toContainText("2 selected"); + + await kanban.sendTaskToWorkflow(first.id, targetWorkflow.id, targetStep.id); + + await expect(kanban.taskCard(first.id)).not.toBeVisible({ timeout: 10_000 }); + await expect(kanban.taskCard(second.id)).not.toBeVisible(); + await expect(kanban.taskCard(unselected.id)).toBeVisible(); + + await testPage.goto(`/?workflowId=${targetWorkflow.id}`); + await expect(kanban.board).toBeVisible(); + await expect(kanban.taskCardInColumn("Batch Move First", targetStep.id)).toBeVisible({ + timeout: 10_000, + }); + await expect(kanban.taskCardInColumn("Batch Move Second", targetStep.id)).toBeVisible(); + await expect(kanban.taskCardInColumn("Batch Move Unselected", targetStep.id)).not.toBeVisible(); + }); + + test("right-clicking an unselected task moves only that task", async ({ + testPage, + apiClient, + seedData, + }) => { + const targetWorkflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Clicked Only Target Workflow", + ); + const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Clicked Incoming", 0); + const [selectedA, selectedB, clicked] = await Promise.all([ + apiClient.createTask(seedData.workspaceId, "Clicked Only Selected A", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + apiClient.createTask(seedData.workspaceId, "Clicked Only Selected B", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + apiClient.createTask(seedData.workspaceId, "Clicked Only Moved", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }), + ]); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.selectTask(selectedA.id); + await kanban.selectTask(selectedB.id); + await expect(kanban.multiSelectToolbar).toContainText("2 selected"); + + await kanban.sendTaskToWorkflow(clicked.id, targetWorkflow.id, targetStep.id); + + await expect(kanban.taskCard(clicked.id)).not.toBeVisible({ timeout: 10_000 }); + await expect(kanban.taskCard(selectedA.id)).toBeVisible(); + await expect(kanban.taskCard(selectedB.id)).toBeVisible(); + await expect(kanban.multiSelectToolbar).toContainText("2 selected"); + + await testPage.goto(`/?workflowId=${targetWorkflow.id}`); + await expect(kanban.board).toBeVisible(); + await expect(kanban.taskCardInColumn("Clicked Only Moved", targetStep.id)).toBeVisible({ + timeout: 10_000, + }); + await expect( + kanban.taskCardInColumn("Clicked Only Selected A", targetStep.id), + ).not.toBeVisible(); + await expect( + kanban.taskCardInColumn("Clicked Only Selected B", targetStep.id), + ).not.toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/kanban/cross-workflow-task-move.spec.ts b/apps/web/e2e/tests/kanban/cross-workflow-task-move.spec.ts new file mode 100644 index 0000000000..7cacc51b77 --- /dev/null +++ b/apps/web/e2e/tests/kanban/cross-workflow-task-move.spec.ts @@ -0,0 +1,167 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; + +test.describe("Cross-workflow task move from home", () => { + test("keeps same-workflow Move to as the short path", async ({ + testPage, + apiClient, + seedData, + }) => { + const targetStep = seedData.steps.find((step) => step.id !== seedData.startStepId); + if (!targetStep) { + test.skip(true, "seed workflow needs at least two steps"); + return; + } + + const task = await apiClient.createTask(seedData.workspaceId, "Same Workflow Context Move", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.openTaskContextMenu(task.id); + await expect(kanban.contextMoveTo()).toBeVisible(); + await expect(kanban.contextSendToWorkflow()).not.toBeVisible(); + await kanban.contextMoveTo().hover(); + await kanban.contextStep(targetStep.id).click(); + + await expect(kanban.taskCardInColumn("Same Workflow Context Move", targetStep.id)).toBeVisible({ + timeout: 10_000, + }); + }); + + test("sends one task to another workflow without changing the current view", async ({ + testPage, + apiClient, + seedData, + }) => { + const targetWorkflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Target Workflow Context Send", + ); + const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Incoming", 0); + const task = await apiClient.createTask(seedData.workspaceId, "Cross Workflow Context Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const beforeUrl = testPage.url(); + + await kanban.sendTaskToWorkflow(task.id, targetWorkflow.id, targetStep.id); + + await expect(kanban.taskCard(task.id)).not.toBeVisible({ timeout: 10_000 }); + await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 }); + expect(testPage.url()).toBe(beforeUrl); + + await testPage.goto(`/?workflowId=${targetWorkflow.id}`); + await expect(kanban.board).toBeVisible(); + await expect(kanban.taskCardInColumn("Cross Workflow Context Task", targetStep.id)).toBeVisible( + { timeout: 10_000 }, + ); + + await testPage.reload(); + await expect(kanban.taskCardInColumn("Cross Workflow Context Task", targetStep.id)).toBeVisible( + { timeout: 10_000 }, + ); + }); + + test("keeps right-click and three-dot menus aligned for workflow moves", async ({ + testPage, + apiClient, + seedData, + }) => { + const sameWorkflowStep = seedData.steps.find((step) => step.id !== seedData.startStepId); + if (!sameWorkflowStep) { + test.skip(true, "seed workflow needs at least two steps"); + return; + } + + const targetWorkflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Target Workflow Actions Send", + ); + const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Incoming", 0); + const task = await apiClient.createTask(seedData.workspaceId, "Cross Workflow Actions Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const expectedLabels = ["Edit", "Move to", "Send to workflow", "Archive", "Delete"]; + await kanban.openTaskContextMenu(task.id); + for (const label of expectedLabels) { + await expect(testPage.getByRole("menuitem", { name: label })).toBeVisible(); + } + await testPage.keyboard.press("Escape"); + + await kanban.openTaskActionsMenu(task.id); + for (const label of expectedLabels) { + await expect(testPage.getByRole("menuitem", { name: label })).toBeVisible(); + } + await kanban.contextSendToWorkflow().hover(); + await kanban.contextWorkflow(targetWorkflow.id).hover(); + await kanban.contextStep(targetStep.id).click(); + + await expect(kanban.taskCard(task.id)).not.toBeVisible({ timeout: 10_000 }); + await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 }); + + await testPage.goto(`/?workflowId=${targetWorkflow.id}`); + await expect(kanban.taskCardInColumn("Cross Workflow Actions Task", targetStep.id)).toBeVisible( + { timeout: 10_000 }, + ); + }); + + test("shows no-step workflows as disabled send targets", async ({ + testPage, + apiClient, + seedData, + }) => { + const emptyWorkflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Empty Workflow Context Send", + ); + const task = await apiClient.createTask(seedData.workspaceId, "No Step Target Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.openTaskContextMenu(task.id); + await kanban.contextSendToWorkflow().hover(); + + await expect(kanban.contextWorkflow(emptyWorkflow.id)).toBeVisible(); + await expect(kanban.contextWorkflow(emptyWorkflow.id)).toBeDisabled(); + await expect( + kanban.contextWorkflow(emptyWorkflow.id).getByTestId("task-context-disabled-reason"), + ).toContainText("No steps"); + }); + + test("marks auto-start target steps before moving", async ({ testPage, apiClient, seedData }) => { + const targetWorkflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Auto Start Context Target", + ); + const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Run agent", 0); + await apiClient.updateWorkflowStep(targetStep.id, { + prompt: 'e2e:message("context move auto-started")', + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + const task = await apiClient.createTask(seedData.workspaceId, "Auto Start Marker Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + agent_profile_id: seedData.agentProfileId, + repository_ids: [seedData.repositoryId], + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.openTaskContextMenu(task.id); + await kanban.contextSendToWorkflow().hover(); + await kanban.contextWorkflow(targetWorkflow.id).hover(); + await expect(kanban.contextAutoStartStep(targetStep.id)).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/task/sidebar-send-to-workflow.spec.ts b/apps/web/e2e/tests/task/sidebar-send-to-workflow.spec.ts new file mode 100644 index 0000000000..fd67fdcf23 --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-send-to-workflow.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; +import { SessionPage } from "../../pages/session-page"; + +test.describe("Task sidebar send to workflow", () => { + test("right-click sends a sidebar task to another workflow without navigation", async ({ + testPage, + apiClient, + seedData, + }) => { + const targetWorkflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Sidebar Target Workflow", + ); + const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Sidebar Incoming", 0); + const anchor = await apiClient.createTask(seedData.workspaceId, "Sidebar Anchor Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await apiClient.createTask(seedData.workspaceId, "Sidebar Send Candidate", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + await testPage.goto(`/t/${anchor.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.sidebarTaskItem("Sidebar Send Candidate")).toBeVisible({ + timeout: 15_000, + }); + const beforeUrl = testPage.url(); + + await session.sendSidebarTaskToWorkflow( + "Sidebar Send Candidate", + targetWorkflow.id, + targetStep.id, + ); + + await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 }); + expect(testPage.url()).toBe(beforeUrl); + + const kanban = new KanbanPage(testPage); + await testPage.goto(`/?workflowId=${targetWorkflow.id}`); + await expect(kanban.board).toBeVisible(); + await expect(kanban.taskCardInColumn("Sidebar Send Candidate", targetStep.id)).toBeVisible({ + timeout: 10_000, + }); + + await testPage.reload(); + await expect(kanban.taskCardInColumn("Sidebar Send Candidate", targetStep.id)).toBeVisible({ + timeout: 10_000, + }); + }); +}); diff --git a/apps/web/hooks/use-task-workflow-move.test.ts b/apps/web/hooks/use-task-workflow-move.test.ts new file mode 100644 index 0000000000..0cc6c88107 --- /dev/null +++ b/apps/web/hooks/use-task-workflow-move.test.ts @@ -0,0 +1,98 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; +import { bulkMoveSelectedTasks } from "@/lib/api"; + +const mockToast = vi.fn(); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: mockToast }), +})); + +vi.mock("@/lib/api", () => ({ + bulkMoveSelectedTasks: vi.fn(), +})); + +const mockBulkMoveSelectedTasks = vi.mocked(bulkMoveSelectedTasks); +const TASK_1 = "task-1"; +const TASK_2 = "task-2"; +const TARGET_WORKFLOW_ID = "wf-2"; +const TARGET_STEP_ID = "step-2"; + +describe("useTaskWorkflowMove", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("dedupes and filters task ids before moving", async () => { + mockBulkMoveSelectedTasks.mockResolvedValue({ moved_count: 2 }); + const { result } = renderHook(() => useTaskWorkflowMove()); + + await act(async () => { + await result.current([TASK_1, "", TASK_1, TASK_2], TARGET_WORKFLOW_ID, TARGET_STEP_ID); + }); + + expect(mockBulkMoveSelectedTasks).toHaveBeenCalledWith({ + task_ids: [TASK_1, TASK_2], + target_workflow_id: TARGET_WORKFLOW_ID, + target_step_id: TARGET_STEP_ID, + }); + }); + + it("does nothing for an empty task id list", async () => { + const { result } = renderHook(() => useTaskWorkflowMove()); + + await act(async () => { + await result.current([""], TARGET_WORKFLOW_ID, TARGET_STEP_ID); + }); + + expect(mockBulkMoveSelectedTasks).not.toHaveBeenCalled(); + expect(mockToast).not.toHaveBeenCalled(); + }); + + it("shows singular, plural, and zero-count success messages", async () => { + mockBulkMoveSelectedTasks + .mockResolvedValueOnce({ moved_count: 1 }) + .mockResolvedValueOnce({ moved_count: 2 }) + .mockResolvedValueOnce({ moved_count: 0 }); + const { result } = renderHook(() => useTaskWorkflowMove()); + + await act(async () => { + await result.current([TASK_1], TARGET_WORKFLOW_ID, TARGET_STEP_ID); + await result.current([TASK_1, TASK_2], TARGET_WORKFLOW_ID, TARGET_STEP_ID); + await result.current([TASK_1], TARGET_WORKFLOW_ID, TARGET_STEP_ID); + }); + + expect(mockToast).toHaveBeenNthCalledWith(1, { + title: "Moved task to workflow", + description: "Switch to the destination workflow to see it.", + variant: "success", + }); + expect(mockToast).toHaveBeenNthCalledWith(2, { + title: "Moved 2 tasks to workflow", + description: "Switch to the destination workflow to see them.", + variant: "success", + }); + expect(mockToast).toHaveBeenNthCalledWith(3, { + title: "Moved 0 tasks to workflow", + description: "Switch to the destination workflow to see them.", + variant: "success", + }); + }); + + it("shows an error toast and rethrows move failures", async () => { + const error = new Error("cannot move running task"); + mockBulkMoveSelectedTasks.mockRejectedValue(error); + const { result } = renderHook(() => useTaskWorkflowMove()); + + await expect(result.current([TASK_1], TARGET_WORKFLOW_ID, TARGET_STEP_ID)).rejects.toThrow( + error, + ); + + expect(mockToast).toHaveBeenCalledWith({ + title: "Failed to move task", + description: "cannot move running task", + variant: "error", + }); + }); +}); diff --git a/apps/web/hooks/use-task-workflow-move.ts b/apps/web/hooks/use-task-workflow-move.ts new file mode 100644 index 0000000000..a5f21c0ad1 --- /dev/null +++ b/apps/web/hooks/use-task-workflow-move.ts @@ -0,0 +1,51 @@ +"use client"; + +import { useCallback } from "react"; +import { bulkMoveSelectedTasks } from "@/lib/api"; +import { useToast } from "@/components/toast-provider"; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : "Failed to move task"; +} + +function movedTitle(movedCount: number) { + if (movedCount === 1) return "Moved task to workflow"; + return `Moved ${movedCount} tasks to workflow`; +} + +function movedDescription(movedCount: number) { + return movedCount === 1 + ? "Switch to the destination workflow to see it." + : "Switch to the destination workflow to see them."; +} + +export function useTaskWorkflowMove() { + const { toast } = useToast(); + + return useCallback( + async (taskIds: string[], targetWorkflowId: string, targetStepId: string) => { + const ids = [...new Set(taskIds.filter(Boolean))]; + if (ids.length === 0) return; + try { + const result = await bulkMoveSelectedTasks({ + task_ids: ids, + target_workflow_id: targetWorkflowId, + target_step_id: targetStepId, + }); + toast({ + title: movedTitle(result.moved_count), + description: movedDescription(result.moved_count), + variant: "success", + }); + } catch (error) { + toast({ + title: "Failed to move task", + description: errorMessage(error), + variant: "error", + }); + throw error; + } + }, + [toast], + ); +} diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index ad4fecb9b0..56a83099c9 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -120,6 +120,16 @@ export async function moveTask( }); } +export async function bulkMoveSelectedTasks( + payload: { task_ids: string[]; target_workflow_id: string; target_step_id: string }, + options?: ApiRequestOptions, +) { + return fetchJson<{ moved_count: number }>("/api/v1/tasks/bulk-move", { + ...options, + init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + export async function fetchTask(taskId: string, options?: ApiRequestOptions) { return fetchJson<Task>(`/api/v1/tasks/${taskId}`, options); } diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index fdb011af44..ceb1bc4ec7 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -117,6 +117,7 @@ export type KanbanUpdatePayload = { export type TaskEventPayload = { task_id: string; workflow_id: string; + old_workflow_id?: string | null; workflow_step_id: string; title: string; description?: string; diff --git a/apps/web/lib/ws/handlers/tasks.test.ts b/apps/web/lib/ws/handlers/tasks.test.ts index abad6aa08a..bcb59f2fb4 100644 --- a/apps/web/lib/ws/handlers/tasks.test.ts +++ b/apps/web/lib/ws/handlers/tasks.test.ts @@ -195,6 +195,38 @@ describe("task.updated primary-session focus follow", () => { }); }); +describe("task.updated cross-workflow placement", () => { + it("removes the task from its old workflow snapshot before upserting into the new one", () => { + const task = { id: "t1", title: "Test", workflowId: "wf1", workflowStepId: "step1" }; + const store = makeStore({ + kanban: { + workflowId: "wf1", + steps: [], + tasks: [task], + } as unknown as AppState["kanban"], + kanbanMulti: { + isLoading: false, + snapshots: { + wf1: { workflow: { id: "wf1" }, steps: [], tasks: [task] }, + wf2: { workflow: { id: "wf2" }, steps: [], tasks: [] }, + }, + } as unknown as AppState["kanbanMulti"], + }); + + const handlers = registerTasksHandlers(store); + handlers["task.updated"]!( + makeMessage({ ...makeTask("t1", null, "wf2"), old_workflow_id: "wf1" }), + ); + + const state = store.getState(); + expect(state.kanban.tasks).toHaveLength(0); + expect(state.kanbanMulti.snapshots.wf1.tasks).toHaveLength(0); + expect(state.kanbanMulti.snapshots.wf2.tasks).toHaveLength(1); + expect(state.kanbanMulti.snapshots.wf2.tasks[0]?.id).toBe("t1"); + expect(state.kanbanMulti.snapshots.wf2.tasks[0]?.workflowStepId).toBe("step1"); + }); +}); + describe("task.deleted cleanup", () => { it("removes the deleted task from recent task history", () => { const store = makeStore({ diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 1374fb0c27..82def7acd7 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -36,6 +36,7 @@ function upsertMultiTask(state: AppState, workflowId: string, task: KanbanTask): type TaskEventPayload = TaskLike & { workflow_id: string; + old_workflow_id?: string | null; is_ephemeral?: boolean; archived_at?: string | null; }; @@ -123,12 +124,18 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers { store.setState((state) => { const wfId = message.payload.workflow_id; + const oldWfId = message.payload.old_workflow_id; + let next = state; - if (message.payload.archived_at) { - return removeTaskFromBothKanbans(state, wfId, taskId); + if (oldWfId && oldWfId !== wfId) { + next = removeTaskFromBothKanbans(next, oldWfId, taskId); } - return upsertTaskInBothKanbans(state, wfId, message.payload); + if (message.payload.archived_at) { + return removeTaskFromBothKanbans(next, wfId, taskId); + } + + return upsertTaskInBothKanbans(next, wfId, message.payload); }); // Follow focus to the new primary when: From bce9fd266ab324e62f19dd25fbe070f415288761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 5 May 2026 19:14:57 +0100 Subject: [PATCH 12/33] feat: multi-question support for ask_user_question_kandev (#828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- apps/backend/cmd/kandev/adapters.go | 111 ++-- apps/backend/cmd/mock-agent/scenarios.go | 86 ++- .../internal/clarification/canceller.go | 34 +- .../internal/clarification/canceller_test.go | 55 +- .../internal/clarification/handlers.go | 447 ++++++++++--- .../internal/clarification/handlers_test.go | 244 +++++++- apps/backend/internal/clarification/store.go | 17 + .../internal/clarification/store_test.go | 48 +- apps/backend/internal/clarification/types.go | 24 +- .../backend/internal/mcp/handlers/handlers.go | 50 +- .../mcp/server/ask_user_question_test.go | 246 ++++++++ apps/backend/internal/mcp/server/handlers.go | 260 +++++--- apps/backend/internal/mcp/server/server.go | 130 ++-- .../event_handlers_clarification.go | 13 + .../executor/executor_mocks_test.go | 6 + .../task/handlers/process_handlers_test.go | 6 + .../internal/task/repository/interface.go | 2 + .../task/repository/sqlite/message.go | 60 +- .../internal/task/service/service_messages.go | 21 +- .../quick-chat/quick-chat-content.tsx | 11 +- .../task/chat/clarification-input-overlay.tsx | 586 +++++++++++------- .../task/chat/clarification-overlay-parts.tsx | 257 ++++++++ apps/web/components/task/task-chat-panel.tsx | 3 +- apps/web/e2e/pages/session-page.ts | 61 ++ apps/web/e2e/tests/chat/clarification.spec.ts | 366 +++++++++-- .../session/use-clarification-group.test.ts | 187 ++++++ .../session/use-clarification-group.ts | 153 +++++ apps/web/hooks/use-processed-messages.ts | 10 +- apps/web/lib/types/http-agents.ts | 7 + .../lib/utils/pending-clarification.test.ts | 72 ++- apps/web/lib/utils/pending-clarification.ts | 29 + 31 files changed, 2995 insertions(+), 607 deletions(-) create mode 100644 apps/backend/internal/mcp/server/ask_user_question_test.go create mode 100644 apps/web/components/task/chat/clarification-overlay-parts.tsx create mode 100644 apps/web/hooks/domains/session/use-clarification-group.test.ts create mode 100644 apps/web/hooks/domains/session/use-clarification-group.ts diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 272db2b370..f71f6c8af6 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -622,51 +622,86 @@ func (a *messageCreatorAdapter) UpdatePermissionMessage(ctx context.Context, ses return a.svc.UpdatePermissionMessage(ctx, sessionID, pendingID, status) } -// CreateClarificationRequestMessage creates a message for a clarification request. -// This allows clarification requests to appear in the chat as messages. -func (a *messageCreatorAdapter) CreateClarificationRequestMessage(ctx context.Context, taskID, sessionID, pendingID string, question clarification.Question, clarificationContext string) (string, error) { - // Convert question options to interface{} for metadata storage - options := make([]interface{}, len(question.Options)) - for j, opt := range question.Options { - options[j] = map[string]interface{}{ - "option_id": opt.ID, - "label": opt.Label, - "description": opt.Description, +// CreateClarificationRequestMessages emits one chat message per question in a +// clarification request bundle, all sharing the given pending_id. The frontend +// renders them as a stacked group; only the last message sets RequestsInput=true +// so the chat scrolls to the bottom of the group when the bundle arrives. +// Returns the created message IDs in the same order as the input questions. +// +// If any individual CreateMessage call fails, every previously created message +// in the bundle is deleted so we don't leave a half-rendered group dangling in +// the chat. Best-effort: if cleanup itself fails the caller still receives the +// original error and the orphan messages stay (logged at warn-level). +func (a *messageCreatorAdapter) CreateClarificationRequestMessages(ctx context.Context, taskID, sessionID, pendingID string, questions []clarification.Question, clarificationContext string) ([]string, error) { + ids := make([]string, 0, len(questions)) + total := len(questions) + for i, question := range questions { + options := make([]interface{}, len(question.Options)) + for j, opt := range question.Options { + options[j] = map[string]interface{}{ + "option_id": opt.ID, + "label": opt.Label, + "description": opt.Description, + } } - } - questionData := map[string]interface{}{ - "id": question.ID, - "title": question.Title, - "prompt": question.Prompt, - "options": options, - } + questionData := map[string]interface{}{ + "id": question.ID, + "title": question.Title, + "prompt": question.Prompt, + "options": options, + } - metadata := map[string]interface{}{ - "pending_id": pendingID, - "question": questionData, - "context": clarificationContext, - "status": "pending", - } + metadata := map[string]interface{}{ + "pending_id": pendingID, + "question_id": question.ID, + "question": questionData, + "question_index": i, + "question_total": total, + "context": clarificationContext, + "status": "pending", + } - msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ - TaskSessionID: sessionID, - TaskID: taskID, - Content: question.Prompt, - AuthorType: "agent", - Type: "clarification_request", - Metadata: metadata, - RequestsInput: true, // This marks the session as waiting for input - }) - if err != nil { - return "", err + // Only the last message marks the session as waiting for input so the + // chat scrolls to the end of the group when the bundle arrives. + requestsInput := i == total-1 + + msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: taskID, + Content: question.Prompt, + AuthorType: "agent", + Type: "clarification_request", + Metadata: metadata, + RequestsInput: requestsInput, + }) + if err != nil { + a.rollbackPartialBundle(ctx, ids, pendingID) + return nil, err + } + ids = append(ids, msg.ID) } - return msg.ID, nil + return ids, nil } -// UpdateClarificationMessage updates a clarification message's status and response -func (a *messageCreatorAdapter) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, status string, answer *clarification.Answer) error { - return a.svc.UpdateClarificationMessage(ctx, sessionID, pendingID, status, answer) +// rollbackPartialBundle removes the messages already created when later writes +// in a multi-question bundle fail. Best-effort — failures are logged but do +// not bubble up because the caller already has a more useful error. +func (a *messageCreatorAdapter) rollbackPartialBundle(ctx context.Context, ids []string, pendingID string) { + for _, id := range ids { + if err := a.svc.DeleteMessage(ctx, id); err != nil { + a.logger.Warn("failed to roll back partial clarification bundle message", + zap.String("pending_id", pendingID), + zap.String("message_id", id), + zap.Error(err)) + } + } +} + +// UpdateClarificationMessage updates a clarification message's status and answer +// for a specific (pending_id, question_id) pair within the session. +func (a *messageCreatorAdapter) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, questionID, status string, answer *clarification.Answer) error { + return a.svc.UpdateClarificationMessageForQuestion(ctx, sessionID, pendingID, questionID, status, answer) } // CreateAgentMessageStreaming creates a new agent message with a pre-generated ID. diff --git a/apps/backend/cmd/mock-agent/scenarios.go b/apps/backend/cmd/mock-agent/scenarios.go index 067a4ec784..e6dea59122 100644 --- a/apps/backend/cmd/mock-agent/scenarios.go +++ b/apps/backend/cmd/mock-agent/scenarios.go @@ -28,6 +28,7 @@ var scenarioRegistry = map[string]func(e *emitter){ "untracked-file-setup": scenarioUntrackedFileSetup, "untracked-file-modify": scenarioUntrackedFileModify, "clarification": scenarioClarification, + "clarification-multi": scenarioClarificationMulti, "clarification-timeout": scenarioClarificationTimeout, "multi-permission": scenarioMultiPermission, "review-cumulative-setup": scenarioReviewCumulativeSetup, @@ -420,18 +421,75 @@ func scenarioUntrackedFileModify(e *emitter) { e.text("untracked-file-modify complete: untracked_test.txt now has MODIFIED_CONTENT") } -// clarificationQuestionArgs returns the MCP arguments for the clarification question. +// MCP argument keys used by the clarification scenarios. Pulled out so goconst +// stays happy when the same string would otherwise repeat across both helpers. +const ( + clarificationOptionsKey = "options" + clarificationLabelKey = "label" + clarificationDescKey = "description" + clarificationPromptKey = "prompt" + clarificationIDKey = "id" +) + +func mockOption(label, description string) map[string]any { + return map[string]any{clarificationLabelKey: label, clarificationDescKey: description} +} + +// clarificationQuestionArgs returns the MCP arguments for a single-question +// clarification call. Used by the simplest e2e scenario where a one-question +// bundle exercises the same code path as multi-question. func clarificationQuestionArgs() map[string]any { return map[string]any{ - "prompt": "Which database should we use for this project?", - "options": []map[string]any{ - {"label": "PostgreSQL", "description": "Relational database with strong consistency"}, - {"label": "MongoDB", "description": "Document database for flexible schemas"}, - {"label": "SQLite", "description": "Embedded database for simplicity"}, + "questions": []map[string]any{ + { + clarificationIDKey: "db", + clarificationPromptKey: "Which database should we use for this project?", + clarificationOptionsKey: []map[string]any{ + mockOption("PostgreSQL", "Relational database with strong consistency"), + mockOption("MongoDB", "Document database for flexible schemas"), + mockOption("SQLite", "Embedded database for simplicity"), + }, + }, }, } } +// clarificationMultiQuestionArgs returns the MCP arguments for a 3-question +// clarification bundle that exercises the multi-question UI flow. +func clarificationMultiQuestionArgs() map[string]any { + return map[string]any{ + "questions": []map[string]any{ + { + clarificationIDKey: "db", + clarificationPromptKey: "Which database should we use?", + clarificationOptionsKey: []map[string]any{ + mockOption("PostgreSQL", "Relational database with strong consistency"), + mockOption("MongoDB", "Document database for flexible schemas"), + mockOption("SQLite", "Embedded database for simplicity"), + }, + }, + { + clarificationIDKey: "language", + clarificationPromptKey: "Which language should we use?", + clarificationOptionsKey: []map[string]any{ + mockOption("Go", "Strong typing, fast compile"), + mockOption("TypeScript", "Familiar to the team"), + mockOption("Rust", "Best for systems work"), + }, + }, + { + clarificationIDKey: "deploy", + clarificationPromptKey: "How should we deploy?", + clarificationOptionsKey: []map[string]any{ + mockOption("Docker", "Containerized deploy"), + mockOption("Bare metal", "Run directly on hosts"), + }, + }, + }, + "context": "Picking the foundational stack — answer all three so we can move forward.", + } +} + // scenarioClarification: happy path — ask a question via MCP and wait for the answer. func scenarioClarification(e *emitter) { fixedDelay(100) @@ -447,6 +505,22 @@ func scenarioClarification(e *emitter) { e.text(fmt.Sprintf("You answered: %s", result)) } +// scenarioClarificationMulti: stress the multi-question path — 3 questions in +// one bundle, all required, single MCP call. +func scenarioClarificationMulti(e *emitter) { + fixedDelay(100) + e.text("Let me ask you a few questions about the project setup.") + + result, err := callMCPTool("kandev", "ask_user_question_kandev", clarificationMultiQuestionArgs()) + if err != nil { + e.text(fmt.Sprintf("Questions failed: %s", err)) + return + } + + fixedDelay(50) + e.text(fmt.Sprintf("You answered: %s", result)) +} + // scenarioClarificationTimeout: ask a question with a short timeout, then continue. func scenarioClarificationTimeout(e *emitter) { fixedDelay(100) diff --git a/apps/backend/internal/clarification/canceller.go b/apps/backend/internal/clarification/canceller.go index dc6c628360..ecf5199ddf 100644 --- a/apps/backend/internal/clarification/canceller.go +++ b/apps/backend/internal/clarification/canceller.go @@ -49,28 +49,28 @@ func (c *Canceller) CancelSessionAndNotify(ctx context.Context, sessionID string } for _, id := range pendingIDs { - msg, err := c.repo.FindMessageByPendingID(ctx, id) - if err != nil || msg == nil { - c.logger.Debug("message not found for cancelled clarification", + msgs, err := c.repo.FindMessagesByPendingID(ctx, id) + if err != nil || len(msgs) == 0 { + c.logger.Debug("messages not found for cancelled clarification", zap.String("pending_id", id), zap.Error(err)) continue } - if msg.Metadata == nil { - msg.Metadata = map[string]any{} + for _, msg := range msgs { + if msg.Metadata == nil { + msg.Metadata = map[string]any{} + } + msg.Metadata["agent_disconnected"] = true + msg.Metadata["status"] = string(StatusExpired) + if err := c.repo.UpdateMessage(ctx, msg); err != nil { + c.logger.Warn("failed to update message with expired status", + zap.String("pending_id", id), + zap.String("message_id", msg.ID), + zap.Error(err)) + continue + } + c.publishMessageUpdated(ctx, msg) } - msg.Metadata["agent_disconnected"] = true - msg.Metadata["status"] = "expired" - if err := c.repo.UpdateMessage(ctx, msg); err != nil { - c.logger.Warn("failed to update message with expired status", - zap.String("pending_id", id), - zap.String("message_id", msg.ID), - zap.Error(err)) - continue - } - - // Publish message.updated event so the frontend picks up the metadata change - c.publishMessageUpdated(ctx, msg) } return len(pendingIDs) diff --git a/apps/backend/internal/clarification/canceller_test.go b/apps/backend/internal/clarification/canceller_test.go index 774fbb8470..81ec97fa17 100644 --- a/apps/backend/internal/clarification/canceller_test.go +++ b/apps/backend/internal/clarification/canceller_test.go @@ -12,7 +12,7 @@ import ( ) type stubMessageStore struct { - messages map[string]*taskmodels.Message + messages map[string][]*taskmodels.Message updated []*taskmodels.Message } @@ -21,11 +21,19 @@ func (s *stubMessageStore) GetTaskSession(context.Context, string) (*taskmodels. } func (s *stubMessageStore) FindMessageByPendingID(_ context.Context, pendingID string) (*taskmodels.Message, error) { - m, ok := s.messages[pendingID] - if !ok { + msgs, ok := s.messages[pendingID] + if !ok || len(msgs) == 0 { return nil, errors.New("not found") } - return m, nil + return msgs[0], nil +} + +func (s *stubMessageStore) FindMessagesByPendingID(_ context.Context, pendingID string) ([]*taskmodels.Message, error) { + msgs, ok := s.messages[pendingID] + if !ok { + return nil, nil + } + return msgs, nil } func (s *stubMessageStore) UpdateMessage(_ context.Context, m *taskmodels.Message) error { @@ -42,7 +50,7 @@ func (s *stubEventBus) Publish(_ context.Context, _ string, ev *bus.Event) error return nil } -func newTestCanceller(t *testing.T, msgs map[string]*taskmodels.Message) (*Canceller, *stubMessageStore, *stubEventBus) { +func newTestCanceller(t *testing.T, msgs map[string][]*taskmodels.Message) (*Canceller, *stubMessageStore, *stubEventBus) { t.Helper() store := NewStore(time.Minute) repo := &stubMessageStore{messages: msgs} @@ -56,17 +64,17 @@ func newTestCanceller(t *testing.T, msgs map[string]*taskmodels.Message) (*Cance // stays interactive and a user clicking X triggers an unintended new turn via // the event fallback path in the respond handler. func TestCanceller_MarksStatusExpiredOnDisconnect(t *testing.T) { - msgs := map[string]*taskmodels.Message{} + msgs := map[string][]*taskmodels.Message{} c, repo, _ := newTestCanceller(t, msgs) pendingID := c.store.CreateRequest(&Request{SessionID: "s1"}) - msgs[pendingID] = &taskmodels.Message{ + msgs[pendingID] = []*taskmodels.Message{{ ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{ "status": "pending", }, - } + }} cancelled := c.CancelSessionAndNotify(context.Background(), "s1") if cancelled != 1 { @@ -88,7 +96,7 @@ func TestCanceller_MarksStatusExpiredOnDisconnect(t *testing.T) { // TestCanceller_NoMessagesToUpdate confirms that a cancel with no pending // clarifications returns 0 and does not touch the repo. func TestCanceller_NoMessagesToUpdate(t *testing.T) { - c, repo, _ := newTestCanceller(t, map[string]*taskmodels.Message{}) + c, repo, _ := newTestCanceller(t, map[string][]*taskmodels.Message{}) if got := c.CancelSessionAndNotify(context.Background(), "nonexistent"); got != 0 { t.Errorf("expected 0 cancelled, got %d", got) @@ -101,15 +109,15 @@ func TestCanceller_NoMessagesToUpdate(t *testing.T) { // TestCanceller_PublishesMessageUpdatedEvent confirms the canceller publishes // a message.updated event so the frontend refreshes the message in place. func TestCanceller_PublishesMessageUpdatedEvent(t *testing.T) { - msgs := map[string]*taskmodels.Message{} + msgs := map[string][]*taskmodels.Message{} c, _, eventBus := newTestCanceller(t, msgs) pendingID := c.store.CreateRequest(&Request{SessionID: "s1"}) - msgs[pendingID] = &taskmodels.Message{ + msgs[pendingID] = []*taskmodels.Message{{ ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending"}, - } + }} c.CancelSessionAndNotify(context.Background(), "s1") @@ -117,3 +125,26 @@ func TestCanceller_PublishesMessageUpdatedEvent(t *testing.T) { t.Fatalf("expected 1 event, got %d", len(eventBus.events)) } } + +// TestCanceller_MultiQuestion_MarksAllMessagesExpired confirms a multi-question +// bundle has every message marked expired (and emits one update event each) +// so the frontend overlay closes for the whole stack on agent timeout. +func TestCanceller_MultiQuestion_MarksAllMessagesExpired(t *testing.T) { + msgs := map[string][]*taskmodels.Message{} + c, _, eventBus := newTestCanceller(t, msgs) + + pendingID := c.store.CreateRequest(&Request{SessionID: "s1"}) + msgs[pendingID] = []*taskmodels.Message{ + {ID: "m1", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "question_id": "q1"}}, + {ID: "m2", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "question_id": "q2"}}, + {ID: "m3", TaskSessionID: "s1", Metadata: map[string]any{"status": "pending", "question_id": "q3"}}, + } + + cancelled := c.CancelSessionAndNotify(context.Background(), "s1") + if cancelled != 1 { + t.Fatalf("expected 1 cancelled bundle, got %d", cancelled) + } + if len(eventBus.events) != 3 { + t.Fatalf("expected 3 message.updated events, got %d", len(eventBus.events)) + } +} diff --git a/apps/backend/internal/clarification/handlers.go b/apps/backend/internal/clarification/handlers.go index 3b4c7413da..a2784926f9 100644 --- a/apps/backend/internal/clarification/handlers.go +++ b/apps/backend/internal/clarification/handlers.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "net/http" + "sort" + "strings" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/common/logger" @@ -16,10 +18,19 @@ import ( "go.uber.org/zap" ) +// Metadata key constants used when constructing event payloads and reading +// per-message clarification metadata. Pulled out so goconst stays happy and +// renames stay safe. +const ( + metaQuestionKey = "question" + metaQuestionIDKey = "question_id" +) + // messageStore is the minimal task repository interface required by clarification handlers. type messageStore interface { GetTaskSession(ctx context.Context, id string) (*taskmodels.TaskSession, error) FindMessageByPendingID(ctx context.Context, pendingID string) (*taskmodels.Message, error) + FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*taskmodels.Message, error) UpdateMessage(ctx context.Context, message *taskmodels.Message) error } @@ -30,10 +41,16 @@ type Broadcaster interface { // MessageCreator interface for creating messages in the database type MessageCreator interface { - // CreateClarificationRequestMessage creates a message for a clarification request - CreateClarificationRequestMessage(ctx context.Context, taskID, sessionID, pendingID string, question Question, clarificationContext string) (string, error) - // UpdateClarificationMessage updates a clarification message's status and response - UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, status string, answer *Answer) error + // CreateClarificationRequestMessages creates one chat message per question in + // a multi-question clarification request, all sharing the given pending_id. + // Only the last message returned should set RequestsInput=true so the chat + // scrolls to the bottom of the group. Returns the created message IDs in the + // same order as the input questions. + CreateClarificationRequestMessages(ctx context.Context, taskID, sessionID, pendingID string, questions []Question, clarificationContext string) ([]string, error) + // UpdateClarificationMessage updates the per-question clarification message's + // status (and stores the matching answer if any) for a (pending_id, question_id) + // pair within the session. + UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, questionID, status string, answer *Answer) error } // EventBus interface for publishing events. @@ -74,11 +91,13 @@ func RegisterRoutes(router *gin.Engine, store *Store, hub Broadcaster, messageCr } // CreateRequestBody is the request body for creating a clarification request. +// A single request may bundle 1..N questions; the bundle is gated on the user +// answering every question (or rejecting the bundle as a whole). type CreateRequestBody struct { - SessionID string `json:"session_id" binding:"required"` - TaskID string `json:"task_id"` - Question Question `json:"question" binding:"required"` - Context string `json:"context"` + SessionID string `json:"session_id" binding:"required"` + TaskID string `json:"task_id"` + Questions []Question `json:"questions" binding:"required,min=1,dive"` + Context string `json:"context"` } // CreateRequestResponse is the response for creating a clarification request. @@ -93,15 +112,9 @@ func (h *Handlers) httpCreateRequest(c *gin.Context) { return } - // Validate question has ID, generate if missing - if body.Question.ID == "" { - body.Question.ID = "q1" - } - // Validate options have IDs - for j := range body.Question.Options { - if body.Question.Options[j].ID == "" { - body.Question.Options[j].ID = generateOptionID(0, j) - } + if errMsg := NormalizeAndValidateQuestions(body.Questions); errMsg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": errMsg}) + return } // Look up the task ID for this session @@ -121,35 +134,87 @@ func (h *Handlers) httpCreateRequest(c *gin.Context) { req := &Request{ SessionID: sessionID, TaskID: taskID, - Question: body.Question, + Questions: body.Questions, Context: body.Context, } pendingID := h.store.CreateRequest(req) - // Create a message in the database for the clarification request. - // This triggers the session.message.added WebSocket event which the frontend listens to. + // Create one message per question in the database; all share the same + // pending_id and are rendered as a stacked group on the frontend. The + // session.message.added WebSocket event fires per message. On failure we + // also cancel the in-store pending entry so any blocking WaitForResponse + // caller unblocks immediately rather than waiting for the MCP timeout. if h.messageCreator != nil { - _, err := h.messageCreator.CreateClarificationRequestMessage( + _, err := h.messageCreator.CreateClarificationRequestMessages( c.Request.Context(), taskID, sessionID, pendingID, - body.Question, + body.Questions, body.Context, ) if err != nil { - h.logger.Error("failed to create clarification request message", + h.logger.Error("failed to create clarification request messages", zap.String("pending_id", pendingID), zap.String("session_id", sessionID), zap.Error(err)) - // Don't fail the request - the clarification is still created in the store + h.store.CancelRequest(pendingID) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "failed to create clarification messages: " + err.Error(), + }) + return } } c.JSON(http.StatusOK, CreateRequestResponse{PendingID: pendingID}) } +// NormalizeAndValidateQuestions is the single source of truth for clarification +// bundle validation. It mutates `questions` to assign missing IDs (q1, q2, ...) +// and option IDs, and enforces: +// - 1..4 questions per bundle +// - unique question IDs (rejects duplicates) +// - non-empty prompt +// - 2..6 options per question +// +// Both the HTTP handler (httpCreateRequest) and the WebSocket-side MCP handler +// (handleAskUserQuestion) call this so validation never drifts between paths. +// Returns "" on success or an error message describing the first failure. +func NormalizeAndValidateQuestions(questions []Question) string { + if len(questions) == 0 { + return "questions must contain at least 1 question" + } + if len(questions) > 4 { + return "questions must contain at most 4 questions" + } + seen := map[string]bool{} + for i := range questions { + if questions[i].ID == "" { + questions[i].ID = fmt.Sprintf("q%d", i+1) + } + if seen[questions[i].ID] { + return fmt.Sprintf("duplicate question id %q", questions[i].ID) + } + seen[questions[i].ID] = true + if questions[i].Prompt == "" { + return fmt.Sprintf("question %d is missing required 'prompt'", i+1) + } + if len(questions[i].Options) < 2 { + return fmt.Sprintf("question %d must have at least 2 options", i+1) + } + if len(questions[i].Options) > 6 { + return fmt.Sprintf("question %d must have at most 6 options", i+1) + } + for j := range questions[i].Options { + if questions[i].Options[j].ID == "" { + questions[i].Options[j].ID = generateOptionID(i, j) + } + } + } + return "" +} + func (h *Handlers) httpGetRequest(c *gin.Context) { pendingID := c.Param("id") @@ -174,8 +239,12 @@ func (h *Handlers) httpWaitForResponse(c *gin.Context) { } // RespondBody is the request body for responding to a clarification request. +// The frontend posts every answer at once when the user finishes the bundle +// (decision A: per-question commit collected in the hook, batched on the wire). +// Answers must contain exactly one entry per question in the original request, +// or be empty when Rejected=true. type RespondBody struct { - Answers []Answer `json:"answers"` // Keep as array for frontend compatibility, but only first is used + Answers []Answer `json:"answers"` Rejected bool `json:"rejected"` RejectReason string `json:"reject_reason"` } @@ -189,16 +258,21 @@ func (h *Handlers) httpRespond(c *gin.Context) { return } - // Extract the single answer (first one if provided) - var answer *Answer - if len(body.Answers) > 0 { - answer = &body.Answers[0] + // Gate: when not rejecting, the user must have answered every question and + // each answer must reference a question id from the original bundle (no + // duplicates, no fabricated ids). We compare against the in-store request + // first; if the entry is gone (agent already moved on), fall back to the + // persisted messages so we still validate even after the in-memory cleanup. + if !body.Rejected { + if errMsg := h.validateRespondAnswers(c.Request.Context(), pendingID, body.Answers); errMsg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": errMsg}) + return + } } - // Build the response object resp := &Response{ PendingID: pendingID, - Answer: answer, + Answers: body.Answers, Rejected: body.Rejected, RejectReason: body.RejectReason, } @@ -209,11 +283,13 @@ func (h *Handlers) httpRespond(c *gin.Context) { err := h.store.Respond(pendingID, resp) if err == nil { - // Primary path succeeded — agent will receive the answer directly. - h.updateClarificationMessage(c, pendingID, body.Rejected, answer) - h.publishPrimaryAnsweredEvent(c, pendingID, answer, body.Rejected, body.RejectReason) + // Primary path succeeded — agent will receive the answers directly. + h.applyAnswersToMessages(c, pendingID, body.Rejected, body.Answers) + h.publishPrimaryAnsweredEvent(c, pendingID, body.Answers, body.Rejected, body.RejectReason) h.logger.Info("clarification answered via primary path (same turn)", - zap.String("pending_id", pendingID)) + zap.String("pending_id", pendingID), + zap.Int("answers", len(body.Answers)), + zap.Bool("rejected", body.Rejected)) c.JSON(http.StatusOK, gin.H{"success": true}) return } @@ -246,32 +322,145 @@ func (h *Handlers) httpRespond(c *gin.Context) { zap.String("pending_id", pendingID), zap.String("error", err.Error())) - h.updateClarificationMessage(c, pendingID, body.Rejected, answer) - h.respondViaEventFallback(c, pendingID, answer, body.Rejected, body.RejectReason) + h.applyAnswersToMessages(c, pendingID, body.Rejected, body.Answers) + h.respondViaEventFallback(c, pendingID, body.Answers, body.Rejected, body.RejectReason) c.JSON(http.StatusOK, gin.H{"success": true}) } -// updateClarificationMessage updates the message in the database with status and answer. -func (h *Handlers) updateClarificationMessage(c *gin.Context, pendingID string, rejected bool, answer *Answer) { +// validateRespondAnswers enforces the all-required gate **and** the question-id +// invariant: every answer must target a real question in the bundle, every +// question must have an answer, and no question id may be answered twice. +// Returns "" on success or an error message describing the first failure. +// +// The expected question ids come from the in-store request; if the in-memory +// entry has already been cleaned up (agent timed out before user responded), +// the persisted messages serve as fallback so the late-respond path is +// validated the same way as the primary path. +func (h *Handlers) validateRespondAnswers(ctx context.Context, pendingID string, answers []Answer) string { + expected := h.expectedQuestionIDs(ctx, pendingID) + if len(expected) == 0 { + // Couldn't determine the expected set — fall back to permissive (the + // primary-path Respond will still error sensibly if the bundle is gone). + return "" + } + expectedSet := make(map[string]bool, len(expected)) + for _, id := range expected { + expectedSet[id] = true + } + + if len(answers) != len(expected) { + return fmt.Sprintf("expected %d answers, got %d", len(expected), len(answers)) + } + + seen := make(map[string]bool, len(answers)) + for i, a := range answers { + if a.QuestionID == "" { + return fmt.Sprintf("answer %d is missing question_id", i+1) + } + if !expectedSet[a.QuestionID] { + return fmt.Sprintf("answer %d references unknown question id %q", i+1, a.QuestionID) + } + if seen[a.QuestionID] { + return fmt.Sprintf("answer %d duplicates question id %q", i+1, a.QuestionID) + } + seen[a.QuestionID] = true + } + return "" +} + +// expectedQuestionIDs returns the ordered question ids the user is expected to +// answer for the given pending bundle. Falls back to the persisted messages if +// the in-store request has been cleaned up. +func (h *Handlers) expectedQuestionIDs(ctx context.Context, pendingID string) []string { + if req, ok := h.store.GetRequest(pendingID); ok && req != nil { + ids := make([]string, 0, len(req.Questions)) + for _, q := range req.Questions { + ids = append(ids, q.ID) + } + return ids + } + if h.repo == nil { + return nil + } + msgs, err := h.repo.FindMessagesByPendingID(ctx, pendingID) + if err != nil || len(msgs) == 0 { + return nil + } + ids := make([]string, 0, len(msgs)) + for _, m := range msgs { + if id := stringFromMetadata(m.Metadata, metaQuestionIDKey); id != "" { + ids = append(ids, id) + } + } + return ids +} + +// applyAnswersToMessages flips per-question status (answered/rejected) on every +// message that belongs to the bundle. When rejected, every question is marked +// rejected; when answered, each answer updates the matching question's row. +func (h *Handlers) applyAnswersToMessages(c *gin.Context, pendingID string, rejected bool, answers []Answer) { if h.messageCreator == nil { return } sessionID := h.lookupSessionForPending(c, pendingID) - status := "answered" + if rejected { - status = "rejected" + // Mark every question in the bundle as rejected. Guard h.repo for + // parity with the sibling expectedQuestionIDs path; production wires + // both, but unit tests sometimes pass a nil repo. + if h.repo == nil { + return + } + msgs, err := h.repo.FindMessagesByPendingID(c.Request.Context(), pendingID) + if err != nil || len(msgs) == 0 { + h.logger.Debug("rejected clarification: no messages to update", + zap.String("pending_id", pendingID), + zap.Error(err)) + return + } + for _, msg := range msgs { + questionID := stringFromMetadata(msg.Metadata, "question_id") + if questionID == "" { + continue + } + if err := h.messageCreator.UpdateClarificationMessage(c.Request.Context(), sessionID, pendingID, questionID, "rejected", nil); err != nil { + h.logger.Warn("failed to mark clarification question rejected", + zap.String("pending_id", pendingID), + zap.String("question_id", questionID), + zap.Error(err)) + } + } + return } - if err := h.messageCreator.UpdateClarificationMessage(c.Request.Context(), sessionID, pendingID, status, answer); err != nil { - h.logger.Warn("failed to update clarification message", - zap.String("pending_id", pendingID), - zap.Error(err)) + + for i := range answers { + ans := answers[i] + if ans.QuestionID == "" { + continue + } + if err := h.messageCreator.UpdateClarificationMessage(c.Request.Context(), sessionID, pendingID, ans.QuestionID, "answered", &ans); err != nil { + h.logger.Warn("failed to update clarification question", + zap.String("pending_id", pendingID), + zap.String("question_id", ans.QuestionID), + zap.Error(err)) + } } } +func stringFromMetadata(meta map[string]any, key string) string { + if meta == nil { + return "" + } + if v, ok := meta[key].(string); ok { + return v + } + return "" +} + // respondViaEventFallback publishes a ClarificationAnswered event for the orchestrator // to resume the agent with a new turn. Used when the agent timed out. -func (h *Handlers) respondViaEventFallback(c *gin.Context, pendingID string, answer *Answer, rejected bool, rejectReason string) { +func (h *Handlers) respondViaEventFallback(c *gin.Context, pendingID string, answers []Answer, rejected bool, rejectReason string) { if h.eventBus == nil { return } @@ -291,13 +480,13 @@ func (h *Handlers) respondViaEventFallback(c *gin.Context, pendingID string, ans return } - answerText := buildAnswerText(answer, rejected, rejectReason) + answerText := buildAnswerSummary(clarificationCtx.Questions, answers, rejected, rejectReason) eventData := map[string]any{ "session_id": clarificationCtx.SessionID, "task_id": clarificationCtx.TaskID, "pending_id": pendingID, - "question": clarificationCtx.Question, + metaQuestionKey: clarificationCtx.QuestionSummary, "answer_text": answerText, "rejected": rejected, "reject_reason": rejectReason, @@ -334,7 +523,7 @@ func (h *Handlers) lookupSessionForPending(c *gin.Context, pendingID string) str return msg.TaskSessionID } -func (h *Handlers) publishPrimaryAnsweredEvent(c *gin.Context, pendingID string, answer *Answer, rejected bool, rejectReason string) { +func (h *Handlers) publishPrimaryAnsweredEvent(c *gin.Context, pendingID string, answers []Answer, rejected bool, rejectReason string) { if h.eventBus == nil { return } @@ -353,12 +542,12 @@ func (h *Handlers) publishPrimaryAnsweredEvent(c *gin.Context, pendingID string, return } - answerText := buildAnswerText(answer, rejected, rejectReason) + answerText := buildAnswerSummary(clarificationCtx.Questions, answers, rejected, rejectReason) eventData := map[string]any{ "session_id": clarificationCtx.SessionID, "task_id": clarificationCtx.TaskID, "pending_id": pendingID, - "question": clarificationCtx.Question, + metaQuestionKey: clarificationCtx.QuestionSummary, "answer_text": answerText, "rejected": rejected, "reject_reason": rejectReason, @@ -376,9 +565,10 @@ func (h *Handlers) publishPrimaryAnsweredEvent(c *gin.Context, pendingID string, } type clarificationEventContext struct { - SessionID string - TaskID string - Question string + SessionID string + TaskID string + Questions []Question // Source-of-truth questions used to label answers; falls back to a single synthetic Question when only metadata is available. + QuestionSummary string // Pre-formatted multi-line "Q1: ...\nQ2: ..." used by the orchestrator resume prompt. } func (h *Handlers) resolveClarificationEventContext(ctx context.Context, pendingID string) (clarificationEventContext, error) { @@ -387,57 +577,166 @@ func (h *Handlers) resolveClarificationEventContext(ctx context.Context, pending if req, ok := h.store.GetRequest(pendingID); ok && req != nil { out.SessionID = req.SessionID out.TaskID = req.TaskID - out.Question = req.Question.Prompt + out.Questions = req.Questions + out.QuestionSummary = formatQuestionSummary(req.Questions) + if out.SessionID != "" && out.TaskID != "" && len(out.Questions) > 0 { + return out, nil + } } - if out.SessionID != "" && out.TaskID != "" && out.Question != "" { - return out, nil - } if h.repo == nil { return out, fmt.Errorf("message repository unavailable") } - msg, err := h.repo.FindMessageByPendingID(ctx, pendingID) + msgs, err := h.repo.FindMessagesByPendingID(ctx, pendingID) if err != nil { return out, err } + if len(msgs) == 0 { + return out, fmt.Errorf("no messages for pending_id %s", pendingID) + } + if out.SessionID == "" { - out.SessionID = msg.TaskSessionID + out.SessionID = msgs[0].TaskSessionID } if out.TaskID == "" { - out.TaskID = msg.TaskID + out.TaskID = msgs[0].TaskID } - if out.Question == "" && msg.Metadata != nil { - if qData, ok := msg.Metadata["question"].(map[string]interface{}); ok { - if q, ok := qData["prompt"].(string); ok { - out.Question = q - } - } + if len(out.Questions) == 0 { + out.Questions = questionsFromMessages(msgs) + out.QuestionSummary = formatQuestionSummary(out.Questions) } return out, nil } -// buildAnswerText constructs a human-readable answer text from the response. -func buildAnswerText(answer *Answer, rejected bool, rejectReason string) string { +// questionsFromMessages reconstructs a Question slice from persisted clarification +// messages, ordered by metadata.question_index so the rebuilt summary matches +// the bundle the agent originally sent. Used as a fallback when the in-store +// request has been cleaned up but the persisted metadata still carries the +// original question text. +func questionsFromMessages(msgs []*taskmodels.Message) []Question { + sorted := make([]*taskmodels.Message, len(msgs)) + copy(sorted, msgs) + sort.SliceStable(sorted, func(i, j int) bool { + return questionIndexFromMetadata(sorted[i].Metadata) < questionIndexFromMetadata(sorted[j].Metadata) + }) + out := make([]Question, 0, len(sorted)) + for _, m := range sorted { + out = append(out, questionFromMessageMetadata(m.Metadata)) + } + return out +} + +func questionIndexFromMetadata(meta map[string]any) int { + if meta == nil { + return 0 + } + switch v := meta["question_index"].(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + } + return 0 +} + +func questionFromMessageMetadata(meta map[string]any) Question { + q := Question{ID: stringFromMetadata(meta, metaQuestionIDKey)} + qData, ok := meta[metaQuestionKey].(map[string]any) + if !ok { + return q + } + if v, ok := qData["prompt"].(string); ok { + q.Prompt = v + } + if v, ok := qData["title"].(string); ok { + q.Title = v + } + if q.ID == "" { + if v, ok := qData["id"].(string); ok { + q.ID = v + } + } + return q +} + +func formatQuestionSummary(questions []Question) string { + if len(questions) == 0 { + return "" + } + if len(questions) == 1 { + return questions[0].Prompt + } + parts := make([]string, 0, len(questions)) + for i, q := range questions { + parts = append(parts, fmt.Sprintf("Q%d: %s", i+1, q.Prompt)) + } + return strings.Join(parts, "\n") +} + +// buildAnswerSummary constructs a human-readable summary of the user's response +// across every question in the bundle. Used in the orchestrator resume prompt +// and for chat history rendering. +func buildAnswerSummary(questions []Question, answers []Answer, rejected bool, rejectReason string) string { if rejected { if rejectReason != "" { return fmt.Sprintf("User declined to answer. Reason: %s", rejectReason) } return "User declined to answer." } - if answer == nil { + if len(answers) == 0 { return "User provided no specific answer." } - if answer.CustomText != "" { - return fmt.Sprintf("User answered: %s", answer.CustomText) + if len(questions) <= 1 && len(answers) == 1 { + return formatSingleAnswer(answers[0]) } - if len(answer.SelectedOptions) > 0 { - return fmt.Sprintf("User selected: %v", answer.SelectedOptions) + + answersByID := make(map[string]Answer, len(answers)) + for _, a := range answers { + answersByID[a.QuestionID] = a + } + + parts := make([]string, 0, len(answers)) + for i, q := range questions { + ans, ok := answersByID[q.ID] + if !ok { + continue + } + parts = append(parts, fmt.Sprintf("A%d: %s", i+1, formatAnswerBody(ans))) + } + if len(parts) == 0 { + // No matches by id — fall back to positional formatting so we still + // surface the answers rather than silently dropping them. + for i, a := range answers { + parts = append(parts, fmt.Sprintf("A%d: %s", i+1, formatAnswerBody(a))) + } + } + return strings.Join(parts, "\n") +} + +func formatSingleAnswer(a Answer) string { + if a.CustomText != "" { + return fmt.Sprintf("User answered: %s", a.CustomText) + } + if len(a.SelectedOptions) > 0 { + return fmt.Sprintf("User selected: %v", a.SelectedOptions) } return "User provided no specific answer." } +func formatAnswerBody(a Answer) string { + if a.CustomText != "" { + return a.CustomText + } + if len(a.SelectedOptions) > 0 { + return fmt.Sprintf("%v", a.SelectedOptions) + } + return "(no answer)" +} + func generateOptionID(questionIndex, optionIndex int) string { return fmt.Sprintf("q%d_opt%d", questionIndex+1, optionIndex+1) } diff --git a/apps/backend/internal/clarification/handlers_test.go b/apps/backend/internal/clarification/handlers_test.go index 37301f2cbb..7dee9fb5d9 100644 --- a/apps/backend/internal/clarification/handlers_test.go +++ b/apps/backend/internal/clarification/handlers_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -17,28 +18,36 @@ import ( type stubMessageCreator struct { updates []struct { - pendingID string - status string + pendingID string + questionID string + status string } + created [][]Question } -func (s *stubMessageCreator) CreateClarificationRequestMessage( - _ context.Context, _, _, _ string, _ Question, _ string, -) (string, error) { - return "msg-id", nil +func (s *stubMessageCreator) CreateClarificationRequestMessages( + _ context.Context, _, _, _ string, questions []Question, _ string, +) ([]string, error) { + s.created = append(s.created, questions) + ids := make([]string, len(questions)) + for i := range questions { + ids[i] = "msg-id" + } + return ids, nil } func (s *stubMessageCreator) UpdateClarificationMessage( - _ context.Context, _, pendingID, status string, _ *Answer, + _ context.Context, _, pendingID, questionID, status string, _ *Answer, ) error { s.updates = append(s.updates, struct { - pendingID string - status string - }{pendingID, status}) + pendingID string + questionID string + status string + }{pendingID, questionID, status}) return nil } -func setupTestHandler(t *testing.T, msgs map[string]*taskmodels.Message) (*Handlers, *stubMessageStore, *stubEventBus, *stubMessageCreator) { +func setupTestHandler(t *testing.T, msgs map[string][]*taskmodels.Message) (*Handlers, *stubMessageStore, *stubEventBus, *stubMessageCreator) { t.Helper() gin.SetMode(gin.TestMode) store := NewStore(time.Minute) @@ -56,17 +65,19 @@ func setupTestHandler(t *testing.T, msgs map[string]*taskmodels.Message) (*Handl // to answer" is surprising and wastes a turn. func TestHttpRespond_RejectedAfterTimeout_NoNewTurn(t *testing.T) { // Message exists in DB (agent already moved on; canceller ran). - msgs := map[string]*taskmodels.Message{ - "pending-123": { + msgs := map[string][]*taskmodels.Message{ + "pending-123": {{ ID: "m1", TaskID: "t1", TaskSessionID: "s1", Metadata: map[string]any{ "status": "expired", "agent_disconnected": true, - "question": map[string]interface{}{"prompt": "orig question"}, + "pending_id": "pending-123", + "question_id": "q1", + "question": map[string]interface{}{"id": "q1", "prompt": "orig question"}, }, - }, + }}, } h, _, eventBus, messageCreator := setupTestHandler(t, msgs) @@ -99,17 +110,19 @@ func TestHttpRespond_RejectedAfterTimeout_NoNewTurn(t *testing.T) { // the fallback path and publishes the event so the orchestrator resumes // the agent — the user chose to continue, so a new turn is expected. func TestHttpRespond_AnsweredAfterTimeout_PublishesEvent(t *testing.T) { - msgs := map[string]*taskmodels.Message{ - "pending-456": { + msgs := map[string][]*taskmodels.Message{ + "pending-456": {{ ID: "m2", TaskID: "t1", TaskSessionID: "s1", Metadata: map[string]any{ "status": "expired", "agent_disconnected": true, - "question": map[string]interface{}{"prompt": "orig question"}, + "pending_id": "pending-456", + "question_id": "q1", + "question": map[string]interface{}{"id": "q1", "prompt": "orig question"}, }, - }, + }}, } h, _, eventBus, _ := setupTestHandler(t, msgs) @@ -136,6 +149,199 @@ func TestHttpRespond_AnsweredAfterTimeout_PublishesEvent(t *testing.T) { } } +// TestHttpRespond_DuplicateQuestionID_Rejected400 covers the dedupe gate: +// a payload that names the same question id twice should be rejected even +// when the cardinality matches the bundle size, otherwise the agent could +// receive a phantom answer for the question that was actually skipped. +func TestHttpRespond_DuplicateQuestionID_Rejected400(t *testing.T) { + h, _, _, _ := setupTestHandler(t, map[string][]*taskmodels.Message{}) + pendingID := h.store.CreateRequest(&Request{ + SessionID: "s1", + TaskID: "t1", + Questions: []Question{ + {ID: "q1", Prompt: "First?"}, + {ID: "q2", Prompt: "Second?"}, + }, + }) + body := RespondBody{ + Answers: []Answer{ + {QuestionID: "q1", SelectedOptions: []string{"opt1"}}, + {QuestionID: "q1", SelectedOptions: []string{"opt2"}}, + }, + } + rec := runRespond(t, h, pendingID, body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for duplicate question_id, got %d (body=%s)", rec.Code, rec.Body.String()) + } +} + +// TestHttpRespond_UnknownQuestionID_Rejected400 ensures that fabricated ids +// are rejected even with the right cardinality. +func TestHttpRespond_UnknownQuestionID_Rejected400(t *testing.T) { + h, _, _, _ := setupTestHandler(t, map[string][]*taskmodels.Message{}) + pendingID := h.store.CreateRequest(&Request{ + SessionID: "s1", + TaskID: "t1", + Questions: []Question{ + {ID: "q1", Prompt: "First?"}, + }, + }) + body := RespondBody{ + Answers: []Answer{{QuestionID: "qZZZ", SelectedOptions: []string{"opt1"}}}, + } + rec := runRespond(t, h, pendingID, body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for unknown question_id, got %d (body=%s)", rec.Code, rec.Body.String()) + } +} + +// TestHttpRespond_PartialAnswers_Rejected400 confirms that the handler +// refuses a respond payload that does not contain one answer per question +// in the original bundle. All-required gating is enforced here. +func TestHttpRespond_PartialAnswers_Rejected400(t *testing.T) { + h, _, _, _ := setupTestHandler(t, map[string][]*taskmodels.Message{}) + + pendingID := h.store.CreateRequest(&Request{ + SessionID: "s1", + TaskID: "t1", + Questions: []Question{ + {ID: "q1", Prompt: "First?"}, + {ID: "q2", Prompt: "Second?"}, + {ID: "q3", Prompt: "Third?"}, + }, + }) + + // Only one answer for a 3-question bundle. + body := RespondBody{ + Answers: []Answer{{ + QuestionID: "q1", + SelectedOptions: []string{"opt1"}, + }}, + } + rec := runRespond(t, h, pendingID, body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d (body=%s)", rec.Code, rec.Body.String()) + } +} + +// TestHttpRespond_AllAnswers_PrimaryPath_Success verifies that when every +// question is answered the primary path delivers the full response and +// updates each message exactly once. +func TestHttpRespond_AllAnswers_PrimaryPath_Success(t *testing.T) { + h, _, _, msgCreator := setupTestHandler(t, map[string][]*taskmodels.Message{}) + + pendingID := h.store.CreateRequest(&Request{ + SessionID: "s1", + TaskID: "t1", + Questions: []Question{ + {ID: "q1", Prompt: "First?"}, + {ID: "q2", Prompt: "Second?"}, + }, + }) + + // Drain the response channel so Respond does not block indefinitely. + go func() { + _, _ = h.store.WaitForResponse(context.Background(), pendingID) + }() + + body := RespondBody{ + Answers: []Answer{ + {QuestionID: "q1", SelectedOptions: []string{"opt1"}}, + {QuestionID: "q2", CustomText: "free-form"}, + }, + } + rec := runRespond(t, h, pendingID, body) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body=%s)", rec.Code, rec.Body.String()) + } + + if len(msgCreator.updates) != 2 { + t.Fatalf("expected 2 message updates (one per question), got %d: %+v", + len(msgCreator.updates), msgCreator.updates) + } + for _, u := range msgCreator.updates { + if u.status != "answered" { + t.Errorf("expected status=answered, got %q", u.status) + } + } +} + +// TestValidateAndNormalizeQuestions_AssignsDefaults checks that question and +// option IDs are auto-generated when omitted, in deterministic q1/q1_opt1 form. +func TestValidateAndNormalizeQuestions_AssignsDefaults(t *testing.T) { + qs := []Question{ + {Prompt: "First?", Options: []Option{{Label: "A", Description: "a"}, {Label: "B", Description: "b"}}}, + {Prompt: "Second?", Options: []Option{{Label: "X", Description: "x"}, {Label: "Y", Description: "y"}}}, + } + if err := NormalizeAndValidateQuestions(qs); err != "" { + t.Fatalf("unexpected validation error: %s", err) + } + if qs[0].ID != "q1" || qs[1].ID != "q2" { + t.Errorf("expected q1/q2, got %q/%q", qs[0].ID, qs[1].ID) + } + if qs[0].Options[0].ID != "q1_opt1" || qs[1].Options[1].ID != "q2_opt2" { + t.Errorf("unexpected option IDs: %+v / %+v", qs[0].Options, qs[1].Options) + } +} + +// TestValidateAndNormalizeQuestions_RejectsInvalid covers the edge cases that +// guard against malformed payloads (no questions, too many, bad option counts). +func TestValidateAndNormalizeQuestions_RejectsInvalid(t *testing.T) { + cases := []struct { + name string + input []Question + }{ + {"no questions", nil}, + {"too many", []Question{{}, {}, {}, {}, {}}}, + {"missing prompt", []Question{{Options: []Option{{Label: "A", Description: "a"}, {Label: "B", Description: "b"}}}}}, + {"single option", []Question{{Prompt: "?", Options: []Option{{Label: "A", Description: "a"}}}}}, + {"too many options", []Question{{Prompt: "?", Options: []Option{ + {Label: "1", Description: "1"}, {Label: "2", Description: "2"}, {Label: "3", Description: "3"}, + {Label: "4", Description: "4"}, {Label: "5", Description: "5"}, {Label: "6", Description: "6"}, + {Label: "7", Description: "7"}, + }}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if msg := NormalizeAndValidateQuestions(tc.input); msg == "" { + t.Fatalf("expected validation error, got nil for %+v", tc.input) + } + }) + } +} + +// TestBuildAnswerSummary_SingleQuestion preserves the original "User selected:" +// text shape so existing prompts in the orchestrator stay readable. +func TestBuildAnswerSummary_SingleQuestion(t *testing.T) { + got := buildAnswerSummary( + []Question{{ID: "q1", Prompt: "Which?"}}, + []Answer{{QuestionID: "q1", SelectedOptions: []string{"opt1"}}}, + false, "", + ) + if got != "User selected: [opt1]" { + t.Errorf("unexpected single-q summary: %q", got) + } +} + +// TestBuildAnswerSummary_MultiQuestion produces an A1/A2 layout so the +// orchestrator resume prompt clearly maps each answer to its question. +func TestBuildAnswerSummary_MultiQuestion(t *testing.T) { + got := buildAnswerSummary( + []Question{ + {ID: "q1", Prompt: "First?"}, + {ID: "q2", Prompt: "Second?"}, + }, + []Answer{ + {QuestionID: "q1", SelectedOptions: []string{"opt1"}}, + {QuestionID: "q2", CustomText: "free"}, + }, + false, "", + ) + if got == "" || !strings.Contains(got, "A1:") || !strings.Contains(got, "A2:") { + t.Errorf("expected multi-line summary with A1/A2, got %q", got) + } +} + func runRespond(t *testing.T, h *Handlers, pendingID string, body RespondBody) *httptest.ResponseRecorder { t.Helper() payload, err := json.Marshal(body) diff --git a/apps/backend/internal/clarification/store.go b/apps/backend/internal/clarification/store.go index 07f2dbf0d4..9f309c2024 100644 --- a/apps/backend/internal/clarification/store.go +++ b/apps/backend/internal/clarification/store.go @@ -132,6 +132,23 @@ func (s *Store) Respond(pendingID string, resp *Response) error { } } +// CancelRequest cancels a single pending clarification by id, unblocking any +// WaitForResponse caller that is currently parked on it. Returns true if the +// entry existed (and was removed), false otherwise. Used by callers that need +// to surface a creation-side failure immediately rather than wait for the +// 2-hour MCP timeout. +func (s *Store) CancelRequest(pendingID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + pending, ok := s.pending[pendingID] + if !ok { + return false + } + close(pending.CancelCh) + delete(s.pending, pendingID) + return true +} + // CancelSession cancels all pending clarification requests for a given session. // It closes the CancelCh to unblock any WaitForResponse callers and removes entries. // Returns the list of cancelled pending IDs. diff --git a/apps/backend/internal/clarification/store_test.go b/apps/backend/internal/clarification/store_test.go index b421da5aa1..0e7ed1f8d1 100644 --- a/apps/backend/internal/clarification/store_test.go +++ b/apps/backend/internal/clarification/store_test.go @@ -22,7 +22,7 @@ func TestNewStore_CustomTimeout(t *testing.T) { func TestCreateRequest_GeneratesID(t *testing.T) { s := NewStore(time.Minute) - req := &Request{SessionID: "s1", Question: Question{Prompt: "test?"}} + req := &Request{SessionID: "s1", Questions: []Question{{Prompt: "test?"}}} id := s.CreateRequest(req) @@ -47,7 +47,7 @@ func TestCreateRequest_PreservesExistingID(t *testing.T) { func TestGetRequest_Found(t *testing.T) { s := NewStore(time.Minute) - id := s.CreateRequest(&Request{SessionID: "s1", Question: Question{Prompt: "test?"}}) + id := s.CreateRequest(&Request{SessionID: "s1", Questions: []Question{{Prompt: "test?"}}}) req, ok := s.GetRequest(id) @@ -76,14 +76,14 @@ func TestWaitForResponse_Success(t *testing.T) { // Respond in a goroutine go func() { time.Sleep(10 * time.Millisecond) - _ = s.Respond(id, &Response{Answer: &Answer{CustomText: "hello"}}) + _ = s.Respond(id, &Response{Answers: []Answer{{CustomText: "hello"}}}) }() resp, err := s.WaitForResponse(context.Background(), id) if err != nil { t.Fatalf("unexpected error: %v", err) } - if resp.Answer == nil || resp.Answer.CustomText != "hello" { + if len(resp.Answers) != 1 || resp.Answers[0].CustomText != "hello" { t.Errorf("unexpected response: %+v", resp) } @@ -155,7 +155,7 @@ func TestRespond_Success(t *testing.T) { s := NewStore(time.Minute) id := s.CreateRequest(&Request{SessionID: "s1"}) - err := s.Respond(id, &Response{Answer: &Answer{CustomText: "yes"}}) + err := s.Respond(id, &Response{Answers: []Answer{{CustomText: "yes"}}}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -209,6 +209,44 @@ func TestCancelSession_CancelsMatchingRequests(t *testing.T) { } } +// TestCancelRequest unblocks WaitForResponse for a single pending entry. +// Used by the create-message-failure recovery path so the agent doesn't +// have to wait for the full MCP timeout when the bundle could not be +// persisted. +// +// Synchronisation: the goroutine signals it has started before invoking +// WaitForResponse so we don't rely on a time.Sleep. CancelRequest may run +// either before or after the goroutine reads from the pending map, and both +// paths return an error from WaitForResponse — that's the contract under test. +func TestCancelRequest(t *testing.T) { + s := NewStore(time.Minute) + id := s.CreateRequest(&Request{SessionID: "s1"}) + + started := make(chan struct{}) + done := make(chan error, 1) + go func() { + close(started) + _, err := s.WaitForResponse(context.Background(), id) + done <- err + }() + <-started + + if !s.CancelRequest(id) { + t.Fatalf("CancelRequest returned false for known id") + } + if s.CancelRequest(id) { + t.Errorf("CancelRequest should return false the second time") + } + select { + case err := <-done: + if err == nil { + t.Errorf("expected error from cancelled WaitForResponse") + } + case <-time.After(time.Second): + t.Fatal("WaitForResponse did not return after CancelRequest") + } +} + func TestCancelSession_NoMatch(t *testing.T) { s := NewStore(time.Minute) s.CreateRequest(&Request{SessionID: "s1"}) diff --git a/apps/backend/internal/clarification/types.go b/apps/backend/internal/clarification/types.go index c65049046d..436d2e2eb3 100644 --- a/apps/backend/internal/clarification/types.go +++ b/apps/backend/internal/clarification/types.go @@ -18,30 +18,34 @@ type Question struct { ID string `json:"id"` Title string `json:"title"` // Short label (max 12 chars) Prompt string `json:"prompt"` // Full question text - Options []Option `json:"options"` // 2-4 options + Options []Option `json:"options"` // 2-6 options } -// Request represents a clarification request from an agent. +// Request represents a clarification request from an agent. A request bundles +// one or more questions; the agent stays blocked until every question has been +// answered (or the bundle is rejected as a whole). type Request struct { - PendingID string `json:"pending_id"` - SessionID string `json:"session_id"` - TaskID string `json:"task_id"` - Question Question `json:"question"` // Single question - Context string `json:"context,omitempty"` // Optional context for the question - CreatedAt time.Time `json:"created_at"` + PendingID string `json:"pending_id"` + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + Questions []Question `json:"questions"` // 1-N questions, all required + Context string `json:"context,omitempty"` // Optional shared context for all questions + CreatedAt time.Time `json:"created_at"` } // Answer represents the user's answer to a single question. type Answer struct { QuestionID string `json:"question_id"` - SelectedOptions []string `json:"selected_options,omitempty"` // Option IDs + SelectedOptions []string `json:"selected_options,omitempty"` // Option IDs (single-choice ⇒ at most one) CustomText string `json:"custom_text,omitempty"` // Free-text input } // Response represents the user's response to a clarification request. +// On success, Answers has exactly one entry per question in the request. +// On rejection, Answers may be nil and Rejected/RejectReason describe the skip. type Response struct { PendingID string `json:"pending_id"` - Answer *Answer `json:"answer,omitempty"` + Answers []Answer `json:"answers,omitempty"` Rejected bool `json:"rejected,omitempty"` RejectReason string `json:"reject_reason,omitempty"` // If rejected RespondedAt time.Time `json:"responded_at"` diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index a9e4b61710..509e62ddad 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -33,12 +33,13 @@ import ( type ClarificationService interface { CreateRequest(req *clarification.Request) string WaitForResponse(ctx context.Context, pendingID string) (*clarification.Response, error) + CancelRequest(pendingID string) bool CancelSession(sessionID string) []string } // MessageCreator creates messages for clarification requests. type MessageCreator interface { - CreateClarificationRequestMessage(ctx context.Context, taskID, sessionID, pendingID string, question clarification.Question, clarificationContext string) (string, error) + CreateClarificationRequestMessages(ctx context.Context, taskID, sessionID, pendingID string, questions []clarification.Question, clarificationContext string) ([]string, error) } // SessionRepository interface for updating session state. @@ -940,10 +941,10 @@ func (h *Handlers) publishQueueStatusEvent(ctx context.Context, sessionID string // the event-based fallback in the orchestrator handles resuming with a new turn. func (h *Handlers) handleAskUserQuestion(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - SessionID string `json:"session_id"` - TaskID string `json:"task_id"` - Question clarification.Question `json:"question"` - Context string `json:"context"` + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + Questions []clarification.Question `json:"questions"` + Context string `json:"context"` } if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) @@ -951,19 +952,11 @@ func (h *Handlers) handleAskUserQuestion(ctx context.Context, msg *ws.Message) ( if req.SessionID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) } - if req.Question.Prompt == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "question.prompt is required", nil) - } - - // Generate question ID if missing - if req.Question.ID == "" { - req.Question.ID = "q1" - } - // Generate option IDs if missing - for i := range req.Question.Options { - if req.Question.Options[i].ID == "" { - req.Question.Options[i].ID = generateOptionID(0, i) - } + // Single source of truth — same validator the HTTP handler uses, so + // duplicate IDs / bad option counts / empty prompts can't slip through + // either path. + if errMsg := clarification.NormalizeAndValidateQuestions(req.Questions); errMsg != "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, errMsg, nil) } // Look up task ID from session if not provided @@ -983,20 +976,26 @@ func (h *Handlers) handleAskUserQuestion(ctx context.Context, msg *ws.Message) ( clarificationReq := &clarification.Request{ SessionID: req.SessionID, TaskID: taskID, - Question: req.Question, + Questions: req.Questions, Context: req.Context, } pendingID := h.clarificationSvc.CreateRequest(clarificationReq) - // Create the message in the database (triggers WS event to frontend) + // Create one chat message per question (triggers WS events to frontend). + // If the create fails, the in-store pending entry must be cancelled too — + // otherwise the agent's WaitForResponse would block for the full 2-hour + // timeout while the user never sees clarification cards. if h.messageCreator != nil { - if _, err := h.messageCreator.CreateClarificationRequestMessage( - ctx, taskID, req.SessionID, pendingID, req.Question, req.Context, + if _, err := h.messageCreator.CreateClarificationRequestMessages( + ctx, taskID, req.SessionID, pendingID, req.Questions, req.Context, ); err != nil { - h.logger.Error("failed to create clarification request message", + h.logger.Error("failed to create clarification request messages", zap.String("pending_id", pendingID), zap.String("session_id", req.SessionID), zap.Error(err)) + h.clarificationSvc.CancelRequest(pendingID) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, + "failed to create clarification messages: "+err.Error(), nil) } } @@ -1097,11 +1096,6 @@ func (h *Handlers) setSessionWaitingForInput(ctx context.Context, taskID, sessio } } -// generateOptionID generates an option ID for a question. -func generateOptionID(questionIndex, optionIndex int) string { - return fmt.Sprintf("q%d_opt%d", questionIndex+1, optionIndex+1) -} - // handleCreateTaskPlan creates a new task plan. func (h *Handlers) handleCreateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { diff --git a/apps/backend/internal/mcp/server/ask_user_question_test.go b/apps/backend/internal/mcp/server/ask_user_question_test.go new file mode 100644 index 0000000000..b3deddbfd7 --- /dev/null +++ b/apps/backend/internal/mcp/server/ask_user_question_test.go @@ -0,0 +1,246 @@ +package mcp + +import ( + "encoding/json" + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAskUserQuestion_ToolSchema_RequiresQuestionsArray asserts the tool now +// exposes a "questions" array (not legacy "prompt"/"options" top-level fields). +func TestAskUserQuestion_ToolSchema_RequiresQuestionsArray(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + toolsMap := s.mcpServer.ListTools() + tool, ok := toolsMap["ask_user_question_kandev"] + require.True(t, ok, "ask_user_question tool not registered") + + schema, err := json.Marshal(tool.Tool.InputSchema) + require.NoError(t, err) + + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(schema, &parsed)) + + props, ok := parsed["properties"].(map[string]interface{}) + require.True(t, ok, "schema should have properties") + assert.Contains(t, props, "questions", "schema must expose 'questions'") + assert.Contains(t, props, "context") + assert.NotContains(t, props, "prompt", "legacy 'prompt' must not be top-level anymore") + assert.NotContains(t, props, "options", "legacy 'options' must not be top-level anymore") + + required, _ := parsed["required"].([]interface{}) + requiredSet := make(map[string]bool) + for _, r := range required { + requiredSet[r.(string)] = true + } + assert.True(t, requiredSet["questions"], "questions should be required") +} + +// TestAskUserQuestion_RejectsLegacyPromptShape ensures payloads using the old +// flat shape return a validation error rather than silently dropping the call. +func TestAskUserQuestion_RejectsLegacyPromptShape(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "prompt": "Which database?", + "options": []map[string]interface{}{ + {"label": "Postgres", "description": "Relational"}, + {"label": "Mongo", "description": "Document"}, + }, + }) + assert.True(t, result.IsError, "legacy flat shape must surface a validation error") +} + +// TestAskUserQuestion_SingleQuestion_PayloadShape exercises the simplest +// happy path: one question with two options. +func TestAskUserQuestion_SingleQuestion_PayloadShape(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{ + "answers": []interface{}{ + map[string]interface{}{ + "question_id": "q1", + "selected_options": []interface{}{"q1_opt1"}, + }, + }, + }, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "questions": []map[string]interface{}{ + { + "id": "q1", + "prompt": "Which database?", + "options": []map[string]interface{}{ + {"label": "Postgres", "description": "Relational"}, + {"label": "Mongo", "description": "Document"}, + }, + }, + }, + }) + require.False(t, result.IsError, "single-question call should succeed") + + assert.Equal(t, ws.ActionMCPAskUserQuestion, backend.lastAction) + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + questions, ok := payload["questions"].([]map[string]interface{}) + require.True(t, ok, "questions should be normalized to []map") + require.Len(t, questions, 1) + assert.Equal(t, "q1", questions[0]["id"]) + + // Result text should be a JSON map keyed by question id. + require.NotEmpty(t, result.Content) + textBlock, ok := result.Content[0].(mcplib.TextContent) + require.True(t, ok) + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(textBlock.Text), &parsed)) + q1 := parsed["q1"].(map[string]interface{}) + assert.Equal(t, "q1_opt1", q1["selected_option"]) +} + +// TestAskUserQuestion_MultiQuestion_BuildsMapResponse covers the full multi-q +// path: agent receives a JSON map keyed by every question id. +func TestAskUserQuestion_MultiQuestion_BuildsMapResponse(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{ + "answers": []interface{}{ + map[string]interface{}{ + "question_id": "db", + "selected_options": []interface{}{"db_opt1"}, + }, + map[string]interface{}{ + "question_id": "migration", + "custom_text": "migrate all but flag rows older than 2 years", + }, + map[string]interface{}{ + "question_id": "approach", + "selected_options": []interface{}{"approach_opt2"}, + }, + }, + }, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "questions": []map[string]interface{}{ + { + "id": "db", + "prompt": "Which database?", + "options": []map[string]interface{}{ + {"label": "Postgres", "description": "Relational"}, + {"label": "Mongo", "description": "Document"}, + }, + }, + { + "id": "migration", + "prompt": "How to migrate?", + "options": []map[string]interface{}{ + {"label": "Migrate all", "description": "Keep everything"}, + {"label": "Fresh start", "description": "Drop existing"}, + }, + }, + { + "id": "approach", + "prompt": "Iterative or atomic?", + "options": []map[string]interface{}{ + {"label": "Iterative", "description": "Step by step"}, + {"label": "Atomic", "description": "One big change"}, + }, + }, + }, + }) + require.False(t, result.IsError) + + require.NotEmpty(t, result.Content) + textBlock, ok := result.Content[0].(mcplib.TextContent) + require.True(t, ok) + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(textBlock.Text), &parsed)) + + require.Len(t, parsed, 3) + assert.Equal(t, "db_opt1", parsed["db"].(map[string]interface{})["selected_option"]) + assert.Equal(t, "migrate all but flag rows older than 2 years", parsed["migration"].(map[string]interface{})["custom_text"]) + assert.Equal(t, "approach_opt2", parsed["approach"].(map[string]interface{})["selected_option"]) +} + +// TestAskUserQuestion_RejectsTooManyQuestions caps the bundle at 4 questions — +// past that the agent should batch the work differently. +func TestAskUserQuestion_RejectsTooManyQuestions(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + makeQ := func(id string) map[string]interface{} { + return map[string]interface{}{ + "id": id, + "prompt": "anything?", + "options": []map[string]interface{}{ + {"label": "yes", "description": "y"}, + {"label": "no", "description": "n"}, + }, + } + } + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "questions": []map[string]interface{}{ + makeQ("q1"), makeQ("q2"), makeQ("q3"), makeQ("q4"), makeQ("q5"), + }, + }) + assert.True(t, result.IsError, "more than 4 questions must be rejected") +} + +// TestAskUserQuestion_RejectsTooFewOptions guards against degenerate payloads +// (a question with a single option is just an alert, not a real question). +func TestAskUserQuestion_RejectsTooFewOptions(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "questions": []map[string]interface{}{ + { + "id": "q1", + "prompt": "?", + "options": []map[string]interface{}{ + {"label": "only one", "description": "lonely"}, + }, + }, + }, + }) + assert.True(t, result.IsError, "single-option question must be rejected") +} + +// TestAskUserQuestion_RejectionPath returns a friendly text result when the +// user skipped the bundle. +func TestAskUserQuestion_RejectionPath(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{ + "rejected": true, + "reject_reason": "User skipped", + }, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "ask_user_question_kandev", map[string]interface{}{ + "questions": []map[string]interface{}{ + { + "id": "q1", + "prompt": "Which?", + "options": []map[string]interface{}{ + {"label": "Yes", "description": "y"}, + {"label": "No", "description": "n"}, + }, + }, + }, + }) + require.False(t, result.IsError) + + textBlock, ok := result.Content[0].(mcplib.TextContent) + require.True(t, ok) + assert.Contains(t, textBlock.Text, "rejected") + assert.Contains(t, textBlock.Text, "User skipped") +} diff --git a/apps/backend/internal/mcp/server/handlers.go b/apps/backend/internal/mcp/server/handlers.go index a7d2267540..379d6e9fb5 100644 --- a/apps/backend/internal/mcp/server/handlers.go +++ b/apps/backend/internal/mcp/server/handlers.go @@ -11,10 +11,21 @@ import ( "go.uber.org/zap" ) -// promptArg is the name of the MCP tool argument that carries the user-facing -// prompt text. Repeated across tool handlers; pulled out here so goconst stays -// happy and renames stay safe. -const promptArg = "prompt" +// Argument-name constants used across the ask_user_question_kandev handler. +// Pulled out so goconst stays happy and renames stay safe. +const ( + promptArg = "prompt" + questionsArg = "questions" + optionsArg = "options" + idArg = "id" + titleArg = "title" + labelArg = "label" + descriptionArg = "description" + optionIDFieldName = "option_id" + questionIDFieldKey = "question_id" + answeredFieldKey = "answered" + rejectedFieldKey = "rejected" +) func (s *Server) listWorkspacesHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -291,26 +302,15 @@ func copyOptionalMessageTypesArg(payload map[string]interface{}, req mcp.CallToo func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - prompt, err := req.RequireString(promptArg) - if err != nil { - return mcp.NewToolResultError("prompt is required"), nil - } - - options, errResult := parseQuestionOptions(req) + questions, errResult := parseQuestions(req) if errResult != nil { return errResult, nil } questionCtx := req.GetString("context", "") - question := map[string]interface{}{ - "id": "q1", - "title": "Question", - promptArg: prompt, - "options": options, - } payload := map[string]interface{}{ "session_id": s.sessionID, - "question": question, + questionsArg: questions, "context": questionCtx, } @@ -327,90 +327,208 @@ func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc { return mcp.NewToolResultError(err.Error()), nil } - return extractQuestionAnswer(result), nil + return extractQuestionAnswers(result, questions), nil } } -// parseQuestionOptions extracts and validates the "options" argument from the request. -// Returns (options, nil) on success or (nil, *mcp.CallToolResult) on validation failure. -func parseQuestionOptions(req mcp.CallToolRequest) ([]map[string]interface{}, *mcp.CallToolResult) { - args := req.GetArguments() - optionsRaw, ok := args["options"] - if !ok { - return nil, mcp.NewToolResultError("options is required") - } - - optionsJSON, err := json.Marshal(optionsRaw) - if err != nil { - return nil, mcp.NewToolResultError(fmt.Sprintf("failed to parse options: %v", err)) - } - - var options []map[string]interface{} - if err := json.Unmarshal(optionsJSON, &options); err != nil { - return nil, mcp.NewToolResultError("options must be an array of objects with 'label' and 'description' fields. Example: [{\"label\": \"Option A\", \"description\": \"Description of option A\"}]") - } - - if len(options) < 2 { - return nil, mcp.NewToolResultError("options must contain at least 2 choices") - } - if len(options) > 6 { - return nil, mcp.NewToolResultError("options must contain at most 6 choices") - } - - if errResult := validateAndNormalizeOptions(options); errResult != nil { +// parseQuestions extracts and validates the "questions" array from the request. +// Returns the normalized question payloads (with auto-assigned ids) on success +// or a tool-result error describing the first validation failure. +func parseQuestions(req mcp.CallToolRequest) ([]map[string]interface{}, *mcp.CallToolResult) { + questions, errResult := decodeQuestionsArg(req) + if errResult != nil { return nil, errResult } + seenIDs := map[string]bool{} + for i, q := range questions { + if errResult := normalizeAndValidateQuestion(q, i, seenIDs); errResult != nil { + return nil, errResult + } + questions[i] = q + } + return questions, nil +} + +// decodeQuestionsArg unmarshals the raw "questions" argument and enforces +// the bundle-size invariants (1..4 questions). +func decodeQuestionsArg(req mcp.CallToolRequest) ([]map[string]interface{}, *mcp.CallToolResult) { + args := req.GetArguments() + questionsRaw, ok := args[questionsArg] + if !ok { + return nil, mcp.NewToolResultError("questions is required (array of 1-4 question objects)") + } + questionsJSON, err := json.Marshal(questionsRaw) + if err != nil { + return nil, mcp.NewToolResultError(fmt.Sprintf("failed to parse questions: %v", err)) + } + var questions []map[string]interface{} + if err := json.Unmarshal(questionsJSON, &questions); err != nil { + return nil, mcp.NewToolResultError(`questions must be an array of objects with "prompt" and "options". Example: [{"prompt": "...", "options": [{"label": "A", "description": "..."}, {"label": "B", "description": "..."}]}]`) + } + if len(questions) < 1 { + return nil, mcp.NewToolResultError("questions must contain at least 1 question") + } + if len(questions) > 4 { + return nil, mcp.NewToolResultError("questions must contain at most 4 questions") + } + return questions, nil +} + +// normalizeAndValidateQuestion mutates a question payload in place: assigns a +// default id, parses options, and reports the first validation failure. +func normalizeAndValidateQuestion(q map[string]interface{}, index int, seenIDs map[string]bool) *mcp.CallToolResult { + prompt, hasPrompt := q[promptArg].(string) + if !hasPrompt || prompt == "" { + return mcp.NewToolResultError(fmt.Sprintf("question %d is missing required 'prompt' field", index+1)) + } + id, _ := q[idArg].(string) + if id == "" { + id = fmt.Sprintf("q%d", index+1) + q[idArg] = id + } + if seenIDs[id] { + return mcp.NewToolResultError(fmt.Sprintf("question %d has duplicate id %q", index+1, id)) + } + seenIDs[id] = true + + options, errResult := decodeOptionsForQuestion(q, index) + if errResult != nil { + return errResult + } + if errResult := validateAndNormalizeOptions(options, index+1); errResult != nil { + return errResult + } + q[optionsArg] = options + return nil +} + +func decodeOptionsForQuestion(q map[string]interface{}, index int) ([]map[string]interface{}, *mcp.CallToolResult) { + optionsRaw, hasOptions := q[optionsArg] + if !hasOptions { + return nil, mcp.NewToolResultError(fmt.Sprintf("question %d is missing required 'options' field", index+1)) + } + optionsJSON, err := json.Marshal(optionsRaw) + if err != nil { + return nil, mcp.NewToolResultError(fmt.Sprintf("question %d: failed to parse options: %v", index+1, err)) + } + var options []map[string]interface{} + if err := json.Unmarshal(optionsJSON, &options); err != nil { + return nil, mcp.NewToolResultError(fmt.Sprintf("question %d: options must be an array of objects with 'label' and 'description' fields", index+1)) + } + if len(options) < 2 { + return nil, mcp.NewToolResultError(fmt.Sprintf("question %d must have at least 2 options", index+1)) + } + if len(options) > 6 { + return nil, mcp.NewToolResultError(fmt.Sprintf("question %d must have at most 6 options", index+1)) + } return options, nil } // validateAndNormalizeOptions checks each option for required fields and assigns a default option_id. -func validateAndNormalizeOptions(options []map[string]interface{}) *mcp.CallToolResult { +func validateAndNormalizeOptions(options []map[string]interface{}, questionNum int) *mcp.CallToolResult { for i, opt := range options { - label, hasLabel := opt["label"].(string) + label, hasLabel := opt[labelArg].(string) if !hasLabel || label == "" { - return mcp.NewToolResultError(fmt.Sprintf("option %d is missing required 'label' field (1-5 words describing the choice)", i+1)) + return mcp.NewToolResultError(fmt.Sprintf("question %d option %d is missing required 'label' field (1-5 words describing the choice)", questionNum, i+1)) } - description, hasDesc := opt["description"].(string) + description, hasDesc := opt[descriptionArg].(string) if !hasDesc || description == "" { - return mcp.NewToolResultError(fmt.Sprintf("option %d is missing required 'description' field (explanation of what this option means)", i+1)) + return mcp.NewToolResultError(fmt.Sprintf("question %d option %d is missing required 'description' field (explanation of what this option means)", questionNum, i+1)) } // Generate option_id if not provided - if _, hasID := opt["option_id"].(string); !hasID { - opt["option_id"] = fmt.Sprintf("opt_%d", i+1) + if _, hasID := opt[optionIDFieldName].(string); !hasID { + opt[optionIDFieldName] = fmt.Sprintf("q%d_opt%d", questionNum, i+1) } } return nil } -// extractQuestionAnswer converts the backend question response into an MCP tool result. -func extractQuestionAnswer(result map[string]interface{}) *mcp.CallToolResult { - if answer, ok := result["answer"]; ok { - if res := extractAnswerMap(answer); res != nil { - return res +// extractQuestionAnswers converts the backend response into a JSON tool result +// keyed by question id. The shape is consistent across happy / rejected paths +// so the agent can always parse the response as a JSON object — rejected +// bundles emit an envelope { "rejected": true, "reject_reason": "..." } as +// well as per-question stub entries. +func extractQuestionAnswers(result map[string]interface{}, questions []map[string]interface{}) *mcp.CallToolResult { + rejected, _ := result["rejected"].(bool) + rejectReason, _ := result["reject_reason"].(string) + + out := make(map[string]interface{}, len(questions)+2) + if rejected { + out[rejectedFieldKey] = true + if rejectReason != "" { + out["reject_reason"] = rejectReason } } - if rejected, ok := result["rejected"].(bool); ok && rejected { - reason, _ := result["reject_reason"].(string) - return mcp.NewToolResultText(fmt.Sprintf("User rejected the question: %s", reason)) + + answers, _ := result["answers"].([]interface{}) + answersByID := make(map[string]map[string]interface{}, len(answers)) + for _, raw := range answers { + ans, ok := raw.(map[string]interface{}) + if !ok { + continue + } + qid, _ := ans[questionIDFieldKey].(string) + if qid == "" { + continue + } + answersByID[qid] = simplifyAnswer(ans, rejected) } - data, _ := json.MarshalIndent(result, "", " ") + + for _, q := range questions { + qid, _ := q[idArg].(string) + if qid == "" { + continue + } + if entry, ok := answersByID[qid]; ok { + out[qid] = entry + continue + } + stub := map[string]interface{}{answeredFieldKey: false} + if rejected { + stub[rejectedFieldKey] = true + } + out[qid] = stub + } + + if len(out) == 0 { + // Nothing matched by id — surface the raw payload so the agent can still inspect it. + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(data)) + } + + data, _ := json.MarshalIndent(out, "", " ") return mcp.NewToolResultText(string(data)) } -// extractAnswerMap inspects an answer map for selected options or custom text. -func extractAnswerMap(answer interface{}) *mcp.CallToolResult { - answerMap, ok := answer.(map[string]interface{}) - if !ok { - return nil +// simplifyAnswer normalizes the answer map. Single-choice per question, but +// the user can also type a custom text alongside an option pick — we preserve +// both fields so the agent gets the full context. When the bundle was +// rejected we annotate the entry to make the partial-answer scenario explicit. +// +// Output shape (one or more keys may be set): +// +// {"selected_option": "<id>"} +// {"custom_text": "<text>"} +// {"selected_option": "<id>", "custom_text": "<text>"} +// {"answered": false} +func simplifyAnswer(ans map[string]interface{}, rejected bool) map[string]interface{} { + out := map[string]interface{}{} + if selected, ok := ans["selected_options"].([]interface{}); ok && len(selected) > 0 { + if first, ok := selected[0].(string); ok && first != "" { + out["selected_option"] = first + } } - if selectedOptions, ok := answerMap["selected_options"].([]interface{}); ok && len(selectedOptions) > 0 { - return mcp.NewToolResultText(fmt.Sprintf("User selected: %v", selectedOptions[0])) + if customText, ok := ans["custom_text"].(string); ok && customText != "" { + out["custom_text"] = customText } - if customText, ok := answerMap["custom_text"].(string); ok && customText != "" { - return mcp.NewToolResultText(fmt.Sprintf("User answered: %s", customText)) + if rejected && len(out) == 0 { + return map[string]interface{}{answeredFieldKey: false, rejectedFieldKey: true} } - return nil + if len(out) == 0 { + return map[string]interface{}{answeredFieldKey: false} + } + return out } // notifyClarificationTimeout sends a fire-and-forget notification to the backend diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index b15bdddbb7..2cd7e607e2 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -483,54 +483,70 @@ IMPORTANT: func (s *Server) registerInteractionTools() { s.mcpServer.AddTool( mcp.NewTool("ask_user_question_kandev", - mcp.WithDescription(`Ask the user a clarifying question with multiple choice options. + mcp.WithDescription(`Ask the user one or more clarifying questions in a single tool call. -Use this tool when you need user input to proceed. The user will see the prompt and can select one of the options or provide a custom text response. +Use this tool when you need user input to proceed. Bundle related questions +together in one call so the user answers them all in one back-and-forth instead +of sequential round-trips. Each question is rendered as its own card; the user +selects an option or provides a custom text response per question, and the +agent receives a map keyed by question id once every question has been answered. -IMPORTANT: Each option must be a concrete, actionable choice - NOT meta-text like "Answer questions below". - -Options format - array of objects with: -- "label": Short text (1-5 words) shown as the clickable option -- "description": Brief explanation of what this option means +IMPORTANT: +- Provide 1 to 4 questions per call. +- Each question must have 2 to 6 concrete, actionable options. +- Each option must have a short "label" (1-5 words) and a "description" + explaining what selecting it means. NEVER use meta-text like "Answer below". +- Only call this tool when you genuinely need information you cannot infer. Example usage: { - "prompt": "Which database should I use for this project?", - "options": [ - {"label": "PostgreSQL", "description": "Relational database, good for complex queries"}, - {"label": "MongoDB", "description": "Document database, flexible schema"}, - {"label": "SQLite", "description": "Embedded database, simple setup"} + "questions": [ + { + "id": "db", + "prompt": "Which database should I use for this project?", + "options": [ + {"label": "PostgreSQL", "description": "Relational, good for complex queries"}, + {"label": "MongoDB", "description": "Document database, flexible schema"}, + {"label": "SQLite", "description": "Embedded, simple setup"} + ] + }, + { + "id": "migration", + "prompt": "How should I handle the existing user data during migration?", + "options": [ + {"label": "Migrate all", "description": "Keep all existing records"}, + {"label": "Archive old", "description": "Archive records older than 1 year"}, + {"label": "Fresh start", "description": "Delete existing data and start fresh"} + ] + } ], - "context": "The project requires storing user profiles and relationships between entities." + "context": "Backend redesign — picking the persistence layer and migration policy together." } -Another example: +The response is a JSON object keyed by each question id. Each entry may include +"selected_option" (the option_id the user picked), "custom_text" (the user's +free-form answer; can co-exist with a selected option), or "answered": false +when the user did not respond to that question. When the user skipped the entire +bundle, the envelope also carries "rejected": true and an optional +"reject_reason". Example success response: { - "prompt": "How should I handle the existing user data during migration?", - "options": [ - {"label": "Migrate all", "description": "Keep all existing records"}, - {"label": "Archive old", "description": "Archive records older than 1 year"}, - {"label": "Fresh start", "description": "Delete existing data and start fresh"} - ] + "db": {"selected_option": "q1_opt1"}, + "migration": {"custom_text": "Migrate all but flag rows older than 2 years"} +} +Example rejection: +{ + "rejected": true, + "reject_reason": "User skipped", + "db": {"answered": false, "rejected": true}, + "migration": {"answered": false, "rejected": true} }`), - mcp.WithString("prompt", mcp.Required(), mcp.Description("The question to ask the user. Be specific and clear.")), - mcp.WithArray("options", mcp.Required(), mcp.Description(`Array of option objects. Each option must have: "label" (1-5 words, the clickable choice) and "description" (explanation of the option). Provide 2-4 concrete, actionable options.`), - mcp.Items(map[string]any{ - "type": "object", - "properties": map[string]any{ - "label": map[string]any{ - "type": "string", - "description": "Short text (1-5 words) shown as the clickable option", - }, - "description": map[string]any{ - "type": "string", - "description": "Brief explanation of what this option means", - }, - }, - "required": []string{"label", "description"}, - }), + mcp.WithArray(questionsArg, mcp.Required(), + mcp.Description(`Array of 1-4 question objects. Each question must have a "prompt" (the question text) and an "options" array (2-6 entries with label + description). Optional fields: "id" (stable identifier in the response map; auto-generated if omitted), "title" (≤12 chars short label).`), + mcp.MinItems(1), + mcp.MaxItems(4), + mcp.Items(buildQuestionSchemaItem()), ), - mcp.WithString("context", mcp.Description("Optional background information to help the user understand why you're asking this question.")), + mcp.WithString("context", mcp.Description("Optional shared background information to help the user understand why you're asking these questions.")), ), s.wrapHandler("ask_user_question_kandev", s.askUserQuestionHandler()), ) @@ -570,3 +586,43 @@ func (s *Server) registerPlanTools() { s.wrapHandler("delete_task_plan_kandev", s.deleteTaskPlanHandler()), ) } + +// buildQuestionSchemaItem describes the shape of a single question object in +// the ask_user_question_kandev tool schema. Hoisted out of registerInteractionTools +// to keep the registration body short and to deduplicate the JSON-schema +// keyword strings (linter goconst rules). +func buildQuestionSchemaItem() map[string]any { + const typeKey = "type" + const propsKey = "properties" + const reqKey = "required" + const objType = "object" + const stringType = "string" + + str := func(desc string) map[string]any { + return map[string]any{typeKey: stringType, descriptionArg: desc} + } + + return map[string]any{ + typeKey: objType, + propsKey: map[string]any{ + idArg: str("Stable identifier used as the key in the response map. Auto-assigned (q1, q2, ...) if omitted."), + titleArg: str("Optional short label (≤12 chars) shown above the prompt."), + promptArg: str("The question text shown to the user."), + optionsArg: map[string]any{ + typeKey: "array", + descriptionArg: "2-6 concrete, actionable choices.", + "minItems": 2, + "maxItems": 6, + "items": map[string]any{ + typeKey: objType, + propsKey: map[string]any{ + labelArg: str("Short text (1-5 words) shown as the clickable option."), + descriptionArg: str("Brief explanation of what this option means."), + }, + reqKey: []string{labelArg, descriptionArg}, + }, + }, + }, + reqKey: []string{promptArg, optionsArg}, + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_clarification.go b/apps/backend/internal/orchestrator/event_handlers_clarification.go index 54e69b7514..3cb81ccb79 100644 --- a/apps/backend/internal/orchestrator/event_handlers_clarification.go +++ b/apps/backend/internal/orchestrator/event_handlers_clarification.go @@ -295,15 +295,28 @@ func (s *Service) cancelAllClarificationWatchdogs() { } // buildClarificationPrompt constructs the resume prompt from a clarification answer. +// Handles both single- and multi-question bundles: when data.Question contains +// newlines it is treated as a pre-formatted multi-line summary and embedded +// as-is rather than quoted. func buildClarificationPrompt(data clarificationAnsweredData) string { + multiQuestion := strings.Contains(data.Question, "\n") + if data.Rejected { reason := data.RejectReason if reason == "" { reason = "No reason provided" } + if multiQuestion { + return fmt.Sprintf("The user declined to answer your questions:\n%s\nReason: %s\nPlease continue without this information.", + data.Question, reason) + } return fmt.Sprintf("The user declined to answer your question: %q\nReason: %s\nPlease continue without this information.", data.Question, reason) } + if multiQuestion { + return fmt.Sprintf("You previously asked the user:\n%s\n\n%s\nPlease continue with this information.", + data.Question, data.AnswerText) + } return fmt.Sprintf("You previously asked the user: %q\n%s\nPlease continue with this information.", data.Question, data.AnswerText) } diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 5286dcc84f..3ffa3f42dd 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -421,6 +421,12 @@ func (m *mockRepository) GetMessageByPendingID(ctx context.Context, sessionID, p func (m *mockRepository) FindMessageByPendingID(ctx context.Context, pendingID string) (*models.Message, error) { return nil, nil } +func (m *mockRepository) FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*models.Message, error) { + return nil, nil +} +func (m *mockRepository) FindMessageByPendingIDAndQuestion(ctx context.Context, sessionID, pendingID, questionID string) (*models.Message, error) { + return nil, nil +} func (m *mockRepository) UpdateMessage(ctx context.Context, message *models.Message) error { return nil } diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 3ff59ddf33..20d694d268 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -139,6 +139,12 @@ func (m *mockRepository) GetMessageByPendingID(ctx context.Context, sessionID, p func (m *mockRepository) FindMessageByPendingID(ctx context.Context, pendingID string) (*models.Message, error) { return nil, nil } +func (m *mockRepository) FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*models.Message, error) { + return nil, nil +} +func (m *mockRepository) FindMessageByPendingIDAndQuestion(ctx context.Context, sessionID, pendingID, questionID string) (*models.Message, error) { + return nil, nil +} func (m *mockRepository) UpdateMessage(ctx context.Context, message *models.Message) error { return nil } diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 1585ca3e2e..7d8ea057bd 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -66,6 +66,8 @@ type MessageRepository interface { GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error) FindMessageByPendingID(ctx context.Context, pendingID string) (*models.Message, error) + FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*models.Message, error) + FindMessageByPendingIDAndQuestion(ctx context.Context, sessionID, pendingID, questionID string) (*models.Message, error) UpdateMessage(ctx context.Context, message *models.Message) error ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) ListMessagesPaginated(ctx context.Context, sessionID string, opts models.ListMessagesOptions) ([]*models.Message, bool, error) diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index 77db8a51db..e832a33a2b 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -312,8 +312,10 @@ func (r *Repository) GetMessageByPendingID(ctx context.Context, sessionID, pendi return r.getMessageByMetadataField(ctx, sessionID, "pending_id", pendingID, "") } -// FindMessageByPendingID finds a message by pending_id alone (without requiring session ID). +// FindMessageByPendingID finds the most-recent message by pending_id alone. // This is useful when we only have the pending ID (e.g., from expired clarification responses). +// For multi-question clarification requests, prefer FindMessagesByPendingID to retrieve +// every related message in one shot. func (r *Repository) FindMessageByPendingID(ctx context.Context, pendingID string) (*models.Message, error) { message := &models.Message{} var requestsInput int @@ -338,6 +340,62 @@ func (r *Repository) FindMessageByPendingID(ctx context.Context, pendingID strin return message, nil } +// FindMessagesByPendingID returns every message that carries the given pending_id +// in its metadata, ordered by creation time (oldest first). Multi-question +// clarification requests emit one message per question, all sharing the same +// pending_id; this lookup lets the canceller / status-update path touch all of +// them without N round-trips. +func (r *Repository) FindMessagesByPendingID(ctx context.Context, pendingID string) ([]*models.Message, error) { + drv := r.ro.DriverName() + query := fmt.Sprintf(` + SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at + FROM task_session_messages WHERE %s = ? + ORDER BY created_at ASC + `, dialect.JSONExtract(drv, "metadata", "pending_id")) + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), pendingID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + result, _, err := scanMessageRows(rows, 0) + return result, err +} + +// FindMessageByPendingIDAndQuestion finds the message for a specific (pending_id, +// question_id) pair within a session. Used to flip per-question status (answered / +// rejected) on multi-question clarification bundles. The persistence layer stores +// the question id at metadata.question_id (flat, alongside metadata.question) so +// the JSON path lookup works on both SQLite and Postgres. +func (r *Repository) FindMessageByPendingIDAndQuestion(ctx context.Context, sessionID, pendingID, questionID string) (*models.Message, error) { + drv := r.ro.DriverName() + query := fmt.Sprintf(` + SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at + FROM task_session_messages + WHERE task_session_id = ? AND %s = ? AND %s = ? + ORDER BY created_at ASC LIMIT 1 + `, + dialect.JSONExtract(drv, "metadata", "pending_id"), + dialect.JSONExtract(drv, "metadata", "question_id"), + ) + message := &models.Message{} + var requestsInput int + var messageType string + var metadataJSON string + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), sessionID, pendingID, questionID).Scan( + &message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, + &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt, + ) + if err != nil { + return nil, err + } + message.RequestsInput = requestsInput == 1 + message.Type = models.MessageType(messageType) + if metadataJSON != "" { + _ = json.Unmarshal([]byte(metadataJSON), &message.Metadata) + } + return message, nil +} + // CompletePendingToolCallsForTurn marks all non-terminal tool call messages for a turn as "complete". // This is a safety net to ensure no tool calls remain stuck in a non-terminal state (pending, // running, in_progress, etc.) after a turn completes. diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index 8be14b9867..d126857a14 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -584,19 +584,19 @@ func (s *Service) UpdatePermissionMessage(ctx context.Context, sessionID, pendin return nil } -// UpdateClarificationMessage updates a clarification request message's status and response. -// It includes retry logic to handle race conditions. -// The answers parameter should be a slice of answer objects with question_id, selected_options, and custom_text. -func (s *Service) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, status string, answers interface{}) error { +// UpdateClarificationMessageForQuestion updates a single clarification message +// (identified by pending_id + question_id) with the new status and the answer +// payload. Used by both single- and multi-question clarification bundles since +// each question lives in its own chat message. +func (s *Service) UpdateClarificationMessageForQuestion(ctx context.Context, sessionID, pendingID, questionID, status string, answer interface{}) error { const maxRetries = 5 const retryDelay = 100 * time.Millisecond var message *models.Message var err error - // Retry loop to handle race condition for attempt := 0; attempt < maxRetries; attempt++ { - message, err = s.messages.GetMessageByPendingID(ctx, sessionID, pendingID) + message, err = s.messages.FindMessageByPendingIDAndQuestion(ctx, sessionID, pendingID, questionID) if err == nil { break } @@ -609,6 +609,7 @@ func (s *Service) UpdateClarificationMessage(ctx context.Context, sessionID, pen s.logger.Debug("clarification message not found, retrying", zap.String("session_id", sessionID), zap.String("pending_id", pendingID), + zap.String("question_id", questionID), zap.Int("attempt", attempt+1), zap.Int("max_retries", maxRetries)) time.Sleep(retryDelay) @@ -619,6 +620,7 @@ func (s *Service) UpdateClarificationMessage(ctx context.Context, sessionID, pen s.logger.Warn("clarification message not found for update after retries", zap.String("session_id", sessionID), zap.String("pending_id", pendingID), + zap.String("question_id", questionID), zap.Int("retries", maxRetries), zap.Error(err)) return err @@ -628,24 +630,25 @@ func (s *Service) UpdateClarificationMessage(ctx context.Context, sessionID, pen message.Metadata = make(map[string]interface{}) } message.Metadata["status"] = status - if answers != nil { - message.Metadata["response"] = answers + if answer != nil { + message.Metadata["response"] = answer } if err := s.messages.UpdateMessage(ctx, message); err != nil { s.logger.Error("failed to update clarification message", zap.String("message_id", message.ID), zap.String("pending_id", pendingID), + zap.String("question_id", questionID), zap.Error(err)) return err } - // Publish message.updated event s.publishMessageEvent(ctx, events.MessageUpdated, message) s.logger.Info("clarification message updated", zap.String("message_id", message.ID), zap.String("pending_id", pendingID), + zap.String("question_id", questionID), zap.String("status", status)) return nil diff --git a/apps/web/components/quick-chat/quick-chat-content.tsx b/apps/web/components/quick-chat/quick-chat-content.tsx index ec2cca3234..db8850680d 100644 --- a/apps/web/components/quick-chat/quick-chat-content.tsx +++ b/apps/web/components/quick-chat/quick-chat-content.tsx @@ -74,7 +74,14 @@ export const QuickChatContent = memo(function QuickChatContent({ handleCancelQueue, handleQueueEditComplete, } = state; - const { taskId, pendingClarification, isQueued, queuedMessage, updateQueueContent } = panelState; + const { + taskId, + pendingClarification, + pendingClarificationGroup, + isQueued, + queuedMessage, + updateQueueContent, + } = panelState; useEffect(() => { const timer = setTimeout(() => chatInputRef.current?.focusInput(), 50); @@ -111,7 +118,7 @@ export const QuickChatContent = memo(function QuickChatContent({ {pendingClarification && ( <div className="flex-shrink-0 border-t border-sky-400/30 bg-card px-1"> <ClarificationInputOverlay - message={pendingClarification} + messages={pendingClarificationGroup} onResolved={handleClarificationResolved} /> </div> diff --git a/apps/web/components/task/chat/clarification-input-overlay.tsx b/apps/web/components/task/chat/clarification-input-overlay.tsx index 49ea2d2837..f286bfd450 100644 --- a/apps/web/components/task/chat/clarification-input-overlay.tsx +++ b/apps/web/components/task/chat/clarification-input-overlay.tsx @@ -1,10 +1,8 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IconX, IconMessageQuestion, IconInfoCircle } from "@tabler/icons-react"; import ReactMarkdown from "react-markdown"; -import { cn } from "@/lib/utils"; -import { getBackendConfig } from "@/lib/config"; import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; import type { Message, @@ -12,262 +10,392 @@ import type { ClarificationAnswer, ClarificationQuestion, } from "@/lib/types/http"; - -const RESULT_EXPIRED = "expired"; +import { useClarificationGroup } from "@/hooks/domains/session/use-clarification-group"; +import { + ClarificationCarouselNav, + ClarificationCustomInput, + ClarificationOptions, + ClarificationStepper, +} from "./clarification-overlay-parts"; type ClarificationInputOverlayProps = { - message: Message; + messages: readonly Message[] | null | undefined; onResolved: () => void; }; -/** - * Post a response to the clarification endpoint. Returns true on success. - */ -async function postClarificationResponse( - pendingId: string, - body: Record<string, unknown>, -): Promise<"ok" | "expired" | "error"> { - const { apiBaseUrl } = getBackendConfig(); - const response = await fetch(`${apiBaseUrl}/api/v1/clarification/${pendingId}/respond`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (response.ok) return "ok"; - if (response.status === 410) { - const data = await response.json().catch(() => ({})); - console.warn("Clarification expired:", data); - return RESULT_EXPIRED; - } - console.error("Clarification request failed:", response.status, response.statusText); - return "error"; -} - -function handleClarificationResult(result: "ok" | "expired" | "error", onResolved: () => void) { - if (result === "ok" || result === RESULT_EXPIRED) onResolved(); -} - -type ClarificationOptionsProps = { +type SingleQuestionMeta = { + message: Message; + metadata: ClarificationRequestMetadata; question: ClarificationQuestion; - isSubmitting: boolean; - onSelectOption: (optionId: string) => void; + questionId: string; }; -function ClarificationOptions({ - question, - isSubmitting, - onSelectOption, -}: ClarificationOptionsProps) { - return ( - <div className="space-y-0.5 mb-1.5 ml-6"> - {question.options.map((option) => ( - <button - key={option.option_id} - type="button" - onClick={() => onSelectOption(option.option_id)} - disabled={isSubmitting} - data-testid="clarification-option" - className={cn( - "flex items-start gap-2 w-full text-left text-xs rounded px-1.5 py-1 -ml-1.5 transition-colors", - "hover:bg-blue-500/15 hover:text-blue-600 dark:hover:text-blue-400", - isSubmitting ? "opacity-50 cursor-not-allowed" : "text-foreground/80 cursor-pointer", - )} - > - <span className="text-muted-foreground flex-shrink-0 leading-5">•</span> - <span className="flex-1 min-w-0"> - <span data-testid="clarification-option-label" className="block leading-5"> - {option.label} - </span> - {option.description && ( - <span - data-testid="clarification-option-description" - className="block text-muted-foreground/70 mt-0.5 leading-snug" - > - {option.description} - </span> - )} - </span> - </button> - ))} - </div> - ); +function readSingleQuestionMeta(message: Message | null | undefined): SingleQuestionMeta | null { + if (!message) return null; + const metadata = message.metadata as ClarificationRequestMetadata | undefined; + if (!metadata?.question) return null; + const questionId = metadata.question_id ?? metadata.question.id; + if (!questionId) return null; + return { message, metadata, question: metadata.question, questionId }; } -type ClarificationCustomInputProps = { - customText: string; +function resolveQuestionMessages(messages: readonly Message[] | null | undefined): Message[] { + if (messages && messages.length > 0) return [...messages]; + return []; +} + +function sortMessagesByQuestionIndex(messages: Message[]): Message[] { + return messages.slice().sort((a, b) => { + const ai = (a.metadata as ClarificationRequestMetadata | undefined)?.question_index ?? 0; + const bi = (b.metadata as ClarificationRequestMetadata | undefined)?.question_index ?? 0; + return ai - bi; + }); +} + +type CardProps = { + meta: SingleQuestionMeta; + index: number; + total: number; + selectedOption: string | null; + customCommittedText: string | null; + customDraft: string; isSubmitting: boolean; - onChange: (text: string) => void; - onSubmit: () => void; + showAgentDisconnected: boolean; + onSelectOption: (optionId: string) => void; + onCustomDraftChange: (text: string) => void; + onSubmitCustom: (text: string) => void; }; -function ClarificationCustomInput({ - customText, - isSubmitting, - onChange, - onSubmit, -}: ClarificationCustomInputProps) { +function ClarificationCard(props: CardProps) { + const { + meta, + index, + total, + selectedOption, + customCommittedText, + customDraft, + isSubmitting, + showAgentDisconnected, + onSelectOption, + onCustomDraftChange, + onSubmitCustom, + } = props; + const { question, metadata } = meta; return ( - <div className="flex items-center gap-2 ml-6"> - <span className="text-muted-foreground flex-shrink-0">•</span> - <input - type="text" - placeholder="Type something..." - value={customText} - onChange={(e) => onChange(e.target.value)} - disabled={isSubmitting} - data-testid="clarification-input" - className="flex-1 text-sm bg-transparent placeholder:text-muted-foreground focus:outline-none" - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey && customText.trim()) { - e.preventDefault(); - onSubmit(); - } - }} + <div + data-testid="clarification-question-card" + data-question-id={meta.questionId} + data-question-index={String(index)} + className="px-4 pt-3 pb-4" + > + <div className="flex items-center gap-2 mb-2 text-xs text-muted-foreground"> + <IconMessageQuestion className="h-4 w-4 text-blue-500" /> + {total > 1 && ( + <span data-testid="clarification-progress-chip"> + Question {index + 1} of {total} + </span> + )} + {metadata.question.title && ( + <span className="text-muted-foreground/70"> + {total > 1 ? "· " : ""} + {metadata.question.title} + </span> + )} + </div> + <div className="markdown-body max-w-none text-sm font-medium [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 mb-3"> + <ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}> + {question.prompt} + </ReactMarkdown> + </div> + <ClarificationOptions + options={question.options} + selectedOption={selectedOption} + isSubmitting={isSubmitting} + onSelectOption={onSelectOption} + /> + {showAgentDisconnected && ( + <div + data-testid="clarification-deferred-notice" + className="mt-2 text-xs text-amber-500/90 flex items-center gap-1.5" + > + <IconInfoCircle className="h-3.5 w-3.5 flex-shrink-0" /> + The agent has moved on. Your response will be sent as a new message. + </div> + )} + <ClarificationCustomInput + draft={customDraft} + isSubmitting={isSubmitting} + committedText={customCommittedText} + onChange={onCustomDraftChange} + onSubmit={onSubmitCustom} /> </div> ); } -type UseClarificationHandlersParams = { - metadata: ClarificationRequestMetadata | undefined; - isSubmitting: boolean; - setIsSubmitting: React.Dispatch<React.SetStateAction<boolean>>; - customText: string; - onResolved: () => void; -}; - -function useClarificationHandlers({ - metadata, - isSubmitting, - setIsSubmitting, - customText, - onResolved, -}: UseClarificationHandlersParams) { - const handleSubmitOption = useCallback( - async (optionId: string) => { - if (!metadata?.pending_id || isSubmitting) return; - setIsSubmitting(true); - try { - const answer: ClarificationAnswer = { - question_id: metadata.question.id, - selected_options: [optionId], - }; - const result = await postClarificationResponse(metadata.pending_id, { - answers: [answer], - rejected: false, - }); - handleClarificationResult(result, onResolved); - } catch (error) { - console.error("Failed to submit clarification response:", error); - } finally { - setIsSubmitting(false); - } - }, - [metadata, isSubmitting, onResolved, setIsSubmitting], - ); - - const handleSubmitCustom = useCallback(async () => { - if (!metadata?.pending_id || isSubmitting || !customText.trim()) return; - setIsSubmitting(true); - try { - const answer: ClarificationAnswer = { - question_id: metadata.question.id, - selected_options: [], - custom_text: customText.trim(), - }; - const result = await postClarificationResponse(metadata.pending_id, { - answers: [answer], - rejected: false, - }); - handleClarificationResult(result, onResolved); - } catch (error) { - console.error("Failed to submit clarification response:", error); - } finally { - setIsSubmitting(false); +function useResolveCallback( + submitState: ReturnType<typeof useClarificationGroup>["submitState"], + onResolved: () => void, +) { + const last = useRef(submitState); + useEffect(() => { + if (last.current !== submitState && submitState === "ok") { + onResolved(); } - }, [metadata, isSubmitting, customText, onResolved, setIsSubmitting]); - - const handleSkip = useCallback(async () => { - if (!metadata?.pending_id || isSubmitting) return; - setIsSubmitting(true); - try { - const result = await postClarificationResponse(metadata.pending_id, { - answers: [], - rejected: true, - reject_reason: "User skipped", - }); - handleClarificationResult(result, onResolved); - } catch (error) { - console.error("Failed to skip clarification:", error); - } finally { - setIsSubmitting(false); - } - }, [metadata, isSubmitting, onResolved, setIsSubmitting]); - - return { handleSubmitOption, handleSubmitCustom, handleSkip }; + last.current = submitState; + }, [submitState, onResolved]); } -/** - * Inline clarification UI - simple numbered text options like Conductor. - */ -export function ClarificationInputOverlay({ message, onResolved }: ClarificationInputOverlayProps) { - const metadata = message.metadata as ClarificationRequestMetadata | undefined; - const [customText, setCustomText] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const { handleSubmitOption, handleSubmitCustom, handleSkip } = useClarificationHandlers({ - metadata, - isSubmitting, - setIsSubmitting, - customText, - onResolved, +type CarouselShortcutArgs = { + meta: SingleQuestionMeta; + activeIndex: number; + total: number; + canSubmit: boolean; + onPick: (index: number) => void; + onPrev: () => void; + onNext: () => void; + onSkip: () => void; + onSubmit: () => void; +}; + +// shouldIgnoreShortcut filters out events that the overlay must not handle: +// keystrokes inside an input/textarea (the user is typing) and any modifier +// combo (so we don't hijack browser shortcuts like Cmd/Ctrl+1..9 for tab +// switching or Alt+ArrowLeft for back-navigation). +function shouldIgnoreShortcut(e: KeyboardEvent): boolean { + if ( + e.target instanceof HTMLElement && + (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") + ) { + return true; + } + return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; +} + +function CarouselKeyboardShortcuts(args: CarouselShortcutArgs) { + const optionsCount = args.meta.question.options.length; + const isLast = args.activeIndex === args.total - 1; + const { canSubmit, onPick, onPrev, onNext, onSkip, onSubmit } = args; + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (shouldIgnoreShortcut(e)) return; + if (e.key === "Escape") { + e.preventDefault(); + onSkip(); + return; + } + if (e.key === "ArrowLeft") { + e.preventDefault(); + onPrev(); + return; + } + if (e.key === "ArrowRight") { + e.preventDefault(); + if (isLast && canSubmit) onSubmit(); + else onNext(); + return; + } + const num = Number.parseInt(e.key, 10); + if (Number.isFinite(num) && num >= 1 && num <= optionsCount) { + e.preventDefault(); + onPick(num - 1); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [optionsCount, isLast, canSubmit, onPick, onPrev, onNext, onSkip, onSubmit]); + return null; +} + +type CarouselBodyProps = { + sortedMessages: Message[]; + group: ReturnType<typeof useClarificationGroup>; + activeIndex: number; + setActiveIndex: (idx: number) => void; + customDrafts: Record<string, string>; + setCustomDrafts: React.Dispatch<React.SetStateAction<Record<string, string>>>; +}; + +function ClarificationCarouselBody({ + sortedMessages, + group, + activeIndex, + setActiveIndex, + customDrafts, + setCustomDrafts, +}: CarouselBodyProps) { + const total = sortedMessages.length; + const activeMessage = sortedMessages[Math.min(activeIndex, total - 1)] ?? null; + const meta = activeMessage ? readSingleQuestionMeta(activeMessage) : null; + const showAgentDisconnectedAtTop = sortedMessages.some( + (m) => (m.metadata as ClarificationRequestMetadata | undefined)?.agent_disconnected === true, + ); + const isSubmitting = group.submitState === "submitting"; + + const isSingleQuestion = total === 1; + + const allAnswered = sortedMessages.every((m) => { + const id = readSingleQuestionMeta(m)?.questionId; + return id ? Boolean(group.answers[id]) : false; }); - if (!metadata?.question) return null; + const handleSubmit = useCallback(() => { + if (allAnswered) void group.submitCollected(); + }, [allAnswered, group]); - const question = metadata.question; + if (!meta) return null; + + const stored = group.answers[meta.questionId]; + const selectedOption = + stored?.selected_options && stored.selected_options.length > 0 + ? stored.selected_options[0] + : null; + const customCommittedText = stored?.custom_text ?? null; + const draft = customDrafts[meta.questionId] ?? ""; + + // Records the new answer, then either auto-submits (single-question case + // takes the override path so the freshly recorded answer is included even + // though setState is async) or auto-advances to the next step. + const commitAnswer = (answer: ClarificationAnswer) => { + group.recordAnswer(meta.questionId, answer); + if (isSingleQuestion) { + void group.submitCollected({ [meta.questionId]: answer }); + return; + } + if (activeIndex < total - 1) { + setActiveIndex(activeIndex + 1); + } + }; + + const onSelectOption = (optionId: string) => { + commitAnswer({ + question_id: meta.questionId, + selected_options: [optionId], + }); + }; + const onSubmitCustom = (text: string) => { + if (!text.trim()) return; + commitAnswer({ + question_id: meta.questionId, + selected_options: [], + custom_text: text.trim(), + }); + }; return ( - <div className="relative px-3 py-2" data-testid="clarification-overlay"> - <button - type="button" - onClick={handleSkip} - disabled={isSubmitting} - className="absolute top-2 right-3 text-muted-foreground hover:text-foreground z-10 cursor-pointer" - data-testid="clarification-skip" - > - <IconX className="h-4 w-4" /> - </button> + <> + <ClarificationCard + meta={meta} + index={activeIndex} + total={total} + selectedOption={selectedOption} + customCommittedText={customCommittedText} + customDraft={draft} + isSubmitting={isSubmitting} + showAgentDisconnected={activeIndex === 0 && showAgentDisconnectedAtTop} + onSelectOption={onSelectOption} + onCustomDraftChange={(value) => + setCustomDrafts((prev) => ({ ...prev, [meta.questionId]: value })) + } + onSubmitCustom={onSubmitCustom} + /> + {!isSingleQuestion && ( + <ClarificationCarouselNav + activeIndex={activeIndex} + total={total} + isSubmitting={isSubmitting} + onPrev={() => setActiveIndex(Math.max(0, activeIndex - 1))} + onNext={() => setActiveIndex(Math.min(total - 1, activeIndex + 1))} + onSubmit={handleSubmit} + canSubmit={allAnswered} + /> + )} + <CarouselKeyboardShortcuts + meta={meta} + activeIndex={activeIndex} + total={total} + canSubmit={allAnswered} + onPick={(idx) => onSelectOption(meta.question.options[idx].option_id)} + onPrev={() => setActiveIndex(Math.max(0, activeIndex - 1))} + onNext={() => setActiveIndex(Math.min(total - 1, activeIndex + 1))} + onSkip={() => void group.skipAll("User skipped")} + onSubmit={handleSubmit} + /> + </> + ); +} - <div className="pr-6"> - <div className="flex items-start gap-2 mb-1"> - <IconMessageQuestion className="h-4 w-4 text-blue-500 flex-shrink-0 mt-0.5" /> - <div className="markdown-body max-w-none text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"> - <ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}> - {question.prompt} - </ReactMarkdown> - </div> +export function ClarificationInputOverlay({ + messages, + onResolved, +}: ClarificationInputOverlayProps) { + const sortedMessages = useMemo( + () => sortMessagesByQuestionIndex(resolveQuestionMessages(messages)), + [messages], + ); + const group = useClarificationGroup(sortedMessages); + const [customDrafts, setCustomDrafts] = useState<Record<string, string>>({}); + const [rawActiveIndex, setActiveIndex] = useState(0); + // Clamp the active index to the current bundle size so late-arriving + // messages or shrunk bundles never put us out of range. + const total = sortedMessages.length; + const activeIndex = total === 0 ? 0 : Math.min(rawActiveIndex, total - 1); + + useResolveCallback(group.submitState, onResolved); + + if (sortedMessages.length === 0) return null; + const isSubmitting = group.submitState === "submitting"; + + const isAnsweredAt = (index: number) => { + const m = sortedMessages[index]; + if (!m) return false; + const id = readSingleQuestionMeta(m)?.questionId; + return id ? Boolean(group.answers[id]) : false; + }; + + return ( + <div className="relative" data-testid="clarification-overlay"> + <div className="flex items-center justify-between gap-3 px-4 pt-3 pb-2"> + <div className="flex items-center gap-3"> + {total > 1 && ( + <ClarificationStepper + total={total} + activeIndex={activeIndex} + isAnswered={isAnsweredAt} + onJump={setActiveIndex} + isSubmitting={isSubmitting} + /> + )} + {total > 1 && ( + <span + data-testid="clarification-group-progress" + className="text-xs text-muted-foreground" + > + {group.answeredCount} of {group.total} answered + </span> + )} </div> - <ClarificationOptions - question={question} - isSubmitting={isSubmitting} - onSelectOption={handleSubmitOption} - /> - {metadata.agent_disconnected && ( - <div - data-testid="clarification-deferred-notice" - className="ml-6 mb-1 text-xs text-amber-500/80 flex items-center gap-1.5" - > - <IconInfoCircle className="h-3.5 w-3.5 flex-shrink-0" /> - The agent has moved on. Your response will be sent as a new message. - </div> - )} - <ClarificationCustomInput - customText={customText} - isSubmitting={isSubmitting} - onChange={setCustomText} - onSubmit={handleSubmitCustom} - /> + <button + type="button" + onClick={() => void group.skipAll("User skipped")} + disabled={isSubmitting} + className="text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50" + data-testid="clarification-skip" + aria-label="Skip all questions" + > + <IconX className="h-4 w-4" /> + </button> </div> + <ClarificationCarouselBody + sortedMessages={sortedMessages} + group={group} + activeIndex={activeIndex} + setActiveIndex={setActiveIndex} + customDrafts={customDrafts} + setCustomDrafts={setCustomDrafts} + /> </div> ); } + +export type { ClarificationAnswer }; diff --git a/apps/web/components/task/chat/clarification-overlay-parts.tsx b/apps/web/components/task/chat/clarification-overlay-parts.tsx new file mode 100644 index 0000000000..761fa890dd --- /dev/null +++ b/apps/web/components/task/chat/clarification-overlay-parts.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { IconCheck, IconCornerDownLeft, IconArrowLeft, IconArrowRight } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import type { ClarificationOption } from "@/lib/types/http"; + +export function stepClassName(active: boolean, answered: boolean): string { + if (active) { + return "bg-blue-500 text-white border-blue-500 shadow-[0_0_0_3px_rgba(59,130,246,0.18)]"; + } + if (answered) { + return "bg-blue-500/20 text-blue-600 border-blue-500/40 dark:text-blue-300"; + } + return "bg-muted text-muted-foreground border-border hover:bg-muted/70"; +} + +type StepperProps = { + total: number; + activeIndex: number; + isAnswered: (index: number) => boolean; + onJump: (index: number) => void; + isSubmitting: boolean; +}; + +export function ClarificationStepper({ + total, + activeIndex, + isAnswered, + onJump, + isSubmitting, +}: StepperProps) { + return ( + <div + className="flex items-center gap-1.5 select-none" + role="tablist" + data-testid="clarification-stepper" + > + {Array.from({ length: total }).map((_, i) => { + const answered = isAnswered(i); + const active = i === activeIndex; + return ( + <div key={i} className="flex items-center"> + <button + type="button" + role="tab" + aria-selected={active} + aria-label={`Question ${i + 1} of ${total}${answered ? " (answered)" : ""}`} + onClick={() => onJump(i)} + disabled={isSubmitting} + data-testid="clarification-step" + data-step-index={String(i)} + data-active={active ? "true" : "false"} + data-answered={answered ? "true" : "false"} + className={cn( + "h-6 w-6 rounded-full text-[11px] font-semibold flex items-center justify-center transition-colors border cursor-pointer", + stepClassName(active, answered), + isSubmitting ? "opacity-60 cursor-not-allowed" : "", + )} + > + {answered && !active ? <IconCheck className="h-3 w-3" /> : i + 1} + </button> + {i < total - 1 && ( + <div + aria-hidden="true" + className={cn("h-px w-5 mx-0.5", isAnswered(i) ? "bg-blue-500/50" : "bg-border")} + /> + )} + </div> + ); + })} + </div> + ); +} + +type OptionListProps = { + options: ClarificationOption[]; + selectedOption: string | null; + isSubmitting: boolean; + onSelectOption: (optionId: string) => void; +}; + +// The agent receives both the selected option id and the custom text when +// both are present, so we never visually grey out one when the other is +// active — that previously created an asymmetric dimming bug where typing +// a custom answer dimmed the options but selecting an option did not dim +// the custom input. +export function ClarificationOptions({ + options, + selectedOption, + isSubmitting, + onSelectOption, +}: OptionListProps) { + return ( + <div className="space-y-1.5"> + {options.map((option, idx) => { + const isSelected = selectedOption === option.option_id; + return ( + <button + key={option.option_id} + type="button" + onClick={() => onSelectOption(option.option_id)} + disabled={isSubmitting} + data-testid="clarification-option" + data-selected={isSelected ? "true" : "false"} + className={cn( + "group flex items-start gap-3 w-full text-left text-sm rounded-lg px-3 py-2 transition-colors border", + isSelected + ? "bg-blue-500/15 border-blue-500/50 text-foreground" + : "border-border hover:bg-muted/40 hover:border-border/80 text-foreground/90", + isSubmitting ? "opacity-60 cursor-not-allowed" : "cursor-pointer", + )} + > + <kbd + aria-hidden="true" + className="select-none font-mono text-[10px] px-1.5 py-0.5 rounded border border-border bg-muted text-muted-foreground leading-none mt-0.5" + > + {idx + 1} + </kbd> + <span className="flex-1 min-w-0"> + <span + data-testid="clarification-option-label" + className="block leading-5 font-medium" + > + {option.label} + </span> + {option.description && ( + <span + data-testid="clarification-option-description" + className="block text-muted-foreground/80 mt-0.5 text-xs leading-snug" + > + {option.description} + </span> + )} + </span> + {isSelected && <IconCheck className="h-3.5 w-3.5 text-blue-500 mt-1 flex-shrink-0" />} + </button> + ); + })} + </div> + ); +} + +type CustomInputProps = { + draft: string; + isSubmitting: boolean; + committedText: string | null; + onChange: (text: string) => void; + onSubmit: (text: string) => void; +}; + +export function ClarificationCustomInput({ + draft, + isSubmitting, + committedText, + onChange, + onSubmit, +}: CustomInputProps) { + return ( + <div className="mt-2.5 flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-border/70 bg-muted/30"> + <span className="text-muted-foreground text-xs">↳</span> + <input + type="text" + placeholder={ + committedText !== null ? "Press Enter to update your answer…" : "Or type a custom answer…" + } + value={draft} + onChange={(e) => onChange(e.target.value)} + disabled={isSubmitting} + data-testid="clarification-input" + className="flex-1 text-sm bg-transparent placeholder:text-muted-foreground/60 focus:outline-none" + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey && draft.trim()) { + e.preventDefault(); + onSubmit(draft.trim()); + } + }} + /> + <kbd + aria-hidden="true" + className="select-none flex items-center gap-1 font-mono text-[10px] px-1.5 py-0.5 rounded border border-border bg-background text-muted-foreground" + > + <IconCornerDownLeft className="h-2.5 w-2.5" /> + Enter + </kbd> + </div> + ); +} + +type CarouselNavProps = { + activeIndex: number; + total: number; + isSubmitting: boolean; + onPrev: () => void; + onNext: () => void; + onSubmit: () => void; + canSubmit: boolean; +}; + +export function ClarificationCarouselNav({ + activeIndex, + total, + isSubmitting, + onPrev, + onNext, + onSubmit, + canSubmit, +}: CarouselNavProps) { + const isFirst = activeIndex === 0; + const isLast = activeIndex === total - 1; + return ( + <div className="flex items-center justify-between gap-2 px-4 pb-3"> + <button + type="button" + onClick={onPrev} + disabled={isFirst || isSubmitting} + data-testid="clarification-prev" + className={cn( + "inline-flex items-center gap-1 text-xs px-2 py-1 rounded border", + isFirst + ? "border-transparent text-muted-foreground/40 cursor-not-allowed" + : "border-border text-foreground/80 hover:bg-muted/50 cursor-pointer", + )} + > + <IconArrowLeft className="h-3 w-3" /> + Back + </button> + {isLast ? ( + <button + type="button" + onClick={onSubmit} + disabled={!canSubmit || isSubmitting} + data-testid="clarification-submit" + className={cn( + "inline-flex items-center gap-1 text-xs px-3 py-1 rounded font-medium", + canSubmit && !isSubmitting + ? "bg-blue-500 text-white hover:bg-blue-500/90 cursor-pointer" + : "bg-muted text-muted-foreground cursor-not-allowed", + )} + > + {isSubmitting ? "Submitting…" : "Submit answers"} + <IconCheck className="h-3 w-3" /> + </button> + ) : ( + <button + type="button" + onClick={onNext} + disabled={isSubmitting} + data-testid="clarification-next" + className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-border text-foreground/80 hover:bg-muted/50 cursor-pointer" + > + Next + <IconArrowRight className="h-3 w-3" /> + </button> + )} + </div> + ); +} diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 1b1f63bbe1..93bef70158 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -191,6 +191,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ agentMessageCount, cancelQueue, pendingClarification, + pendingClarificationGroup, isQueued, queuedMessage, updateQueueContent, @@ -262,7 +263,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ {pendingClarification && !isArchived && ( <div className="flex-shrink-0 border-t border-sky-400/30 bg-card px-1"> <ClarificationInputOverlay - message={pendingClarification} + messages={pendingClarificationGroup} onResolved={handleClarificationResolved} /> </div> diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 022e8b6edb..80b9f866a6 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -265,6 +265,67 @@ export class SessionPage { return this.clarificationOverlay().getByTestId("clarification-option-description"); } + /** All question cards rendered for the active clarification bundle. */ + clarificationQuestionCards(): Locator { + return this.clarificationOverlay().getByTestId("clarification-question-card"); + } + + /** A single question card by its question id (matches metadata.question_id). */ + clarificationQuestionCardById(questionId: string): Locator { + return this.clarificationOverlay().locator( + `[data-testid="clarification-question-card"][data-question-id="${questionId}"]`, + ); + } + + /** Group-wide progress chip "N of M answered" — only shown for bundles >1. */ + clarificationGroupProgress(): Locator { + return this.clarificationOverlay().getByTestId("clarification-group-progress"); + } + + /** Per-question "Question N of M" progress chip. */ + clarificationProgressChips(): Locator { + return this.clarificationOverlay().getByTestId("clarification-progress-chip"); + } + + /** Custom text input within a specific question card. */ + clarificationInputForQuestion(questionId: string): Locator { + return this.clarificationQuestionCardById(questionId).getByTestId("clarification-input"); + } + + /** Option button (by visible label text) inside a specific question card. */ + clarificationOptionForQuestion(questionId: string, text: string): Locator { + return this.clarificationQuestionCardById(questionId) + .getByTestId("clarification-option") + .filter({ hasText: text }); + } + + /** All step buttons in the horizontal stepper. */ + clarificationSteps(): Locator { + return this.clarificationOverlay().getByTestId("clarification-step"); + } + + /** A single step in the stepper, by its 0-based index. */ + clarificationStep(index: number): Locator { + return this.clarificationOverlay().locator( + `[data-testid="clarification-step"][data-step-index="${index}"]`, + ); + } + + /** Back button inside the carousel nav. */ + clarificationPrev(): Locator { + return this.clarificationOverlay().getByTestId("clarification-prev"); + } + + /** Next button inside the carousel nav. */ + clarificationNext(): Locator { + return this.clarificationOverlay().getByTestId("clarification-next"); + } + + /** Final "Submit answers" button (rendered only on the last step). */ + clarificationSubmit(): Locator { + return this.clarificationOverlay().getByTestId("clarification-submit"); + } + /** All visible "Approve / Deny" rows for pending permission requests. */ permissionActionRows(): Locator { return this.chat.getByTestId("permission-action-row"); diff --git a/apps/web/e2e/tests/chat/clarification.spec.ts b/apps/web/e2e/tests/chat/clarification.spec.ts index f62114e23a..ad7be0dd4d 100644 --- a/apps/web/e2e/tests/chat/clarification.spec.ts +++ b/apps/web/e2e/tests/chat/clarification.spec.ts @@ -50,20 +50,14 @@ test.describe("Clarification flow", () => { "clarification", ); - // Wait for clarification overlay to appear (agent calls ask_user_question MCP tool) await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); - - // Verify the question text appears await expect(session.clarificationOverlay()).toContainText("Which database"); - // Click the PostgreSQL option + // Single-question bundles still expose option click → instant resolve. await session.clarificationOption("PostgreSQL").click(); - // Agent receives the answer and completes its turn await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); - - // Verify the answer was reflected in chat - await expect(session.chat).toContainText(/You answered|User selected/); + await expect(session.chat).toContainText(/You answered|selected_option/); }); test("skip clarification", async ({ testPage, apiClient, seedData }) => { @@ -75,13 +69,8 @@ test.describe("Clarification flow", () => { "clarification", ); - // Wait for clarification overlay await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); - - // Click skip button await session.clarificationSkip().click(); - - // Agent should complete its turn await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); }); @@ -98,23 +87,11 @@ test.describe("Clarification flow", () => { "clarification-timeout", ); - // Wait for clarification overlay to appear await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); - - // Wait for agent to time out (5s) and complete its turn. await expect(session.chat).toContainText("timed out", { timeout: 30_000 }); - - // Overlay should auto-close once the canceller marks status=expired. The - // deferred "your response will be sent as a new message" notice must NOT - // appear — we're not keeping a stale interactive prompt around. await expect(session.clarificationOverlay()).not.toBeVisible({ timeout: 10_000 }); await expect(session.clarificationDeferredNotice()).not.toBeVisible(); - - // Chat history should show the question as expired (orange X + label). await expect(session.clarificationExpiredNotice()).toBeVisible(); - - // Chat input returns to the default idle placeholder — not the clarification - // one. Confirms no new turn was triggered by the timeout flow. await expect(session.idleInput()).toBeVisible({ timeout: 10_000 }); }); @@ -132,16 +109,10 @@ test.describe("Clarification flow", () => { ); await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); - - // The mock scenario uses three options, each with a label and description. const labels = session.clarificationOptionLabels(); const descriptions = session.clarificationOptionDescriptions(); await expect(labels).toHaveCount(3); await expect(descriptions).toHaveCount(3); - - // Label and description must be stacked vertically (description's top - // edge sits below the label's bottom edge). Regression guard for the - // old layout that rendered them side-by-side on a single row. const labelBox = await labels.first().boundingBox(); const descriptionBox = await descriptions.first().boundingBox(); if (!labelBox || !descriptionBox) { @@ -153,7 +124,6 @@ test.describe("Clarification flow", () => { test("plan mode + clarification does not leave pointer-events stuck on body", async ({ testPage, }) => { - // Navigate to kanban board and open the task create dialog const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -161,42 +131,346 @@ test.describe("Clarification flow", () => { const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); - // Fill title await testPage.getByTestId("task-title-input").fill("Plan Mode Clarification PE"); - // Fill description with clarification scenario so the agent starts and - // calls the ask_user_question MCP tool. const descriptionInput = dialog.getByRole("textbox", { name: "Write a prompt for the agent...", }); await descriptionInput.click(); await descriptionInput.fill("/e2e:clarification"); - // With a description present, the footer shows a split button with dropdown. - // Open the chevron dropdown and click "Start task in plan mode". await testPage.getByTestId("submit-start-agent-chevron").click(); await testPage.getByTestId("submit-plan-mode").click(); - // Wait for navigation to session page await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); await session.waitForLoad(); - // Wait for clarification overlay to appear (agent calls ask_user_question MCP tool) await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); - // CRITICAL ASSERTION: body must not have pointer-events: none stuck on it. - // Radix Dialog sets pointer-events: none on body when modal. If the task - // create dialog unmounts mid-close (onOpenChange(false) then router.push), - // Radix never finishes cleanup, leaving the page unclickable. const pointerEvents = await testPage.evaluate(() => document.body.style.pointerEvents); expect(pointerEvents).not.toBe("none"); - // Verify the UI is actually interactive by clicking a clarification option await session.clarificationOption("PostgreSQL").click(); - - // Agent receives the answer and completes its turn (plan mode uses different placeholder) await expect(session.planModeInput()).toBeVisible({ timeout: 30_000 }); }); }); + +// Multi-question carousel UX. Each scenario uses the mock-agent's +// `clarification-multi` scenario which sends 3 questions in a single MCP call. +test.describe("Multi-question clarification carousel", () => { + test.describe.configure({ retries: 1 }); + + test("renders stepper with 3 steps and shows the first question", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q stepper", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + + // 3 stepper buttons rendered; the first is active and unanswered. + await expect(session.clarificationSteps()).toHaveCount(3); + await expect(session.clarificationStep(0)).toHaveAttribute("data-active", "true"); + await expect(session.clarificationStep(0)).toHaveAttribute("data-answered", "false"); + await expect(session.clarificationStep(1)).toHaveAttribute("data-active", "false"); + + // Group progress + per-question chip both rendered. + await expect(session.clarificationGroupProgress()).toContainText("0 of 3 answered"); + await expect(session.clarificationOverlay()).toContainText("Question 1 of 3"); + + // Only one card is visible at a time (carousel UX, not stacked). + await expect(session.clarificationQuestionCards()).toHaveCount(1); + await expect(session.clarificationOverlay()).toContainText("Which database"); + }); + + test("answering option auto-advances to next step and marks step as answered", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q advance", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + + // Pick an option on step 1; auto-advances to step 2. + await session.clarificationOption("PostgreSQL").click(); + await expect(session.clarificationStep(1)).toHaveAttribute("data-active", "true"); + await expect(session.clarificationStep(0)).toHaveAttribute("data-answered", "true"); + await expect(session.clarificationGroupProgress()).toContainText("1 of 3 answered"); + await expect(session.clarificationOverlay()).toContainText("Question 2 of 3"); + await expect(session.clarificationOverlay()).toContainText("Which language"); + }); + + test("Back button restores the previous question and the prior selection", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q back", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + + await session.clarificationOption("PostgreSQL").click(); + await expect(session.clarificationStep(1)).toHaveAttribute("data-active", "true"); + + await session.clarificationPrev().click(); + await expect(session.clarificationStep(0)).toHaveAttribute("data-active", "true"); + + // Previous answer is still selected. + const selectedOption = session + .clarificationQuestionCardById("db") + .locator('[data-testid="clarification-option"][data-selected="true"]'); + await expect(selectedOption).toContainText("PostgreSQL"); + }); + + test("clicking a step in the stepper jumps directly to that question", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q jump", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + + await session.clarificationStep(2).click(); + await expect(session.clarificationStep(2)).toHaveAttribute("data-active", "true"); + await expect(session.clarificationOverlay()).toContainText("Question 3 of 3"); + await expect(session.clarificationOverlay()).toContainText("How should we deploy"); + }); + + test("Submit button disabled until every question is answered", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q submit gating", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + + // Jump to last step without answering. + await session.clarificationStep(2).click(); + const submit = session.clarificationSubmit(); + await expect(submit).toBeVisible(); + await expect(submit).toBeDisabled(); + }); + + test("happy path: answer all 3 then Submit unblocks the agent", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q happy path", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + + await session.clarificationOption("PostgreSQL").click(); + await session.clarificationOption("Go").click(); + await session.clarificationOption("Docker").click(); + + // We're on the last step; Submit is enabled. + const submit = session.clarificationSubmit(); + await expect(submit).toBeEnabled(); + await submit.click(); + + await expect(session.clarificationOverlay()).not.toBeVisible({ timeout: 30_000 }); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await expect(session.chat).toContainText("selected_option"); + }); + + test("mix custom text + option selections round-trips", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q mixed", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + + // Q1: option click → advances. + await session.clarificationOption("SQLite").click(); + + // Q2: custom text via Enter → advances. + const langInput = session.clarificationInputForQuestion("language"); + await langInput.click(); + await langInput.fill("Elixir"); + await langInput.press("Enter"); + await expect(session.clarificationGroupProgress()).toContainText("2 of 3 answered"); + + // Q3: option click; submit batch. + await session.clarificationOption("Bare metal").click(); + await session.clarificationSubmit().click(); + + await expect(session.clarificationOverlay()).not.toBeVisible({ timeout: 30_000 }); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await expect(session.chat).toContainText("Elixir"); + }); + + test("revising an answer via stepper jump updates the response", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q revise", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + + await session.clarificationOption("PostgreSQL").click(); + await session.clarificationOption("Go").click(); + await session.clarificationOption("Docker").click(); + + // Jump back to Q1 and pick a different option. + await session.clarificationStep(0).click(); + await session.clarificationOption("MongoDB").click(); + // Auto-advance brings us to Q2; jump to last step to submit. + await session.clarificationStep(2).click(); + + await session.clarificationSubmit().click(); + await expect(session.clarificationOverlay()).not.toBeVisible({ timeout: 30_000 }); + await expect(session.chat).toContainText("MongoDB"); + }); + + test("skip rejects the entire bundle from any step", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q skip mid", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + + // Answer the first one then skip from step 2. + await session.clarificationOption("PostgreSQL").click(); + await session.clarificationSkip().click(); + + await expect(session.clarificationOverlay()).not.toBeVisible({ timeout: 30_000 }); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await expect(session.chat).toContainText("rejected"); + }); + + test("number key shortcuts pick options on the active step", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q kbd", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + + // Press "1" → first option of the first question, auto-advance. + await testPage.keyboard.press("1"); + await expect(session.clarificationStep(1)).toHaveAttribute("data-active", "true"); + await expect(session.clarificationGroupProgress()).toContainText("1 of 3 answered"); + + // Press "2" → second option of Q2. + await testPage.keyboard.press("2"); + await expect(session.clarificationStep(2)).toHaveAttribute("data-active", "true"); + await expect(session.clarificationGroupProgress()).toContainText("2 of 3 answered"); + + // Press "1" → first option of Q3 (last step, no advance). + await testPage.keyboard.press("1"); + await expect(session.clarificationGroupProgress()).toContainText("3 of 3 answered"); + + // ArrowRight on the last step with all answered → submits. + await testPage.keyboard.press("ArrowRight"); + await expect(session.clarificationOverlay()).not.toBeVisible({ timeout: 30_000 }); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + }); + + test("Esc skips the entire bundle from anywhere in the carousel", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q esc", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + + await session.clarificationStep(1).click(); + await testPage.keyboard.press("Escape"); + + await expect(session.clarificationOverlay()).not.toBeVisible({ timeout: 30_000 }); + await expect(session.chat).toContainText("rejected"); + }); + + test("Back button is disabled on the first step", async ({ testPage, apiClient, seedData }) => { + const session = await seedClarificationTask( + testPage, + apiClient, + seedData, + "Multi-q back disabled", + "clarification-multi", + ); + + await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); + await expect(session.clarificationPrev()).toBeDisabled(); + }); +}); diff --git a/apps/web/hooks/domains/session/use-clarification-group.test.ts b/apps/web/hooks/domains/session/use-clarification-group.test.ts new file mode 100644 index 0000000000..47d5123c30 --- /dev/null +++ b/apps/web/hooks/domains/session/use-clarification-group.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import type { Message } from "@/lib/types/http"; + +vi.mock("@/lib/config", () => ({ + getBackendConfig: () => ({ apiBaseUrl: "https://api.test" }), +})); + +import { useClarificationGroup } from "./use-clarification-group"; + +function clarMessage(opts: { + id: string; + pendingId: string; + questionId: string; + index: number; + total: number; +}): Message { + return { + id: opts.id, + session_id: "s1", + task_id: "t1", + author_type: "agent", + content: "Q", + type: "clarification_request", + created_at: "2026-05-04T00:00:00Z", + metadata: { + pending_id: opts.pendingId, + question_id: opts.questionId, + question_index: opts.index, + question_total: opts.total, + status: "pending", + question: { id: opts.questionId, prompt: "Q?" }, + }, + }; +} + +const fetchMock = vi.fn(); + +function setupFetchMock() { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; +} + +describe("useClarificationGroup — derived state", () => { + beforeEach(setupFetchMock); + + it("derives total + pendingId from the message bundle", () => { + const msgs = [ + clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 2 }), + clarMessage({ id: "m2", pendingId: "p1", questionId: "q2", index: 1, total: 2 }), + ]; + const { result } = renderHook(() => useClarificationGroup(msgs)); + expect(result.current.pendingId).toBe("p1"); + expect(result.current.total).toBe(2); + expect(result.current.answeredCount).toBe(0); + }); + + it("returns null pendingId when there are no messages", () => { + const { result } = renderHook(() => useClarificationGroup([])); + expect(result.current.pendingId).toBeNull(); + expect(result.current.total).toBe(0); + }); + + it("recordAnswer updates local state without posting", async () => { + const msgs = [ + clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 2 }), + clarMessage({ id: "m2", pendingId: "p1", questionId: "q2", index: 1, total: 2 }), + ]; + const { result } = renderHook(() => useClarificationGroup(msgs)); + + await act(async () => { + result.current.recordAnswer("q1", { question_id: "q1", selected_options: ["o1"] }); + }); + + expect(result.current.answers["q1"]?.selected_options).toEqual(["o1"]); + expect(result.current.answeredCount).toBe(1); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("useClarificationGroup — submit + skip", () => { + beforeEach(setupFetchMock); + + it("submitCollected POSTs the batch only when every question has an answer", async () => { + const msgs = [ + clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 2 }), + clarMessage({ id: "m2", pendingId: "p1", questionId: "q2", index: 1, total: 2 }), + ]; + const { result } = renderHook(() => useClarificationGroup(msgs)); + + await act(async () => { + result.current.recordAnswer("q1", { question_id: "q1", selected_options: ["o1"] }); + }); + await act(async () => { + await result.current.submitCollected(); + }); + expect(fetchMock).not.toHaveBeenCalled(); + + await act(async () => { + result.current.recordAnswer("q2", { question_id: "q2", custom_text: "free" }); + }); + await act(async () => { + await result.current.submitCollected(); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe("https://api.test/api/v1/clarification/p1/respond"); + expect(JSON.parse(String(init.body))).toEqual({ + answers: [ + { question_id: "q1", selected_options: ["o1"] }, + { question_id: "q2", custom_text: "free" }, + ], + rejected: false, + }); + expect(result.current.submitState).toBe("ok"); + }); + + it("submitCollected with override merges the freshly recorded answer", async () => { + const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })]; + const { result } = renderHook(() => useClarificationGroup(msgs)); + + await act(async () => { + await result.current.submitCollected({ + q1: { question_id: "q1", selected_options: ["o1"] }, + }); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.current.submitState).toBe("ok"); + }); + + it("skipAll POSTs rejected=true with the supplied reason", async () => { + const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })]; + const { result } = renderHook(() => useClarificationGroup(msgs)); + + await act(async () => { + await result.current.skipAll("Too vague"); + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(String(init.body))).toEqual({ + rejected: true, + reject_reason: "Too vague", + }); + expect(result.current.submitState).toBe("ok"); + }); + + it("submitState transitions to 'error' when fetch returns non-OK", async () => { + fetchMock.mockResolvedValueOnce(new Response("nope", { status: 400 })); + const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })]; + const { result } = renderHook(() => useClarificationGroup(msgs)); + + await act(async () => { + await result.current.submitCollected({ + q1: { question_id: "q1", selected_options: ["o1"] }, + }); + }); + expect(result.current.submitState).toBe("error"); + }); + + // 409 Conflict means a duplicate submit landed after the first one already + // resolved the bundle on the backend. Treat it as success so the overlay + // closes instead of getting stuck in an unrecoverable error state. + it("submitState transitions to 'ok' on 409 (duplicate submit)", async () => { + fetchMock.mockResolvedValueOnce(new Response("conflict", { status: 409 })); + const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })]; + const { result } = renderHook(() => useClarificationGroup(msgs)); + + await act(async () => { + await result.current.submitCollected({ + q1: { question_id: "q1", selected_options: ["o1"] }, + }); + }); + expect(result.current.submitState).toBe("ok"); + }); + + it("skipAll transitions to 'ok' on 409 too", async () => { + fetchMock.mockResolvedValueOnce(new Response("conflict", { status: 409 })); + const msgs = [clarMessage({ id: "m1", pendingId: "p1", questionId: "q1", index: 0, total: 1 })]; + const { result } = renderHook(() => useClarificationGroup(msgs)); + + await act(async () => { + await result.current.skipAll(); + }); + expect(result.current.submitState).toBe("ok"); + }); +}); diff --git a/apps/web/hooks/domains/session/use-clarification-group.ts b/apps/web/hooks/domains/session/use-clarification-group.ts new file mode 100644 index 0000000000..2752dc29cc --- /dev/null +++ b/apps/web/hooks/domains/session/use-clarification-group.ts @@ -0,0 +1,153 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ClarificationAnswer, ClarificationRequestMetadata, Message } from "@/lib/types/http"; +import { getBackendConfig } from "@/lib/config"; + +type SubmitState = "idle" | "submitting" | "ok" | "error"; + +export type ClarificationGroupApi = { + pendingId: string | null; + total: number; + answeredCount: number; + answers: Record<string, ClarificationAnswer>; + submitState: SubmitState; + recordAnswer: (questionId: string, answer: ClarificationAnswer) => void; + // Submits every recorded answer in a single batch. An optional `override` + // map is merged into the current answers right before the POST so callers + // can safely auto-submit immediately after recording an answer (the React + // state update is async, so the hook's stored map may not include the + // freshly recorded answer yet). + submitCollected: (override?: Record<string, ClarificationAnswer>) => Promise<void>; + skipAll: (reason?: string) => Promise<void>; +}; + +function questionIdsFromMessages(messages: readonly Message[]): string[] { + return messages + .slice() + .sort((a, b) => { + const ai = (a.metadata as ClarificationRequestMetadata | undefined)?.question_index ?? 0; + const bi = (b.metadata as ClarificationRequestMetadata | undefined)?.question_index ?? 0; + return ai - bi; + }) + .map((m) => { + const meta = m.metadata as ClarificationRequestMetadata | undefined; + return meta?.question_id ?? meta?.question?.id ?? ""; + }) + .filter(Boolean); +} + +async function postClarificationBatch( + pendingId: string, + answers: ClarificationAnswer[], +): Promise<SubmitState> { + const { apiBaseUrl } = getBackendConfig(); + const res = await fetch(`${apiBaseUrl}/api/v1/clarification/${pendingId}/respond`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ answers, rejected: false }), + }); + if (res.ok) return "ok"; + // 409 Conflict means a duplicate submit (the user clicked Submit twice in + // quick succession). Treat it as success — the first submit already won + // and resolved the bundle on the backend, so the overlay should close. + if (res.status === 409) return "ok"; + console.error("Clarification submit failed:", res.status, res.statusText); + return "error"; +} + +async function postClarificationSkip(pendingId: string, reason: string): Promise<SubmitState> { + const { apiBaseUrl } = getBackendConfig(); + const res = await fetch(`${apiBaseUrl}/api/v1/clarification/${pendingId}/respond`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rejected: true, reject_reason: reason }), + }); + if (res.ok) return "ok"; + if (res.status === 409) return "ok"; + console.error("Clarification skip failed:", res.status, res.statusText); + return "error"; +} + +// useClarificationGroup tracks the per-question answers for a multi-question +// clarification bundle. The carousel UI owns navigation; this hook just stores +// the local answer state and exposes: +// - recordAnswer: write a single question's answer to local state +// - submitCollected: POST every recorded answer in one batch (called from +// the explicit "Submit answers" button on the last step) +// - skipAll: reject the entire bundle. +// Decision A is preserved (per-question commit, batched on the wire) but the +// final submit is no longer implicit — the user clicks "Submit answers" or +// presses ArrowRight on the last step. +export function useClarificationGroup( + messages: readonly Message[] | null | undefined, +): ClarificationGroupApi { + const [answers, setAnswers] = useState<Record<string, ClarificationAnswer>>({}); + const answersRef = useRef(answers); + useEffect(() => { + answersRef.current = answers; + }, [answers]); + const [submitState, setSubmitState] = useState<SubmitState>("idle"); + + const pendingId = useMemo(() => { + if (!messages || messages.length === 0) return null; + const meta = messages[0].metadata as ClarificationRequestMetadata | undefined; + return meta?.pending_id ?? null; + }, [messages]); + + const questionIds = useMemo( + () => (messages ? questionIdsFromMessages(messages) : []), + [messages], + ); + const total = questionIds.length; + const answeredCount = Object.keys(answers).filter((id) => questionIds.includes(id)).length; + + const recordAnswer = useCallback((questionId: string, answer: ClarificationAnswer) => { + setAnswers((prev) => ({ ...prev, [questionId]: answer })); + }, []); + + const submitCollected = useCallback( + async (override?: Record<string, ClarificationAnswer>) => { + if (!pendingId) return; + const current = { ...answersRef.current, ...(override ?? {}) }; + const haveAll = questionIds.every((id) => Boolean(current[id])); + if (!haveAll) return; + setSubmitState("submitting"); + const ordered = questionIds + .map((id) => current[id]) + .filter((a): a is ClarificationAnswer => Boolean(a)); + try { + setSubmitState(await postClarificationBatch(pendingId, ordered)); + } catch (err) { + console.error("Clarification submit threw:", err); + setSubmitState("error"); + } + }, + [pendingId, questionIds], + ); + + const skipAll = useCallback( + async (reason?: string) => { + if (!pendingId) return; + setSubmitState("submitting"); + try { + setSubmitState(await postClarificationSkip(pendingId, reason ?? "User skipped")); + } catch (err) { + console.error("Clarification skip threw:", err); + setSubmitState("error"); + } + }, + [pendingId], + ); + + return { + pendingId, + total, + answeredCount, + answers, + submitState, + recordAnswer, + submitCollected, + skipAll, + }; +} diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts index 8d75f6e19a..22b5b36cce 100644 --- a/apps/web/hooks/use-processed-messages.ts +++ b/apps/web/hooks/use-processed-messages.ts @@ -1,7 +1,10 @@ import { useMemo } from "react"; import type { Message, ClarificationRequestMetadata, MessageType } from "@/lib/types/http"; import type { ToolCallMetadata } from "@/components/task/chat/types"; -import { findPendingClarification } from "@/lib/utils/pending-clarification"; +import { + findPendingClarification, + findPendingClarificationGroup, +} from "@/lib/utils/pending-clarification"; const ACTIVITY_MESSAGE_TYPES: Set<MessageType> = new Set([ "thinking", @@ -247,6 +250,10 @@ export function useProcessedMessages( [childrenByParentToolCallId], ); const pendingClarification = useMemo(() => findPendingClarification(messages), [messages]); + const pendingClarificationGroup = useMemo( + () => findPendingClarificationGroup(messages), + [messages], + ); const visibleMessages = useMemo( () => filterVisibleMessages(messages, toolCallIds, subagentChildIds), @@ -323,5 +330,6 @@ export function useProcessedMessages( todoItems, agentMessageCount, pendingClarification, + pendingClarificationGroup, }; } diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index 3e3e6b8217..c6950832c5 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -230,11 +230,18 @@ export type ClarificationQuestion = { options: ClarificationOption[]; }; +// Each per-question chat message carries its own metadata. For multi-question +// bundles, every message in the group shares the same pending_id and includes +// question_index/question_total so the renderer can show "Question 2 of 3" +// progress chips. export type ClarificationRequestMetadata = { pending_id: string; session_id: string; task_id?: string; question: ClarificationQuestion; + question_id?: string; + question_index?: number; + question_total?: number; context?: string; status?: "pending" | "answered" | "rejected" | "expired"; response?: ClarificationAnswer; diff --git a/apps/web/lib/utils/pending-clarification.test.ts b/apps/web/lib/utils/pending-clarification.test.ts index 15568a63cf..95b180cfd5 100644 --- a/apps/web/lib/utils/pending-clarification.test.ts +++ b/apps/web/lib/utils/pending-clarification.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Message } from "@/lib/types/http"; -import { hasPendingClarification } from "./pending-clarification"; +import { findPendingClarificationGroup, hasPendingClarification } from "./pending-clarification"; function message(overrides: Partial<Message>): Message { return { @@ -62,3 +62,73 @@ describe("hasPendingClarification", () => { ).toBe(false); }); }); + +describe("findPendingClarificationGroup", () => { + it("returns empty array when there are no messages", () => { + expect(findPendingClarificationGroup([])).toEqual([]); + expect(findPendingClarificationGroup(null)).toEqual([]); + expect(findPendingClarificationGroup(undefined)).toEqual([]); + }); + + it("returns empty array when there is no pending clarification", () => { + expect( + findPendingClarificationGroup([ + message({ type: "clarification_request", metadata: { status: "answered" } }), + ]), + ).toEqual([]); + }); + + it("returns the single message when no pending_id is set", () => { + const msg = message({ type: "clarification_request" }); + expect(findPendingClarificationGroup([msg])).toEqual([msg]); + }); + + it("returns every message that shares the latest pending_id", () => { + const a = message({ + id: "a", + type: "clarification_request", + metadata: { pending_id: "p1", question_total: 3, question_index: 0, status: "pending" }, + }); + const b = message({ + id: "b", + type: "clarification_request", + metadata: { pending_id: "p1", question_total: 3, question_index: 1, status: "pending" }, + }); + const c = message({ + id: "c", + type: "clarification_request", + metadata: { pending_id: "p1", question_total: 3, question_index: 2, status: "pending" }, + }); + const noise = message({ id: "n", type: "message" }); + expect(findPendingClarificationGroup([noise, a, b, c]).map((m) => m.id)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("gates on question_total — returns [] when not all messages have arrived", () => { + // Backend declared 3 questions but only 1 has streamed in; the overlay + // should stay hidden until the remaining 2 land. + const a = message({ + id: "a", + type: "clarification_request", + metadata: { pending_id: "p1", question_total: 3, question_index: 0, status: "pending" }, + }); + expect(findPendingClarificationGroup([a])).toEqual([]); + }); + + it("ignores messages from a different pending bundle", () => { + const old = message({ + id: "old", + type: "clarification_request", + metadata: { pending_id: "p0", status: "answered" }, + }); + const a = message({ + id: "a", + type: "clarification_request", + metadata: { pending_id: "p1", question_total: 1, status: "pending" }, + }); + expect(findPendingClarificationGroup([old, a]).map((m) => m.id)).toEqual(["a"]); + }); +}); diff --git a/apps/web/lib/utils/pending-clarification.ts b/apps/web/lib/utils/pending-clarification.ts index f8932d6e69..129af13fad 100644 --- a/apps/web/lib/utils/pending-clarification.ts +++ b/apps/web/lib/utils/pending-clarification.ts @@ -14,6 +14,35 @@ export function findPendingClarification(messages?: readonly Message[] | null): return null; } +// findPendingClarificationGroup returns every clarification_request message +// that shares the latest pending message's pending_id, ordered by chat position. +// Multi-question bundles emit one message per question; the chat panel uses this +// list to render every pending question card together. +// +// Gates on `question_total` from metadata: returns an empty array until the +// number of messages received equals the expected bundle size. This prevents +// a user from acting on a partially-arrived bundle (clicking an option before +// the rest of the N messages have been streamed in via the WS), which would +// otherwise trigger a 400 from the backend's all-required gate. +export function findPendingClarificationGroup(messages?: readonly Message[] | null): Message[] { + if (!messages) return []; + const last = findPendingClarification(messages); + if (!last) return []; + const meta = last.metadata as ClarificationRequestMetadata | undefined; + const pendingID = meta?.pending_id; + if (!pendingID) return [last]; + const group = messages.filter((m) => { + if (m.type !== "clarification_request") return false; + const mMeta = m.metadata as ClarificationRequestMetadata | undefined; + return mMeta?.pending_id === pendingID; + }); + const expectedTotal = meta?.question_total; + if (typeof expectedTotal === "number" && expectedTotal > 0 && group.length < expectedTotal) { + return []; + } + return group; +} + export function hasPendingClarification(messages?: readonly Message[] | null): boolean { return findPendingClarification(messages) !== null; } From 5f4b3ec0c008550bebca0197689e331823469ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= <joao.salavisa@gmail.com> Date: Tue, 5 May 2026 21:47:35 +0100 Subject: [PATCH 13/33] feat(web): allow renaming quick-chat tabs locally (#830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .../quick-chat/quick-chat-modal.test.ts | 31 ++++- .../quick-chat/quick-chat-modal.tsx | 39 +++--- .../quick-chat/quick-chat-tab-item.test.tsx | 84 +++++++++++++ .../quick-chat/quick-chat-tab-item.tsx | 116 ++++++++++++++++++ .../quick-chat/use-quick-chat-modal.ts | 9 ++ apps/web/lib/local-storage.ts | 31 +++++ apps/web/lib/state/hydration/hydrator.test.ts | 73 +++++++++++ apps/web/lib/state/hydration/hydrator.ts | 12 +- apps/web/lib/state/slices/ui/ui-slice.ts | 9 +- 9 files changed, 374 insertions(+), 30 deletions(-) create mode 100644 apps/web/components/quick-chat/quick-chat-tab-item.test.tsx create mode 100644 apps/web/components/quick-chat/quick-chat-tab-item.tsx create mode 100644 apps/web/lib/state/hydration/hydrator.test.ts diff --git a/apps/web/components/quick-chat/quick-chat-modal.test.ts b/apps/web/components/quick-chat/quick-chat-modal.test.ts index 770880d1fe..54b0e8ed18 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.test.ts +++ b/apps/web/components/quick-chat/quick-chat-modal.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { createUISlice } from "@/lib/state/slices/ui/ui-slice"; +import { getStoredQuickChatNames } from "@/lib/local-storage"; import type { UISlice } from "@/lib/state/slices/ui/types"; const SESSION_ID = "sess-1"; @@ -48,3 +49,31 @@ describe("openQuickChat agentProfileId persistence", () => { expect(findSession(store)?.agentProfileId).toBe(PROFILE_ID); }); }); + +describe("renameQuickChatSession local persistence", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("updates the session name in the store", () => { + const store = makeStore(); + store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID); + store.getState().renameQuickChatSession(SESSION_ID, "My renamed chat"); + expect(findSession(store)?.name).toBe("My renamed chat"); + }); + + it("persists the rename to localStorage so it survives reload", () => { + const store = makeStore(); + store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID); + store.getState().renameQuickChatSession(SESSION_ID, "Persisted name"); + expect(getStoredQuickChatNames()).toEqual({ [SESSION_ID]: "Persisted name" }); + }); + + it("does not write to storage when the session does not exist in state", () => { + const store = makeStore(); + expect(() => + store.getState().renameQuickChatSession("ghost-session", "Whatever"), + ).not.toThrow(); + expect(getStoredQuickChatNames()).toEqual({}); + }); +}); diff --git a/apps/web/components/quick-chat/quick-chat-modal.tsx b/apps/web/components/quick-chat/quick-chat-modal.tsx index 23c25d60fc..22cecbe515 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.tsx +++ b/apps/web/components/quick-chat/quick-chat-modal.tsx @@ -4,11 +4,12 @@ import { memo } from "react"; import { useShallow } from "zustand/react/shallow"; import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog"; import { Button } from "@kandev/ui/button"; -import { IconLoader2, IconMessageCircle, IconPlus, IconX } from "@tabler/icons-react"; +import { IconLoader2, IconMessageCircle, IconPlus } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { PassthroughTerminal } from "@/components/task/passthrough-terminal"; import { QuickChatContent } from "./quick-chat-content"; import { QuickChatDeleteDialog } from "./quick-chat-delete-dialog"; +import { QuickChatTabItem } from "./quick-chat-tab-item"; import { useQuickChatModal } from "./use-quick-chat-modal"; type QuickChatModalProps = { @@ -21,12 +22,14 @@ function QuickChatTabs({ onTabChange, onTabClose, onNewChat, + onRename, }: { sessions: Array<{ sessionId: string; workspaceId: string; name?: string }>; activeSessionId: string; onTabChange: (sessionId: string) => void; onTabClose: (sessionId: string) => void; onNewChat: () => void; + onRename: (sessionId: string, name: string) => void; }) { if (sessions.length === 0) return null; @@ -34,34 +37,18 @@ function QuickChatTabs({ <div className="flex items-center gap-1 px-2 py-1 border-b bg-muted/20"> <div className="flex items-center gap-1 overflow-x-auto flex-1 scrollbar-hide"> {sessions.map((s, index) => { - const isActive = s.sessionId === activeSessionId; // Show "New Chat" for empty session IDs (agent picker tabs) const tabName = s.sessionId === "" ? "New Chat" : s.name || `Chat ${index + 1}`; return ( - <div + <QuickChatTabItem key={s.sessionId || `new-${index}`} - className={`flex items-center gap-1 rounded transition-colors whitespace-nowrap ${ - isActive - ? "bg-background text-foreground shadow-sm" - : "text-muted-foreground hover:bg-muted" - }`} - > - <button - type="button" - onClick={() => onTabChange(s.sessionId)} - className="flex items-center px-2.5 py-1 text-xs cursor-pointer" - > - <span className="truncate max-w-[100px]">{tabName}</span> - </button> - <button - type="button" - aria-label={`Close ${tabName}`} - className="p-1 cursor-pointer opacity-60 hover:opacity-100" - onClick={() => onTabClose(s.sessionId)} - > - <IconX className="h-3 w-3" /> - </button> - </div> + name={tabName} + isActive={s.sessionId === activeSessionId} + isRenameable={s.sessionId !== ""} + onActivate={() => onTabChange(s.sessionId)} + onClose={() => onTabClose(s.sessionId)} + onRename={(name) => onRename(s.sessionId, name)} + /> ); })} </div> @@ -172,6 +159,7 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic handleSelectAgent, handleCloseTab, handleConfirmClose, + handleRename, } = useQuickChatModal(workspaceId); return ( @@ -189,6 +177,7 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic onTabChange={setActiveQuickChatSession} onTabClose={handleCloseTab} onNewChat={handleNewChat} + onRename={handleRename} /> {activeSessionId && !activeSessionNeedsAgent && ( <QuickChatSessionView sessionId={activeSessionId} /> diff --git a/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx b/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx new file mode 100644 index 0000000000..9f438668d6 --- /dev/null +++ b/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx @@ -0,0 +1,84 @@ +import { afterEach, describe, it, expect, vi } from "vitest"; +import { cleanup, render, fireEvent } from "@testing-library/react"; +import { QuickChatTabItem } from "./quick-chat-tab-item"; + +afterEach(cleanup); + +function makeProps(overrides: Partial<Parameters<typeof QuickChatTabItem>[0]> = {}) { + return { + name: "Original", + isActive: true, + isRenameable: true, + onActivate: vi.fn(), + onClose: vi.fn(), + onRename: vi.fn(), + ...overrides, + }; +} + +const RENAME_LABEL = "Rename chat"; + +function startEditing(label: HTMLElement) { + fireEvent.doubleClick(label); +} + +describe("QuickChatTabItem rename", () => { + it("commits the rename on Enter, calling onRename exactly once", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(<QuickChatTabItem {...makeProps({ onRename })} />); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: "New name" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onRename).toHaveBeenCalledTimes(1); + expect(onRename).toHaveBeenCalledWith("New name"); + }); + + it("discards the draft on Escape — onRename is NOT called even after blur fires on unmount", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(<QuickChatTabItem {...makeProps({ onRename })} />); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: "Should be discarded" } }); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(onRename).not.toHaveBeenCalled(); + }); + + it("does not call onRename when the trimmed draft equals the original name", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(<QuickChatTabItem {...makeProps({ onRename })} />); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: " Original " } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onRename).not.toHaveBeenCalled(); + }); + + it("ignores Enter while IME composition is active", () => { + const onRename = vi.fn(); + const { getByText, getByLabelText } = render(<QuickChatTabItem {...makeProps({ onRename })} />); + startEditing(getByText("Original")); + + const input = getByLabelText(RENAME_LABEL) as HTMLInputElement; + fireEvent.change(input, { target: { value: "Composing candidate" } }); + // Enter pressed while IME is composing — should be a no-op (IME confirms candidate). + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + + expect(onRename).not.toHaveBeenCalled(); + }); + + it("does not enter edit mode when isRenameable is false", () => { + const { getByText, queryByLabelText } = render( + <QuickChatTabItem {...makeProps({ isRenameable: false })} />, + ); + startEditing(getByText("Original")); + + expect(queryByLabelText(RENAME_LABEL)).toBeNull(); + }); +}); diff --git a/apps/web/components/quick-chat/quick-chat-tab-item.tsx b/apps/web/components/quick-chat/quick-chat-tab-item.tsx new file mode 100644 index 0000000000..377a627c8d --- /dev/null +++ b/apps/web/components/quick-chat/quick-chat-tab-item.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { IconX } from "@tabler/icons-react"; + +type QuickChatTabItemProps = { + name: string; + isActive: boolean; + isRenameable: boolean; + onActivate: () => void; + onClose: () => void; + onRename: (name: string) => void; +}; + +/** Tab in the quick-chat modal. Double-click the label to rename (local-only). */ +export const QuickChatTabItem = memo(function QuickChatTabItem({ + name, + isActive, + isRenameable, + onActivate, + onClose, + onRename, +}: QuickChatTabItemProps) { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(name); + const inputRef = useRef<HTMLInputElement>(null); + // Both Enter and Escape close edit mode by blurring the input so onBlur is + // the single commit path. Escape additionally sets this ref so commit knows + // to skip the rename — the blur fires synchronously with the typed draft + // still in the closure, and we'd otherwise rename to whatever the user typed. + const cancelledRef = useRef(false); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const commit = useCallback(() => { + if (cancelledRef.current) { + cancelledRef.current = false; + setIsEditing(false); + return; + } + const trimmed = draft.trim(); + if (trimmed && trimmed !== name) onRename(trimmed); + setIsEditing(false); + }, [draft, name, onRename]); + + const cancel = useCallback(() => { + cancelledRef.current = true; + setDraft(name); + inputRef.current?.blur(); + }, [name]); + + const handleStartEdit = useCallback(() => { + if (!isRenameable) return; + setDraft(name); + setIsEditing(true); + }, [isRenameable, name]); + + return ( + <div + className={`flex items-center gap-1 rounded transition-colors whitespace-nowrap ${ + isActive + ? "bg-background text-foreground shadow-sm" + : "text-muted-foreground hover:bg-muted" + }`} + > + {isEditing ? ( + <input + ref={inputRef} + aria-label="Rename chat" + value={draft} + onChange={(e) => setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + // IME composition uses Enter to confirm a candidate; let the IME + // handle it instead of committing the rename. + if (e.nativeEvent.isComposing) return; + if (e.key === "Enter") { + e.preventDefault(); + // Trigger blur instead of calling commit() directly: the input + // unmount on setIsEditing(false) would otherwise fire onBlur and + // call commit() a second time. + inputRef.current?.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } + }} + className="px-2.5 py-1 text-xs bg-background border border-input rounded outline-none focus:ring-1 focus:ring-ring max-w-[160px]" + /> + ) : ( + <button + type="button" + onClick={onActivate} + onDoubleClick={handleStartEdit} + title={isRenameable ? "Double-click to rename" : undefined} + className="flex items-center px-2.5 py-1 text-xs cursor-pointer" + > + <span className="truncate max-w-[160px]">{name}</span> + </button> + )} + <button + type="button" + aria-label={`Close ${name}`} + className="p-1 cursor-pointer opacity-60 hover:opacity-100" + onClick={onClose} + > + <IconX className="h-3 w-3" /> + </button> + </div> + ); +}); diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.ts b/apps/web/components/quick-chat/use-quick-chat-modal.ts index b59ccff41a..a2da0e70f5 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.ts @@ -155,6 +155,14 @@ export function useQuickChatModal(workspaceId: string) { [reset, store], ); + const handleRename = useCallback( + (sessionId: string, name: string) => { + if (!sessionId) return; + store.renameQuickChatSession(sessionId, name); + }, + [store], + ); + const handleConfirmClose = useCallback(async () => { if (!sessionToClose) return; const sessionId = sessionToClose; @@ -188,5 +196,6 @@ export function useQuickChatModal(workspaceId: string) { handleSelectAgent, handleCloseTab, handleConfirmClose, + handleRename, }; } diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index ba7277ea1d..e24771bb47 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -642,6 +642,7 @@ export function cleanupTaskStorage( // Session-keyed storage — drafts, files panel state, scroll, etc. for (const sessionId of sessionIds) { + removeStoredQuickChatName(sessionId); removeSessionStorage(`${PR_PANEL_OFFERED_PREFIX}${sessionId}`); removeSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`); removeSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`); @@ -658,6 +659,36 @@ export function cleanupTaskStorage( } } +// --- Quick chat custom names (localStorage, global) --- +// +// Local-only: not persisted to the backend. Keyed by sessionId so the rename +// follows the underlying chat across reloads but vanishes if the user clears +// browser storage. The backend task title remains the auto-generated name. + +const QUICK_CHAT_NAMES_KEY = "kandev.quickChat.names"; + +export function getStoredQuickChatNames(): Record<string, string> { + const raw = getLocalStorage<Record<string, string>>(QUICK_CHAT_NAMES_KEY, {}); + if (!raw || typeof raw !== "object") return {}; + return raw; +} + +export function setStoredQuickChatName(sessionId: string, name: string): void { + if (!sessionId) return; + const all = getStoredQuickChatNames(); + if (name) all[sessionId] = name; + else delete all[sessionId]; + setLocalStorage(QUICK_CHAT_NAMES_KEY, all); +} + +export function removeStoredQuickChatName(sessionId: string): void { + if (!sessionId) return; + const all = getStoredQuickChatNames(); + if (!(sessionId in all)) return; + delete all[sessionId]; + setLocalStorage(QUICK_CHAT_NAMES_KEY, all); +} + // --- Sidebar filter views (localStorage, global) --- const SIDEBAR_VIEWS_KEY = "kandev.sidebar.views"; diff --git a/apps/web/lib/state/hydration/hydrator.test.ts b/apps/web/lib/state/hydration/hydrator.test.ts new file mode 100644 index 0000000000..170f64aa34 --- /dev/null +++ b/apps/web/lib/state/hydration/hydrator.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { produce } from "immer"; +import type { Draft } from "immer"; +import { hydrateUI } from "./hydrator"; +import { defaultUIState } from "@/lib/state/slices/ui/ui-slice"; +import type { AppState } from "@/lib/state/store"; + +function makeDraft(): AppState { + // hydrateUI only touches UI-slice fields; an empty object cast satisfies + // the rest without dragging the full AppState shape into this test. + return { ...defaultUIState } as unknown as AppState; +} + +describe("hydrateUI — quick chat name overlay", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("overlays a locally-renamed name onto the SSR-provided session name", () => { + window.localStorage.setItem( + "kandev.quickChat.names", + JSON.stringify({ "sess-1": "My custom name" }), + ); + + const result = produce(makeDraft(), (draft: Draft<AppState>) => { + hydrateUI(draft, { + quickChat: { + isOpen: false, + activeSessionId: null, + sessions: [{ sessionId: "sess-1", workspaceId: "ws-1", name: "Agent A - Chat 1" }], + }, + }); + }); + + expect(result.quickChat.sessions[0].name).toBe("My custom name"); + }); + + it("keeps the SSR-provided name when no local rename exists", () => { + const result = produce(makeDraft(), (draft: Draft<AppState>) => { + hydrateUI(draft, { + quickChat: { + isOpen: false, + activeSessionId: null, + sessions: [{ sessionId: "sess-2", workspaceId: "ws-1", name: "Agent A - Chat 1" }], + }, + }); + }); + + expect(result.quickChat.sessions[0].name).toBe("Agent A - Chat 1"); + }); + + it("only overlays sessions that have a stored rename, leaving siblings untouched", () => { + window.localStorage.setItem( + "kandev.quickChat.names", + JSON.stringify({ "sess-a": "Renamed A" }), + ); + + const result = produce(makeDraft(), (draft: Draft<AppState>) => { + hydrateUI(draft, { + quickChat: { + isOpen: false, + activeSessionId: null, + sessions: [ + { sessionId: "sess-a", workspaceId: "ws-1", name: "Original A" }, + { sessionId: "sess-b", workspaceId: "ws-1", name: "Original B" }, + ], + }, + }); + }); + + expect(result.quickChat.sessions.map((s) => s.name)).toEqual(["Renamed A", "Original B"]); + }); +}); diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index 6df1d4f0ba..05b8e3354a 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -1,6 +1,7 @@ import type { Draft } from "immer"; import type { AppState } from "../store"; import { migrateView } from "../slices/ui/ui-slice"; +import { getStoredQuickChatNames } from "@/lib/local-storage"; import { deepMerge, mergeSessionMap, mergeLoadingState } from "./merge-strategies"; /** @@ -205,14 +206,21 @@ function hydrateSessionRuntime( } /** Hydrate UI slices without overwriting active connection state. */ -function hydrateUI(draft: Draft<AppState>, state: Partial<AppState>): void { +export function hydrateUI(draft: Draft<AppState>, state: Partial<AppState>): void { if (state.previewPanel) deepMerge(draft.previewPanel, state.previewPanel); if (state.rightPanel) deepMerge(draft.rightPanel, state.rightPanel); if (state.diffs) deepMerge(draft.diffs, state.diffs); if (state.quickChat) { // Merge quick chat sessions, preserving isOpen from client if (state.quickChat.sessions) { - draft.quickChat.sessions = state.quickChat.sessions; + // Local renames live in localStorage and override the SSR-provided name + // (which derives from the backend task title). Apply on every hydration + // so a renamed chat keeps its local name across reloads and tab switches. + const storedNames = getStoredQuickChatNames(); + draft.quickChat.sessions = state.quickChat.sessions.map((s) => { + const local = storedNames[s.sessionId]; + return local ? { ...s, name: local } : s; + }); // Validate activeSessionId exists in sessions after merge if ( draft.quickChat.activeSessionId && diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 10005d25e8..c441a4d5c9 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -8,6 +8,7 @@ import { removeStoredSidebarDraft, setLocalStorage, setStoredCollapsedSubtaskParents, + setStoredQuickChatName, setStoredSidebarActiveViewId, setStoredSidebarDraft, setStoredSidebarUserViews, @@ -584,13 +585,17 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], [] set((draft) => { draft.quickChat.activeSessionId = sessionId; }), - renameQuickChatSession: (sessionId, name) => + renameQuickChatSession: (sessionId, name) => { + let renamed = false; set((draft) => { const session = draft.quickChat.sessions.find((s) => s.sessionId === sessionId); if (session) { session.name = name; + renamed = true; } - }), + }); + if (renamed) setStoredQuickChatName(sessionId, name); + }, setSessionFailureNotification: (n) => set((draft) => { draft.sessionFailureNotification = n; From 6ceda6ae4e28a8d9305e4a9cf442ca2e9308709d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= <joao.salavisa@gmail.com> Date: Tue, 5 May 2026 22:05:01 +0100 Subject: [PATCH 14/33] fix(local-executor): preserve commits in changes panel after refresh (#834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(local-executor): preserve commits in changes panel after refresh For a local executor on a feature branch, the create-task dialog sent the chip's branch as base_branch with empty checkout_branch. agentctl then recomputed merge-base(HEAD, origin/<branch>) on every changes-panel refresh; for a never-pushed branch that collapsed to HEAD and the panel went empty. Repository creation also pinned default_branch to .git/HEAD, permanently anchoring the repo's integration ref to whatever feature branch the user happened to be on at first task-create time. The frontend now splits the chip into base_branch=default_branch + checkout_branch=working_branch (skipping the split when fresh-branch is on, where the chip semantically means "fork from"). The backend probes origin/HEAD then origin/main → main → master (new common/gitref package) to pick a repo's integration branch, and resolveRepoInput no longer falls back to BaseBranch when default_branch is missing. Covered by 9 unit tests on the helper, 6 Go tests on the probe order, and 7 e2e tests across plain creation, the fork toggle, and the validate-path probe. * fix: address PR review feedback - gitref: preserve slashes in HEAD-fallback branch names (TrimPrefix refs/heads/ instead of Split + last component); branches like "feature/my-feature" no longer collapse to "my-feature" and poison the stored default_branch. - gitref: drop the broken packed-refs symref scan in readOriginHEAD; the loose refs/remotes/origin/HEAD check + pickFirstExistingBranch candidates already cover every realistic clone. - service.resolveRepoInput: validate repoInput.LocalPath via resolveAllowedLocalPath() before probing — closes 8 CodeQL path-injection alerts. - buildRepositoriesPayload: fall through to the legacy non-split shape when default_branch is unknown for the row (legacy repos with unset default_branch). Synthesizing base_branch=rowBranch was reproducing the very bug this PR fixes. * fix(gitref): explicit traversal guard at I/O boundary for CodeQL Move the path validation from service.resolveAllowedLocalPath (which CodeQL's taint analysis doesn't recognize as a sanitizer) into a guardRepoPath check at gitref.DefaultBranch's entry. The new check rejects relative paths and any '..' segments before any os.Stat / os.ReadFile call sees the value, which CodeQL recognizes as a go/path-injection sanitizer. The upstream allowlist check stays in place — this is a belt-and- suspenders second barrier at the actual I/O boundary, and it also makes the gitref package safe for any future callers (migrations, tests) without requiring them to thread a Service in. * fix: address review feedback on hardening + e2e race - gitref.guardRepoPath: check the RAW input for '..' segments before filepath.Clean. Clean resolves '..' away from absolute paths ("/allowed/../etc" -> "/etc"), so iterating the cleaned path was a silent no-op for the very inputs we wanted to reject. Split on both '/' and the OS separator. Updated the test with three traversal cases. - gitref_test.go: switch from strings.EqualFold to strict equality — git branch names are case-sensitive, "Main" should not pass for "main". - e2e: replace waitForRequest with waitForResponse in local-executor-branch-split.spec.ts. waitForRequest resolves on send (before the server has persisted the task), so the followup apiClient.getTask call could race the write on slow CI. The two tests that need the request body keep access via (await waitForResponse).request(). * fix(ws): forward CheckoutBranch in wsUpdateTask The PR fix added CheckoutBranch forwarding to wsCreateTask but missed the same conversion loop in wsUpdateTask. Any task-update payload with a repositories array would silently zero out checkout_branch and re-introduce the merge-base collapse on refresh — same root cause this PR fixes, in the update path. --------- Co-authored-by: Kandev Agent <agent@kandev.dev> --- apps/backend/internal/common/gitref/gitref.go | 207 ++++++ .../internal/common/gitref/gitref_test.go | 67 ++ apps/backend/internal/task/dto/dto.go | 34 +- .../task/handlers/task_ws_handlers.go | 26 +- .../task/service/repository_discovery.go | 27 +- .../task/service/repository_discovery_test.go | 119 +++- .../internal/task/service/service_tasks.go | 19 +- .../components/task-create-dialog-footer.tsx | 1 + ...k-create-dialog-helpers.multi-repo.test.ts | 155 ++++- .../components/task-create-dialog-helpers.ts | 80 ++- .../components/task-create-dialog-setup.tsx | 4 + .../components/task-create-dialog-submit.tsx | 5 + .../components/task-create-dialog-types.ts | 2 + apps/web/components/task-create-dialog.tsx | 4 + apps/web/e2e/helpers/api-client.ts | 8 + .../task/local-executor-branch-split.spec.ts | 594 ++++++++++++++++++ 16 files changed, 1279 insertions(+), 73 deletions(-) create mode 100644 apps/backend/internal/common/gitref/gitref.go create mode 100644 apps/backend/internal/common/gitref/gitref_test.go create mode 100644 apps/web/e2e/tests/task/local-executor-branch-split.spec.ts diff --git a/apps/backend/internal/common/gitref/gitref.go b/apps/backend/internal/common/gitref/gitref.go new file mode 100644 index 0000000000..dfa0fae0f9 --- /dev/null +++ b/apps/backend/internal/common/gitref/gitref.go @@ -0,0 +1,207 @@ +// Package gitref reads git ref state directly from the on-disk .git +// directory, without invoking the git binary. The functions here are kept +// minimal and stdlib-only so they can be imported from both the task service +// and the sqlite migration layer (where adding a service dependency would +// invert the existing layering). +package gitref + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const headFile = "HEAD" + +// DefaultBranch returns the repository's *integration* branch — the branch +// that work is meant to merge back into. It is intentionally NOT the current +// HEAD: a developer who runs the dialog while checked out on a feature +// branch must still get "main" (or "master") back, otherwise downstream +// consumers (changes-panel merge-base, executor BaseBranch fallback) anchor +// to the wrong ref. +// +// Resolution order: +// 1. refs/remotes/origin/HEAD when set (the upstream's declared default) +// 2. The first ref that exists from +// {origin/main, origin/master, main, master} +// 3. The current HEAD as a last resort, so brand-new repos with only a +// feature branch still produce a value — callers that care about +// correctness can override. +func DefaultBranch(repoPath string) (string, error) { + safe, err := guardRepoPath(repoPath) + if err != nil { + return "", err + } + gitDir, err := ResolveGitDir(safe) + if err != nil { + return "", err + } + commonDir := ResolveCommonGitDir(gitDir) + if branch := readOriginHEAD(commonDir); branch != "" { + return branch, nil + } + if branch := pickFirstExistingBranch(commonDir); branch != "" { + return branch, nil + } + return readHEADBranchFallback(gitDir) +} + +// guardRepoPath rejects relative paths and any path containing `..` segments +// before passing the value into the file-reading helpers below. Callers +// already validate against an allowlist (see service.resolveAllowedLocalPath), +// but CodeQL's go/path-injection taint analysis doesn't trace that as a +// sanitizer, so we re-check here at the I/O boundary. The check is also +// genuinely useful: any caller — migration code, tests, future callers — +// must hand us an absolute, traversal-free path or we refuse to read. +func guardRepoPath(repoPath string) (string, error) { + if repoPath == "" { + return "", fmt.Errorf("repository path is required") + } + if !filepath.IsAbs(repoPath) { + return "", fmt.Errorf("repository path must be absolute") + } + // Check the RAW input for '..' segments before cleaning. filepath.Clean + // resolves traversal away from absolute paths (`/allowed/../etc` → + // `/etc`), so iterating the cleaned path would silently pass attempted + // escapes. Splitting on both separators handles cross-platform inputs + // without depending on the OS-native separator. + for _, part := range strings.FieldsFunc(repoPath, func(r rune) bool { + return r == '/' || r == filepath.Separator + }) { + if part == ".." { + return "", fmt.Errorf("repository path must not contain '..' segments") + } + } + return filepath.Clean(repoPath), nil +} + +// ResolveGitDir returns the actual git directory for repoPath, following +// `.git` files (worktree pointers) when present. +func ResolveGitDir(repoPath string) (string, error) { + gitPath := filepath.Join(repoPath, ".git") + info, err := os.Stat(gitPath) + if err != nil { + return "", err + } + if info.IsDir() { + return gitPath, nil + } + content, err := os.ReadFile(gitPath) + if err != nil { + return "", err + } + line := strings.TrimSpace(string(content)) + if !strings.HasPrefix(line, "gitdir:") { + return "", fmt.Errorf("invalid gitdir reference") + } + gitDir := strings.TrimSpace(strings.TrimPrefix(line, "gitdir:")) + if filepath.IsAbs(gitDir) { + return gitDir, nil + } + return filepath.Clean(filepath.Join(repoPath, gitDir)), nil +} + +// ResolveCommonGitDir returns the shared git dir for a worktree, or gitDir +// itself for a regular repo. Refs (refs/heads/*, refs/remotes/*, packed-refs) +// live under the common dir, not the worktree's gitDir. +func ResolveCommonGitDir(gitDir string) string { + commonFile := filepath.Join(gitDir, "commondir") + content, err := os.ReadFile(commonFile) + if err != nil { + return gitDir + } + commonDir := strings.TrimSpace(string(content)) + if commonDir == "" { + return gitDir + } + if filepath.IsAbs(commonDir) { + return filepath.Clean(commonDir) + } + return filepath.Clean(filepath.Join(gitDir, commonDir)) +} + +func readOriginHEAD(commonDir string) string { + headPath := filepath.Join(commonDir, "refs", "remotes", "origin", headFile) + content, err := os.ReadFile(headPath) + if err != nil { + // origin/HEAD only lives in packed-refs after a `git pack-refs --all`, + // and the on-disk symref format there is gnarly to parse correctly. + // We deliberately skip that case rather than maintain a broken parser: + // pickFirstExistingBranch's origin/main → origin/master → main → + // master fallbacks cover every realistic clone in practice. + return "" + } + return parseSymbolicRefToBranch(strings.TrimSpace(string(content))) +} + +func parseSymbolicRefToBranch(line string) string { + ref, ok := strings.CutPrefix(line, "ref: ") + if !ok { + ref = line + } + switch { + case strings.HasPrefix(ref, "refs/remotes/origin/"): + return strings.TrimPrefix(ref, "refs/remotes/origin/") + case strings.HasPrefix(ref, "refs/heads/"): + return strings.TrimPrefix(ref, "refs/heads/") + } + return "" +} + +func pickFirstExistingBranch(commonDir string) string { + for _, candidate := range []struct{ ref, branch string }{ + {"refs/remotes/origin/main", "main"}, + {"refs/remotes/origin/master", "master"}, + {"refs/heads/main", "main"}, + {"refs/heads/master", "master"}, + } { + if refExists(commonDir, candidate.ref) { + return candidate.branch + } + } + return "" +} + +func refExists(commonDir, ref string) bool { + if _, err := os.Stat(filepath.Join(commonDir, ref)); err == nil { + return true + } + content, err := os.ReadFile(filepath.Join(commonDir, "packed-refs")) + if err != nil { + return false + } + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "^") { + continue + } + _, after, ok := strings.Cut(line, " ") + if !ok { + continue + } + if after == ref { + return true + } + } + return false +} + +func readHEADBranchFallback(gitDir string) (string, error) { + headPath := filepath.Join(gitDir, headFile) + content, err := os.ReadFile(headPath) + if err != nil { + return "", err + } + trimmed := strings.TrimSpace(string(content)) + if ref, ok := strings.CutPrefix(trimmed, "ref: "); ok { + // Strip refs/heads/ as a prefix rather than splitting on "/" — branch + // names legally contain slashes (e.g. "feature/my-feature"), so taking + // the last path component would silently corrupt every nested branch. + return strings.TrimPrefix(ref, "refs/heads/"), nil + } + if trimmed != "" { + return headFile, nil + } + return "", fmt.Errorf("unable to determine branch") +} diff --git a/apps/backend/internal/common/gitref/gitref_test.go b/apps/backend/internal/common/gitref/gitref_test.go new file mode 100644 index 0000000000..00e43e17c9 --- /dev/null +++ b/apps/backend/internal/common/gitref/gitref_test.go @@ -0,0 +1,67 @@ +package gitref + +import ( + "os" + "path/filepath" + "testing" +) + +// TestDefaultBranch_RejectsRelativePath — guardRepoPath refuses relative +// paths so callers can't sneak `repos/..` past the I/O boundary even if +// the upstream allowlist check is bypassed. +func TestDefaultBranch_RejectsRelativePath(t *testing.T) { + if _, err := DefaultBranch("repos/foo"); err == nil { + t.Fatal("expected error for relative path") + } +} + +// TestDefaultBranch_RejectsTraversal — explicit guard against ".." +// segments in the RAW input. We must check before filepath.Clean: Clean +// resolves '..' away from absolute paths (`/allowed/../etc` → `/etc`) and +// would silently let a traversal attempt through. +func TestDefaultBranch_RejectsTraversal(t *testing.T) { + cases := []string{ + "/tmp/../etc", + "/foo/bar/../../etc", + "/a/b/..", + } + for _, p := range cases { + if _, err := DefaultBranch(p); err == nil { + t.Errorf("expected error for path %q (contains '..')", p) + } + } +} + +// TestDefaultBranch_RejectsEmpty — guardRepoPath errors on empty input +// rather than letting filepath.Join silently turn it into ".git". +func TestDefaultBranch_RejectsEmpty(t *testing.T) { + if _, err := DefaultBranch(""); err == nil { + t.Fatal("expected error for empty path") + } +} + +// TestDefaultBranch_AcceptsAbsolutePathToValidRepo — sanity check that +// the guard doesn't reject legitimate absolute paths. We seed a minimal +// .git directory with a HEAD on main and assert the probe finds it. +func TestDefaultBranch_AcceptsAbsolutePathToValidRepo(t *testing.T) { + repoPath := t.TempDir() + if !filepath.IsAbs(repoPath) { + t.Fatalf("t.TempDir() returned non-absolute path: %q", repoPath) + } + gitDir := filepath.Join(repoPath, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("mkdir git: %v", err) + } + if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil { + t.Fatalf("write HEAD: %v", err) + } + branch, err := DefaultBranch(repoPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Git branch names are case-sensitive — assert exact equality, not + // EqualFold. A function that returns "Main" or "MAIN" should fail. + if branch != "main" { + t.Fatalf("expected main, got %q", branch) + } +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index e201c45af5..d29ca9d02d 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -139,14 +139,15 @@ type TaskDTO struct { } type TaskRepositoryDTO struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - RepositoryID string `json:"repository_id"` - BaseBranch string `json:"base_branch"` - Position int `json:"position"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + TaskID string `json:"task_id"` + RepositoryID string `json:"repository_id"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch,omitempty"` + Position int `json:"position"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type TaskSessionDTO struct { @@ -519,14 +520,15 @@ func FromTaskWithSessionInfo( var repositories []TaskRepositoryDTO for _, repo := range task.Repositories { repositories = append(repositories, TaskRepositoryDTO{ - ID: repo.ID, - TaskID: repo.TaskID, - RepositoryID: repo.RepositoryID, - BaseBranch: repo.BaseBranch, - Position: repo.Position, - Metadata: repo.Metadata, - CreatedAt: repo.CreatedAt, - UpdatedAt: repo.UpdatedAt, + ID: repo.ID, + TaskID: repo.TaskID, + RepositoryID: repo.RepositoryID, + BaseBranch: repo.BaseBranch, + CheckoutBranch: repo.CheckoutBranch, + Position: repo.Position, + Metadata: repo.Metadata, + CreatedAt: repo.CreatedAt, + UpdatedAt: repo.UpdatedAt, }) } diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index b87b02ee42..97ee65ca53 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -119,12 +119,13 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "repository_id, local_path, or github_url is required", nil) } repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - GitHubURL: r.GitHubURL, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, }) } @@ -248,12 +249,13 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M if req.Repositories != nil { for _, r := range req.Repositories { repos = append(repos, dto.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - LocalPath: r.LocalPath, - Name: r.Name, - DefaultBranch: r.DefaultBranch, - GitHubURL: r.GitHubURL, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + LocalPath: r.LocalPath, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + GitHubURL: r.GitHubURL, }) } } diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index cfbafab43c..4a5e8b526e 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -10,6 +10,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/kandev/kandev/internal/common/gitref" ) type RepositoryDiscoveryConfig struct { @@ -555,28 +557,11 @@ func readGitDirtyFiles(ctx context.Context, repoPath string) ([]string, error) { return paths, nil } +// readGitDefaultBranch is a thin wrapper around gitref.DefaultBranch so this +// file's existing callers (ValidateLocalRepositoryPath, repoWalker.makeRepo, +// the test suite) keep their existing API. func readGitDefaultBranch(repoPath string) (string, error) { - gitDir, err := resolveGitDir(repoPath) - if err != nil { - return "", err - } - headPath := filepath.Join(gitDir, gitHEAD) - content, err := os.ReadFile(headPath) - if err != nil { - return "", err - } - trimmed := strings.TrimSpace(string(content)) - if strings.HasPrefix(trimmed, "ref: ") { - ref := strings.TrimPrefix(trimmed, "ref: ") - parts := strings.Split(ref, "/") - if len(parts) > 0 { - return parts[len(parts)-1], nil - } - } - if trimmed != "" { - return gitHEAD, nil - } - return "", fmt.Errorf("unable to determine branch") + return gitref.DefaultBranch(repoPath) } func resolveGitDir(repoPath string) (string, error) { diff --git a/apps/backend/internal/task/service/repository_discovery_test.go b/apps/backend/internal/task/service/repository_discovery_test.go index 8750c7c854..5bd13c8869 100644 --- a/apps/backend/internal/task/service/repository_discovery_test.go +++ b/apps/backend/internal/task/service/repository_discovery_test.go @@ -132,17 +132,84 @@ func TestNormalizeRootsDedupesAndCleans(t *testing.T) { } } -func TestReadGitDefaultBranch(t *testing.T) { - repoPath := t.TempDir() +// seedBareGitDir creates a minimal .git directory at repoPath with HEAD set +// to the given branch (or commit SHA for detached). Returns the gitDir path. +func seedBareGitDir(t *testing.T, repoPath, headContent string) string { + t.Helper() gitDir := filepath.Join(repoPath, ".git") if err := os.MkdirAll(gitDir, 0o755); err != nil { t.Fatalf("mkdir git: %v", err) } - - headPath := filepath.Join(gitDir, "HEAD") - if err := os.WriteFile(headPath, []byte("ref: refs/heads/develop\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte(headContent), 0o644); err != nil { t.Fatalf("write HEAD: %v", err) } + return gitDir +} + +// TestReadGitDefaultBranch_PrefersOriginHEAD — the upstream's declared +// default beats every other heuristic. Regression for the bug where the +// repo's stored default_branch latched onto whatever feature branch the user +// happened to be on at task-creation time. +func TestReadGitDefaultBranch_PrefersOriginHEAD(t *testing.T) { + repoPath := t.TempDir() + gitDir := seedBareGitDir(t, repoPath, "ref: refs/heads/feature/x\n") + if err := os.MkdirAll(filepath.Join(gitDir, "refs", "remotes", "origin"), 0o755); err != nil { + t.Fatalf("mkdir origin: %v", err) + } + if err := os.WriteFile( + filepath.Join(gitDir, "refs", "remotes", "origin", "HEAD"), + []byte("ref: refs/remotes/origin/main\n"), + 0o644, + ); err != nil { + t.Fatalf("write origin/HEAD: %v", err) + } + branch, err := readGitDefaultBranch(repoPath) + if err != nil { + t.Fatalf("readGitDefaultBranch error: %v", err) + } + if branch != "main" { + t.Fatalf("expected main, got %q", branch) + } +} + +// TestReadGitDefaultBranch_FallsBackToRemoteCandidates — when origin/HEAD +// isn't set (older clones, never run `git remote set-head`), the conventional +// origin/main / origin/master refs win over local branches. +func TestReadGitDefaultBranch_FallsBackToRemoteCandidates(t *testing.T) { + repoPath := t.TempDir() + gitDir := seedBareGitDir(t, repoPath, "ref: refs/heads/feature/y\n") + writeRef(t, filepath.Join(gitDir, "refs", "remotes", "origin", "main")) + branch, err := readGitDefaultBranch(repoPath) + if err != nil { + t.Fatalf("readGitDefaultBranch error: %v", err) + } + if branch != "main" { + t.Fatalf("expected main, got %q", branch) + } +} + +// TestReadGitDefaultBranch_FallsBackToLocalCandidates — local-only repos +// (no remotes) still produce the conventional integration branch when one +// exists locally, even if the user is checked out on a feature branch. +func TestReadGitDefaultBranch_FallsBackToLocalCandidates(t *testing.T) { + repoPath := t.TempDir() + gitDir := seedBareGitDir(t, repoPath, "ref: refs/heads/feature/z\n") + writeRef(t, filepath.Join(gitDir, "refs", "heads", "main")) + branch, err := readGitDefaultBranch(repoPath) + if err != nil { + t.Fatalf("readGitDefaultBranch error: %v", err) + } + if branch != "main" { + t.Fatalf("expected main, got %q", branch) + } +} + +// TestReadGitDefaultBranch_FallsBackToHEAD — a brand-new repo with only +// a feature branch checked out and no main/master falls back to HEAD so +// callers still get *some* answer instead of an error. +func TestReadGitDefaultBranch_FallsBackToHEAD(t *testing.T) { + repoPath := t.TempDir() + seedBareGitDir(t, repoPath, "ref: refs/heads/develop\n") branch, err := readGitDefaultBranch(repoPath) if err != nil { t.Fatalf("readGitDefaultBranch error: %v", err) @@ -150,21 +217,47 @@ func TestReadGitDefaultBranch(t *testing.T) { if branch != "develop" { t.Fatalf("expected develop, got %q", branch) } +} - if err := os.WriteFile(headPath, []byte("3a3f2d3b\n"), 0o644); err != nil { - t.Fatalf("write HEAD detached: %v", err) - } - branch, err = readGitDefaultBranch(repoPath) +// TestReadGitDefaultBranch_FallsBackToHEAD_PreservesSlashes — branch names +// can contain slashes (e.g. "feature/my-feature"). The HEAD fallback must +// strip the refs/heads/ prefix verbatim rather than split on "/" and take +// the tail, otherwise nested branches get silently corrupted into their +// last path component, which then becomes the stored default_branch and +// poisons every downstream merge-base lookup. +func TestReadGitDefaultBranch_FallsBackToHEAD_PreservesSlashes(t *testing.T) { + repoPath := t.TempDir() + seedBareGitDir(t, repoPath, "ref: refs/heads/feature/my-feature\n") + branch, err := readGitDefaultBranch(repoPath) if err != nil { - t.Fatalf("readGitDefaultBranch detached error: %v", err) + t.Fatalf("readGitDefaultBranch error: %v", err) + } + if branch != "feature/my-feature" { + t.Fatalf("expected feature/my-feature, got %q", branch) + } +} + +// TestReadGitDefaultBranch_DetachedHEADReturnsHEADLiteral — detached HEAD on +// a repo with no integration branches yields the literal "HEAD" sentinel +// (preserved from the previous behavior so callers depending on it don't +// silently start failing). +func TestReadGitDefaultBranch_DetachedHEADReturnsHEADLiteral(t *testing.T) { + repoPath := t.TempDir() + seedBareGitDir(t, repoPath, "3a3f2d3b\n") + branch, err := readGitDefaultBranch(repoPath) + if err != nil { + t.Fatalf("readGitDefaultBranch error: %v", err) } if branch != "HEAD" { t.Fatalf("expected HEAD, got %q", branch) } +} - if err := os.WriteFile(headPath, []byte("\n"), 0o644); err != nil { - t.Fatalf("write HEAD empty: %v", err) - } +// TestReadGitDefaultBranch_ErrorsOnEmptyHEAD — preserves the existing +// contract: HEAD with no content is an unparseable repo. +func TestReadGitDefaultBranch_ErrorsOnEmptyHEAD(t *testing.T) { + repoPath := t.TempDir() + seedBareGitDir(t, repoPath, "\n") if _, err := readGitDefaultBranch(repoPath); err == nil { t.Fatalf("expected error for empty HEAD") } diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 66228cea40..9618db3de0 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -13,6 +13,7 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" "go.uber.org/zap" + "github.com/kandev/kandev/internal/common/gitref" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/worktree" @@ -198,9 +199,25 @@ func (s *Service) resolveRepoInput(ctx context.Context, workspaceID string, repo if name == "" { name = filepath.Base(repoInput.LocalPath) } + // Resolve default_branch by probing the repo on disk so it's anchored + // to the integration branch (origin/HEAD or main/master) rather than + // whatever feature branch the user happens to have checked out. The + // frontend's `default_branch` hint wins when set; otherwise we probe + // directly. Falling back to repoInput.BaseBranch is wrong because in + // the local-executor flow that field carries the user's working + // branch, which would permanently pin repositories.default_branch to + // a feature branch and break every downstream merge-base lookup. defaultBranch := repoInput.DefaultBranch if defaultBranch == "" { - defaultBranch = repoInput.BaseBranch + // Probe must operate on a path validated against the discovery + // allowlist — repoInput.LocalPath comes straight from the HTTP body + // and feeds into os.Stat/ReadFile inside gitref.DefaultBranch, so + // without this guard a caller could traverse the filesystem. + if safePath, pathErr := s.resolveAllowedLocalPath(repoInput.LocalPath); pathErr == nil { + if probed, err := gitref.DefaultBranch(safePath); err == nil && probed != "" && probed != "HEAD" { + defaultBranch = probed + } + } } created, createErr := s.CreateRepository(ctx, &CreateRepositoryRequest{ WorkspaceID: workspaceID, diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index 2b78f929f8..2412dd6981 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -108,6 +108,7 @@ function StartTaskSplitButton({ <DropdownMenuItem onClick={onAltAction} className="cursor-pointer whitespace-nowrap focus:bg-muted/80 hover:bg-muted/80" + data-testid="submit-create-without-agent" > <IconPlus className="h-3.5 w-3.5 mr-1.5" /> {isEditMode ? "Update task" : "Create without starting agent"} diff --git a/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts index 95321d64fe..aee02224a6 100644 --- a/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts +++ b/apps/web/components/task-create-dialog-helpers.multi-repo.test.ts @@ -17,9 +17,9 @@ describe("buildRepositoriesPayload — unified rows", () => { discoveredRepositories: [], }); expect(payload).toEqual([ - { repository_id: "repo-front", base_branch: "main" }, - { repository_id: "repo-back", base_branch: "develop" }, - { repository_id: "repo-shared", base_branch: undefined }, + { repository_id: "repo-front", base_branch: "main", checkout_branch: undefined }, + { repository_id: "repo-back", base_branch: "develop", checkout_branch: undefined }, + { repository_id: "repo-shared", base_branch: undefined, checkout_branch: undefined }, ]); }); @@ -42,13 +42,156 @@ describe("buildRepositoriesPayload — unified rows", () => { { repository_id: "", base_branch: "trunk", + checkout_branch: undefined, local_path: "/home/me/projects/local-project", default_branch: "trunk", }, - { repository_id: "repo-back", base_branch: "main" }, + { repository_id: "repo-back", base_branch: "main", checkout_branch: undefined }, + ]); + }); +}); + +// Regression for the "new branch on local executor" bug: the chip's branch +// is the working branch on disk (e.g. "feature/x"), not the integration +// branch. We must send it as `checkout_branch`, with `base_branch` anchored +// to the repo's `default_branch`. Without this, agentctl recomputes +// merge-base(HEAD, origin/feature/x) which collapses to HEAD and the +// changes panel is empty after refresh. +describe("buildRepositoriesPayload — local executor branch split (core)", () => { + it("rowBranch != default_branch → swap into checkout_branch", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-1", branch: "feature/x" }], + discoveredRepositories: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any, + isLocalExecutor: true, + }); + expect(payload).toEqual([ + { repository_id: "repo-1", base_branch: "main", checkout_branch: "feature/x" }, ]); }); + it("rowBranch matches default_branch → no checkout_branch", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-1", branch: "main" }], + discoveredRepositories: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any, + isLocalExecutor: true, + }); + expect(payload).toEqual([ + { repository_id: "repo-1", base_branch: "main", checkout_branch: undefined }, + ]); + }); + + it("localPath row uses discoveredRepositories.default_branch", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", localPath: "/p/r", branch: "feature/y" }], + discoveredRepositories: [ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { path: "/p/r", default_branch: "main" } as any, + ], + isLocalExecutor: true, + }); + expect(payload).toEqual([ + { + repository_id: "", + base_branch: "main", + checkout_branch: "feature/y", + local_path: "/p/r", + default_branch: "main", + }, + ]); + }); +}); + +describe("buildRepositoriesPayload — local executor branch split (edge cases)", () => { + it("fresh-branch flow: skips the split so the picked base is preserved as base_branch", () => { + // When the user enables "Fork a new branch", the chip's branch is the + // BASE TO FORK FROM (e.g. "develop"), not a working branch. The backend + // creates a new branch from that base and rewrites base_branch to the + // new branch name. If we split here, base_branch would land on the + // repo's default ("main") and the fork would happen from main instead + // of develop — silently wrong. + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-1", branch: "develop" }], + discoveredRepositories: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any, + isLocalExecutor: true, + freshBranch: { confirmDiscard: false, consentedDirtyFiles: [] }, + }); + expect(payload).toEqual([ + { + repository_id: "repo-1", + base_branch: "develop", + checkout_branch: undefined, + fresh_branch: true, + confirm_discard: false, + consented_dirty_files: [], + }, + ]); + }); + + it("falls through when default_branch is unknown (legacy repos)", () => { + // Repos created before the backend probe fix may have an unset + // default_branch in the workspace store. If we synthesize base_branch= + // rowBranch here (as the original draft did), we reproduce the very bug + // this PR fixes: agentctl recomputes merge-base(HEAD, origin/<rowBranch>) + // → collapses to HEAD → empty changes panel. Better to leave the legacy + // shape alone — the next backend createRepository call will populate + // default_branch via the gitref probe. + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-1", branch: "feature/x" }], + discoveredRepositories: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspaceRepositories: [{ id: "repo-1", default_branch: "" }] as any, + isLocalExecutor: true, + }); + expect(payload).toEqual([ + { repository_id: "repo-1", base_branch: "feature/x", checkout_branch: undefined }, + ]); + }); + + it("non-local executor leaves rowBranch as base_branch (worktree flow unchanged)", () => { + const payload = buildRepositoriesPayload({ + useGitHubUrl: false, + githubUrl: "", + githubBranch: "", + githubPrHeadBranch: null, + repositories: [{ key: "r0", repositoryId: "repo-1", branch: "main" }], + discoveredRepositories: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + workspaceRepositories: [{ id: "repo-1", default_branch: "main" }] as any, + isLocalExecutor: false, + }); + expect(payload).toEqual([ + { repository_id: "repo-1", base_branch: "main", checkout_branch: undefined }, + ]); + }); +}); + +describe("buildRepositoriesPayload — single-row and URL mode", () => { it("URL mode produces a single github_url entry and ignores the rows", () => { const payload = buildRepositoriesPayload({ useGitHubUrl: true, @@ -77,6 +220,8 @@ describe("buildRepositoriesPayload — unified rows", () => { repositories: [{ key: "r0", repositoryId: "repo-only", branch: "main" }], discoveredRepositories: [], }); - expect(payload).toEqual([{ repository_id: "repo-only", base_branch: "main" }]); + expect(payload).toEqual([ + { repository_id: "repo-only", base_branch: "main", checkout_branch: undefined }, + ]); }); }); diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 51cf63fd97..6cde8bd3e4 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -1,5 +1,5 @@ import type { useRouter } from "next/navigation"; -import type { Task, Branch, LocalRepository } from "@/lib/types/http"; +import type { Task, Branch, LocalRepository, Repository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; import type { AppState } from "@/lib/state/store"; import type { StepType, TaskRepoRow } from "@/components/task-create-dialog-types"; @@ -172,6 +172,18 @@ export function buildRepositoriesPayload(opts: { repositories: TaskRepoRow[]; /** Used to look up `default_branch` for `localPath` rows. */ discoveredRepositories: LocalRepository[]; + /** Workspace repositories — used to look up `default_branch` for `repositoryId` rows. */ + workspaceRepositories?: Repository[]; + /** + * For the local executor (no worktree), the chip's branch field represents + * the working branch on disk, not the parent integration branch. Send it as + * `checkout_branch` so the session's `base_branch` stays anchored to the + * repo's `default_branch` (which is what git log / cumulative diff use as + * the merge-base reference). Without this, "new branch on local executor" + * collapses the merge-base recomputation to HEAD and the changes panel + * goes empty after a refresh. + */ + isLocalExecutor?: boolean; /** * Optional fresh-branch metadata. The UI gates this to single-row + local * executor; when present we apply it to every row (which is at most one). @@ -195,23 +207,81 @@ export function buildRepositoriesPayload(opts: { consented_dirty_files: opts.freshBranch.consentedDirtyFiles, } : {}; + // Fresh-branch flow inverts the chip's semantics: instead of "the working + // branch I'm on", row.branch becomes "the base I want to fork from". The + // backend then creates a new branch and rewrites repos[i].BaseBranch to it. + // Splitting here would force `base_branch=default_branch, checkout_branch= + // <picked-base>` and the backend would fork from the wrong base. Skip the + // split entirely when fresh-branch is active. + const isLocalExecutor = !!opts.isLocalExecutor && !opts.freshBranch; return opts.repositories .filter((row) => row.repositoryId || row.localPath) .map((row) => { + const defaultBranch = resolveRowDefaultBranch(row, opts); + const branches = splitLocalExecutorBranches({ + rowBranch: row.branch, + defaultBranch, + isLocalExecutor, + }); if (row.repositoryId) { return { repository_id: row.repositoryId, - base_branch: row.branch || undefined, + base_branch: branches.base_branch, + checkout_branch: branches.checkout_branch, ...fresh, }; } - const local = opts.discoveredRepositories.find((d) => d.path === row.localPath); return { repository_id: "", - base_branch: row.branch || undefined, + base_branch: branches.base_branch, + checkout_branch: branches.checkout_branch, local_path: row.localPath, - default_branch: local?.default_branch || undefined, + default_branch: defaultBranch || undefined, ...fresh, }; }); } + +function resolveRowDefaultBranch( + row: TaskRepoRow, + opts: { + discoveredRepositories: LocalRepository[]; + workspaceRepositories?: Repository[]; + }, +): string | undefined { + if (row.repositoryId) { + return opts.workspaceRepositories?.find((r) => r.id === row.repositoryId)?.default_branch; + } + return opts.discoveredRepositories.find((d) => d.path === row.localPath)?.default_branch; +} + +/** + * For the local executor: return `base_branch=defaultBranch`, + * `checkout_branch=rowBranch` so the session anchors merge-base to the repo's + * integration branch while the preparer still checks out the user's working + * branch. When `rowBranch === defaultBranch` we omit checkout_branch — the + * preparer treats matching values as "use current state" and skips git ops. + * + * For non-local executors (worktree-based): keep the historical shape where + * `base_branch=rowBranch` (the worktree creates a new branch off of it). + */ +function splitLocalExecutorBranches(args: { + rowBranch?: string; + defaultBranch?: string; + isLocalExecutor: boolean; +}): { base_branch: string | undefined; checkout_branch: string | undefined } { + // Without a known default_branch we can't anchor base_branch to the + // integration ref — and using rowBranch as a stand-in reproduces the + // exact bug this PR fixes (changes panel collapses to HEAD on refresh). + // Fall through to the legacy non-split shape: a workspace repo with an + // unset default_branch is no worse off than before, and the backend's + // resolveRepoInput probe will populate it on the next CreateRepository + // call. Wait for that probe rather than synthesizing a guess here. + if (!args.isLocalExecutor || !args.defaultBranch) { + return { base_branch: args.rowBranch || undefined, checkout_branch: undefined }; + } + const base = args.defaultBranch; + const checkout = + args.rowBranch && args.rowBranch !== args.defaultBranch ? args.rowBranch : undefined; + return { base_branch: base, checkout_branch: checkout }; +} diff --git a/apps/web/components/task-create-dialog-setup.tsx b/apps/web/components/task-create-dialog-setup.tsx index 61fe021bc3..589f18e778 100644 --- a/apps/web/components/task-create-dialog-setup.tsx +++ b/apps/web/components/task-create-dialog-setup.tsx @@ -144,6 +144,7 @@ type SubmitWiringArgs = { props: TaskCreateDialogProps; fs: ReturnType<typeof useDialogFormState>; computed: ReturnType<typeof useTaskCreateDialogData>["computed"]; + workspaceRepositories: ReturnType<typeof useTaskCreateDialogData>["repositories"]; repositoryLocalPath: string; isSessionMode: boolean; isEditMode: boolean; @@ -153,6 +154,7 @@ function useSubmitHandlersWiring({ props, fs, computed, + workspaceRepositories, repositoryLocalPath, isSessionMode, isEditMode, @@ -171,6 +173,7 @@ function useSubmitHandlersWiring({ effectiveDefaultStepId: computed.effectiveDefaultStepId, repositories: fs.repositories, discoveredRepositories: fs.discoveredRepositories, + workspaceRepositories, useGitHubUrl: fs.useGitHubUrl, githubUrl: fs.githubUrl, githubPrHeadBranch: fs.githubPrHeadBranch, @@ -256,6 +259,7 @@ export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { props, fs, computed, + workspaceRepositories: repositories, repositoryLocalPath, isSessionMode, isEditMode, diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index 2717feba53..eec89f6345 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -33,6 +33,7 @@ export function useTaskSubmitHandlers({ effectiveDefaultStepId, repositories, discoveredRepositories, + workspaceRepositories, useGitHubUrl, githubUrl, githubPrHeadBranch, @@ -128,6 +129,8 @@ export function useTaskSubmitHandlers({ githubPrHeadBranch, repositories, discoveredRepositories, + workspaceRepositories, + isLocalExecutor, freshBranch: buildFreshBranchPayload(consentedDirtyFiles), }), // buildFreshBranchPayload is a closure over current scope; lint exception kept narrow. @@ -139,6 +142,8 @@ export function useTaskSubmitHandlers({ githubPrHeadBranch, repositories, discoveredRepositories, + workspaceRepositories, + isLocalExecutor, isFreshBranchActive, ], ); diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index 2217aab9af..c5086823e0 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -223,6 +223,8 @@ export type SubmitHandlersDeps = { repositories: TaskRepoRow[]; /** All on-machine discovered repos — used to look up `default_branch` for `localPath` rows. */ discoveredRepositories: LocalRepository[]; + /** Workspace repositories — used to look up `default_branch` for `repositoryId` rows. */ + workspaceRepositories: Repository[]; useGitHubUrl: boolean; githubUrl: string; githubPrHeadBranch: string | null; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 1bf32245ea..c36a535896 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -339,6 +339,7 @@ type SubmitWiringArgs = { props: TaskCreateDialogProps; fs: ReturnType<typeof useDialogFormState>; computed: ReturnType<typeof useTaskCreateDialogData>["computed"]; + workspaceRepositories: ReturnType<typeof useTaskCreateDialogData>["repositories"]; repositoryLocalPath: string; isSessionMode: boolean; isEditMode: boolean; @@ -348,6 +349,7 @@ function useSubmitHandlersWiring({ props, fs, computed, + workspaceRepositories, repositoryLocalPath, isSessionMode, isEditMode, @@ -366,6 +368,7 @@ function useSubmitHandlersWiring({ effectiveDefaultStepId: computed.effectiveDefaultStepId, repositories: fs.repositories, discoveredRepositories: fs.discoveredRepositories, + workspaceRepositories, useGitHubUrl: fs.useGitHubUrl, githubUrl: fs.githubUrl, githubPrHeadBranch: fs.githubPrHeadBranch, @@ -451,6 +454,7 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { props, fs, computed, + workspaceRepositories: repositories, repositoryLocalPath, isSessionMode, isEditMode, diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 78db75edff..6b7ebbf2ca 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -749,6 +749,14 @@ export class ApiClient { title: string; primary_session_id?: string | null; state?: string; + repositories?: Array<{ + id: string; + task_id: string; + repository_id: string; + base_branch: string; + checkout_branch?: string; + position: number; + }>; }> { return this.request("GET", `/api/v1/tasks/${taskId}`); } diff --git a/apps/web/e2e/tests/task/local-executor-branch-split.spec.ts b/apps/web/e2e/tests/task/local-executor-branch-split.spec.ts new file mode 100644 index 0000000000..7d75aa2801 --- /dev/null +++ b/apps/web/e2e/tests/task/local-executor-branch-split.spec.ts @@ -0,0 +1,594 @@ +import path from "node:path"; +import fs from "node:fs"; +import { execSync } from "node:child_process"; +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; +import { makeGitEnv } from "../../helpers/git-helper"; + +/** + * Regression suite for the "new branch on local executor empties the changes + * panel after refresh" bug. + * + * Failure mode that originally shipped: + * 1. The dialog seeded the chip with the user's currently-checked-out branch + * and submitted it as `base_branch`. + * 2. agentctl recomputed merge-base(HEAD, origin/<chip-branch>) on every + * refresh; for a never-pushed feature branch that collapsed to HEAD and + * `git log HEAD..HEAD` returned empty. + * 3. Live polling kept showing commits via WS while the user was on the page, + * but the moment they refreshed, the panel went blank. + * + * The forward fix splits the chip's branch into two pieces for the local + * executor (the only mode where there is no worktree and the chip semantics + * differ): `base_branch` anchors to the repo's `default_branch` (the + * integration target — what the merge-base needs to reference), and + * `checkout_branch` carries the actual working branch the preparer should + * check out. The shape is what these tests assert end-to-end. + * + * They also pin the second half of the fix: `repositories.default_branch` + * must be probed from the repo's actual integration ref (origin/HEAD, + * origin/main, origin/master, …), not from `.git/HEAD` — otherwise a user + * who first ran the dialog while checked out on a feature branch would + * permanently pin their repo's default to that feature branch, and every + * downstream merge-base lookup would be anchored wrong. + */ +test.describe("Local executor branch split", () => { + test.describe.configure({ retries: 1 }); + + type Setup = { + repoDir: string; + repoName: string; + repositoryId: string; + profileId: string; + profileName: string; + defaultBranchOnDisk: string; + }; + + /** + * Build an isolated repo per test inside backend.tmpDir so: + * - The discoveryRoots() allowlist accepts it (createRepository hits the + * same path validation as the dialog). + * - `git init -b main` ensures the integration branch exists locally and + * readGitDefaultBranch will probe it correctly. + * - Multiple feature branches let the chip exercise both "match default" + * and "differs from default" paths. + */ + async function setupLocalRepo(opts: { + apiClient: import("../../helpers/api-client").ApiClient; + backendTmpDir: string; + workspaceId: string; + suffix: string; + /** Branch the test wants the working tree checked out on at task-create + * time. Must already be created below. */ + headBranch: string; + /** Extra branches to create alongside `main`. The chip dropdown lists + * them; the test picks one to drive the split. */ + extraBranches?: string[]; + }): Promise<Setup | null> { + const { executors } = await opts.apiClient.listExecutors(); + const localExec = executors.find((e) => e.type === "local"); + if (!localExec) return null; + const profileName = `E2E Branch Split ${opts.suffix}`; + const profile = await opts.apiClient.createExecutorProfile(localExec.id, profileName); + + const repoName = `E2E Branch Split Repo ${opts.suffix}`; + const repoDir = path.join(opts.backendTmpDir, "repos", `e2e-branch-split-${opts.suffix}`); + fs.mkdirSync(repoDir, { recursive: true }); + const env = makeGitEnv(opts.backendTmpDir); + execSync("git init -b main", { cwd: repoDir, env }); + execSync('git commit --allow-empty -m "init on main"', { cwd: repoDir, env }); + for (const branch of opts.extraBranches ?? []) { + execSync(`git checkout -B ${branch}`, { cwd: repoDir, env }); + execSync(`git commit --allow-empty -m "${branch} commit"`, { cwd: repoDir, env }); + } + execSync(`git checkout ${opts.headBranch}`, { cwd: repoDir, env }); + + // Register the repo with default_branch=main so the workspace store has + // the correct integration ref. The frontend's split logic looks this + // value up to populate base_branch. + const repo = await opts.apiClient.createRepository(opts.workspaceId, repoDir, "main", { + name: repoName, + }); + return { + repoDir, + repoName, + repositoryId: repo.id, + profileId: profile.id, + profileName, + defaultBranchOnDisk: "main", + }; + } + + function escapeRe(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + /** + * Drives the create-task dialog up to the point of submission with the + * local executor profile and the seeded repo selected. Returns when + * submission has fired so callers can read back the persisted task. + */ + async function createTaskViaDialog(opts: { + testPage: import("@playwright/test").Page; + title: string; + description: string; + profileName: string; + repoName: string; + /** When set, click the chip and pick this branch; otherwise leave the + * auto-seeded current branch. */ + pickBranch?: string; + }) { + const { testPage } = opts; + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + await testPage.getByTestId("task-title-input").fill(opts.title); + await testPage.getByTestId("task-description-input").fill(opts.description); + + await testPage.getByTestId("repo-chip-trigger").first().click(); + await testPage + .getByRole("option", { name: new RegExp(`^${escapeRe(opts.repoName)}\\b`, "i") }) + .first() + .click(); + + await testPage.getByTestId("executor-profile-selector").click(); + await testPage + .getByRole("option", { name: new RegExp(`^${escapeRe(opts.profileName)}\\b`, "i") }) + .first() + .click(); + + // Wait for the chip to settle on the workspace's current branch — the + // autoselect effect runs after currentLocalBranch resolves, and submitting + // before then would race with the very logic we're testing. + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); + await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); + + if (opts.pickBranch) { + await branchSelector.click(); + await testPage + .getByRole("option", { name: new RegExp(`^${escapeRe(opts.pickBranch)}\\b`) }) + .first() + .click(); + await expect(branchSelector).toContainText(opts.pickBranch); + } + + // Use the "create without starting agent" path so the test doesn't wait + // for agent boot. We care about the persisted task shape, not session + // lifecycle. The chevron opens the split-button menu. + // + // Wait on the *response* rather than the request — waitForRequest + // resolves the moment the browser sends the POST, before the server has + // finished writing to the database. Callers that read the task back via + // the API immediately after would race the persistence. + const createTaskResponse = opts.testPage.waitForResponse( + (res) => res.url().endsWith("/api/v1/tasks") && res.request().method() === "POST", + ); + await testPage.getByTestId("submit-start-agent-chevron").click(); + await testPage.getByTestId("submit-create-without-agent").click(); + await createTaskResponse; + } + + test("submits with base_branch=default_branch and checkout_branch=working_branch", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + // The pre-fix shape was {base_branch: "feature/x", checkout_branch: ""} + // which caused the post-refresh empty-panel bug. The new shape isolates + // the integration ref from the working branch so the merge-base + // recomputation downstream lands on the actual fork point instead of + // collapsing to HEAD. + const setup = await setupLocalRepo({ + apiClient, + backendTmpDir: backend.tmpDir, + workspaceId: seedData.workspaceId, + suffix: "split", + headBranch: "feature/x", + extraBranches: ["feature/x"], + }); + if (!setup) { + test.skip(true, "No local executor available"); + return; + } + try { + await createTaskViaDialog({ + testPage, + title: "Local executor branch split", + description: "regression: base/checkout split for local executor", + profileName: setup.profileName, + repoName: setup.repoName, + }); + + // Find the just-created task by its title and assert its repository row. + const tasksRes = await apiClient.rawRequest( + "GET", + `/api/v1/workspaces/${seedData.workspaceId}/tasks`, + ); + const tasksJson = (await tasksRes.json()) as { + tasks: Array<{ id: string; title: string }>; + }; + const task = tasksJson.tasks.find((t) => t.title === "Local executor branch split"); + expect(task, "task should exist").toBeTruthy(); + + const fullTask = await apiClient.getTask(task!.id); + const taskRepo = fullTask.repositories?.[0]; + expect(taskRepo, "task should have a repository row").toBeTruthy(); + expect(taskRepo!.repository_id).toBe(setup.repositoryId); + expect( + taskRepo!.base_branch, + "base_branch must be the repo's default_branch — that's what the changes panel uses as merge-base reference", + ).toBe("main"); + expect( + taskRepo!.checkout_branch, + "checkout_branch carries the working branch the preparer must switch to", + ).toBe("feature/x"); + } finally { + await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {}); + } + }); + + test("checkout_branch is omitted when picked branch matches default_branch", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + // When the user is on `main` (matching default_branch), there's nothing + // for the preparer to check out. The frontend should NOT send a redundant + // checkout_branch — the LocalPreparer's "skip when current matches" + // optimization relies on it being absent so we don't fire spurious git + // ops on every task creation. + const setup = await setupLocalRepo({ + apiClient, + backendTmpDir: backend.tmpDir, + workspaceId: seedData.workspaceId, + suffix: "match", + headBranch: "main", + }); + if (!setup) { + test.skip(true, "No local executor available"); + return; + } + try { + await createTaskViaDialog({ + testPage, + title: "Branch matches default", + description: "no checkout_branch needed", + profileName: setup.profileName, + repoName: setup.repoName, + }); + + const tasksRes = await apiClient.rawRequest( + "GET", + `/api/v1/workspaces/${seedData.workspaceId}/tasks`, + ); + const tasksJson = (await tasksRes.json()) as { + tasks: Array<{ id: string; title: string }>; + }; + const task = tasksJson.tasks.find((t) => t.title === "Branch matches default"); + expect(task, "task should exist").toBeTruthy(); + + const fullTask = await apiClient.getTask(task!.id); + const taskRepo = fullTask.repositories?.[0]; + expect(taskRepo!.base_branch).toBe("main"); + // omitempty on the DTO — empty string round-trips as undefined/missing. + expect(taskRepo!.checkout_branch ?? "").toBe(""); + } finally { + await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {}); + } + }); + + test("explicit branch pick: chip switches to develop, payload still splits correctly", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + // The chip auto-seeds with the workspace's current branch (main here), + // but the user can switch to any existing branch. The split must still + // anchor base_branch to default_branch regardless of how the chip got + // its value (autoselect vs explicit pick). + const setup = await setupLocalRepo({ + apiClient, + backendTmpDir: backend.tmpDir, + workspaceId: seedData.workspaceId, + suffix: "pick", + headBranch: "main", + extraBranches: ["develop"], + }); + if (!setup) { + test.skip(true, "No local executor available"); + return; + } + try { + await createTaskViaDialog({ + testPage, + title: "Branch pick split", + description: "user explicitly picked develop", + profileName: setup.profileName, + repoName: setup.repoName, + pickBranch: "develop", + }); + + const tasksRes = await apiClient.rawRequest( + "GET", + `/api/v1/workspaces/${seedData.workspaceId}/tasks`, + ); + const tasksJson = (await tasksRes.json()) as { + tasks: Array<{ id: string; title: string }>; + }; + const task = tasksJson.tasks.find((t) => t.title === "Branch pick split"); + expect(task, "task should exist").toBeTruthy(); + + const fullTask = await apiClient.getTask(task!.id); + const taskRepo = fullTask.repositories?.[0]; + expect(taskRepo!.base_branch).toBe("main"); + expect(taskRepo!.checkout_branch).toBe("develop"); + } finally { + await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {}); + } + }); +}); + +/** + * Fresh-branch flow has different chip semantics: row.branch is the base to + * fork FROM, not a working branch. The split must NOT apply here — otherwise + * picking a non-default base would silently fork from main instead of the + * picked base. + */ +test.describe("Local executor + fresh-branch toggle", () => { + test.describe.configure({ retries: 1 }); + + type Setup = { + repoDir: string; + repoName: string; + profileId: string; + profileName: string; + }; + + async function setupFreshBranchRepo(opts: { + apiClient: import("../../helpers/api-client").ApiClient; + backendTmpDir: string; + workspaceId: string; + suffix: string; + }): Promise<Setup | null> { + const { executors } = await opts.apiClient.listExecutors(); + const localExec = executors.find((e) => e.type === "local"); + if (!localExec) return null; + const profileName = `E2E Fresh Split ${opts.suffix}`; + const profile = await opts.apiClient.createExecutorProfile(localExec.id, profileName); + const repoName = `E2E Fresh Split Repo ${opts.suffix}`; + const repoDir = path.join(opts.backendTmpDir, "repos", `e2e-fresh-split-${opts.suffix}`); + fs.mkdirSync(repoDir, { recursive: true }); + const env = makeGitEnv(opts.backendTmpDir); + execSync("git init -b main", { cwd: repoDir, env }); + execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env }); + execSync("git checkout -b develop", { cwd: repoDir, env }); + execSync('git commit --allow-empty -m "develop work"', { cwd: repoDir, env }); + execSync("git checkout main", { cwd: repoDir, env }); + await opts.apiClient.createRepository(opts.workspaceId, repoDir, "main", { name: repoName }); + return { repoDir, repoName, profileId: profile.id, profileName }; + } + + function escapeRe(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + test("payload sends base_branch=picked-base verbatim, no checkout_branch split", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + // Regression for the gap noticed during review: the local-executor branch + // split would otherwise force base_branch=default_branch and the backend's + // applyFreshBranch would fork from main instead of the user's pick. + // + // The frontend's contract is to send the chip's value verbatim as + // base_branch when fresh-branch is active. Backend persistence of the + // resulting fork-branch name is a separate concern covered by + // service-layer tests on PerformFreshBranch. We only assert the wire + // payload here so this test stays focused on the regression and doesn't + // entangle with the post-create session-launch path. + const setup = await setupFreshBranchRepo({ + apiClient, + backendTmpDir: backend.tmpDir, + workspaceId: seedData.workspaceId, + suffix: "develop-base", + }); + if (!setup) { + test.skip(true, "No local executor available"); + return; + } + try { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + await testPage.getByTestId("task-title-input").fill("Fork from develop"); + await testPage.getByTestId("task-description-input").fill("non-default fork base"); + await testPage.getByTestId("repo-chip-trigger").first().click(); + await testPage + .getByRole("option", { name: new RegExp(`^${escapeRe(setup.repoName)}\\b`, "i") }) + .first() + .click(); + await testPage.getByTestId("executor-profile-selector").click(); + await testPage + .getByRole("option", { name: new RegExp(`^${escapeRe(setup.profileName)}\\b`, "i") }) + .first() + .click(); + + // Flip on fresh-branch and pick "develop" as the base to fork from. + await testPage.getByTestId("fresh-branch-toggle").click(); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); + await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); + await branchSelector.click(); + await testPage + .getByRole("option", { name: /develop/ }) + .first() + .click(); + await expect(branchSelector).toContainText("develop"); + + // Wait on the response so the server has finished persisting before we + // inspect the request body. waitForRequest resolves on send, which + // would race the rest of the test on slow CI. + const createTaskResponse = testPage.waitForResponse( + (res) => res.url().endsWith("/api/v1/tasks") && res.request().method() === "POST", + ); + await testPage.getByTestId("submit-start-agent-chevron").click(); + await testPage.getByTestId("submit-create-without-agent").click(); + const req = (await createTaskResponse).request(); + + // Wire-level contract: base_branch must carry "develop" verbatim and + // checkout_branch must be absent. fresh_branch must be true so the + // backend's applyFreshBranch flow knows to fork. Without the split + // bypass, base_branch would be "main" and applyFreshBranch would fork + // from the wrong base. + const payload = JSON.parse(req.postData() ?? "{}") as { + repositories?: Array<{ + base_branch?: string; + checkout_branch?: string; + fresh_branch?: boolean; + }>; + }; + const repo = payload.repositories?.[0]; + expect(repo?.base_branch).toBe("develop"); + expect(repo?.checkout_branch ?? undefined).toBeUndefined(); + expect(repo?.fresh_branch).toBe(true); + } finally { + await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {}); + } + }); + + test("forks from default base when chip is left on the auto-seeded current branch", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + // Common path: user is on main (the workspace default), enables fresh- + // branch, leaves the chip alone. Backend forks from main. We just verify + // the resulting branch points at main's tip and the wire payload sent + // base_branch="main" without a redundant checkout_branch. + const setup = await setupFreshBranchRepo({ + apiClient, + backendTmpDir: backend.tmpDir, + workspaceId: seedData.workspaceId, + suffix: "main-base", + }); + if (!setup) { + test.skip(true, "No local executor available"); + return; + } + try { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + await testPage.getByTestId("task-title-input").fill("Fork from main"); + await testPage.getByTestId("task-description-input").fill("default fork base"); + await testPage.getByTestId("repo-chip-trigger").first().click(); + await testPage + .getByRole("option", { name: new RegExp(`^${escapeRe(setup.repoName)}\\b`, "i") }) + .first() + .click(); + await testPage.getByTestId("executor-profile-selector").click(); + await testPage + .getByRole("option", { name: new RegExp(`^${escapeRe(setup.profileName)}\\b`, "i") }) + .first() + .click(); + await testPage.getByTestId("fresh-branch-toggle").click(); + const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); + await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); + await expect(branchSelector).toContainText("main"); + + const createTaskResponse = testPage.waitForResponse( + (res) => res.url().endsWith("/api/v1/tasks") && res.request().method() === "POST", + ); + await testPage.getByTestId("submit-start-agent-chevron").click(); + await testPage.getByTestId("submit-create-without-agent").click(); + const req = (await createTaskResponse).request(); + + const payload = JSON.parse(req.postData() ?? "{}") as { + repositories?: Array<{ base_branch?: string; checkout_branch?: string }>; + }; + expect(payload.repositories?.[0]?.base_branch).toBe("main"); + expect(payload.repositories?.[0]?.checkout_branch ?? undefined).toBeUndefined(); + } finally { + await apiClient.deleteExecutorProfile(setup.profileId).catch(() => {}); + } + }); +}); + +/** + * Pins the readGitDefaultBranch fix: the validate-path endpoint (which the + * dialog uses to populate `default_branch` for discovered local paths) must + * return the integration branch, not whatever ref `.git/HEAD` happens to + * point at. The pre-fix code read HEAD directly, so a user checked out on + * a feature branch would permanently pin the resulting `repositories` + * row's `default_branch` to that feature branch. + */ +test.describe("Repository default_branch detection", () => { + test.describe.configure({ retries: 1 }); + + test("validate-path returns 'main' even when HEAD is on a feature branch", async ({ + apiClient, + backend, + seedData, + }) => { + const repoDir = path.join(backend.tmpDir, "repos", "e2e-default-branch-probe"); + fs.mkdirSync(repoDir, { recursive: true }); + const env = makeGitEnv(backend.tmpDir); + execSync("git init -b main", { cwd: repoDir, env }); + execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env }); + execSync("git checkout -b feature/probe", { cwd: repoDir, env }); + execSync('git commit --allow-empty -m "feature work"', { cwd: repoDir, env }); + // Leave HEAD on feature/probe — the pre-fix code returned this string, + // which is exactly what we're guarding against. + expect( + execSync("git rev-parse --abbrev-ref HEAD", { cwd: repoDir, env }).toString().trim(), + ).toBe("feature/probe"); + + const res = await apiClient.rawRequest( + "GET", + `/api/v1/workspaces/${seedData.workspaceId}/repositories/validate?path=${encodeURIComponent(repoDir)}`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + is_git: boolean; + default_branch: string; + allowed: boolean; + }; + expect(body.is_git).toBe(true); + expect(body.allowed).toBe(true); + expect( + body.default_branch, + "must probe origin/HEAD or main/master, not echo back .git/HEAD", + ).toBe("main"); + }); + + test("validate-path falls back to feature branch only when no main/master exists", async ({ + apiClient, + backend, + seedData, + }) => { + // A truly local-only repo with only a feature branch has no integration + // ref to anchor to. The probe should return the current HEAD as a last + // resort so the dialog doesn't error out — callers can still override. + const repoDir = path.join(backend.tmpDir, "repos", "e2e-default-branch-fallback"); + fs.mkdirSync(repoDir, { recursive: true }); + const env = makeGitEnv(backend.tmpDir); + execSync("git init -b solo", { cwd: repoDir, env }); + execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env }); + + const res = await apiClient.rawRequest( + "GET", + `/api/v1/workspaces/${seedData.workspaceId}/repositories/validate?path=${encodeURIComponent(repoDir)}`, + ); + const body = (await res.json()) as { default_branch: string }; + expect(body.default_branch).toBe("solo"); + }); +}); From 5e3248a66b3fe36831fff1928acca7e0c111c9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= <joao.salavisa@gmail.com> Date: Tue, 5 May 2026 22:06:24 +0100 Subject: [PATCH 15/33] feat(web): pin tasks and drag-to-reorder in sidebar (#829) Co-authored-by: Kandev Agent <agent@kandev.dev> --- .../mobile/session-task-switcher-sheet.tsx | 18 +- .../task/sidebar-filter/sort-picker.tsx | 42 +- apps/web/components/task/task-item.tsx | 12 + .../components/task/task-session-sidebar.tsx | 25 +- .../task/task-switcher-context-menu.tsx | 155 +++++++ apps/web/components/task/task-switcher.tsx | 385 ++++++++++-------- .../sidebar/use-effective-sidebar-view.ts | 18 + .../domains/sidebar/use-sidebar-task-prefs.ts | 37 ++ apps/web/lib/local-storage.ts | 38 ++ apps/web/lib/sidebar/apply-view.test.ts | 193 ++++++++- apps/web/lib/sidebar/apply-view.ts | 114 +++++- apps/web/lib/state/default-state.ts | 1 + .../slices/ui/sidebar-task-prefs-actions.ts | 36 ++ .../lib/state/slices/ui/sidebar-view-types.ts | 2 +- apps/web/lib/state/slices/ui/types.ts | 17 + apps/web/lib/state/slices/ui/ui-slice.test.ts | 70 ++++ apps/web/lib/state/slices/ui/ui-slice.ts | 17 +- apps/web/lib/state/store.ts | 4 + apps/web/lib/ws/handlers/tasks.test.ts | 1 + apps/web/lib/ws/handlers/tasks.ts | 4 + 20 files changed, 968 insertions(+), 221 deletions(-) create mode 100644 apps/web/components/task/task-switcher-context-menu.tsx create mode 100644 apps/web/hooks/domains/sidebar/use-effective-sidebar-view.ts create mode 100644 apps/web/hooks/domains/sidebar/use-sidebar-task-prefs.ts create mode 100644 apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 2ea8aaed80..bd77534589 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -7,6 +7,8 @@ import { Button } from "@kandev/ui/button"; import { TaskSwitcher } from "../task-switcher"; import type { TaskSwitcherItem } from "../task-switcher"; import { applyView } from "@/lib/sidebar/apply-view"; +import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view"; +import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs"; import { WorkspaceSwitcher } from "../workspace-switcher"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog"; @@ -411,12 +413,13 @@ function MobileTaskList({ deletingTaskId: string | null; isLoading?: boolean; }) { - const sidebarSlice = useAppStore((s) => s.sidebarViews); - const view = useMemo(() => { - const active = sidebarSlice.views.find((v) => v.id === sidebarSlice.activeViewId); - return active ?? sidebarSlice.views[0]; - }, [sidebarSlice]); - const grouped = useMemo(() => applyView(tasks, view), [tasks, view]); + const view = useEffectiveSidebarView(); + const { pinnedTaskIds, orderedTaskIds, togglePinnedTask, handleReorderGroup } = + useSidebarTaskPrefs(); + const grouped = useMemo( + () => applyView(tasks, view, { pinnedTaskIds, orderedTaskIds }), + [tasks, view, pinnedTaskIds, orderedTaskIds], + ); return ( <TaskSwitcher grouped={grouped} @@ -425,6 +428,9 @@ function MobileTaskList({ onSelectTask={onSelectTask} onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} + onTogglePin={togglePinnedTask} + onReorderGroup={handleReorderGroup} + pinnedTaskIds={pinnedTaskIds} deletingTaskId={deletingTaskId} isLoading={isLoading} totalTaskCount={tasks.length} diff --git a/apps/web/components/task/sidebar-filter/sort-picker.tsx b/apps/web/components/task/sidebar-filter/sort-picker.tsx index c563eaf33a..4b613e2342 100644 --- a/apps/web/components/task/sidebar-filter/sort-picker.tsx +++ b/apps/web/components/task/sidebar-filter/sort-picker.tsx @@ -10,6 +10,7 @@ const SORT_OPTIONS: Array<{ key: SortKey; label: string }> = [ { key: "updatedAt", label: "Updated" }, { key: "createdAt", label: "Created" }, { key: "title", label: "Title" }, + { key: "custom", label: "Custom" }, ]; type Props = { @@ -18,6 +19,9 @@ type Props = { }; export function SortPicker({ value, onChange }: Props) { + // Direction has no meaning for the custom sort — its order is the user's + // drag, not an asc/desc field. Hide the toggle to avoid surprising flips. + const isCustom = value.key === "custom"; return ( <div className="flex items-center gap-1.5"> <Select value={value.key} onValueChange={(v) => onChange({ ...value, key: v as SortKey })}> @@ -32,24 +36,26 @@ export function SortPicker({ value, onChange }: Props) { ))} </SelectContent> </Select> - <Button - type="button" - variant="outline" - size="sm" - className="h-7 cursor-pointer" - onClick={() => - onChange({ ...value, direction: value.direction === "asc" ? "desc" : "asc" }) - } - data-testid="sort-direction-toggle" - data-direction={value.direction} - aria-label={`Sort direction ${value.direction}`} - > - {value.direction === "asc" ? ( - <IconArrowUp className="h-3.5 w-3.5" /> - ) : ( - <IconArrowDown className="h-3.5 w-3.5" /> - )} - </Button> + {!isCustom && ( + <Button + type="button" + variant="outline" + size="sm" + className="h-7 cursor-pointer" + onClick={() => + onChange({ ...value, direction: value.direction === "asc" ? "desc" : "asc" }) + } + data-testid="sort-direction-toggle" + data-direction={value.direction} + aria-label={`Sort direction ${value.direction}`} + > + {value.direction === "asc" ? ( + <IconArrowUp className="h-3.5 w-3.5" /> + ) : ( + <IconArrowDown className="h-3.5 w-3.5" /> + )} + </Button> + )} </div> ); } diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 219e0c0002..ccb81de755 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -8,6 +8,7 @@ import { IconDots, IconGitPullRequest, IconMessageQuestion, + IconPinFilled, } from "@tabler/icons-react"; import { PRTaskIcon } from "@/components/github/pr-task-icon"; import { IssueTaskIcon } from "@/components/github/issue-task-icon"; @@ -55,6 +56,7 @@ type TaskItemProps = { repositories?: string[]; prInfo?: { number: number; state: string }; issueInfo?: { url: string; number: number }; + isPinned?: boolean; }; function formatRelativeTime(dateString: string): string { @@ -221,6 +223,7 @@ function TaskItemContent({ remoteExecutorName, primarySessionId, isArchived, + isPinned, repositories, updatedAt, prInfo, @@ -234,6 +237,7 @@ function TaskItemContent({ remoteExecutorName?: string; primarySessionId?: string | null; isArchived?: boolean; + isPinned?: boolean; repositories?: string[]; updatedAt?: string; prInfo?: { number: number; state: string }; @@ -246,6 +250,12 @@ function TaskItemContent({ > <span className="flex items-center gap-1 min-w-0 text-[13px] font-medium text-foreground leading-tight"> <ScrollOnOverflow className="min-w-0">{title}</ScrollOnOverflow> + {isPinned && ( + <IconPinFilled + data-testid="task-pinned-icon" + className="h-3 w-3 shrink-0 text-muted-foreground/60" + /> + )} <TaskPRIcon taskId={taskId} prInfo={prInfo} /> {issueInfo && <IssueTaskIcon issueInfo={issueInfo} />} {isRemoteExecutor && ( @@ -296,6 +306,7 @@ export const TaskItem = memo(function TaskItem({ repositories, prInfo, issueInfo, + isPinned, }: TaskItemProps) { const effectiveMenuOpen = menuOpen || isDeleting === true; const isInProgress = computeIsInProgress(state, sessionState); @@ -341,6 +352,7 @@ export const TaskItem = memo(function TaskItem({ remoteExecutorName={remoteExecutorName} primarySessionId={primarySessionId} isArchived={isArchived} + isPinned={isPinned} repositories={repositories} updatedAt={updatedAt} prInfo={prInfo} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 29525c6106..175260aa74 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -20,6 +20,8 @@ import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; +import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view"; +import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs"; import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useTaskRemoval } from "@/hooks/use-task-removal"; import { buildSwitchToSession, selectTaskWithLayout } from "./task-select-helpers"; @@ -508,17 +510,6 @@ function useBulkGitStatusSubscription(primarySessionIds: string[]) { }, [primarySessionIds, connectionStatus, activeSessionId]); } -function useEffectiveSidebarView() { - const sidebarSlice = useAppStore((state) => state.sidebarViews); - return useMemo(() => { - const active = sidebarSlice.views.find((v) => v.id === sidebarSlice.activeViewId); - if (!active) return sidebarSlice.views[0]; - const d = sidebarSlice.draft; - if (!d || d.baseViewId !== active.id) return active; - return { ...active, filters: d.filters, sort: d.sort, group: d.group }; - }, [sidebarSlice.views, sidebarSlice.activeViewId, sidebarSlice.draft]); -} - export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId, }: TaskSessionSidebarProps) { @@ -539,6 +530,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ useBulkGitStatusSubscription(primarySessionIds); + const sidebarActions = useSidebarActions(store); const { deletingTaskId, preparingTaskId, @@ -554,7 +546,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ setArchivingTask, isArchiving, handleArchiveConfirm, - } = useSidebarActions(store); + } = sidebarActions; const displayTasks = useMemo(() => { if (MOCK_SIDEBAR) return MOCK_ITEMS; @@ -568,10 +560,12 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ const toggleSidebarGroupCollapsed = useAppStore((state) => state.toggleSidebarGroupCollapsed); const collapsedSubtaskParents = useAppStore((state) => state.collapsedSubtaskParents); const toggleSubtaskCollapsed = useAppStore((state) => state.toggleSubtaskCollapsed); + const { pinnedTaskIds, orderedTaskIds, togglePinnedTask, handleReorderGroup } = + useSidebarTaskPrefs(); const effectiveView = useEffectiveSidebarView(); const grouped = useMemo( - () => applyView(displayTasks, effectiveView), - [displayTasks, effectiveView], + () => applyView(displayTasks, effectiveView, { pinnedTaskIds, orderedTaskIds }), + [displayTasks, effectiveView, pinnedTaskIds, orderedTaskIds], ); return ( @@ -593,6 +587,9 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ onArchiveTask={handleArchiveTask} onDeleteTask={handleDeleteTask} onMoveToStep={handleMoveToStep} + onTogglePin={togglePinnedTask} + onReorderGroup={handleReorderGroup} + pinnedTaskIds={pinnedTaskIds} deletingTaskId={deletingTaskId} isLoading={isLoadingWorkflow} totalTaskCount={displayTasks.length} diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx new file mode 100644 index 0000000000..1bb84ffa5b --- /dev/null +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { cloneElement, isValidElement, useState } from "react"; +import { + IconArchive, + IconCopy, + IconLoader, + IconPencil, + IconPin, + IconPinFilled, + IconTrash, +} from "@tabler/icons-react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@kandev/ui/context-menu"; +import { + TaskMoveContextMenuItems, + type TaskMoveWorkflow, +} from "@/components/task/task-move-context-menu"; +import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; +import type { TaskSwitcherItem } from "./task-switcher"; + +export type StepDef = { + id: string; + title: string; + color?: string; + events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> }; +}; + +type ContextMenuProps = { + task: TaskSwitcherItem; + workflows?: TaskMoveWorkflow[]; + stepsByWorkflowId?: Record<string, StepDef[]>; + steps?: StepDef[]; + children: React.ReactElement<{ menuOpen?: boolean }>; + onRenameTask?: (taskId: string, currentTitle: string) => void; + onArchiveTask?: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; + onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; + onTogglePin?: (taskId: string) => void; + isPinned?: boolean; + isDeleting?: boolean; +}; + +export function TaskItemWithContextMenu({ + task, + workflows, + stepsByWorkflowId, + steps, + children, + onRenameTask, + onArchiveTask, + onDeleteTask, + onMoveToStep, + onTogglePin, + isPinned, + isDeleting, +}: ContextMenuProps) { + const [contextOpen, setContextOpen] = useState(false); + const [menuKey, setMenuKey] = useState(0); + const moveTasks = useTaskWorkflowMove(); + const menuOpen = contextOpen || isDeleting === true; + const closeMenu = () => { + setContextOpen(false); + setMenuKey((k) => k + 1); + }; + + return ( + <ContextMenu key={menuKey} onOpenChange={setContextOpen}> + <ContextMenuTrigger asChild> + <div>{cloneWithMenuOpen(children, menuOpen)}</div> + </ContextMenuTrigger> + <ContextMenuContent className="w-48"> + {onTogglePin && ( + <ContextMenuItem disabled={isDeleting} onSelect={() => onTogglePin(task.id)}> + {isPinned ? ( + <IconPinFilled className="mr-2 h-4 w-4" /> + ) : ( + <IconPin className="mr-2 h-4 w-4" /> + )} + {isPinned ? "Unpin" : "Pin"} + </ContextMenuItem> + )} + {onRenameTask && ( + <ContextMenuItem disabled={isDeleting} onSelect={() => onRenameTask(task.id, task.title)}> + <IconPencil className="mr-2 h-4 w-4" /> + Rename + </ContextMenuItem> + )} + <ContextMenuItem disabled> + <IconCopy className="mr-2 h-4 w-4" /> + Duplicate + </ContextMenuItem> + {onArchiveTask && ( + <ContextMenuItem disabled={isDeleting} onSelect={() => onArchiveTask(task.id)}> + <IconArchive className="mr-2 h-4 w-4" /> + Archive + </ContextMenuItem> + )} + {task.workflowId && ( + <TaskMoveContextMenuItems + currentWorkflowId={task.workflowId} + currentStepId={task.workflowStepId} + workflows={workflows ?? []} + stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [task.workflowId]: steps } : {})} + disabled={isDeleting || task.isArchived} + onMoveToStep={ + onMoveToStep + ? (stepId) => { + closeMenu(); + onMoveToStep(task.id, task.workflowId!, stepId); + } + : undefined + } + onSendToWorkflow={(workflowId, stepId) => { + closeMenu(); + void moveTasks([task.id], workflowId, stepId).catch(() => { + // useTaskWorkflowMove already shows the failure toast. + }); + }} + /> + )} + {onDeleteTask && ( + <> + <ContextMenuSeparator /> + <ContextMenuItem + variant="destructive" + disabled={isDeleting} + onSelect={() => onDeleteTask(task.id)} + > + {isDeleting ? ( + <IconLoader className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <IconTrash className="mr-2 h-4 w-4" /> + )} + Delete + </ContextMenuItem> + </> + )} + </ContextMenuContent> + </ContextMenu> + ); +} + +function cloneWithMenuOpen( + children: React.ReactElement<{ menuOpen?: boolean }>, + menuOpen: boolean, +): React.ReactNode { + if (isValidElement(children)) return cloneElement(children, { menuOpen }); + return children; +} diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 49587402bb..837463508e 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -1,30 +1,30 @@ "use client"; -import { cloneElement, isValidElement, memo, useState } from "react"; +import { memo, useCallback, useMemo } from "react"; +import { IconChevronDown } from "@tabler/icons-react"; import { - IconChevronDown, - IconPencil, - IconCopy, - IconArchive, - IconTrash, - IconLoader, -} from "@tabler/icons-react"; + DndContext, + PointerSensor, + closestCenter, + type DragEndEvent, + useSensor, + useSensors, +} from "@dnd-kit/core"; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@kandev/ui/context-menu"; + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; import { cn } from "@/lib/utils"; import { TaskItem } from "./task-item"; +import { TaskItemWithContextMenu, type StepDef } from "./task-switcher-context-menu"; import type { GroupedSidebarList, SidebarGroup } from "@/lib/sidebar/apply-view"; -import { - TaskMoveContextMenuItems, - type TaskMoveWorkflow, -} from "@/components/task/task-move-context-menu"; -import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; +import { type TaskMoveWorkflow } from "@/components/task/task-move-context-menu"; + +const DRAG_ACTIVATION_DISTANCE = 8; export type TaskSwitcherItem = { id: string; @@ -55,13 +55,6 @@ export type TaskSwitcherItem = { issueInfo?: { url: string; number: number }; }; -type StepDef = { - id: string; - title: string; - color?: string; - events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> }; -}; - type TaskSwitcherProps = { grouped: GroupedSidebarList; workflows?: TaskMoveWorkflow[]; @@ -77,6 +70,9 @@ type TaskSwitcherProps = { onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; + onTogglePin?: (taskId: string) => void; + onReorderGroup?: (groupTaskIds: string[]) => void; + pinnedTaskIds?: string[]; deletingTaskId?: string | null; isLoading?: boolean; totalTaskCount?: number; @@ -148,6 +144,8 @@ type TaskRowProps = { onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; + onTogglePin?: (taskId: string) => void; + isPinned?: boolean; deletingTaskId?: string | null; }; @@ -164,6 +162,8 @@ function TaskRow({ onArchiveTask, onDeleteTask, onMoveToStep, + onTogglePin, + isPinned, deletingTaskId, }: TaskRowProps) { const isSelected = task.id === selectedTaskId || task.id === activeTaskId; @@ -178,6 +178,8 @@ function TaskRow({ onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} onMoveToStep={onMoveToStep} + onTogglePin={onTogglePin} + isPinned={isPinned} isDeleting={deletingTaskId === task.id} > <TaskItem @@ -203,11 +205,168 @@ function TaskRow({ onToggleSubtasks={subtaskToggle?.onToggleSubtasks} onClick={() => onSelectTask(task.id)} isDeleting={deletingTaskId === task.id} + isPinned={isPinned} /> </TaskItemWithContextMenu> ); } +function SortableTaskBlock({ + taskId, + parent, + subTasks, +}: { + taskId: string; + parent: React.ReactNode; + subTasks?: React.ReactNode; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: taskId, + }); + const sortableAttributes = { + ...attributes, + role: undefined, + "aria-roledescription": undefined, + }; + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : undefined, + }; + // setNodeRef stays on the outer wrapper so the CSS transform applies to + // the parent row + its subtasks together. Drag listeners only attach to + // the parent row's wrapper, so a pointer-down on a subtask row does NOT + // trigger a drag of the parent block. + return ( + <div + ref={setNodeRef} + style={style} + data-testid="sortable-task-block" + data-task-id={taskId} + className={cn(isDragging && "z-50")} + > + <div + {...sortableAttributes} + {...listeners} + // Strip dnd-kit's default tabIndex={0}: only PointerSensor is wired, + // so keyboard tab stops here lead nowhere. If KeyboardSensor is + // added later, drop this override. + tabIndex={undefined} + data-testid="sortable-task-handle" + className="cursor-grab active:cursor-grabbing" + > + {parent} + </div> + {subTasks} + </div> + ); +} + +type GroupSectionProps = { + group: SidebarGroup; + subTasksByParentId: Map<string, TaskSwitcherItem[]>; + workflows?: TaskMoveWorkflow[]; + stepsByWorkflowId?: Record<string, StepDef[]>; + activeTaskId: string | null; + selectedTaskId: string | null; + isCollapsed: boolean; + onToggleCollapsed: () => void; + collapsedSubtaskParentIds?: string[]; + onToggleSubtasks?: (parentTaskId: string) => void; + showHeader: boolean; + onSelectTask: (taskId: string) => void; + onRenameTask?: (taskId: string, currentTitle: string) => void; + onArchiveTask?: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; + onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; + onTogglePin?: (taskId: string) => void; + onReorderGroup?: (groupTaskIds: string[]) => void; + pinnedSet: Set<string>; + deletingTaskId?: string | null; +}; + +function useGroupDnd( + groupTasks: TaskSwitcherItem[], + onReorderGroup?: (groupTaskIds: string[]) => void, +) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }), + ); + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + if (!onReorderGroup) return; + const { active, over } = event; + if (!over || active.id === over.id) return; + const ids = groupTasks.map((t) => t.id); + const oldIndex = ids.indexOf(String(active.id)); + const newIndex = ids.indexOf(String(over.id)); + if (oldIndex < 0 || newIndex < 0) return; + onReorderGroup(arrayMove(ids, oldIndex, newIndex)); + }, + [groupTasks, onReorderGroup], + ); + const sortableIds = useMemo(() => groupTasks.map((t) => t.id), [groupTasks]); + return { sensors, handleDragEnd, sortableIds }; +} + +function GroupTaskList({ + group, + subTasksByParentId, + collapsedSubs, + onToggleSubtasks, + pinnedSet, + rowProps, +}: { + group: SidebarGroup; + subTasksByParentId: Map<string, TaskSwitcherItem[]>; + collapsedSubs: Set<string>; + onToggleSubtasks?: (parentTaskId: string) => void; + pinnedSet: Set<string>; + rowProps: Omit<TaskRowProps, "task" | "subtaskToggle" | "isPinned" | "isSubTask">; +}) { + return ( + <> + {group.tasks.map((task) => { + const subs = subTasksByParentId.get(task.id); + const hasSubs = !!subs?.length; + const subsHidden = hasSubs && !!onToggleSubtasks && collapsedSubs.has(task.id); + const toggleInfo: SubtaskToggleInfo | undefined = + hasSubs && onToggleSubtasks + ? { + subtaskCount: subs!.length, + subtasksCollapsed: subsHidden, + onToggleSubtasks: () => onToggleSubtasks(task.id), + } + : undefined; + return ( + <SortableTaskBlock + key={task.id} + taskId={task.id} + parent={ + <TaskRow + task={task} + subtaskToggle={toggleInfo} + isPinned={pinnedSet.has(task.id)} + {...rowProps} + /> + } + subTasks={ + !subsHidden && + subs?.map((sub) => ( + // Subtasks aren't independently sortable or pinnable — pinning + // would show an icon but `floatPinnedToTop` only operates on + // root tasks, so the row wouldn't move. Drop both props to + // avoid the misleading no-op menu item. + <TaskRow key={sub.id} task={sub} isSubTask {...rowProps} onTogglePin={undefined} /> + )) + } + /> + ); + })} + </> + ); +} + function GroupSection({ group, subTasksByParentId, @@ -225,26 +384,11 @@ function GroupSection({ onArchiveTask, onDeleteTask, onMoveToStep, + onTogglePin, + onReorderGroup, + pinnedSet, deletingTaskId, -}: { - group: SidebarGroup; - subTasksByParentId: Map<string, TaskSwitcherItem[]>; - workflows?: TaskMoveWorkflow[]; - stepsByWorkflowId?: Record<string, StepDef[]>; - activeTaskId: string | null; - selectedTaskId: string | null; - isCollapsed: boolean; - onToggleCollapsed: () => void; - collapsedSubtaskParentIds?: string[]; - onToggleSubtasks?: (parentTaskId: string) => void; - showHeader: boolean; - onSelectTask: (taskId: string) => void; - onRenameTask?: (taskId: string, currentTitle: string) => void; - onArchiveTask?: (taskId: string) => void; - onDeleteTask?: (taskId: string) => void; - onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; - deletingTaskId?: string | null; -}) { +}: GroupSectionProps) { const totalCount = group.tasks.reduce( (sum, t) => sum + 1 + (subTasksByParentId.get(t.id)?.length ?? 0), 0, @@ -260,8 +404,10 @@ function GroupSection({ onArchiveTask, onDeleteTask, onMoveToStep, + onTogglePin, deletingTaskId, }; + const { sensors, handleDragEnd, sortableIds } = useGroupDnd(group.tasks, onReorderGroup); return ( <div> @@ -274,138 +420,24 @@ function GroupSection({ onToggle={onToggleCollapsed} /> )} - {!isCollapsed && - group.tasks.map((task) => { - const subs = subTasksByParentId.get(task.id); - const hasSubs = !!subs?.length; - const subsHidden = hasSubs && !!onToggleSubtasks && collapsedSubs.has(task.id); - const toggleInfo: SubtaskToggleInfo | undefined = - hasSubs && onToggleSubtasks - ? { - subtaskCount: subs!.length, - subtasksCollapsed: subsHidden, - onToggleSubtasks: () => onToggleSubtasks(task.id), - } - : undefined; - return ( - <div key={task.id}> - <TaskRow task={task} subtaskToggle={toggleInfo} {...rowProps} /> - {!subsHidden && - subs?.map((sub) => <TaskRow key={sub.id} task={sub} isSubTask {...rowProps} />)} - </div> - ); - })} + {!isCollapsed && ( + <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> + <SortableContext items={sortableIds} strategy={verticalListSortingStrategy}> + <GroupTaskList + group={group} + subTasksByParentId={subTasksByParentId} + collapsedSubs={collapsedSubs} + onToggleSubtasks={onToggleSubtasks} + pinnedSet={pinnedSet} + rowProps={rowProps} + /> + </SortableContext> + </DndContext> + )} </div> ); } -function TaskItemWithContextMenu({ - task, - workflows, - stepsByWorkflowId, - steps, - children, - onRenameTask, - onArchiveTask, - onDeleteTask, - onMoveToStep, - isDeleting, -}: { - task: TaskSwitcherItem; - workflows?: TaskMoveWorkflow[]; - stepsByWorkflowId?: Record<string, StepDef[]>; - steps?: StepDef[]; - children: React.ReactElement<{ menuOpen?: boolean }>; - onRenameTask?: (taskId: string, currentTitle: string) => void; - onArchiveTask?: (taskId: string) => void; - onDeleteTask?: (taskId: string) => void; - onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; - isDeleting?: boolean; -}) { - const [contextOpen, setContextOpen] = useState(false); - const [menuKey, setMenuKey] = useState(0); - const moveTasks = useTaskWorkflowMove(); - const menuOpen = contextOpen || isDeleting === true; - const closeMenu = () => { - setContextOpen(false); - setMenuKey((k) => k + 1); - }; - - return ( - <ContextMenu key={menuKey} onOpenChange={setContextOpen}> - <ContextMenuTrigger asChild> - <div>{cloneWithMenuOpen(children, menuOpen)}</div> - </ContextMenuTrigger> - <ContextMenuContent className="w-48"> - {onRenameTask && ( - <ContextMenuItem disabled={isDeleting} onClick={() => onRenameTask(task.id, task.title)}> - <IconPencil className="mr-2 h-4 w-4" /> - Rename - </ContextMenuItem> - )} - <ContextMenuItem disabled> - <IconCopy className="mr-2 h-4 w-4" /> - Duplicate - </ContextMenuItem> - {onArchiveTask && ( - <ContextMenuItem disabled={isDeleting} onClick={() => onArchiveTask(task.id)}> - <IconArchive className="mr-2 h-4 w-4" /> - Archive - </ContextMenuItem> - )} - {task.workflowId && ( - <TaskMoveContextMenuItems - currentWorkflowId={task.workflowId} - currentStepId={task.workflowStepId} - workflows={workflows ?? []} - stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [task.workflowId]: steps } : {})} - disabled={isDeleting || task.isArchived} - onMoveToStep={ - onMoveToStep - ? (stepId) => { - closeMenu(); - onMoveToStep(task.id, task.workflowId!, stepId); - } - : undefined - } - onSendToWorkflow={(workflowId, stepId) => { - closeMenu(); - void moveTasks([task.id], workflowId, stepId).catch(() => { - // useTaskWorkflowMove already shows the failure toast. - }); - }} - /> - )} - {onDeleteTask && ( - <> - <ContextMenuSeparator /> - <ContextMenuItem - variant="destructive" - disabled={isDeleting} - onClick={() => onDeleteTask(task.id)} - > - {isDeleting ? ( - <IconLoader className="mr-2 h-4 w-4 animate-spin" /> - ) : ( - <IconTrash className="mr-2 h-4 w-4" /> - )} - Delete - </ContextMenuItem> - </> - )} - </ContextMenuContent> - </ContextMenu> - ); -} - -function cloneWithMenuOpen( - children: React.ReactElement<{ menuOpen?: boolean }>, - menuOpen: boolean, -): React.ReactNode { - if (isValidElement(children)) return cloneElement(children, { menuOpen }); - return children; -} - export const TaskSwitcher = memo(function TaskSwitcher({ grouped, workflows, @@ -421,10 +453,14 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onArchiveTask, onDeleteTask, onMoveToStep, + onTogglePin, + onReorderGroup, + pinnedTaskIds, deletingTaskId, isLoading = false, totalTaskCount, }: TaskSwitcherProps) { + const pinnedSet = useMemo(() => new Set(pinnedTaskIds ?? []), [pinnedTaskIds]); if (isLoading) return <TaskSwitcherSkeleton />; const totalTasks = totalTaskCount ?? grouped.groups.reduce((sum, g) => sum + g.tasks.length, 0); if (totalTasks === 0) { @@ -457,6 +493,9 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} onMoveToStep={onMoveToStep} + onTogglePin={onTogglePin} + onReorderGroup={onReorderGroup} + pinnedSet={pinnedSet} deletingTaskId={deletingTaskId} /> ))} diff --git a/apps/web/hooks/domains/sidebar/use-effective-sidebar-view.ts b/apps/web/hooks/domains/sidebar/use-effective-sidebar-view.ts new file mode 100644 index 0000000000..20211fce74 --- /dev/null +++ b/apps/web/hooks/domains/sidebar/use-effective-sidebar-view.ts @@ -0,0 +1,18 @@ +import { useMemo } from "react"; +import { useAppStore } from "@/components/state-provider"; + +/** + * Active sidebar view merged with any in-flight draft. Used by both desktop + * and mobile sidebars so a draft (e.g. sort flipped to "custom" by a drag) + * actually drives `applyView` and not just the sort picker. + */ +export function useEffectiveSidebarView() { + const sidebarSlice = useAppStore((state) => state.sidebarViews); + return useMemo(() => { + const active = sidebarSlice.views.find((v) => v.id === sidebarSlice.activeViewId); + if (!active) return sidebarSlice.views[0]; + const d = sidebarSlice.draft; + if (!d || d.baseViewId !== active.id) return active; + return { ...active, filters: d.filters, sort: d.sort, group: d.group }; + }, [sidebarSlice.views, sidebarSlice.activeViewId, sidebarSlice.draft]); +} diff --git a/apps/web/hooks/domains/sidebar/use-sidebar-task-prefs.ts b/apps/web/hooks/domains/sidebar/use-sidebar-task-prefs.ts new file mode 100644 index 0000000000..32b94604a6 --- /dev/null +++ b/apps/web/hooks/domains/sidebar/use-sidebar-task-prefs.ts @@ -0,0 +1,37 @@ +import { useCallback } from "react"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { mergeGroupOrder } from "@/lib/sidebar/apply-view"; + +/** + * Read sidebar pin/order prefs and expose drag/pin handlers. + * + * `handleReorderGroup` records the new order *and* flips the active sort to + * "Custom" via the view draft — read draft-first so subsequent drags don't + * try to re-set custom on top of an already-custom draft. Both desktop and + * mobile sidebars use this hook to stay in sync. + */ +export function useSidebarTaskPrefs() { + const store = useAppStoreApi(); + const pinnedTaskIds = useAppStore((s) => s.sidebarTaskPrefs.pinnedTaskIds); + const orderedTaskIds = useAppStore((s) => s.sidebarTaskPrefs.orderedTaskIds); + const togglePinnedTask = useAppStore((s) => s.togglePinnedTask); + const setSidebarTaskOrder = useAppStore((s) => s.setSidebarTaskOrder); + const updateSidebarDraft = useAppStore((s) => s.updateSidebarDraft); + + const handleReorderGroup = useCallback( + (groupTaskIds: string[]) => { + const state = store.getState(); + const current = state.sidebarTaskPrefs.orderedTaskIds; + setSidebarTaskOrder(mergeGroupOrder(current, groupTaskIds)); + const sliceState = state.sidebarViews; + const baseSort = + sliceState.draft?.sort ?? + sliceState.views.find((v) => v.id === sliceState.activeViewId)?.sort; + if (!baseSort || baseSort.key === "custom") return; + updateSidebarDraft({ sort: { key: "custom", direction: baseSort.direction } }); + }, + [store, setSidebarTaskOrder, updateSidebarDraft], + ); + + return { pinnedTaskIds, orderedTaskIds, togglePinnedTask, handleReorderGroup }; +} diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index e24771bb47..20e42a9272 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -634,6 +634,17 @@ export function cleanupTaskStorage( setStoredCollapsedSubtaskParents(collapsed.filter((id) => id !== taskId)); } + // Sidebar pin + manual-order arrays. Strip the deleted task so the lists + // don't grow unboundedly across reloads. + const pinned = getStoredPinnedTaskIds(); + if (pinned.includes(taskId)) { + setStoredPinnedTaskIds(pinned.filter((id) => id !== taskId)); + } + const ordered = getStoredOrderedTaskIds(); + if (ordered.includes(taskId)) { + setStoredOrderedTaskIds(ordered.filter((id) => id !== taskId)); + } + // Env-keyed storage — dockview layout + maximize live under task envs. for (const envId of envIds) { removeEnvMaximizeState(envId); @@ -726,6 +737,33 @@ export function removeStoredSidebarDraft(): void { removeLocalStorage(SIDEBAR_DRAFT_KEY); } +// --- Sidebar task prefs: pin + manual order (localStorage, global) --- + +const SIDEBAR_PINNED_TASKS_KEY = "kandev.sidebar.pinnedTaskIds"; +const SIDEBAR_TASK_ORDER_KEY = "kandev.sidebar.orderedTaskIds"; + +function readStringArray(key: string): string[] { + const raw = getLocalStorage<string[]>(key, []) as unknown; + if (!Array.isArray(raw)) return []; + return raw.filter((id): id is string => typeof id === "string"); +} + +export function getStoredPinnedTaskIds(): string[] { + return readStringArray(SIDEBAR_PINNED_TASKS_KEY); +} + +export function setStoredPinnedTaskIds(ids: string[]): void { + setLocalStorage(SIDEBAR_PINNED_TASKS_KEY, ids); +} + +export function getStoredOrderedTaskIds(): string[] { + return readStringArray(SIDEBAR_TASK_ORDER_KEY); +} + +export function setStoredOrderedTaskIds(ids: string[]): void { + setLocalStorage(SIDEBAR_TASK_ORDER_KEY, ids); +} + // --- Sidebar collapsed subtask parents (sessionStorage, tab-scoped) --- const COLLAPSED_SUBTASKS_KEY = "kandev.sidebar.collapsedSubtasks"; diff --git a/apps/web/lib/sidebar/apply-view.test.ts b/apps/web/lib/sidebar/apply-view.test.ts index 08a36fe614..e1cfa998f0 100644 --- a/apps/web/lib/sidebar/apply-view.test.ts +++ b/apps/web/lib/sidebar/apply-view.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import type { TaskSwitcherItem } from "@/components/task/task-switcher"; -import { applyFilters, applyGroup, applySort, applyView } from "./apply-view"; +import { applyFilters, applyGroup, applySort, applyView, mergeGroupOrder } from "./apply-view"; import type { FilterClause, SidebarView } from "@/lib/state/slices/ui/sidebar-view-types"; import { DEFAULT_VIEW } from "@/lib/state/slices/ui/sidebar-view-builtins"; @@ -321,6 +321,197 @@ describe("applyView (integration)", () => { }); }); +describe("applyView — pinned tasks", () => { + const titleAscView: SidebarView = { + id: "v", + name: "v", + filters: [], + sort: { key: "title", direction: "asc" }, + group: "none", + collapsedGroups: [], + }; + + it("floats pinned tasks to top of group regardless of sort", () => { + const tasks = [ + task({ id: "a", title: "Alpha" }), + task({ id: "b", title: "Beta" }), + task({ id: "c", title: "Gamma" }), + ]; + const out = applyView(tasks, titleAscView, { pinnedTaskIds: ["c"], orderedTaskIds: [] }); + expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["c", "a", "b"]); + }); + + it("preserves pin order among pinned tasks", () => { + const tasks = [ + task({ id: "a", title: "Alpha" }), + task({ id: "b", title: "Beta" }), + task({ id: "c", title: "Gamma" }), + ]; + const out = applyView(tasks, titleAscView, { pinnedTaskIds: ["c", "a"], orderedTaskIds: [] }); + expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["c", "a", "b"]); + }); + + it("noop when no tasks are pinned", () => { + const tasks = [task({ id: "a", title: "Alpha" }), task({ id: "b", title: "Beta" })]; + const out = applyView(tasks, titleAscView, { pinnedTaskIds: [], orderedTaskIds: [] }); + expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("applies pin per-group when grouping is active", () => { + const view: SidebarView = { ...titleAscView, group: "workflow" }; + const tasks = [ + task({ id: "a", title: "Alpha", workflowId: "w1", workflowName: "W1" }), + task({ id: "b", title: "Beta", workflowId: "w1", workflowName: "W1" }), + task({ id: "c", title: "Gamma", workflowId: "w2", workflowName: "W2" }), + task({ id: "d", title: "Delta", workflowId: "w2", workflowName: "W2" }), + ]; + const out = applyView(tasks, view, { pinnedTaskIds: ["b", "d"], orderedTaskIds: [] }); + const w1 = out.groups.find((g) => g.label === "W1"); + const w2 = out.groups.find((g) => g.label === "W2"); + expect(w1?.tasks.map((t) => t.id)).toEqual(["b", "a"]); + expect(w2?.tasks.map((t) => t.id)).toEqual(["d", "c"]); + }); + + it("subtasks remain anchored to parent regardless of pin", () => { + const tasks = [ + task({ id: "p1", title: "P1" }), + task({ id: "c1", title: "C1", parentTaskId: "p1" }), + task({ id: "p2", title: "P2" }), + ]; + const out = applyView(tasks, titleAscView, { pinnedTaskIds: ["p2"], orderedTaskIds: [] }); + // root tasks: p2 pinned first, then p1 + expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["p2", "p1"]); + // subtasks unchanged + expect(out.subTasksByParentId.get("p1")?.map((t) => t.id)).toEqual(["c1"]); + }); +}); + +describe("applyView — custom sort", () => { + const customView: SidebarView = { + id: "v", + name: "v", + filters: [], + sort: { key: "custom", direction: "asc" }, + group: "none", + collapsedGroups: [], + }; + + it("orders tasks by index in orderedTaskIds", () => { + const tasks = [ + task({ id: "a", title: "Alpha" }), + task({ id: "b", title: "Beta" }), + task({ id: "c", title: "Gamma" }), + ]; + const out = applyView(tasks, customView, { + pinnedTaskIds: [], + orderedTaskIds: ["c", "a", "b"], + }); + expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["c", "a", "b"]); + }); + + it("places tasks not in orderedTaskIds after listed ones, newest createdAt first", () => { + const tasks = [ + task({ id: "a", title: "Alpha", createdAt: "2026-01-01" }), + task({ id: "b", title: "Beta", createdAt: "2026-04-01" }), + task({ id: "c", title: "Gamma", createdAt: "2026-02-01" }), + ]; + const out = applyView(tasks, customView, { pinnedTaskIds: [], orderedTaskIds: ["c"] }); + expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["c", "b", "a"]); + }); + + it("pinned tasks still float to top within the custom sort", () => { + const tasks = [ + task({ id: "a", title: "Alpha" }), + task({ id: "b", title: "Beta" }), + task({ id: "c", title: "Gamma" }), + ]; + const out = applyView(tasks, customView, { + pinnedTaskIds: ["b"], + orderedTaskIds: ["c", "a", "b"], + }); + expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["b", "c", "a"]); + }); + + it("direction=desc on custom sort does NOT reverse the manual order", () => { + const descView: SidebarView = { ...customView, sort: { key: "custom", direction: "desc" } }; + const tasks = [ + task({ id: "a", title: "Alpha" }), + task({ id: "b", title: "Beta" }), + task({ id: "c", title: "Gamma" }), + ]; + const out = applyView(tasks, descView, { + pinnedTaskIds: [], + orderedTaskIds: ["c", "a", "b"], + }); + // Same as direction=asc — the manual order is intentional, direction does not flip it + expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["c", "a", "b"]); + }); + + it("reordering a parent moves it but its subtasks stay anchored beneath it", () => { + const tasks = [ + task({ id: "p1", title: "P1", createdAt: "2026-01-01" }), + task({ id: "p2", title: "P2", createdAt: "2026-01-02" }), + task({ id: "p3", title: "P3", createdAt: "2026-01-03" }), + task({ id: "c1", title: "C1", parentTaskId: "p1", createdAt: "2026-02-01" }), + task({ id: "c2", title: "C2", parentTaskId: "p1", createdAt: "2026-02-02" }), + ]; + // Simulates a drag of P3 to the front: handleReorderGroup writes only + // root IDs into orderedTaskIds. + const out = applyView(tasks, customView, { + pinnedTaskIds: [], + orderedTaskIds: ["p3", "p1", "p2"], + }); + // Root order matches the drag. + expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["p3", "p1", "p2"]); + // Subtasks stay attached to p1 — they're never in orderedTaskIds and they + // never appear in group.tasks, so dragging the parent doesn't separate + // them or reorder them relative to each other. + expect(out.subTasksByParentId.get("p1")?.map((t) => t.id)).toEqual(["c2", "c1"]); + expect(out.subTasksByParentId.get("p2")).toBeUndefined(); + expect(out.subTasksByParentId.get("p3")).toBeUndefined(); + }); + + it("non-custom sorts ignore orderedTaskIds", () => { + const titleView: SidebarView = { ...customView, sort: { key: "title", direction: "asc" } }; + const tasks = [ + task({ id: "a", title: "Alpha" }), + task({ id: "b", title: "Beta" }), + task({ id: "c", title: "Gamma" }), + ]; + const out = applyView(tasks, titleView, { + pinnedTaskIds: [], + orderedTaskIds: ["c", "b", "a"], + }); + expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["a", "b", "c"]); + }); +}); + +describe("mergeGroupOrder", () => { + it("appends when no group items are in current", () => { + expect(mergeGroupOrder([], ["a", "b"])).toEqual(["a", "b"]); + expect(mergeGroupOrder(["x", "y"], ["a", "b"])).toEqual(["x", "y", "a", "b"]); + }); + + it("anchors at the first occurrence of any group item", () => { + expect(mergeGroupOrder(["x", "a", "y", "b"], ["b", "a"])).toEqual(["x", "b", "a", "y"]); + }); + + it("does not move other-group tasks on a repeated drag in the same group", () => { + // First drag: group A reordered. Second drag: group A reordered again. + // The relative order of A vs B (and the position of A's section) must stay stable. + const afterFirst = mergeGroupOrder([], ["a1", "a2", "a3"]); + expect(afterFirst).toEqual(["a1", "a2", "a3"]); + const afterSecondGroupB = mergeGroupOrder(afterFirst, ["b1", "b2"]); + expect(afterSecondGroupB).toEqual(["a1", "a2", "a3", "b1", "b2"]); + // Now drag A again — A must NOT move to the end (the original bug). + const afterThirdGroupA = mergeGroupOrder(afterSecondGroupB, ["a3", "a1", "a2"]); + expect(afterThirdGroupA).toEqual(["a3", "a1", "a2", "b1", "b2"]); + // And dragging B again must keep A first. + const afterFourthGroupB = mergeGroupOrder(afterThirdGroupA, ["b2", "b1"]); + expect(afterFourthGroupB).toEqual(["a3", "a1", "a2", "b2", "b1"]); + }); +}); + describe("default view semantics", () => { it("default view shows everything, grouped by repository", () => { const tasks = [ diff --git a/apps/web/lib/sidebar/apply-view.ts b/apps/web/lib/sidebar/apply-view.ts index 1f468a31f2..524679b34c 100644 --- a/apps/web/lib/sidebar/apply-view.ts +++ b/apps/web/lib/sidebar/apply-view.ts @@ -23,6 +23,11 @@ export type GroupedSidebarList = { subTasksByParentId: Map<string, TaskSwitcherItem[]>; }; +export type SidebarTaskPrefs = { + pinnedTaskIds: string[]; + orderedTaskIds: string[]; +}; + type DimensionExtractor = (task: TaskSwitcherItem) => FilterValue | undefined; const STATE_BUCKET_ORDER: Record<TaskBucket, number> = { @@ -93,7 +98,9 @@ export function applyFilters( return tasks.filter((task) => clauses.every((clause) => evaluateClause(task, clause))); } -const SORT_COMPARATORS: Record<SortKey, (a: TaskSwitcherItem, b: TaskSwitcherItem) => number> = { +type SortComparator = (a: TaskSwitcherItem, b: TaskSwitcherItem) => number; + +const SORT_COMPARATORS: Record<Exclude<SortKey, "custom">, SortComparator> = { state: (a, b) => { const bucket = STATE_BUCKET_ORDER[getStateBucket(a)] - STATE_BUCKET_ORDER[getStateBucket(b)]; if (bucket !== 0) return bucket; @@ -105,9 +112,32 @@ const SORT_COMPARATORS: Record<SortKey, (a: TaskSwitcherItem, b: TaskSwitcherIte title: (a, b) => (a.title ?? "").localeCompare(b.title ?? ""), }; -export function applySort(tasks: TaskSwitcherItem[], spec: SortSpec): TaskSwitcherItem[] { - const cmp = SORT_COMPARATORS[spec.key]; - const sign = spec.direction === "desc" ? -1 : 1; +function customComparator(orderedTaskIds: string[]): SortComparator { + const order = new Map<string, number>(); + for (let i = 0; i < orderedTaskIds.length; i++) order.set(orderedTaskIds[i], i); + return (a, b) => { + const ai = order.get(a.id); + const bi = order.get(b.id); + if (ai !== undefined && bi !== undefined) return ai - bi; + if (ai !== undefined) return -1; + if (bi !== undefined) return 1; + // Fallback for tasks the user hasn't placed yet: newest createdAt first. + return (b.createdAt ?? "").localeCompare(a.createdAt ?? ""); + }; +} + +export function applySort( + tasks: TaskSwitcherItem[], + spec: SortSpec, + orderedTaskIds: string[] = [], +): TaskSwitcherItem[] { + const cmp: SortComparator = + spec.key === "custom" ? customComparator(orderedTaskIds) : SORT_COMPARATORS[spec.key]; + // "Custom" is a manual order — reversing it on direction=desc would flip + // the user's drag, which has no intuitive meaning. The picker hides the + // direction toggle for custom; this guard keeps the data layer consistent + // even if a stored view ends up with desc direction. + const sign = spec.key !== "custom" && spec.direction === "desc" ? -1 : 1; const withIndex = tasks.map((t, i) => ({ t, i })); withIndex.sort((a, b) => { const primary = cmp(a.t, b.t) * sign; @@ -221,10 +251,80 @@ function sortRepoGroups(groups: SidebarGroup[]): void { }); } -export function applyView(tasks: TaskSwitcherItem[], view: SidebarView): GroupedSidebarList { +/** + * Float pinned tasks to the top within a group, preserving the order the user + * pinned them in. The active sort still determines the order of unpinned + * tasks (and acts as a tiebreaker among pinned tasks not in pinIndex). + */ +function floatPinnedToTop( + tasks: TaskSwitcherItem[], + pinnedSet: Set<string>, + pinIndex: Map<string, number>, +): TaskSwitcherItem[] { + if (pinnedSet.size === 0) return tasks; + const withSortIndex = tasks.map((t, i) => ({ t, sortIdx: i })); + withSortIndex.sort((a, b) => { + const ap = pinnedSet.has(a.t.id); + const bp = pinnedSet.has(b.t.id); + if (ap !== bp) return ap ? -1 : 1; + if (ap && bp) { + const ai = pinIndex.get(a.t.id) ?? 0; + const bi = pinIndex.get(b.t.id) ?? 0; + if (ai !== bi) return ai - bi; + } + return a.sortIdx - b.sortIdx; + }); + return withSortIndex.map((x) => x.t); +} + +/** + * Splice a group's reordered task IDs back into the global manual-order list, + * preserving the position of *other* groups. Without an anchor, repeatedly + * appending the dragged group to the end shuffles section headers (the next + * `applyGroup` pass keys section order off first encounter, so the dragged + * group always ends up last). + * + * Anchor: index of the first existing group task in `current`. If the group + * is being placed for the first time (`firstIdx === -1`), append at the end — + * subsequent drags will pin its position via the anchor. + */ +export function mergeGroupOrder(current: string[], groupTaskIds: string[]): string[] { + const groupSet = new Set(groupTaskIds); + let firstIdx = -1; + for (let i = 0; i < current.length; i++) { + if (groupSet.has(current[i])) { + firstIdx = i; + break; + } + } + const remaining = current.filter((id) => !groupSet.has(id)); + if (firstIdx === -1) return [...remaining, ...groupTaskIds]; + // Items before `firstIdx` in `current` are all non-group (firstIdx is the + // first group occurrence), so translation to `remaining` is identity. + return [...remaining.slice(0, firstIdx), ...groupTaskIds, ...remaining.slice(firstIdx)]; +} + +function buildIndex(ids: string[]): Map<string, number> { + const m = new Map<string, number>(); + for (let i = 0; i < ids.length; i++) m.set(ids[i], i); + return m; +} + +export function applyView( + tasks: TaskSwitcherItem[], + view: SidebarView, + prefs?: SidebarTaskPrefs, +): GroupedSidebarList { const filtered = applyFilters(tasks, view.filters); - const sorted = applySort(filtered, view.sort); - return applyGroup(sorted, view.group); + const sorted = applySort(filtered, view.sort, prefs?.orderedTaskIds); + const grouped = applyGroup(sorted, view.group); + if (!prefs || prefs.pinnedTaskIds.length === 0) return grouped; + const pinnedSet = new Set(prefs.pinnedTaskIds); + const pinIndex = buildIndex(prefs.pinnedTaskIds); + for (const group of grouped.groups) { + group.tasks = floatPinnedToTop(group.tasks, pinnedSet, pinIndex); + } + return grouped; } export function opIsNegative(op: FilterOp): boolean { diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index 8126f74070..d41a3f6bf3 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -81,6 +81,7 @@ export const defaultState = { sidebarViews: defaultUIState.sidebarViews, collapsedSubtaskParents: defaultUIState.collapsedSubtaskParents, kanbanPreviewedTaskId: defaultUIState.kanbanPreviewedTaskId, + sidebarTaskPrefs: defaultUIState.sidebarTaskPrefs, }; export type DefaultState = typeof defaultState; diff --git a/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts new file mode 100644 index 0000000000..4090e5c4b2 --- /dev/null +++ b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts @@ -0,0 +1,36 @@ +import { setStoredOrderedTaskIds, setStoredPinnedTaskIds } from "@/lib/local-storage"; +import type { UISlice } from "./types"; + +type ImmerSet = (recipe: (draft: UISlice) => void, shouldReplace?: false | undefined) => void; + +export function buildSidebarTaskPrefsActions(set: ImmerSet) { + return { + togglePinnedTask: (taskId: string) => + set((draft) => { + const list = draft.sidebarTaskPrefs.pinnedTaskIds; + const idx = list.indexOf(taskId); + if (idx === -1) list.push(taskId); + else list.splice(idx, 1); + setStoredPinnedTaskIds(list); + }), + setSidebarTaskOrder: (orderedTaskIds: string[]) => + set((draft) => { + draft.sidebarTaskPrefs.orderedTaskIds = orderedTaskIds; + setStoredOrderedTaskIds(orderedTaskIds); + }), + removeTaskFromSidebarPrefs: (taskId: string) => + set((draft) => { + const prefs = draft.sidebarTaskPrefs; + const pinIdx = prefs.pinnedTaskIds.indexOf(taskId); + if (pinIdx !== -1) { + prefs.pinnedTaskIds.splice(pinIdx, 1); + setStoredPinnedTaskIds(prefs.pinnedTaskIds); + } + const orderIdx = prefs.orderedTaskIds.indexOf(taskId); + if (orderIdx !== -1) { + prefs.orderedTaskIds.splice(orderIdx, 1); + setStoredOrderedTaskIds(prefs.orderedTaskIds); + } + }), + }; +} diff --git a/apps/web/lib/state/slices/ui/sidebar-view-types.ts b/apps/web/lib/state/slices/ui/sidebar-view-types.ts index 565f3f0ca1..bbf9f22fa2 100644 --- a/apps/web/lib/state/slices/ui/sidebar-view-types.ts +++ b/apps/web/lib/state/slices/ui/sidebar-view-types.ts @@ -22,7 +22,7 @@ export type FilterClause = { value: FilterValue; }; -export type SortKey = "state" | "updatedAt" | "createdAt" | "title"; +export type SortKey = "state" | "updatedAt" | "createdAt" | "title" | "custom"; export type SortDirection = "asc" | "desc"; export type SortSpec = { key: SortKey; direction: SortDirection }; diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index aedf9c1e5f..e2bc25a613 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -100,6 +100,13 @@ export type BottomTerminalState = { pendingCommand: string | null; }; +export type SidebarTaskPrefsState = { + /** Pinned task IDs in display order (first ID renders highest within its group). */ + pinnedTaskIds: string[]; + /** Manual order. Tasks not present fall back to the active sort. */ + orderedTaskIds: string[]; +}; + export type UISliceState = { previewPanel: PreviewPanelState; rightPanel: RightPanelState; @@ -119,6 +126,8 @@ export type UISliceState = { collapsedSubtaskParents: string[]; /** Task ID currently shown in the kanban preview side-panel, or null if closed. */ kanbanPreviewedTaskId: string | null; + /** Sidebar pin + manual-order. Per-browser, persisted to localStorage. */ + sidebarTaskPrefs: SidebarTaskPrefsState; }; export type UISliceActions = { @@ -171,6 +180,14 @@ export type UISliceActions = { clearSidebarSyncError: () => void; migrateLocalViewsToBackend: () => void; setKanbanPreviewedTaskId: (taskId: string | null) => void; + togglePinnedTask: (taskId: string) => void; + setSidebarTaskOrder: (orderedTaskIds: string[]) => void; + /** + * Drop a task ID from both pinned and ordered arrays in-memory and persist. + * Called on task deletion so the in-memory state doesn't out-of-date the + * already-cleaned localStorage and silently re-write the deleted ID back. + */ + removeTaskFromSidebarPrefs: (taskId: string) => void; }; export type { SidebarView, SidebarViewDraft }; diff --git a/apps/web/lib/state/slices/ui/ui-slice.test.ts b/apps/web/lib/state/slices/ui/ui-slice.test.ts index 975bb2eec8..75b27f5c54 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.test.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.test.ts @@ -73,6 +73,76 @@ describe("toggleSubtaskCollapsed", () => { }); }); +describe("sidebar task prefs (pin + manual order)", () => { + const PINNED_KEY = "kandev.sidebar.pinnedTaskIds"; + const ORDER_KEY = "kandev.sidebar.orderedTaskIds"; + + beforeEach(() => { + window.localStorage.clear(); + }); + + it("hydrates pinned + ordered from localStorage", () => { + window.localStorage.setItem(PINNED_KEY, JSON.stringify(["t1"])); + window.localStorage.setItem(ORDER_KEY, JSON.stringify(["t2", "t1"])); + const store = makeStore(); + expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1"]); + expect(store.getState().sidebarTaskPrefs.orderedTaskIds).toEqual(["t2", "t1"]); + }); + + it("togglePinnedTask adds, removes, and persists", () => { + const store = makeStore(); + store.getState().togglePinnedTask("t1"); + expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1"]); + expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t1"]); + + store.getState().togglePinnedTask("t2"); + expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1", "t2"]); + + store.getState().togglePinnedTask("t1"); + expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t2"]); + expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t2"]); + }); + + it("setSidebarTaskOrder replaces and persists", () => { + const store = makeStore(); + store.getState().setSidebarTaskOrder(["a", "b", "c"]); + expect(store.getState().sidebarTaskPrefs.orderedTaskIds).toEqual(["a", "b", "c"]); + expect(JSON.parse(window.localStorage.getItem(ORDER_KEY) ?? "null")).toEqual(["a", "b", "c"]); + + store.getState().setSidebarTaskOrder(["c", "a"]); + expect(store.getState().sidebarTaskPrefs.orderedTaskIds).toEqual(["c", "a"]); + expect(JSON.parse(window.localStorage.getItem(ORDER_KEY) ?? "null")).toEqual(["c", "a"]); + }); + + it("removeTaskFromSidebarPrefs strips the id from both arrays and persists", () => { + const store = makeStore(); + store.getState().togglePinnedTask("t1"); + store.getState().togglePinnedTask("t2"); + store.getState().setSidebarTaskOrder(["t1", "t2", "t3"]); + + store.getState().removeTaskFromSidebarPrefs("t1"); + + expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t2"]); + expect(store.getState().sidebarTaskPrefs.orderedTaskIds).toEqual(["t2", "t3"]); + expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t2"]); + expect(JSON.parse(window.localStorage.getItem(ORDER_KEY) ?? "null")).toEqual(["t2", "t3"]); + + // Subsequent togglePinnedTask must NOT bring "t1" back from a stale draft. + store.getState().togglePinnedTask("t3"); + expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t2", "t3"]); + expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t2", "t3"]); + }); + + it("removeTaskFromSidebarPrefs is a no-op for unknown ids", () => { + const store = makeStore(); + store.getState().togglePinnedTask("t1"); + const before = window.localStorage.getItem(PINNED_KEY); + store.getState().removeTaskFromSidebarPrefs("ghost"); + expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1"]); + expect(window.localStorage.getItem(PINNED_KEY)).toBe(before); + }); +}); + describe("reorderSidebarViews", () => { beforeEach(() => { window.localStorage.clear(); diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index c441a4d5c9..0602cbd601 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -2,6 +2,8 @@ import type { StateCreator } from "zustand"; import { updateUserSettings } from "@/lib/api/domains/settings-api"; import { getStoredCollapsedSubtaskParents, + getStoredOrderedTaskIds, + getStoredPinnedTaskIds, getStoredSidebarActiveViewId, getStoredSidebarDraft, getStoredSidebarUserViews, @@ -13,6 +15,7 @@ import { setStoredSidebarDraft, setStoredSidebarUserViews, } from "@/lib/local-storage"; +import { buildSidebarTaskPrefsActions } from "./sidebar-task-prefs-actions"; import { DEFAULT_ACTIVE_VIEW_ID, DEFAULT_VIEW } from "./sidebar-view-builtins"; import type { FilterClause, @@ -49,7 +52,13 @@ export const KNOWN_DIMENSIONS = new Set<string>([ "titleMatch", ]); -export const KNOWN_SORT_KEYS = new Set<string>(["state", "updatedAt", "createdAt", "title"]); +export const KNOWN_SORT_KEYS = new Set<string>([ + "state", + "updatedAt", + "createdAt", + "title", + "custom", +]); // Drops clauses whose dimension is no longer known (e.g. renamed or removed in an upgrade), // and resets stale sort keys, so the popover does not crash when rendering stored views. @@ -111,6 +120,7 @@ export const defaultUIState: UISliceState = { sidebarViews: loadSidebarState(), collapsedSubtaskParents: [], kanbanPreviewedTaskId: null, + sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [] }, }; type ImmerSet = Parameters<typeof createUISlice>[0]; @@ -511,11 +521,16 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], [] // Hydrate from sessionStorage at slice creation (runs in the browser, after // the default static state) so tests and SSR both see a fresh read. collapsedSubtaskParents: getStoredCollapsedSubtaskParents(), + sidebarTaskPrefs: { + pinnedTaskIds: getStoredPinnedTaskIds(), + orderedTaskIds: getStoredOrderedTaskIds(), + }, ...buildPreviewActions(set), ...buildMobileActions(set), ...buildBottomTerminalActions(set), ...buildConfigChatActions(set), ...buildSidebarViewActions(set, get), + ...buildSidebarTaskPrefsActions(set), ...buildCollapsedSubtaskActions(set, get), ...buildSystemHealthActions(set), setRightPanelActiveTab: (sessionId, tab) => diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 5b968cbd49..440804e0d4 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -240,6 +240,7 @@ export type AppState = { sidebarViews: (typeof defaultUIState)["sidebarViews"]; collapsedSubtaskParents: (typeof defaultUIState)["collapsedSubtaskParents"]; kanbanPreviewedTaskId: (typeof defaultUIState)["kanbanPreviewedTaskId"]; + sidebarTaskPrefs: (typeof defaultUIState)["sidebarTaskPrefs"]; // GitHub actions setGitHubStatus: (status: GitHubStatus | null) => void; @@ -498,6 +499,9 @@ export type AppState = { clearSidebarSyncError: UIA["clearSidebarSyncError"]; migrateLocalViewsToBackend: UIA["migrateLocalViewsToBackend"]; setKanbanPreviewedTaskId: UIA["setKanbanPreviewedTaskId"]; + togglePinnedTask: UIA["togglePinnedTask"]; + setSidebarTaskOrder: UIA["setSidebarTaskOrder"]; + removeTaskFromSidebarPrefs: UIA["removeTaskFromSidebarPrefs"]; }; export type AppStore = ReturnType<typeof createAppStore>; diff --git a/apps/web/lib/ws/handlers/tasks.test.ts b/apps/web/lib/ws/handlers/tasks.test.ts index bcb59f2fb4..257627834b 100644 --- a/apps/web/lib/ws/handlers/tasks.test.ts +++ b/apps/web/lib/ws/handlers/tasks.test.ts @@ -42,6 +42,7 @@ function makeStore(initial: Partial<AppState> = {}) { }, }; }), + removeTaskFromSidebarPrefs: vi.fn(), ...initial, } as unknown as AppState; diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 82def7acd7..021fbae6ed 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -179,6 +179,10 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers { ), ); cleanupTaskStorage(deletedId, sessionIds, envIds); + // Keep the in-memory sidebar pin/order arrays in sync — without this, + // a later togglePinnedTask / setSidebarTaskOrder would persist the + // stale state (still containing the deleted ID) back to localStorage. + currentState.removeTaskFromSidebarPrefs(deletedId); for (const sid of sessionIds) { useContextFilesStore.getState().clearSession(sid); } From 9fe2cbd87498cee32815147aae33357a74450b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= <joao.salavisa@gmail.com> Date: Tue, 5 May 2026 22:09:02 +0100 Subject: [PATCH 16/33] fix: source commit pushed status from git remote, not PR commits (#833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- apps/backend/internal/agentctl/client/git.go | 4 + .../agentctl/server/process/git_log.go | 55 +++++++++++++ .../agentctl/server/process/git_log_test.go | 81 +++++++++++++++++++ .../components/task/changes-panel-helpers.ts | 29 ++++--- .../web/components/task/changes-panel.test.ts | 31 ++++++- .../lib/state/slices/session-runtime/types.ts | 8 ++ 6 files changed, 197 insertions(+), 11 deletions(-) diff --git a/apps/backend/internal/agentctl/client/git.go b/apps/backend/internal/agentctl/client/git.go index 99285313f4..59165ee10d 100644 --- a/apps/backend/internal/agentctl/client/git.go +++ b/apps/backend/internal/agentctl/client/git.go @@ -380,6 +380,10 @@ type GitCommitInfo struct { FilesChanged int `json:"files_changed"` Insertions int `json:"insertions"` Deletions int `json:"deletions"` + // Pushed mirrors process.GitCommitInfo.Pushed — true when the commit is + // reachable from the branch's upstream tracking ref. Sourced from git, so + // it stays correct without an open PR and across multi-repo workspaces. + Pushed bool `json:"pushed"` // RepositoryName tags commits when fetched from a multi-repo subpath. Empty // for single-repo workspaces. Stamped client-side after the per-repo call // so callers can fan out across repos and merge. diff --git a/apps/backend/internal/agentctl/server/process/git_log.go b/apps/backend/internal/agentctl/server/process/git_log.go index e6b7c19a12..7f1e75357d 100644 --- a/apps/backend/internal/agentctl/server/process/git_log.go +++ b/apps/backend/internal/agentctl/server/process/git_log.go @@ -37,6 +37,11 @@ type GitCommitInfo struct { FilesChanged int `json:"files_changed"` Insertions int `json:"insertions"` Deletions int `json:"deletions"` + // Pushed is true when the commit is reachable from the branch's upstream + // tracking ref (i.e. present on the remote). Sourced from git itself — + // not from any PR API — so it stays correct when no PR exists yet, after + // rebases, and across multi-repo workspaces. + Pushed bool `json:"pushed"` // RepositoryName tags commits returned from a multi-repo log fan-out. // Empty for single-repo workspaces. Set by the API layer after the call. RepositoryName string `json:"repository_name,omitempty"` @@ -151,10 +156,60 @@ func (g *GitOperator) GetLog(ctx context.Context, baseCommit string, limit int) }) } + g.markPushedCommits(ctx, result.Commits) + result.Success = true return result, nil } +// markPushedCommits sets Pushed on each commit by looking up which commits in +// HEAD's history are NOT reachable from the upstream tracking ref. A commit is +// pushed iff it is not in that "ahead" set. When the branch has no upstream +// (never been pushed) or the lookup fails, all commits stay Pushed=false — the +// safer default than falsely claiming a commit is on the remote. +func (g *GitOperator) markPushedCommits(ctx context.Context, commits []*GitCommitInfo) { + if len(commits) == 0 { + return + } + upstream := g.getUpstreamRef(ctx) + if upstream == "" { + return + } + // Resolve to a SHA so we can pass it to rev-list as `^<sha>`. The caret + // prefix means the arg doesn't match LooksLikeCommitSHA; it falls through + // to the non-branch catch-all in runGitCommand, which passes it unchanged. + // Safe: upstreamSHA is local rev-parse output, not user input. + upstreamSHA, err := g.runGitCommand(ctx, "rev-parse", upstream) + if err != nil { + return + } + upstreamSHA = strings.TrimSpace(upstreamSHA) + if upstreamSHA == "" { + return + } + // Cap the walk to the number of commits we're marking. Without this, a + // branch with many local-only commits would walk unbounded history per + // GetLog call. rev-list walks newest-first the same way GetLog does, so + // the N most recent unpushed SHAs cover the N commits in our result. + output, err := g.runGitCommand(ctx, "rev-list", + fmt.Sprintf("-n%d", len(commits)), "HEAD", "^"+upstreamSHA) + if err != nil { + return + } + unpushed := make(map[string]struct{}) + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + sha := strings.TrimSpace(line) + if sha != "" { + unpushed[sha] = struct{}{} + } + } + for _, c := range commits { + if _, isUnpushed := unpushed[c.CommitSHA]; !isUnpushed { + c.Pushed = true + } + } +} + // GetCumulativeDiff returns the cumulative diff from baseCommit to the working tree // (including uncommitted/unstaged changes). func (g *GitOperator) GetCumulativeDiff(ctx context.Context, baseCommit string) (*CumulativeDiffResult, error) { diff --git a/apps/backend/internal/agentctl/server/process/git_log_test.go b/apps/backend/internal/agentctl/server/process/git_log_test.go index dd46cc7224..71a954dbc8 100644 --- a/apps/backend/internal/agentctl/server/process/git_log_test.go +++ b/apps/backend/internal/agentctl/server/process/git_log_test.go @@ -125,6 +125,87 @@ func TestGetLog_StaleLocalBranchScenario(t *testing.T) { } } +// TestGetLog_PushedReflectsRemoteState verifies that GetLog tags each commit +// with Pushed = true iff it's reachable from the branch's upstream tracking +// ref. This is the fix for the bug where commits showed as "not pushed" when +// they were actually on the remote — sourcing the flag from PR commits broke +// any flow without an open PR. Sourcing it from git's own remote tracking ref +// keeps the answer correct regardless of PR state. +func TestGetLog_PushedReflectsRemoteState(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + log := newTestLogger(t) + ctx := context.Background() + + // setupTestRepo leaves us on main with one initial commit already pushed. + // Branch off and create one pushed + one unpushed commit so we exercise + // both states inside the same log range. + runGit(t, repoDir, "checkout", "-b", "feature/x") + writeFile(t, repoDir, "a.txt", "a\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "feat: a") + pushedSHA := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) + runGit(t, repoDir, "push", "-u", "origin", "feature/x") + + writeFile(t, repoDir, "b.txt", "b\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "feat: b") + unpushedSHA := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) + + gitOp := NewGitOperator(repoDir, log, nil) + result, err := gitOp.GetLog(ctx, "", 0) + if err != nil { + t.Fatalf("GetLog returned error: %v", err) + } + if !result.Success { + t.Fatalf("GetLog failed: %s", result.Error) + } + + got := make(map[string]bool, len(result.Commits)) + for _, c := range result.Commits { + got[c.CommitSHA] = c.Pushed + } + if pushed, ok := got[pushedSHA]; !ok { + t.Fatalf("pushed commit %s missing from log", pushedSHA) + } else if !pushed { + t.Errorf("expected pushed commit %s to be Pushed=true, got false", pushedSHA) + } + if pushed, ok := got[unpushedSHA]; !ok { + t.Fatalf("unpushed commit %s missing from log", unpushedSHA) + } else if pushed { + t.Errorf("expected local-only commit %s to be Pushed=false, got true", unpushedSHA) + } +} + +// TestGetLog_PushedFalseWhenNoUpstream guards against falsely marking commits +// as pushed on branches that have never been pushed (no upstream configured). +// The "safer default" path must keep Pushed=false rather than guessing. +func TestGetLog_PushedFalseWhenNoUpstream(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + log := newTestLogger(t) + ctx := context.Background() + + // Branch with no upstream — never pushed. + runGit(t, repoDir, "checkout", "-b", "feature/no-upstream") + writeFile(t, repoDir, "a.txt", "a\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "feat: a") + + gitOp := NewGitOperator(repoDir, log, nil) + result, err := gitOp.GetLog(ctx, "", 0) + if err != nil { + t.Fatalf("GetLog returned error: %v", err) + } + for _, c := range result.Commits { + if c.Pushed { + t.Errorf("commit %s on branch with no upstream should be Pushed=false, got true", c.CommitSHA) + } + } +} + // TestGetLog_FirstParentSkipsMergedInCommits verifies that GetLog uses // --first-parent so commits brought in via a merge from main do not appear in // the branch's commit list. Without --first-parent, "git log <merge-base>..HEAD" diff --git a/apps/web/components/task/changes-panel-helpers.ts b/apps/web/components/task/changes-panel-helpers.ts index 56fb1a4392..f139fe2e11 100644 --- a/apps/web/components/task/changes-panel-helpers.ts +++ b/apps/web/components/task/changes-panel-helpers.ts @@ -149,10 +149,15 @@ type MergedCommit = { }; /** - * Merge local session commits and PR commits into a single list. Local - * commits matched to a PR commit are marked pushed; unmatched are unpushed. - * PR-only commits (e.g. from other contributors) are appended as pushed. - * Order: unpushed first, then pushed. + * Merge local session commits and PR commits into a single list. A commit is + * pushed when EITHER source confirms it: the backend's `pushed` field + * (computed from the upstream tracking ref) or a SHA match in `prCommits` (PR + * existence implies the commit is on the remote). The git signal fixes the + * original bug — pushed commits showing as unpushed when no PR exists — while + * keeping the PR signal so a commit visible only via a PR (e.g. authored by + * another contributor, or a rebased SHA the local repo doesn't track yet) + * still renders as pushed. PR commits not represented locally are appended + * as pushed. Order: unpushed first, then pushed. */ export function mergeCommits( localCommits: { @@ -162,24 +167,28 @@ export function mergeCommits( deletions: number; /** Multi-repo: name of the repo this commit was made in. */ repository_name?: string; + pushed?: boolean; }[], prCommits: { sha: string; message: string; additions: number; deletions: number }[], ): MergedCommit[] { - const matchesPR = (localSha: string) => - prCommits.some((pr) => pr.sha.startsWith(localSha) || localSha.startsWith(pr.sha)); + const shaMatches = (a: string, b: string) => a.startsWith(b) || b.startsWith(a); const unpushed: MergedCommit[] = []; const pushed: MergedCommit[] = []; const matchedPRShas = new Set<string>(); for (const c of localCommits) { - if (matchesPR(c.commit_sha)) { + const matchesPR = prCommits.some((pr) => shaMatches(pr.sha, c.commit_sha)); + const isPushed = c.pushed === true || matchesPR; + if (isPushed) { pushed.push({ ...c, pushed: true }); + } else { + unpushed.push({ ...c, pushed: false }); + } + if (matchesPR) { for (const pr of prCommits) { - if (pr.sha.startsWith(c.commit_sha) || c.commit_sha.startsWith(pr.sha)) { + if (shaMatches(pr.sha, c.commit_sha)) { matchedPRShas.add(pr.sha); } } - } else { - unpushed.push({ ...c, pushed: false }); } } for (const pr of prCommits) { diff --git a/apps/web/components/task/changes-panel.test.ts b/apps/web/components/task/changes-panel.test.ts index 8b714a75c6..3eb01404de 100644 --- a/apps/web/components/task/changes-panel.test.ts +++ b/apps/web/components/task/changes-panel.test.ts @@ -71,7 +71,36 @@ describe("mergeCommits", () => { ]); }); - it("marks local commits as pushed when they match PR commits", () => { + it("trusts the backend pushed flag even with no PR (the bug fix)", () => { + // The original bug: local commits were marked unpushed whenever no PR + // existed, because pushed was derived from PR-SHA matching alone. + // Sourcing pushed from git on the backend means commits pushed to a + // branch without a PR show as pushed. + const local = [ + { ...makeLocal("aaa1111", "pushed"), pushed: true }, + { ...makeLocal("bbb2222", "local"), pushed: false }, + ]; + const result = mergeCommits(local, []); + expect(result[0]).toMatchObject({ commit_sha: "bbb2222", pushed: false }); + expect(result[1]).toMatchObject({ commit_sha: "aaa1111", pushed: true }); + }); + + it("backend pushed flag wins over PR-SHA mismatch (post-rebase scenario)", () => { + // After a rebase the local SHA no longer matches the PR's SHA, but the + // commit IS on the remote (force-pushed). The backend's pushed=true + // must override the missing PR match. + const local = [{ ...makeLocal("rebased1", "rebased"), pushed: true }]; + const pr = [makePRFull("oldsha9999", "rebased")]; + const result = mergeCommits(local, pr); + expect(result[0]).toMatchObject({ commit_sha: "rebased1", pushed: true }); + // PR-only SHA still appears, since it isn't matched. + expect(result).toHaveLength(2); + expect(result[1]).toMatchObject({ commit_sha: "oldsha9999", pushed: true }); + }); + + it("falls back to PR-SHA matching when backend pushed flag is absent", () => { + // Older commit_created notifications don't carry the pushed flag. + // PR matching kicks in as a backstop. const local = [makeLocal("aaa1111", "first")]; const pr = [makePRFull("aaa1111bbbccc", "first", "user")]; const result = mergeCommits(local, pr); diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 77724932bd..4dc72ce667 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -112,6 +112,14 @@ export type SessionCommit = { created_at: string; /** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */ repository_name?: string; + /** + * True when the commit is reachable from the branch's upstream tracking ref. + * Sourced from git on the backend so it stays correct without an open PR. + * Optional because incremental commit_created notifications don't carry it + * (newly-made commits are always unpushed); the next full refetch fills it + * in with the real value. + */ + pushed?: boolean; }; export type CumulativeDiff = { From b1cd2d2076ef4ad7f06dcb2245c29142d263cbe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= <carlosmflorencio@gmail.com> Date: Wed, 6 May 2026 16:51:06 +0100 Subject: [PATCH 17/33] feat(github): rate-limit awareness, poller throttling, and GraphQL batching (#821) --- apps/backend/cmd/kandev/helpers.go | 30 +- .../server/api/git_log_merge_base_test.go | 2 +- .../agentctl/server/process/git_log_test.go | 2 +- apps/backend/internal/events/types.go | 13 +- .../gateway/websocket/task_notifications.go | 1 + .../websocket/task_notifications_test.go | 2 +- apps/backend/internal/github/gh_client.go | 119 +++- .../backend/internal/github/gh_client_test.go | 87 +++ apps/backend/internal/github/graphql.go | 540 ++++++++++++++++++ apps/backend/internal/github/graphql_test.go | 296 ++++++++++ apps/backend/internal/github/models.go | 23 +- apps/backend/internal/github/pat_client.go | 88 ++- .../internal/github/pat_client_test.go | 140 +++++ apps/backend/internal/github/poller.go | 239 +++++++- apps/backend/internal/github/poller_test.go | 336 +++++++++++ apps/backend/internal/github/provider.go | 24 + apps/backend/internal/github/ratelimit.go | 244 ++++++++ .../backend/internal/github/ratelimit_test.go | 230 ++++++++ apps/backend/internal/github/service.go | 80 ++- .../internal/github/service_token_test.go | 20 + apps/backend/internal/health/checks.go | 66 ++- apps/backend/internal/health/checks_test.go | 37 ++ apps/backend/pkg/websocket/actions.go | 1 + .../components/github/github-rate-limit.tsx | 90 +++ apps/web/components/github/github-status.tsx | 2 + .../state/slices/github/github-slice.test.ts | 106 ++++ .../lib/state/slices/github/github-slice.ts | 19 + apps/web/lib/state/slices/github/types.ts | 2 + apps/web/lib/state/store.ts | 2 + apps/web/lib/types/backend.ts | 6 +- apps/web/lib/types/github.ts | 23 + apps/web/lib/ws/handlers/github.ts | 8 +- 32 files changed, 2843 insertions(+), 35 deletions(-) create mode 100644 apps/backend/internal/github/graphql.go create mode 100644 apps/backend/internal/github/graphql_test.go create mode 100644 apps/backend/internal/github/ratelimit.go create mode 100644 apps/backend/internal/github/ratelimit_test.go create mode 100644 apps/web/components/github/github-rate-limit.tsx create mode 100644 apps/web/lib/state/slices/github/github-slice.test.ts diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index dad4ef4c60..d3d2a84a00 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -654,16 +654,44 @@ func registerSecondaryRoutes( // registerHealthRoutes sets up the system health endpoint with all health checkers. func registerHealthRoutes(p routeParams) { var githubProvider health.GitHubStatusProvider + var githubRateProvider health.GitHubRateLimitProvider if p.services.GitHub != nil { githubProvider = p.services.GitHub + githubRateProvider = githubRateLimitAdapter{svc: p.services.GitHub} + } + githubChecker := health.NewGitHubChecker(githubProvider) + if githubRateProvider != nil { + githubChecker.WithRateLimitProvider(githubRateProvider) } healthSvc := health.NewService(p.log, - health.NewGitHubChecker(githubProvider), + githubChecker, health.NewAgentChecker(p.agentSettingsController), ) health.RegisterRoutes(p.router, healthSvc, p.log) } +// githubRateLimitAdapter bridges the github.Service's per-resource exhaustion +// snapshot to the structural shape consumed by the health package without +// importing health into github (cycle). +type githubRateLimitAdapter struct { + svc *github.Service +} + +func (a githubRateLimitAdapter) ExhaustedRateLimits() []health.GitHubRateLimitStatus { + if a.svc == nil { + return nil + } + src := a.svc.ExhaustedRateLimits() + if len(src) == 0 { + return nil + } + out := make([]health.GitHubRateLimitStatus, len(src)) + for i, s := range src { + out[i] = health.GitHubRateLimitStatus{Resource: s.Resource, ResetAt: s.ResetAt} + } + return out +} + // registerMCPAndDebugRoutes registers MCP and debug routes and wires the MCP handler. func registerMCPAndDebugRoutes( p routeParams, diff --git a/apps/backend/internal/agentctl/server/api/git_log_merge_base_test.go b/apps/backend/internal/agentctl/server/api/git_log_merge_base_test.go index 21c622b864..5f26028d4d 100644 --- a/apps/backend/internal/agentctl/server/api/git_log_merge_base_test.go +++ b/apps/backend/internal/agentctl/server/api/git_log_merge_base_test.go @@ -34,7 +34,7 @@ func TestComputeMergeBase_PrefersOriginOverStaleLocalBranch(t *testing.T) { advancedMain := strings.TrimSpace(runGitAPI(t, repoDir, "rev-parse", "HEAD")) runGitAPI(t, repoDir, "push", "origin", "main") runGitAPI(t, repoDir, "reset", "--hard", staleLocalMain) - runGitAPI(t, repoDir, "fetch", "origin", "main:refs/remotes/origin/main") + runGitAPI(t, repoDir, "fetch", "origin") // Branch from the (advanced) origin/main and add one commit. runGitAPI(t, repoDir, "checkout", "-b", "feature/x", advancedMain) diff --git a/apps/backend/internal/agentctl/server/process/git_log_test.go b/apps/backend/internal/agentctl/server/process/git_log_test.go index 71a954dbc8..d63ee00f3e 100644 --- a/apps/backend/internal/agentctl/server/process/git_log_test.go +++ b/apps/backend/internal/agentctl/server/process/git_log_test.go @@ -79,7 +79,7 @@ func TestGetLog_StaleLocalBranchScenario(t *testing.T) { // Reset local main back to staleLocalMain (simulates "didn't fetch"). runGit(t, repoDir, "reset", "--hard", staleLocalMain) // Now refetch so origin/main is ahead but local main isn't. - runGit(t, repoDir, "fetch", "origin", "main:refs/remotes/origin/main") + runGit(t, repoDir, "fetch", "origin") // Branch from origin/main (the current upstream tip). runGit(t, repoDir, "checkout", "-b", "feature/x", advancedMain) diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 750d6d595a..2e569961d2 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -198,12 +198,13 @@ const ( // Event types for GitHub integration const ( - GitHubPRFeedback = "github.pr_feedback" // PR has new feedback (UI notification only) - GitHubPRStateChanged = "github.pr_state_changed" // PR state changed (merged, closed, etc.) - GitHubNewReviewPR = "github.new_pr_to_review" // New PR found needing review - GitHubNewIssue = "github.new_issue" // New issue found matching issue watch - GitHubTaskPRUpdated = "github.task_pr.updated" // TaskPR record updated (for UI refresh) - GitHubWatchEvent = "github.watch.event" // Watch created/deleted + GitHubPRFeedback = "github.pr_feedback" // PR has new feedback (UI notification only) + GitHubPRStateChanged = "github.pr_state_changed" // PR state changed (merged, closed, etc.) + GitHubNewReviewPR = "github.new_pr_to_review" // New PR found needing review + GitHubNewIssue = "github.new_issue" // New issue found matching issue watch + GitHubTaskPRUpdated = "github.task_pr.updated" // TaskPR record updated (for UI refresh) + GitHubWatchEvent = "github.watch.event" // Watch created/deleted + GitHubRateLimitUpdated = "github.rate_limit.updated" // GitHub API rate-limit snapshot changed ) // Event types for Jira integration diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index 5956fb0974..89ebfe3234 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -73,6 +73,7 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.TurnCompleted, ws.ActionSessionTurnCompleted) b.subscribe(eventBus, events.MessageQueueStatusChanged, ws.ActionMessageQueueStatusChanged) b.subscribe(eventBus, events.GitHubTaskPRUpdated, ws.ActionGitHubTaskPRUpdated) + b.subscribe(eventBus, events.GitHubRateLimitUpdated, ws.ActionGitHubRateLimitUpdated) go func() { <-ctx.Done() diff --git a/apps/backend/internal/gateway/websocket/task_notifications_test.go b/apps/backend/internal/gateway/websocket/task_notifications_test.go index 3efae8079c..4bd046838e 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications_test.go +++ b/apps/backend/internal/gateway/websocket/task_notifications_test.go @@ -39,7 +39,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) { // // Update this number when adding or removing event subscriptions in // RegisterTaskNotifications — it is intentionally exact. - const wantSubscriptions = 48 + const wantSubscriptions = 49 if got := len(b.subscriptions); got != wantSubscriptions { t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+ "did an event get subscribed twice?", got, wantSubscriptions) diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index 03a95468c0..73c9ba7ebb 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -13,13 +13,79 @@ import ( ) // GHClient implements Client using the gh CLI. -type GHClient struct{} +type GHClient struct { + rateTracker *RateTracker +} // NewGHClient creates a new gh CLI-based client. func NewGHClient() *GHClient { return &GHClient{} } +// WithRateTracker attaches a rate tracker so stderr output is inspected for +// rate-limit signals after every gh invocation. Returns the client for +// chaining; safe to call before any commands run. +func (c *GHClient) WithRateTracker(t *RateTracker) *GHClient { + c.rateTracker = t + return c +} + +// reGHRateLimit matches the prose gh prints when a request hit a primary or +// secondary rate limit. The exact text varies by gh version and locale, but +// "rate limit" / "API rate limit" appear consistently. +var ghRateLimitMarkers = []string{"rate limit", "abuse detection"} + +func ghStderrIndicatesRateLimit(stderr string) bool { + if stderr == "" { + return false + } + lower := strings.ToLower(stderr) + for _, marker := range ghRateLimitMarkers { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// inspectRateStderr inspects the stderr from a failed `gh` invocation and +// flags the appropriate resource bucket as exhausted. `gh api <path>` is REST +// (Core) by default, with `api graphql` and `api search/*` as the documented +// exceptions; non-`api` subcommands like `pr`, `issue`, and `repo` are +// implemented against GraphQL by gh itself, so they map to the GraphQL bucket. +func (c *GHClient) inspectRateStderr(args []string, stderr string) { + if c.rateTracker == nil { + return + } + if !ghStderrIndicatesRateLimit(stderr) { + return + } + c.rateTracker.markRateExhausted(resourceForGHArgs(args), time.Time{}) +} + +// resourceForGHArgs maps a `gh` argv to the rate-limit bucket the call hits. +// Exposed at package scope so the table-driven test can pin every branch. +func resourceForGHArgs(args []string) Resource { + if len(args) == 0 { + return ResourceCore + } + if args[0] != "api" { + // `gh pr`, `gh issue`, `gh repo`, etc. are all GraphQL under the hood. + return ResourceGraphQL + } + if len(args) < 2 { + return ResourceCore + } + switch { + case args[1] == "graphql": + return ResourceGraphQL + case strings.HasPrefix(args[1], "search/"): + return ResourceSearch + default: + return ResourceCore + } +} + // GHAvailable checks if the gh CLI is installed and accessible. func GHAvailable() bool { _, err := exec.LookPath("gh") @@ -516,11 +582,62 @@ func (c *GHClient) run(ctx context.Context, args ...string) (string, error) { cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + c.inspectRateStderr(args, stderr.String()) return stdout.String(), fmt.Errorf("gh %s: %w: %s", args[0], err, stderr.String()) } return stdout.String(), nil } +// ghRateLimitResponse mirrors the GET /rate_limit JSON shape so we can seed +// the tracker on startup and after CLI failures without parsing prose. +type ghRateLimitResponse struct { + Resources struct { + Core ghRateLimitBucket `json:"core"` + GraphQL ghRateLimitBucket `json:"graphql"` + Search ghRateLimitBucket `json:"search"` + } `json:"resources"` +} + +type ghRateLimitBucket struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + Reset int64 `json:"reset"` +} + +// FetchRateLimit calls `gh api rate_limit` and seeds the tracker with the +// returned snapshots. Best-effort: a failure (e.g. CLI absent, network) is +// logged and ignored. +func (c *GHClient) FetchRateLimit(ctx context.Context) error { + if c.rateTracker == nil { + return nil + } + out, err := c.run(ctx, "api", "rate_limit") + if err != nil { + return fmt.Errorf("fetch rate limit: %w", err) + } + var raw ghRateLimitResponse + if err := json.Unmarshal([]byte(out), &raw); err != nil { + return fmt.Errorf("parse rate_limit response: %w", err) + } + now := time.Now().UTC() + record := func(resource Resource, b ghRateLimitBucket) { + if b.Limit == 0 && b.Remaining == 0 && b.Reset == 0 { + return + } + c.rateTracker.Record(RateSnapshot{ + Resource: resource, + Limit: b.Limit, + Remaining: b.Remaining, + ResetAt: time.Unix(b.Reset, 0).UTC(), + UpdatedAt: now, + }) + } + record(ResourceCore, raw.Resources.Core) + record(ResourceGraphQL, raw.Resources.GraphQL) + record(ResourceSearch, raw.Resources.Search) + return nil +} + func (c *GHClient) parsePRList(data string, owner, repo string) ([]*PR, error) { var raw []ghPR if err := json.Unmarshal([]byte(data), &raw); err != nil { diff --git a/apps/backend/internal/github/gh_client_test.go b/apps/backend/internal/github/gh_client_test.go index 56c3a1be76..dac69d689b 100644 --- a/apps/backend/internal/github/gh_client_test.go +++ b/apps/backend/internal/github/gh_client_test.go @@ -237,3 +237,90 @@ func TestConvertGHRequestedReviewers(t *testing.T) { t.Errorf("unexpected third reviewer: %#v", got[2]) } } + +func TestGHStderrIndicatesRateLimit(t *testing.T) { + cases := []struct { + stderr string + want bool + }{ + {"GraphQL: API rate limit already exceeded for user ID 12345.", true}, + {"You have exceeded a secondary rate limit.", true}, + {"abuse detection mechanism triggered", true}, + {"network: connection refused", false}, + {"", false}, + } + for _, c := range cases { + if got := ghStderrIndicatesRateLimit(c.stderr); got != c.want { + t.Errorf("ghStderrIndicatesRateLimit(%q) = %v, want %v", c.stderr, got, c.want) + } + } +} + +func TestGHClient_InspectRateStderr_MarksGraphQL(t *testing.T) { + tracker := NewRateTracker(nil, nil) + c := NewGHClient().WithRateTracker(tracker) + c.inspectRateStderr([]string{"pr", "view", "1"}, "GraphQL: API rate limit already exceeded") + if !tracker.IsExhausted(ResourceGraphQL) { + t.Errorf("expected graphql exhausted") + } +} + +func TestGHClient_InspectRateStderr_MarksSearchForSearchEndpoints(t *testing.T) { + tracker := NewRateTracker(nil, nil) + c := NewGHClient().WithRateTracker(tracker) + c.inspectRateStderr([]string{"api", "search/issues"}, "API rate limit exceeded") + if !tracker.IsExhausted(ResourceSearch) { + t.Errorf("expected search exhausted") + } +} + +func TestResourceForGHArgs(t *testing.T) { + cases := []struct { + name string + args []string + want Resource + }{ + // REST endpoints under `gh api <path>` go to Core, not GraphQL. + // This is the regression: previously every gh api failure was + // attributed to GraphQL unless the path started with search/. + {"api repos REST", []string{"api", "repos/o/r/pulls/1"}, ResourceCore}, + {"api user REST", []string{"api", "user"}, ResourceCore}, + {"api rate_limit REST", []string{"api", "rate_limit"}, ResourceCore}, + + // Documented exceptions still resolve to their dedicated buckets. + {"api graphql", []string{"api", "graphql"}, ResourceGraphQL}, + {"api search/issues", []string{"api", "search/issues"}, ResourceSearch}, + {"api search/repositories", []string{"api", "search/repositories"}, ResourceSearch}, + + // Non-`api` subcommands are GraphQL — gh implements pr/issue/repo + // against the GraphQL API. + {"pr view", []string{"pr", "view", "1"}, ResourceGraphQL}, + {"issue list", []string{"issue", "list"}, ResourceGraphQL}, + {"repo view", []string{"repo", "view"}, ResourceGraphQL}, + + // Defensive defaults for malformed argv. + {"empty", nil, ResourceCore}, + {"api only", []string{"api"}, ResourceCore}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resourceForGHArgs(tc.args); got != tc.want { + t.Errorf("resourceForGHArgs(%v) = %s, want %s", tc.args, got, tc.want) + } + }) + } +} + +// Regression: a 429 on `gh api repos/...` (REST) must mark Core, not GraphQL. +// Previously this would pause the GraphQL PR monitor incorrectly. +func TestGHClient_InspectRateStderr_RestEndpointMarksCore(t *testing.T) { + tracker := NewRateTracker(nil, nil) + c := NewGHClient().WithRateTracker(tracker) + c.inspectRateStderr([]string{"api", "repos/o/r/pulls/1"}, "API rate limit exceeded") + if !tracker.IsExhausted(ResourceCore) { + t.Errorf("expected core bucket exhausted for REST endpoint") + } + if tracker.IsExhausted(ResourceGraphQL) { + t.Errorf("REST 429 must not pause graphql bucket") + } +} diff --git a/apps/backend/internal/github/graphql.go b/apps/backend/internal/github/graphql.go new file mode 100644 index 0000000000..5cd5fe4e19 --- /dev/null +++ b/apps/backend/internal/github/graphql.go @@ -0,0 +1,540 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// GraphQLExecutor runs a single GraphQL query against the GitHub API. Both +// PATClient (direct HTTP) and GHClient (`gh api graphql`) implement it so the +// batched poller works regardless of auth method. +type GraphQLExecutor interface { + ExecuteGraphQL(ctx context.Context, query string, variables map[string]any, out any) error +} + +// graphQLPRBatchAlias is the per-PR alias prefix; the index makes aliases +// unique within a single repository block. +const graphQLPRBatchAlias = "pr" + +// graphQLBatchChunkSize bounds the number of aliased fields per request to +// stay well under GitHub's per-query node-count limit (500). 50 PRs × ~10 +// fields = ~500 nodes, leaving headroom. +const graphQLBatchChunkSize = 50 + +// reviewNode is one PR review entry from the batched GraphQL query. +type reviewNode struct { + State string `json:"state"` + Author struct { + Login string `json:"login"` + } `json:"author"` + SubmittedAt time.Time `json:"submittedAt"` +} + +// graphQLPRRef is one entry in a batched PR-status request. +type graphQLPRRef struct { + Owner string + Repo string + Number int +} + +// graphQLBranchRef is one entry in a batched branch-lookup request. +type graphQLBranchRef struct { + Owner string + Repo string + Branch string +} + +// chunkedRefs splits refs into chunks of at most graphQLBatchChunkSize so +// callers can keep individual GraphQL queries under the node-count limit. +func chunkedRefs[T any](refs []T) [][]T { + if len(refs) == 0 { + return nil + } + out := make([][]T, 0, (len(refs)+graphQLBatchChunkSize-1)/graphQLBatchChunkSize) + for i := 0; i < len(refs); i += graphQLBatchChunkSize { + end := i + graphQLBatchChunkSize + if end > len(refs) { + end = len(refs) + } + out = append(out, refs[i:end]) + } + return out +} + +// batchedPRResult is the decoded shape of one aliased pullRequest block. +type batchedPRResult struct { + State string `json:"state"` + Title string `json:"title"` + URL string `json:"url"` + IsDraft bool `json:"isDraft"` + Mergeable string `json:"mergeable"` + MergeStatus string `json:"mergeStateStatus"` + HeadRefName string `json:"headRefName"` + BaseRefName string `json:"baseRefName"` + HeadRefOid string `json:"headRefOid"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Author struct { + Login string `json:"login"` + } `json:"author"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + MergedAt string `json:"mergedAt"` + ClosedAt string `json:"closedAt"` + Reviews struct { + Nodes []reviewNode `json:"nodes"` + } `json:"reviews"` + ReviewRequests struct { + TotalCount int `json:"totalCount"` + } `json:"reviewRequests"` + Commits struct { + Nodes []struct { + Commit struct { + StatusCheckRollup *struct { + State string `json:"state"` + } `json:"statusCheckRollup"` + } `json:"commit"` + } `json:"nodes"` + } `json:"commits"` +} + +// graphQLError mirrors a single entry in a GraphQL response's "errors" array. +type graphQLError struct { + Message string `json:"message"` + Type string `json:"type"` + Path []string `json:"path"` +} + +// graphQLErrorsToErr returns a non-nil error when the GraphQL response carries +// a top-level "errors" array. The first message is included so logs are +// actionable; the count is appended when there are multiple. +func graphQLErrorsToErr(errs []graphQLError) error { + if len(errs) == 0 { + return nil + } + if len(errs) == 1 { + return fmt.Errorf("graphql error: %s", errs[0].Message) + } + return fmt.Errorf("graphql error: %s (and %d more)", errs[0].Message, len(errs)-1) +} + +// graphQLRateLimit mirrors GitHub's top-level rateLimit field, the most +// accurate source of GraphQL quota since limits are point-cost based. +type graphQLRateLimit struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + ResetAt time.Time `json:"resetAt"` + Cost int `json:"cost"` +} + +// buildBatchedPRQuery groups refs by (owner, repo) and emits one aliased +// repository block per group, with one aliased pullRequest per number inside. +// The shape mirrors gh pr view fields used by the existing converter so we +// can reuse the conversion logic without reshaping callers. +func buildBatchedPRQuery(refs []graphQLPRRef) (string, map[string]any) { + // Group refs by (owner, repo) and assign deterministic indices so aliases + // stay stable across runs (helpful for tests and snapshot debugging). + type group struct { + owner string + repo string + numbers []int + } + byKey := map[string]*group{} + keys := []string{} + for _, r := range refs { + key := r.Owner + "/" + r.Repo + g, ok := byKey[key] + if !ok { + g = &group{owner: r.Owner, repo: r.Repo} + byKey[key] = g + keys = append(keys, key) + } + g.numbers = append(g.numbers, r.Number) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString("query Batch { ") + for repoIdx, key := range keys { + g := byKey[key] + fmt.Fprintf(&b, `repo%d: repository(owner: %q, name: %q) { `, repoIdx, g.owner, g.repo) + for prIdx, n := range g.numbers { + fmt.Fprintf(&b, `%s%d: pullRequest(number: %d) { %s } `, graphQLPRBatchAlias, prIdx, n, prFieldsBlock()) + } + b.WriteString(`} `) + } + b.WriteString(`rateLimit { limit remaining resetAt cost } `) + b.WriteString(`}`) + return b.String(), nil +} + +// prFieldsBlock is the GraphQL field selection used in every batched PR +// query. Kept as a constant to make snapshot assertions stable and to keep +// the batched and single-PR paths returning the same data. +func prFieldsBlock() string { + return `state title url isDraft mergeable mergeStateStatus ` + + `headRefName baseRefName headRefOid additions deletions ` + + `author { login } createdAt updatedAt mergedAt closedAt ` + + `reviews(last: 100) { nodes { state author { login } submittedAt } } ` + + `reviewRequests(first: 0) { totalCount } ` + + `commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }` +} + +// buildBatchedBranchQuery emits one aliased associatedPullRequests block per +// (owner, repo, branch) ref. Used to batch the "find PR by branch" path. +func buildBatchedBranchQuery(refs []graphQLBranchRef) (string, map[string]any) { + var b strings.Builder + b.WriteString("query Branches { ") + for i, r := range refs { + fmt.Fprintf(&b, `b%d: repository(owner: %q, name: %q) { ref(qualifiedName: %q) { associatedPullRequests(first: 1, states: OPEN) { nodes { number %s } } } } `, + i, r.Owner, r.Repo, "refs/heads/"+r.Branch, prFieldsBlock()) + } + b.WriteString(`rateLimit { limit remaining resetAt cost } `) + b.WriteString(`}`) + return b.String(), nil +} + +// convertBatchedPRResult turns a batched GraphQL row into the (PR, PRStatus) +// pair the existing poller code uses. +func convertBatchedPRResult(raw *batchedPRResult, owner, repo string, number int) *PRStatus { + state := strings.ToLower(raw.State) + if raw.MergedAt != "" { + state = prStateMerged + } + pr := &PR{ + Number: number, + Title: raw.Title, + URL: raw.URL, + HTMLURL: raw.URL, + State: state, + HeadBranch: raw.HeadRefName, + HeadSHA: raw.HeadRefOid, + BaseBranch: raw.BaseRefName, + AuthorLogin: raw.Author.Login, + RepoOwner: owner, + RepoName: repo, + Draft: raw.IsDraft, + Mergeable: raw.Mergeable == "MERGEABLE", + MergeableState: strings.ToLower(raw.MergeStatus), + Additions: raw.Additions, + Deletions: raw.Deletions, + CreatedAt: raw.CreatedAt, + UpdatedAt: raw.UpdatedAt, + MergedAt: parseTimePtr(raw.MergedAt), + ClosedAt: parseTimePtr(raw.ClosedAt), + } + + reviewState := summarizeReviewState(raw.Reviews.Nodes) + checksState := "" + if len(raw.Commits.Nodes) > 0 && raw.Commits.Nodes[0].Commit.StatusCheckRollup != nil { + checksState = strings.ToLower(raw.Commits.Nodes[0].Commit.StatusCheckRollup.State) + } + return &PRStatus{ + PR: pr, + ReviewState: reviewState, + ChecksState: checksState, + MergeableState: pr.MergeableState, + } +} + +// summarizeReviewState collapses the review history to a single +// "approved"/"changes_requested"/"" value matching the existing +// service-side summary. Per-reviewer dedup: each reviewer's most-recent +// review wins, so a CHANGES_REQUESTED followed by an APPROVED from the +// same author resolves to APPROVED. CHANGES_REQUESTED beats APPROVED across +// distinct reviewers. +func summarizeReviewState(nodes []reviewNode) string { + type latest struct { + state string + at time.Time + } + byAuthor := map[string]latest{} + for _, n := range nodes { + // Anonymous reviewers (deleted users) get a unique bucket so each + // review still counts independently. + key := n.Author.Login + if key == "" { + key = "<anon>:" + n.SubmittedAt.UTC().Format(time.RFC3339Nano) + } + state := strings.ToUpper(n.State) + // COMMENTED and PENDING are non-binding states and shouldn't replace + // a prior APPROVED / CHANGES_REQUESTED for the same reviewer. + if state != reviewStateApproved && state != reviewStateChangesRequested { + if _, ok := byAuthor[key]; ok { + continue + } + } + prev, ok := byAuthor[key] + if !ok || !n.SubmittedAt.Before(prev.at) { + byAuthor[key] = latest{state: state, at: n.SubmittedAt} + } + } + hasApproval := false + for _, l := range byAuthor { + switch l.state { + case reviewStateChangesRequested: + return computedReviewStateChangesRequested + case reviewStateApproved: + hasApproval = true + } + } + if hasApproval { + return computedReviewStateApproved + } + return "" +} + +// PATClient.ExecuteGraphQL satisfies GraphQLExecutor by POSTing to /graphql. +func (c *PATClient) ExecuteGraphQL(ctx context.Context, query string, variables map[string]any, out any) error { + body, err := json.Marshal(map[string]any{"query": query, "variables": variables}) + if err != nil { + return fmt.Errorf("marshal graphql: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubAPIBase+"/graphql", bytes.NewReader(body)) + if err != nil { + return err + } + c.setGitHubHeaders(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("graphql request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + c.recordRateHeaders(resp, "/graphql") + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024)) + if resp.StatusCode >= 400 { + c.maybeMarkRateExhaustedFromBody("/graphql", resp.StatusCode, respBody) + return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: "/graphql", Body: string(respBody)} + } + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decode graphql response: %w", err) + } + recordGraphQLRateFromPayload(c.rateTracker, respBody) + return nil +} + +// recordGraphQLRateFromPayload extracts data.rateLimit from a GraphQL +// response body and feeds it to the tracker. The GraphQL rate-limit field is +// point-cost based and more accurate than HTTP headers. +func recordGraphQLRateFromPayload(tracker *RateTracker, body []byte) { + if tracker == nil { + return + } + var probe struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return + } + raw, ok := probe.Data["rateLimit"] + if !ok || len(raw) == 0 { + return + } + var rl graphQLRateLimit + if err := json.Unmarshal(raw, &rl); err != nil { + return + } + tracker.Record(RateSnapshot{ + Resource: ResourceGraphQL, + Limit: rl.Limit, + Remaining: rl.Remaining, + ResetAt: rl.ResetAt, + UpdatedAt: time.Now().UTC(), + }) +} + +// GHClient.ExecuteGraphQL satisfies GraphQLExecutor via `gh api graphql -f query=...`. +// Variables are passed as -F field_name=value entries; they are ignored when +// the query has no $variables. +func (c *GHClient) ExecuteGraphQL(ctx context.Context, query string, variables map[string]any, out any) error { + args := []string{"api", "graphql", "-f", "query=" + query} + for k, v := range variables { + args = append(args, "-F", fmt.Sprintf("%s=%v", k, v)) + } + stdout, err := c.run(ctx, args...) + if err != nil { + return fmt.Errorf("gh graphql: %w", err) + } + if err := json.Unmarshal([]byte(stdout), out); err != nil { + return fmt.Errorf("decode graphql response: %w", err) + } + recordGraphQLRateFromPayload(c.rateTracker, []byte(stdout)) + return nil +} + +// noopGraphQLExecutorErr is returned when the active client is the noop +// fallback (no auth). Caller paths must handle this gracefully — typically +// by skipping the batched call entirely. +var errGraphQLUnsupported = fmt.Errorf("github client does not support GraphQL") + +// graphQLExecutorFor returns an executor for the given client, or +// errGraphQLUnsupported when the client is nil/noop. +func graphQLExecutorFor(client Client) (GraphQLExecutor, error) { + if exec, ok := client.(GraphQLExecutor); ok && client != nil { + return exec, nil + } + return nil, errGraphQLUnsupported +} + +// runBatchedPRQuery executes the batched query in chunks and merges the +// results into a single map keyed by prStatusCacheKey(owner, repo, number). +func runBatchedPRQuery(ctx context.Context, exec GraphQLExecutor, refs []graphQLPRRef) (map[string]*PRStatus, error) { + if exec == nil || len(refs) == 0 { + return nil, nil + } + result := make(map[string]*PRStatus, len(refs)) + for _, chunk := range chunkedRefs(refs) { + query, vars := buildBatchedPRQuery(chunk) + var resp struct { + Data map[string]json.RawMessage `json:"data"` + Errors []graphQLError `json:"errors"` + } + if err := exec.ExecuteGraphQL(ctx, query, vars, &resp); err != nil { + return nil, err + } + // GitHub returns HTTP 200 with a top-level "errors" array for partial + // auth failures, schema mismatches, or per-alias errors. Surface them + // as an error so the caller falls back to per-watch checks instead of + // silently absorbing the failure. + if err := graphQLErrorsToErr(resp.Errors); err != nil { + return nil, err + } + if err := decodeBatchedPRChunk(chunk, resp.Data, result); err != nil { + return nil, err + } + } + return result, nil +} + +// decodeBatchedPRChunk maps the aliased response back to the input refs and +// fills in the result map. Refs whose alias is missing or null are skipped +// (e.g. PR was deleted upstream); the next poller tick will retry. +func decodeBatchedPRChunk(refs []graphQLPRRef, data map[string]json.RawMessage, result map[string]*PRStatus) error { + type group struct { + idx int + owner string + repo string + prRefs []graphQLPRRef + } + groups := []*group{} + byKey := map[string]*group{} + for _, r := range refs { + k := r.Owner + "/" + r.Repo + g, ok := byKey[k] + if !ok { + g = &group{idx: len(groups), owner: r.Owner, repo: r.Repo} + byKey[k] = g + groups = append(groups, g) + } + g.prRefs = append(g.prRefs, r) + } + keys := make([]string, 0, len(byKey)) + for k := range byKey { + keys = append(keys, k) + } + sort.Strings(keys) + // Reassign group indices in sorted order to match buildBatchedPRQuery. + for i, k := range keys { + byKey[k].idx = i + } + for _, k := range keys { + g := byKey[k] + raw, ok := data[fmt.Sprintf("repo%d", g.idx)] + if !ok || len(raw) == 0 { + continue + } + repoBlock := map[string]json.RawMessage{} + if err := json.Unmarshal(raw, &repoBlock); err != nil { + return fmt.Errorf("decode repo block: %w", err) + } + for prIdx, ref := range g.prRefs { + alias := fmt.Sprintf("%s%d", graphQLPRBatchAlias, prIdx) + rawPR, ok := repoBlock[alias] + if !ok || len(rawPR) == 0 || string(rawPR) == "null" { + continue + } + var raw batchedPRResult + if err := json.Unmarshal(rawPR, &raw); err != nil { + return fmt.Errorf("decode pr alias %s: %w", alias, err) + } + result[prStatusCacheKey(ref.Owner, ref.Repo, ref.Number)] = convertBatchedPRResult(&raw, ref.Owner, ref.Repo, ref.Number) + } + } + return nil +} + +// runBatchedBranchQuery executes the branch-lookup query in chunks and maps +// each branch ref to its first OPEN associated PR (if any). Result keys are +// "owner/repo/branch" so callers can index by their input refs. +func runBatchedBranchQuery(ctx context.Context, exec GraphQLExecutor, refs []graphQLBranchRef) (map[string]*PRStatus, error) { + if exec == nil || len(refs) == 0 { + return nil, nil + } + result := make(map[string]*PRStatus, len(refs)) + for _, chunk := range chunkedRefs(refs) { + query, vars := buildBatchedBranchQuery(chunk) + var resp struct { + Data map[string]json.RawMessage `json:"data"` + Errors []graphQLError `json:"errors"` + } + if err := exec.ExecuteGraphQL(ctx, query, vars, &resp); err != nil { + return nil, err + } + if err := graphQLErrorsToErr(resp.Errors); err != nil { + return nil, err + } + decodeBatchedBranchChunk(chunk, resp.Data, result) + } + return result, nil +} + +func decodeBatchedBranchChunk(refs []graphQLBranchRef, data map[string]json.RawMessage, result map[string]*PRStatus) { + for i, ref := range refs { + raw, ok := data[fmt.Sprintf("b%d", i)] + if !ok || len(raw) == 0 || string(raw) == "null" { + continue + } + var inner struct { + Ref *struct { + AssociatedPullRequests struct { + Nodes []struct { + Number int `json:"number"` + batchedPRResult + } `json:"nodes"` + } `json:"associatedPullRequests"` + } `json:"ref"` + } + if err := json.Unmarshal(raw, &inner); err != nil { + continue + } + if inner.Ref == nil || len(inner.Ref.AssociatedPullRequests.Nodes) == 0 { + continue + } + node := inner.Ref.AssociatedPullRequests.Nodes[0] + status := convertBatchedPRResult(&node.batchedPRResult, ref.Owner, ref.Repo, node.Number) + result[graphqlBranchKey(ref.Owner, ref.Repo, ref.Branch)] = status + } +} + +// graphqlBranchKey is the lookup key used in batched-branch result maps. +// Named graphql* to avoid collision with the mock_client.go branchKey type. +func graphqlBranchKey(owner, repo, branch string) string { + return owner + "/" + repo + "/" + branch +} + +// Compile-time assertions that the existing CLI/PAT clients satisfy +// GraphQLExecutor. If a client stops implementing it the build fails here. +var ( + _ GraphQLExecutor = (*PATClient)(nil) + _ GraphQLExecutor = (*GHClient)(nil) +) diff --git a/apps/backend/internal/github/graphql_test.go b/apps/backend/internal/github/graphql_test.go new file mode 100644 index 0000000000..6a9f887be3 --- /dev/null +++ b/apps/backend/internal/github/graphql_test.go @@ -0,0 +1,296 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" +) + +// stubGraphQLExecutor lets tests inspect/return canned responses without +// touching HTTP or the gh CLI. +type stubGraphQLExecutor struct { + queries []string + response string + err error +} + +func (s *stubGraphQLExecutor) ExecuteGraphQL(_ context.Context, query string, _ map[string]any, out any) error { + s.queries = append(s.queries, query) + if s.err != nil { + return s.err + } + return json.Unmarshal([]byte(s.response), out) +} + +func TestBuildBatchedPRQuery_GroupsByRepo(t *testing.T) { + q, _ := buildBatchedPRQuery([]graphQLPRRef{ + {Owner: "octo", Repo: "alpha", Number: 1}, + {Owner: "octo", Repo: "alpha", Number: 2}, + {Owner: "octo", Repo: "beta", Number: 9}, + }) + if !strings.Contains(q, `repo0: repository(owner: "octo", name: "alpha")`) { + t.Errorf("expected repo0 alias for octo/alpha, got: %s", q) + } + if !strings.Contains(q, `repo1: repository(owner: "octo", name: "beta")`) { + t.Errorf("expected repo1 alias for octo/beta, got: %s", q) + } + if !strings.Contains(q, `pr0: pullRequest(number: 1)`) || + !strings.Contains(q, `pr1: pullRequest(number: 2)`) { + t.Errorf("expected aliased pullRequests inside repo0: %s", q) + } + if !strings.Contains(q, "rateLimit") { + t.Errorf("expected rateLimit field in query") + } +} + +func TestBuildBatchedBranchQuery_AliasesAllBranches(t *testing.T) { + q, _ := buildBatchedBranchQuery([]graphQLBranchRef{ + {Owner: "o", Repo: "r", Branch: "feat-1"}, + {Owner: "o", Repo: "r", Branch: "feat-2"}, + }) + if !strings.Contains(q, `b0: repository`) || !strings.Contains(q, `b1: repository`) { + t.Errorf("expected b0/b1 aliases: %s", q) + } + if !strings.Contains(q, `qualifiedName: "refs/heads/feat-1"`) { + t.Errorf("expected qualifiedName for feat-1: %s", q) + } +} + +func TestRunBatchedPRQuery_DecodesAliasesBackToRefs(t *testing.T) { + exec := &stubGraphQLExecutor{ + response: `{ + "data": { + "repo0": { + "pr0": { + "state": "OPEN", "title": "PR A", "url": "https://x/1", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "h1", "baseRefName": "main", "headRefOid": "abc", + "author": {"login":"alice"}, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-02T00:00:00Z", + "reviews": {"nodes": [{"state": "APPROVED"}]}, + "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]} + } + }, + "rateLimit": {"limit":5000, "remaining":4999, "resetAt":"2030-01-01T00:00:00Z", "cost":1} + } + }`, + } + got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{ + {Owner: "o", Repo: "r", Number: 42}, + }) + if err != nil { + t.Fatalf("run: %v", err) + } + status, ok := got[prStatusCacheKey("o", "r", 42)] + if !ok { + t.Fatalf("expected status for o/r#42, got keys: %v", keysOf(got)) + } + if status.ReviewState != "approved" { + t.Errorf("ReviewState = %q, want approved", status.ReviewState) + } + if status.ChecksState != "success" { + t.Errorf("ChecksState = %q, want success", status.ChecksState) + } + if status.PR == nil || status.PR.Title != "PR A" { + t.Errorf("PR.Title mismatch: %#v", status.PR) + } +} + +func TestRunBatchedPRQuery_DecodesMultipleReposIntoCorrectKeys(t *testing.T) { + // Two repos in one batch: octo/alpha#1 and octo/beta#9. The decoder sorts + // repo group keys; this test guards against future drift between + // buildBatchedPRQuery's sort and decodeBatchedPRChunk's sort by checking + // each PR lands in its correct key slot. + exec := &stubGraphQLExecutor{ + response: `{ + "data": { + "repo0": { + "pr0": { + "state": "OPEN", "title": "alpha PR", "url": "https://x/a/1", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "h1", "baseRefName": "main", "headRefOid": "aaa", + "author": {"login":"alice"}, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + "reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": []} + } + }, + "repo1": { + "pr0": { + "state": "OPEN", "title": "beta PR", "url": "https://x/b/9", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "h2", "baseRefName": "main", "headRefOid": "bbb", + "author": {"login":"alice"}, "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + "reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": []} + } + } + } + }`, + } + got, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{ + {Owner: "octo", Repo: "alpha", Number: 1}, + {Owner: "octo", Repo: "beta", Number: 9}, + }) + if err != nil { + t.Fatalf("run: %v", err) + } + if s, ok := got[prStatusCacheKey("octo", "alpha", 1)]; !ok || s.PR == nil || s.PR.Title != "alpha PR" { + t.Errorf("alpha#1 not decoded into correct slot: %#v", s) + } + if s, ok := got[prStatusCacheKey("octo", "beta", 9)]; !ok || s.PR == nil || s.PR.Title != "beta PR" { + t.Errorf("beta#9 not decoded into correct slot: %#v", s) + } +} + +func TestRunBatchedPRQuery_PropagatesError(t *testing.T) { + exec := &stubGraphQLExecutor{err: errors.New("graphql 500")} + _, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{{Owner: "o", Repo: "r", Number: 1}}) + if err == nil { + t.Fatalf("expected error to propagate") + } +} + +func TestRunBatchedPRQuery_SurfacesGraphQLErrors(t *testing.T) { + exec := &stubGraphQLExecutor{ + response: `{"data": {}, "errors": [{"message": "Field 'foo' doesn't exist on type 'Repository'"}]}`, + } + _, err := runBatchedPRQuery(context.Background(), exec, []graphQLPRRef{{Owner: "o", Repo: "r", Number: 1}}) + if err == nil { + t.Fatalf("expected error from non-empty errors array") + } + if !strings.Contains(err.Error(), "Field 'foo'") { + t.Errorf("expected error to include first message, got: %v", err) + } +} + +func TestRunBatchedBranchQuery_SurfacesGraphQLErrors(t *testing.T) { + exec := &stubGraphQLExecutor{ + response: `{"data": {}, "errors": [{"message": "rate limited"}, {"message": "secondary"}]}`, + } + _, err := runBatchedBranchQuery(context.Background(), exec, []graphQLBranchRef{{Owner: "o", Repo: "r", Branch: "main"}}) + if err == nil { + t.Fatalf("expected error from non-empty errors array") + } + if !strings.Contains(err.Error(), "rate limited") || !strings.Contains(err.Error(), "and 1 more") { + t.Errorf("expected error to include first message + count, got: %v", err) + } +} + +func TestRunBatchedBranchQuery_DecodesPRNode(t *testing.T) { + exec := &stubGraphQLExecutor{ + response: `{ + "data": { + "b0": { + "ref": { + "associatedPullRequests": { + "nodes": [{ + "number": 7, + "state": "OPEN", "title": "branch PR", "url": "https://x/7", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "feat", "baseRefName": "main", "headRefOid": "deadbeef", + "author": {"login":"alice"}, + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + "reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": []} + }] + } + } + } + } + }`, + } + got, err := runBatchedBranchQuery(context.Background(), exec, []graphQLBranchRef{ + {Owner: "o", Repo: "r", Branch: "feat"}, + }) + if err != nil { + t.Fatalf("run: %v", err) + } + status, ok := got[graphqlBranchKey("o", "r", "feat")] + if !ok { + t.Fatalf("expected branch result, got keys: %v", keysOf(got)) + } + if status.PR == nil || status.PR.Number != 7 { + t.Errorf("expected PR number 7, got %#v", status.PR) + } +} + +func TestSummarizeReviewState_PrefersChangesRequested(t *testing.T) { + mk := func(login, state string, sec int) reviewNode { + var n reviewNode + n.Author.Login = login + n.State = state + n.SubmittedAt = time.Unix(int64(sec), 0).UTC() + return n + } + // Different reviewers: CHANGES_REQUESTED beats APPROVED. + if got := summarizeReviewState([]reviewNode{ + mk("alice", "APPROVED", 1), + mk("bob", "CHANGES_REQUESTED", 2), + }); got != "changes_requested" { + t.Errorf("got %q", got) + } + // Different reviewers: APPROVED + COMMENTED -> approved. + if got := summarizeReviewState([]reviewNode{ + mk("alice", "APPROVED", 1), + mk("bob", "COMMENTED", 2), + }); got != "approved" { + t.Errorf("got %q", got) + } + // Single COMMENTED -> empty. + if got := summarizeReviewState([]reviewNode{ + mk("alice", "COMMENTED", 1), + }); got != "" { + t.Errorf("got %q", got) + } + // Same reviewer: CHANGES_REQUESTED then APPROVED -> approved (Greptile P1). + if got := summarizeReviewState([]reviewNode{ + mk("alice", "CHANGES_REQUESTED", 1), + mk("alice", "APPROVED", 2), + }); got != "approved" { + t.Errorf("dedup: got %q, want approved", got) + } + // Same reviewer: APPROVED then CHANGES_REQUESTED -> changes_requested. + if got := summarizeReviewState([]reviewNode{ + mk("alice", "APPROVED", 1), + mk("alice", "CHANGES_REQUESTED", 2), + }); got != "changes_requested" { + t.Errorf("dedup reverse: got %q, want changes_requested", got) + } +} + +func TestChunkedRefs_RespectsBatchSize(t *testing.T) { + refs := make([]graphQLPRRef, graphQLBatchChunkSize+5) + chunks := chunkedRefs(refs) + if len(chunks) != 2 { + t.Fatalf("expected 2 chunks for %d refs, got %d", len(refs), len(chunks)) + } + if len(chunks[0]) != graphQLBatchChunkSize { + t.Errorf("first chunk size = %d, want %d", len(chunks[0]), graphQLBatchChunkSize) + } + if len(chunks[1]) != 5 { + t.Errorf("second chunk size = %d, want 5", len(chunks[1])) + } +} + +func keysOf[K comparable, V any](m map[K]V) []K { + out := make([]K, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +func TestGraphQLExecutorFor_NoopReturnsError(t *testing.T) { + if _, err := graphQLExecutorFor(&NoopClient{}); err == nil { + t.Fatalf("expected unsupported error for NoopClient") + } + if _, err := graphQLExecutorFor(NewPATClient("token")); err != nil { + t.Fatalf("PATClient should satisfy GraphQLExecutor: %v", err) + } + if _, err := graphQLExecutorFor(NewGHClient()); err != nil { + t.Fatalf("GHClient should satisfy GraphQLExecutor: %v", err) + } +} diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index deea89d747..433d65606b 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -234,13 +234,22 @@ type RepoBranch struct { // GitHubStatus represents GitHub connection status. type GitHubStatus struct { - Authenticated bool `json:"authenticated"` - Username string `json:"username"` - AuthMethod string `json:"auth_method"` // "gh_cli", "pat", "none" - TokenConfigured bool `json:"token_configured"` - TokenSecretID string `json:"token_secret_id,omitempty"` - RequiredScopes []string `json:"required_scopes"` - Diagnostics *AuthDiagnostics `json:"diagnostics,omitempty"` + Authenticated bool `json:"authenticated"` + Username string `json:"username"` + AuthMethod string `json:"auth_method"` // "gh_cli", "pat", "none" + TokenConfigured bool `json:"token_configured"` + TokenSecretID string `json:"token_secret_id,omitempty"` + RequiredScopes []string `json:"required_scopes"` + Diagnostics *AuthDiagnostics `json:"diagnostics,omitempty"` + RateLimit *GitHubRateLimitInfo `json:"rate_limit,omitempty"` +} + +// GitHubRateLimitInfo bundles known rate-limit snapshots per resource bucket +// for surfacing in the UI. +type GitHubRateLimitInfo struct { + Core *RateSnapshot `json:"core,omitempty"` + GraphQL *RateSnapshot `json:"graphql,omitempty"` + Search *RateSnapshot `json:"search,omitempty"` } // ConfigureTokenRequest is the request body for configuring a GitHub token. diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go index 8a0726ff2f..5383b21b38 100644 --- a/apps/backend/internal/github/pat_client.go +++ b/apps/backend/internal/github/pat_client.go @@ -31,9 +31,10 @@ func (e *GitHubAPIError) Error() string { // PATClient implements Client using a GitHub Personal Access Token. type PATClient struct { - token string - httpClient *http.Client - username string // cached after first GetAuthenticatedUser call + token string + httpClient *http.Client + username string // cached after first GetAuthenticatedUser call + rateTracker *RateTracker } // NewPATClient creates a new PAT-based GitHub client. @@ -46,6 +47,81 @@ func NewPATClient(token string) *PATClient { } } +// WithRateTracker attaches a rate tracker so response headers are recorded. +// Returns the client for chaining; safe to call before any requests are made. +func (c *PATClient) WithRateTracker(t *RateTracker) *PATClient { + c.rateTracker = t + return c +} + +// recordRateHeaders feeds rate-limit data from a response into the tracker. +// endpoint is used to pick the default resource bucket when the response +// omits the X-RateLimit-Resource header. +func (c *PATClient) recordRateHeaders(resp *http.Response, endpoint string) { + if c.rateTracker == nil || resp == nil { + return + } + defaultResource := ResourceCore + if strings.HasPrefix(endpoint, "/search/") { + defaultResource = ResourceSearch + } else if strings.HasPrefix(endpoint, "/graphql") { + defaultResource = ResourceGraphQL + } + snap, headersOK := parseRateHeaders(resp, defaultResource) + if headersOK { + c.rateTracker.Record(snap) + } + if !isRateLimitStatus(resp.StatusCode) { + return + } + // On a 429, prefer the real X-RateLimit-Reset over the synthetic 1h + // fallback. parseRateHeaders may return ok=true with Remaining>0 if the + // secondary-limit response carried stale headers — only skip the + // fallback when the headers themselves report exhaustion (Remaining<=0 + // + future ResetAt). Otherwise fall through to the conservative pause. + if headersOK && snap.Exhausted() { + return + } + c.rateTracker.markRateExhausted(defaultResource, time.Time{}) +} + +// isRateLimitStatus returns true for status codes GitHub uses to signal +// primary or secondary rate-limit exhaustion. 403 is documented for both +// abuse-detection and primary limits when the body indicates so; 429 is +// secondary limits. +func isRateLimitStatus(status int) bool { + return status == http.StatusTooManyRequests +} + +// maybeMarkRateExhaustedFromBody flags a rate-limit hit when GitHub returned +// a 403/429 whose body contains the rate-limit prose. The headers may be +// missing on these responses (esp. secondary limits), so the body is the +// authoritative signal. +func (c *PATClient) maybeMarkRateExhaustedFromBody(endpoint string, status int, body []byte) { + if c.rateTracker == nil { + return + } + if status != http.StatusForbidden && status != http.StatusTooManyRequests { + return + } + lower := strings.ToLower(string(body)) + if !strings.Contains(lower, "rate limit") && !strings.Contains(lower, "abuse detection") { + return + } + resource := ResourceCore + if strings.HasPrefix(endpoint, "/search/") { + resource = ResourceSearch + } else if strings.HasPrefix(endpoint, "/graphql") { + resource = ResourceGraphQL + } + // If recordRateHeaders already captured a real reset for this bucket on + // the same response, don't clobber it with the synthetic 1h fallback. + if existing, ok := c.rateTracker.Snapshot(resource); ok && existing.Exhausted() { + return + } + c.rateTracker.markRateExhausted(resource, time.Time{}) +} + // setGitHubHeaders sets the common Authorization, Accept, and API version headers. func (c *PATClient) setGitHubHeaders(req *http.Request) { req.Header.Set("Authorization", "token "+c.token) @@ -390,9 +466,11 @@ func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) erro return fmt.Errorf("request POST %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() + c.recordRateHeaders(resp, endpoint) if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody) return fmt.Errorf("GitHub API POST %s returned %d: %s", endpoint, resp.StatusCode, string(respBody)) } return nil @@ -411,9 +489,11 @@ func (c *PATClient) get(ctx context.Context, endpoint string, result interface{} return fmt.Errorf("request %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() + c.recordRateHeaders(resp, endpoint) if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, body) return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)} } return json.NewDecoder(resp.Body).Decode(result) @@ -433,9 +513,11 @@ func (c *PATClient) getPaginated(ctx context.Context, endpoint string, result in return "", fmt.Errorf("request %s: %w", endpoint, err) } defer func() { _ = resp.Body.Close() }() + c.recordRateHeaders(resp, endpoint) if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, body) return "", &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(body)} } if err := json.NewDecoder(resp.Body).Decode(result); err != nil { diff --git a/apps/backend/internal/github/pat_client_test.go b/apps/backend/internal/github/pat_client_test.go index 8d7fe1e304..7902b8aa7a 100644 --- a/apps/backend/internal/github/pat_client_test.go +++ b/apps/backend/internal/github/pat_client_test.go @@ -1,6 +1,11 @@ package github import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" "testing" "time" ) @@ -180,3 +185,138 @@ func TestConvertPatRequestedReviewers(t *testing.T) { t.Errorf("unexpected third reviewer: %#v", got[2]) } } + +func TestPATClient_RecordsRateHeadersOnSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Limit", "5000") + w.Header().Set("X-RateLimit-Remaining", "4998") + w.Header().Set("X-RateLimit-Reset", "2000000000") + w.Header().Set("X-RateLimit-Resource", "core") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"login":"octocat"}`)) + })) + t.Cleanup(srv.Close) + + c := newPATClientPointingAt(t, srv.URL) + tracker := NewRateTracker(nil, nil) + c.WithRateTracker(tracker) + + var out struct { + Login string `json:"login"` + } + if err := c.get(context.Background(), "/user", &out); err != nil { + t.Fatalf("get: %v", err) + } + snap, ok := tracker.Snapshot(ResourceCore) + if !ok { + t.Fatalf("expected core snapshot") + } + if snap.Remaining != 4998 || snap.Limit != 5000 { + t.Fatalf("snap = %+v", snap) + } +} + +// Regression: when a 429 carries valid X-RateLimit-Reset headers, the reset +// time from the headers must win — not the synthetic +1h fallback in +// markRateExhausted. Previously, recordRateHeaders called Record(snap) +// followed by markRateExhausted(time.Time{}), and the second call clobbered +// the real reset with a 1-hour pause that could over-throttle the poller. +func TestPATClient_RateLimit429_PreservesRealReset(t *testing.T) { + realReset := time.Now().Add(7 * time.Minute).UTC().Truncate(time.Second) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-RateLimit-Limit", "5000") + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(realReset.Unix(), 10)) + w.Header().Set("X-RateLimit-Resource", "core") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"message":"API rate limit exceeded"}`)) + })) + t.Cleanup(srv.Close) + + c := newPATClientPointingAt(t, srv.URL) + tracker := NewRateTracker(nil, nil) + c.WithRateTracker(tracker) + + var out struct{} + if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil { + t.Fatalf("expected error from 429") + } + snap, ok := tracker.Snapshot(ResourceCore) + if !ok { + t.Fatalf("expected core snapshot") + } + if !snap.ResetAt.Equal(realReset) { + t.Errorf("expected reset_at preserved from headers (%s), got %s (off by %s)", + realReset, snap.ResetAt, snap.ResetAt.Sub(realReset)) + } + if !tracker.IsExhausted(ResourceCore) { + t.Errorf("expected core to be exhausted") + } +} + +// When a 429 has no rate-limit headers, the synthetic 1h fallback still +// applies so the poller pauses instead of hammering the secondary limit. +func TestPATClient_RateLimit429_NoHeaders_UsesSyntheticReset(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"message":"abuse detection"}`)) + })) + t.Cleanup(srv.Close) + + c := newPATClientPointingAt(t, srv.URL) + tracker := NewRateTracker(nil, nil) + c.WithRateTracker(tracker) + + var out struct{} + if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil { + t.Fatalf("expected error from 429") + } + if !tracker.IsExhausted(ResourceCore) { + t.Fatalf("expected core exhausted via synthetic fallback") + } +} + +func TestPATClient_MarksExhaustedFromRateLimitBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // No headers — secondary limits sometimes omit them entirely. + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"API rate limit exceeded for user."}`)) + })) + t.Cleanup(srv.Close) + + c := newPATClientPointingAt(t, srv.URL) + tracker := NewRateTracker(nil, nil) + c.WithRateTracker(tracker) + + var out struct{} + if err := c.get(context.Background(), "/repos/o/r/pulls/1", &out); err == nil { + t.Fatalf("expected error from 403") + } + if !tracker.IsExhausted(ResourceCore) { + t.Fatalf("expected core exhausted from body parse") + } +} + +// newPATClientPointingAt builds a PATClient whose underlying HTTP client +// reroutes any github API URL to the given test server. +func newPATClientPointingAt(t *testing.T, baseURL string) *PATClient { + t.Helper() + c := NewPATClient("test-token") + c.httpClient = &http.Client{ + Transport: &rewriteTransport{base: baseURL}, + Timeout: 2 * time.Second, + } + return c +} + +type rewriteTransport struct{ base string } + +func (rt *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + rewritten := strings.Replace(req.URL.String(), githubAPIBase, rt.base, 1) + req2, err := http.NewRequestWithContext(req.Context(), req.Method, rewritten, req.Body) + if err != nil { + return nil, err + } + req2.Header = req.Header.Clone() + return http.DefaultTransport.RoundTrip(req2) +} diff --git a/apps/backend/internal/github/poller.go b/apps/backend/internal/github/poller.go index 14e45a711e..2de082c1ee 100644 --- a/apps/backend/internal/github/poller.go +++ b/apps/backend/internal/github/poller.go @@ -13,11 +13,59 @@ import ( ) const ( - defaultPRPollInterval = 30 * time.Second + defaultPRPollInterval = 1 * time.Minute defaultReviewPollInterval = 5 * time.Minute defaultIssuePollInterval = 5 * time.Minute + // rateLimitedSleepCap bounds the rate-limit sleep so a misreported reset + // (e.g. far-future reset_at after a CLI failure) cannot wedge the loop. + rateLimitedSleepCap = 10 * time.Minute ) +// searchBucketExhausted reports whether the search bucket is currently +// exhausted. Used by per-watch loops to bail out mid-cycle once a single +// search request has tripped the limit, so the remaining watches don't issue +// doomed searches that worsen secondary-limit penalties. Returns false when +// the tracker is unavailable (tests, disabled feature). +func (p *Poller) searchBucketExhausted(loop string) bool { + if p.service == nil || p.service.rateTracker == nil { + return false + } + if p.service.rateTracker.WaitDuration(ResourceSearch) <= 0 { + return false + } + p.logger.Debug("search bucket exhausted; stopping watch loop early", + zap.String("loop", loop)) + return true +} + +// waitForRateLimit returns true after sleeping the loop until the relevant +// resource bucket has quota again, or false if ctx was cancelled. When the +// bucket is healthy the call is a no-op and returns true immediately. +func (p *Poller) waitForRateLimit(ctx context.Context, resource Resource, loop string) bool { + if p.service == nil || p.service.rateTracker == nil { + return true + } + d := p.service.rateTracker.WaitDuration(resource) + if d <= 0 { + return true + } + if d > rateLimitedSleepCap { + d = rateLimitedSleepCap + } + p.logger.Info("github rate-limited; pausing poller loop", + zap.String("loop", loop), + zap.String("resource", string(resource)), + zap.Duration("sleep", d)) + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + // TaskBranchInfo describes a task+session that may need a PR watch. // RepositoryID scopes multi-repo tasks: each repo is a separate watch row. // Empty for legacy single-repo tasks. @@ -94,7 +142,9 @@ func (p *Poller) prMonitorLoop(ctx context.Context) { defer p.wg.Done() // Run an initial check immediately so existing watches are evaluated on startup. - p.checkPRWatches(ctx) + if p.waitForRateLimit(ctx, ResourceGraphQL, "pr_monitor") { + p.checkPRWatches(ctx) + } ticker := time.NewTicker(defaultPRPollInterval) defer ticker.Stop() @@ -104,6 +154,9 @@ func (p *Poller) prMonitorLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: + if !p.waitForRateLimit(ctx, ResourceGraphQL, "pr_monitor") { + return + } p.checkPRWatches(ctx) } } @@ -117,11 +170,168 @@ func (p *Poller) checkPRWatches(ctx context.Context) { p.logger.Error("failed to list PR watches", zap.Error(err)) return } + if len(watches) == 0 { + return + } + + // Try the batched GraphQL path first — collapses N HTTP requests into a + // handful of multi-aliased GraphQL calls. On error or unsupported client + // (e.g. NoopClient), fall back to per-watch checks so one bad poll cycle + // doesn't lose state. + if p.tryBatchedPRWatchCheck(ctx, watches) { + return + } for _, watch := range watches { p.checkSinglePRWatch(ctx, watch) } } +// tryBatchedPRWatchCheck runs the batched GraphQL flow for the supplied +// watches. Returns true when the path succeeded so the caller can skip the +// per-watch fallback. +func (p *Poller) tryBatchedPRWatchCheck(ctx context.Context, watches []*PRWatch) bool { + exec, err := graphQLExecutorFor(p.service.client) + if err != nil { + return false + } + numbered, searching := splitPRWatches(watches) + statusByKey, ok := p.fetchBatchedStatuses(ctx, exec, numbered, searching) + if !ok { + return false + } + for _, w := range numbered { + key := prStatusCacheKey(w.Owner, w.Repo, w.PRNumber) + status, ok := statusByKey[key] + if !ok { + // Alias missing — PR may have been deleted; fall through to the + // per-watch path which already handles the not-found case. + p.checkSinglePRWatch(ctx, w) + continue + } + p.applyPRStatus(ctx, w, status) + } + for _, w := range searching { + key := graphqlBranchKey(w.Owner, w.Repo, w.Branch) + status, ok := statusByKey[key] + if !ok || status == nil || status.PR == nil { + // No PR yet for this branch — record the timestamp like the + // per-watch path so liveness updates. + now := time.Now().UTC() + _ = p.service.store.UpdatePRWatchTimestamps(ctx, w.ID, now, nil, "", "") + continue + } + p.applyDetectedPR(ctx, w, status.PR) + } + return true +} + +// fetchBatchedStatuses runs both the numbered-PR query and the branch query +// against GraphQL. The combined map keys numbered watches by +// prStatusCacheKey and searching watches by graphqlBranchKey, so callers can +// look up either kind in one place. Returns (nil, false) when any required +// query failed so the caller falls back to per-watch checks rather than +// silently absorbing the failure as "no result". +func (p *Poller) fetchBatchedStatuses( + ctx context.Context, exec GraphQLExecutor, numbered, searching []*PRWatch, +) (map[string]*PRStatus, bool) { + combined := make(map[string]*PRStatus) + + if len(numbered) > 0 { + refs := make([]graphQLPRRef, 0, len(numbered)) + for _, w := range numbered { + refs = append(refs, graphQLPRRef{Owner: w.Owner, Repo: w.Repo, Number: w.PRNumber}) + } + out, err := runBatchedPRQuery(ctx, exec, refs) + if err != nil { + p.logger.Debug("batched PR status query failed", zap.Error(err)) + return nil, false + } + for k, v := range out { + combined[k] = v + } + } + if len(searching) > 0 { + refs := make([]graphQLBranchRef, 0, len(searching)) + for _, w := range searching { + refs = append(refs, graphQLBranchRef{Owner: w.Owner, Repo: w.Repo, Branch: w.Branch}) + } + out, err := runBatchedBranchQuery(ctx, exec, refs) + if err != nil { + p.logger.Debug("batched branch query failed", zap.Error(err)) + return nil, false + } + for k, v := range out { + combined[k] = v + } + } + return combined, true +} + +// splitPRWatches partitions watches into "we know the PR number" and "still +// searching for one on this branch" buckets so each gets its own batched +// query shape. +func splitPRWatches(watches []*PRWatch) (numbered, searching []*PRWatch) { + for _, w := range watches { + if w.PRNumber == 0 { + searching = append(searching, w) + } else { + numbered = append(numbered, w) + } + } + return numbered, searching +} + +// applyPRStatus is the post-fetch processing extracted from +// checkSinglePRWatch so the batched path can reuse it. +func (p *Poller) applyPRStatus(ctx context.Context, watch *PRWatch, status *PRStatus) { + if status == nil { + return + } + hasNew := status.ChecksState != watch.LastCheckStatus || status.ReviewState != watch.LastReviewState + now := time.Now().UTC() + if err := p.service.store.UpdatePRWatchTimestamps(ctx, watch.ID, now, nil, status.ChecksState, status.ReviewState); err != nil { + p.logger.Error("failed to update PR watch timestamps", zap.String("id", watch.ID), zap.Error(err)) + } + if syncErr := p.service.SyncTaskPR(ctx, watch.TaskID, status); syncErr != nil { + p.logger.Error("failed to sync task PR", + zap.String("task_id", watch.TaskID), zap.Error(syncErr)) + return + } + if status.PR != nil && (status.PR.State == prStateMerged || status.PR.State == prStateClosed) { + p.publishPRStatusEvent(ctx, watch, status) + if resetErr := p.service.store.UpdatePRWatchPRNumber(ctx, watch.ID, 0); resetErr != nil { + p.logger.Error("failed to reset completed PR watch", + zap.String("id", watch.ID), zap.Error(resetErr)) + } + return + } + if hasNew { + p.publishPRStatusEvent(ctx, watch, status) + } +} + +// applyDetectedPR is the searching-watch counterpart to applyPRStatus — +// promotes a PRWatch from "searching" to a known PR number and records the +// task association. +func (p *Poller) applyDetectedPR(ctx context.Context, watch *PRWatch, pr *PR) { + now := time.Now().UTC() + _ = p.service.store.UpdatePRWatchTimestamps(ctx, watch.ID, now, nil, "", "") + if err := p.service.store.UpdatePRWatchPRNumber(ctx, watch.ID, pr.Number); err != nil { + p.logger.Error("failed to update PR watch with detected PR", + zap.String("watch_id", watch.ID), zap.Int("pr_number", pr.Number), zap.Error(err)) + return + } + if _, err := p.service.AssociatePRWithTask(ctx, watch.TaskID, watch.RepositoryID, pr); err != nil { + p.logger.Error("failed to associate detected PR with task", + zap.String("task_id", watch.TaskID), zap.Int("pr_number", pr.Number), zap.Error(err)) + return + } + p.logger.Info("detected PR for session branch (batched)", + zap.String("watch_id", watch.ID), + zap.String("branch", watch.Branch), + zap.Int("pr_number", pr.Number)) +} + func (p *Poller) checkSinglePRWatch(ctx context.Context, watch *PRWatch) { // PRWatch with pr_number=0 means we're still searching for a PR on this branch. if watch.PRNumber == 0 { @@ -302,8 +512,9 @@ func (p *Poller) refreshStaleBranches(ctx context.Context) { func (p *Poller) reviewQueueLoop(ctx context.Context) { defer p.wg.Done() - // Run an initial check immediately so existing watches are evaluated on startup. - p.checkReviewWatches(ctx) + if p.waitForRateLimit(ctx, ResourceSearch, "review_queue") { + p.checkReviewWatches(ctx) + } ticker := time.NewTicker(defaultReviewPollInterval) defer ticker.Stop() @@ -313,6 +524,9 @@ func (p *Poller) reviewQueueLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: + if !p.waitForRateLimit(ctx, ResourceSearch, "review_queue") { + return + } p.checkReviewWatches(ctx) } } @@ -329,6 +543,12 @@ func (p *Poller) checkReviewWatches(ctx context.Context) { } p.logger.Debug("checking review watches", zap.Int("count", len(watches))) for _, watch := range watches { + // A previous iteration in this same cycle may have exhausted the + // search bucket. Stop early instead of issuing another doomed search + // that would only deepen the secondary-limit penalty. + if p.searchBucketExhausted("review_watch") { + return + } p.logger.Debug("polling review watch", zap.String("watch_id", watch.ID), zap.String("workspace_id", watch.WorkspaceID), @@ -368,8 +588,9 @@ func (p *Poller) checkReviewWatches(ctx context.Context) { func (p *Poller) issueWatchLoop(ctx context.Context) { defer p.wg.Done() - // Run an initial check immediately so existing watches are evaluated on startup. - p.checkIssueWatches(ctx) + if p.waitForRateLimit(ctx, ResourceSearch, "issue_watch") { + p.checkIssueWatches(ctx) + } ticker := time.NewTicker(defaultIssuePollInterval) defer ticker.Stop() @@ -379,6 +600,9 @@ func (p *Poller) issueWatchLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: + if !p.waitForRateLimit(ctx, ResourceSearch, "issue_watch") { + return + } p.checkIssueWatches(ctx) } } @@ -395,6 +619,9 @@ func (p *Poller) checkIssueWatches(ctx context.Context) { } p.logger.Debug("checking issue watches", zap.Int("count", len(watches))) for _, watch := range watches { + if p.searchBucketExhausted("issue_watch") { + return + } p.logger.Debug("polling issue watch", zap.String("watch_id", watch.ID), zap.String("workspace_id", watch.WorkspaceID), diff --git a/apps/backend/internal/github/poller_test.go b/apps/backend/internal/github/poller_test.go index 8b47a41684..f6d1aa1c60 100644 --- a/apps/backend/internal/github/poller_test.go +++ b/apps/backend/internal/github/poller_test.go @@ -3,6 +3,9 @@ package github import ( "context" "database/sql" + "encoding/json" + "errors" + "strings" "testing" "time" @@ -533,3 +536,336 @@ func TestRefreshStaleBranches_SkipsWhenResolverReturnsEmpty(t *testing.T) { t.Errorf("expected branch unchanged when resolver returns empty, got %q", updated.Branch) } } + +// graphQLMockClient wraps MockClient with a canned GraphQL executor so tests +// exercise the batched poll path without hitting the network. +type graphQLMockClient struct { + *MockClient + prResponses []string // FIFO; one entry consumed per ExecuteGraphQL call carrying "Batch" + branchResponses []string // FIFO; consumed for "Branches" queries + prErr error // returned for the next "Batch" call + branchErr error // returned for the next "Branches" call + prQueries []string + branchQueries []string +} + +func (m *graphQLMockClient) ExecuteGraphQL(_ context.Context, query string, _ map[string]any, out any) error { + if strings.Contains(query, "query Branches") { + m.branchQueries = append(m.branchQueries, query) + if m.branchErr != nil { + return m.branchErr + } + if len(m.branchResponses) == 0 { + return errors.New("no canned branch response") + } + resp := m.branchResponses[0] + m.branchResponses = m.branchResponses[1:] + return json.Unmarshal([]byte(resp), out) + } + m.prQueries = append(m.prQueries, query) + if m.prErr != nil { + return m.prErr + } + if len(m.prResponses) == 0 { + return errors.New("no canned PR response") + } + resp := m.prResponses[0] + m.prResponses = m.prResponses[1:] + return json.Unmarshal([]byte(resp), out) +} + +func setupBatchedPollerTest(t *testing.T) (*Poller, *Service, *graphQLMockClient, *Store) { + t.Helper() + poller, svc, mockClient, store := setupPollerTest(t) + wrapped := &graphQLMockClient{MockClient: mockClient} + svc.client = wrapped + return poller, svc, wrapped, store +} + +func TestTryBatchedPRWatchCheck_NumberedWatch_AppliesStatus(t *testing.T) { + poller, _, gh, store := setupBatchedPollerTest(t) + ctx := context.Background() + + gh.prResponses = []string{`{ + "data": { + "repo0": { + "pr0": { + "state": "OPEN", "title": "Test PR", "url": "https://x/1", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "feat", "baseRefName": "main", "headRefOid": "abc", + "author": {"login": "alice"}, + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-02T00:00:00Z", + "reviews": {"nodes": [{"state": "APPROVED", "author": {"login": "bob"}, "submittedAt": "2026-01-02T00:00:00Z"}]}, + "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]} + } + } + } + }`} + + watch := &PRWatch{ + SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 42, Branch: "feat", + LastCheckStatus: "pending", LastReviewState: "", + } + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + taskPR := &TaskPR{ + TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 42, + PRURL: "https://x/1", PRTitle: "Test PR", HeadBranch: "feat", BaseBranch: "main", State: "open", + } + if err := store.CreateTaskPR(ctx, taskPR); err != nil { + t.Fatalf("create task PR: %v", err) + } + + if !poller.tryBatchedPRWatchCheck(ctx, []*PRWatch{watch}) { + t.Fatalf("expected batched path to succeed") + } + + updated, err := store.GetTaskPR(ctx, "t1") + if err != nil || updated == nil { + t.Fatalf("get task PR: err=%v, pr=%v", err, updated) + } + if updated.ChecksState != "success" { + t.Errorf("ChecksState = %q, want success", updated.ChecksState) + } + if updated.ReviewState != "approved" { + t.Errorf("ReviewState = %q, want approved", updated.ReviewState) + } +} + +func TestTryBatchedPRWatchCheck_SearchingWatch_DetectsPR(t *testing.T) { + poller, _, gh, store := setupBatchedPollerTest(t) + ctx := context.Background() + + gh.branchResponses = []string{`{ + "data": { + "b0": { + "ref": { + "associatedPullRequests": { + "nodes": [{ + "number": 7, + "state": "OPEN", "title": "branch PR", "url": "https://x/7", + "isDraft": false, "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", + "headRefName": "feat", "baseRefName": "main", "headRefOid": "deadbeef", + "author": {"login": "alice"}, + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", + "reviews": {"nodes": []}, "reviewRequests": {"totalCount": 0}, + "commits": {"nodes": []} + }] + } + } + } + } + }`} + + watch := &PRWatch{ + SessionID: "s2", TaskID: "t2", Owner: "o", Repo: "r", PRNumber: 0, Branch: "feat", + } + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + + if !poller.tryBatchedPRWatchCheck(ctx, []*PRWatch{watch}) { + t.Fatalf("expected batched path to succeed") + } + + updated, err := store.GetPRWatchBySession(ctx, "s2") + if err != nil || updated == nil { + t.Fatalf("get PR watch: err=%v, w=%v", err, updated) + } + if updated.PRNumber != 7 { + t.Errorf("PRNumber = %d, want 7 (PR should be detected and recorded)", updated.PRNumber) + } +} + +func TestTryBatchedPRWatchCheck_FallsBackOnUnsupportedClient(t *testing.T) { + // MockClient does not implement GraphQLExecutor, so the batched path must + // return false to trigger per-watch fallback. + poller, _, _, store := setupPollerTest(t) + ctx := context.Background() + + watch := &PRWatch{SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 1, Branch: "feat"} + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + if poller.tryBatchedPRWatchCheck(ctx, []*PRWatch{watch}) { + t.Errorf("expected false when client does not implement GraphQLExecutor") + } +} + +func TestFetchBatchedStatuses_NumberedQueryError_ReturnsNilFalse(t *testing.T) { + poller, _, gh, _ := setupBatchedPollerTest(t) + ctx := context.Background() + gh.prErr = errors.New("graphql 500") + + numbered := []*PRWatch{{Owner: "o", Repo: "r", PRNumber: 1}} + got, ok := poller.fetchBatchedStatuses(ctx, gh, numbered, nil) + if ok || got != nil { + t.Errorf("expected (nil, false) on numbered query error, got (%v, %v)", got, ok) + } +} + +func TestFetchBatchedStatuses_BranchQueryError_TriggersFallback(t *testing.T) { + // Greptile P2: when only branch query fails (no numbered watches), the + // previous return of an empty non-nil map silently absorbed the failure. + // The fix returns (nil, false) so the caller falls back per-watch. + poller, _, gh, _ := setupBatchedPollerTest(t) + ctx := context.Background() + gh.branchErr = errors.New("graphql 500") + + searching := []*PRWatch{{Owner: "o", Repo: "r", PRNumber: 0, Branch: "feat"}} + got, ok := poller.fetchBatchedStatuses(ctx, gh, nil, searching) + if ok || got != nil { + t.Errorf("expected (nil, false) on branch query error, got (%v, %v)", got, ok) + } +} + +func TestApplyPRStatus_MergedPR_ResetsWatch(t *testing.T) { + poller, _, _, store := setupPollerTest(t) + ctx := context.Background() + + mergedAt := time.Now().UTC().Add(-time.Hour) + watch := &PRWatch{ + SessionID: "s1", TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 99, Branch: "feat", + } + if err := store.CreatePRWatch(ctx, watch); err != nil { + t.Fatalf("create PR watch: %v", err) + } + taskPR := &TaskPR{ + TaskID: "t1", Owner: "o", Repo: "r", PRNumber: 99, + PRURL: "https://x/99", PRTitle: "Merged PR", HeadBranch: "feat", BaseBranch: "main", State: "open", + } + if err := store.CreateTaskPR(ctx, taskPR); err != nil { + t.Fatalf("create task PR: %v", err) + } + + status := &PRStatus{ + PR: &PR{ + Number: 99, State: prStateMerged, RepoOwner: "o", RepoName: "r", + HeadBranch: "feat", BaseBranch: "main", MergedAt: &mergedAt, URL: "https://x/99", Title: "Merged PR", + }, + } + poller.applyPRStatus(ctx, watch, status) + + updated, err := store.GetPRWatchBySession(ctx, "s1") + if err != nil || updated == nil { + t.Fatalf("get PR watch: err=%v, w=%v", err, updated) + } + if updated.PRNumber != 0 { + t.Errorf("expected watch reset to PRNumber=0 after merge, got %d", updated.PRNumber) + } +} + +func TestWaitForRateLimit_SkipsWhenHealthy(t *testing.T) { + poller, _, _, _ := setupPollerTest(t) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if !poller.waitForRateLimit(ctx, ResourceGraphQL, "test") { + t.Errorf("expected true when no exhaustion recorded") + } +} + +func TestSearchBucketExhausted(t *testing.T) { + poller, svc, _, _ := setupPollerTest(t) + + if poller.searchBucketExhausted("test") { + t.Errorf("expected false when no exhaustion recorded") + } + + // Mark the search bucket exhausted with a future reset. + svc.RateTracker().Record(RateSnapshot{ + Resource: ResourceSearch, + Limit: 30, + Remaining: 0, + ResetAt: time.Now().Add(5 * time.Minute), + UpdatedAt: time.Now(), + }) + + if !poller.searchBucketExhausted("test") { + t.Errorf("expected true when search bucket exhausted") + } + + // Other buckets exhausting must not trip the search guard. + poller2, svc2, _, _ := setupPollerTest(t) + svc2.RateTracker().Record(RateSnapshot{ + Resource: ResourceCore, + Remaining: 0, + ResetAt: time.Now().Add(5 * time.Minute), + UpdatedAt: time.Now(), + }) + if poller2.searchBucketExhausted("test") { + t.Errorf("expected false when only core (not search) is exhausted") + } +} + +// Regression: when the search bucket trips mid-cycle, checkReviewWatches must +// stop iterating so the remaining watches don't issue doomed search requests +// that deepen the secondary-limit penalty. +func TestCheckReviewWatches_BailsOutWhenSearchExhaustedMidCycle(t *testing.T) { + poller, svc, _, store := setupPollerTest(t) + ctx := context.Background() + + // Pre-exhaust the search bucket so the very first iteration short-circuits. + svc.RateTracker().Record(RateSnapshot{ + Resource: ResourceSearch, + Remaining: 0, + ResetAt: time.Now().Add(5 * time.Minute), + UpdatedAt: time.Now(), + }) + + for _, id := range []string{"rw1", "rw2", "rw3"} { + if err := store.CreateReviewWatch(ctx, &ReviewWatch{ + ID: id, + WorkspaceID: "ws1", + Enabled: true, + }); err != nil { + t.Fatalf("seed review watch %s: %v", id, err) + } + } + + // Should return cleanly without panic / without errors despite N watches + // being enabled. + poller.checkReviewWatches(ctx) +} + +func TestCheckIssueWatches_BailsOutWhenSearchExhaustedMidCycle(t *testing.T) { + poller, svc, _, store := setupPollerTest(t) + ctx := context.Background() + + svc.RateTracker().Record(RateSnapshot{ + Resource: ResourceSearch, + Remaining: 0, + ResetAt: time.Now().Add(5 * time.Minute), + UpdatedAt: time.Now(), + }) + + for _, id := range []string{"iw1", "iw2"} { + if err := store.CreateIssueWatch(ctx, &IssueWatch{ + ID: id, + WorkspaceID: "ws1", + Enabled: true, + }); err != nil { + t.Fatalf("seed issue watch %s: %v", id, err) + } + } + + poller.checkIssueWatches(ctx) +} + +func TestWaitForRateLimit_ReturnsFalseOnContextCancel(t *testing.T) { + poller, svc, _, _ := setupPollerTest(t) + // Force exhaustion with a far-future reset. + svc.RateTracker().Record(RateSnapshot{ + Resource: ResourceGraphQL, + Limit: 5000, + Remaining: 0, + ResetAt: time.Now().Add(time.Hour), + UpdatedAt: time.Now(), + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately so the timer select returns ctx.Done first + if poller.waitForRateLimit(ctx, ResourceGraphQL, "test") { + t.Errorf("expected false when context cancelled during sleep") + } +} diff --git a/apps/backend/internal/github/provider.go b/apps/backend/internal/github/provider.go index 4030a4a755..7b5e653d25 100644 --- a/apps/backend/internal/github/provider.go +++ b/apps/backend/internal/github/provider.go @@ -2,13 +2,36 @@ package github import ( "context" + "time" "github.com/jmoiron/sqlx" + "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" ) +// rateLimitSeedTimeout caps the startup `gh api rate_limit` probe so a slow +// CLI cannot delay backend boot. +const rateLimitSeedTimeout = 5 * time.Second + +// attachRateTracker wires the rate tracker into whichever client +// implementation we ended up with, then seeds initial snapshots. The seed is +// best-effort — a missing CLI or network blip should not block startup. +func attachRateTracker(client Client, tracker *RateTracker, log *logger.Logger) { + switch c := client.(type) { + case *PATClient: + c.WithRateTracker(tracker) + case *GHClient: + c.WithRateTracker(tracker) + seedCtx, cancel := context.WithTimeout(context.Background(), rateLimitSeedTimeout) + defer cancel() + if err := c.FetchRateLimit(seedCtx); err != nil { + log.Debug("seed gh rate limit failed", zap.Error(err)) + } + } +} + // Provide creates the full GitHub integration stack: store, client, and service. // Returns the service, a cleanup function, and any error. func Provide( @@ -30,6 +53,7 @@ func Provide( } svc := NewService(client, authMethod, secrets, store, eventBus, log) + attachRateTracker(client, svc.RateTracker(), log) svc.subscribeTaskEvents() cleanup := func() error { diff --git a/apps/backend/internal/github/ratelimit.go b/apps/backend/internal/github/ratelimit.go new file mode 100644 index 0000000000..bf4790ed49 --- /dev/null +++ b/apps/backend/internal/github/ratelimit.go @@ -0,0 +1,244 @@ +package github + +import ( + "context" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +// Resource identifies a GitHub API rate-limit bucket. +// +// GitHub maintains separate counters for REST core, GraphQL, and search; the +// `gh` CLI hits GraphQL even for `pr view`, so exhausting one bucket does not +// imply the others are exhausted. +type Resource string + +const ( + ResourceCore Resource = "core" + ResourceGraphQL Resource = "graphql" + ResourceSearch Resource = "search" +) + +// rateUpdateDebounce throttles non-transition update events so settings-page +// counters update at human speed rather than once per request. +const rateUpdateDebounce = 5 * time.Second + +// RateSnapshot captures the rate-limit state for one bucket at a point in time. +type RateSnapshot struct { + Resource Resource `json:"resource"` + Remaining int `json:"remaining"` + Limit int `json:"limit"` + ResetAt time.Time `json:"reset_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Exhausted returns true when the bucket has no quota left and ResetAt is in +// the future. Limit may be unknown (0) when the snapshot was synthesized from +// an out-of-band signal (e.g. `gh` stderr). +func (s RateSnapshot) Exhausted() bool { + return s.Remaining <= 0 && s.ResetAt.After(time.Now()) +} + +// RateLimitUpdatedEvent is published when a snapshot changes, either because +// new headers arrived or because exhaustion state flipped. +// +// Carries all known buckets so subscribers can surface the full picture +// without follow-up queries. +type RateLimitUpdatedEvent struct { + Snapshots []RateSnapshot `json:"snapshots"` + // Trigger names the bucket whose update produced this event; useful for + // downstream filtering/debug. + Trigger Resource `json:"trigger"` + // ExhaustionTransition is non-empty on the tick where the trigger bucket + // flipped between exhausted and recovered ("exhausted" or "recovered"). + ExhaustionTransition string `json:"exhaustion_transition,omitempty"` +} + +// RateTracker accumulates rate-limit snapshots from the GitHub clients and +// publishes events on change. +type RateTracker struct { + mu sync.RWMutex + snapshots map[Resource]RateSnapshot + exhausted map[Resource]bool + lastEmitted map[Resource]time.Time + bus bus.EventBus + log *logger.Logger +} + +// NewRateTracker constructs a tracker. The event bus may be nil (tests). +func NewRateTracker(eventBus bus.EventBus, log *logger.Logger) *RateTracker { + return &RateTracker{ + snapshots: make(map[Resource]RateSnapshot), + exhausted: make(map[Resource]bool), + lastEmitted: make(map[Resource]time.Time), + bus: eventBus, + log: log, + } +} + +// Record stores a snapshot. Always emits an event on exhaustion transition; +// otherwise debounces to at most one update per resource per +// rateUpdateDebounce window. +func (r *RateTracker) Record(snap RateSnapshot) { + if snap.Resource == "" { + return + } + if snap.UpdatedAt.IsZero() { + snap.UpdatedAt = time.Now().UTC() + } + + r.mu.Lock() + prev := r.exhausted[snap.Resource] + now := snap.Exhausted() + r.snapshots[snap.Resource] = snap + r.exhausted[snap.Resource] = now + + transition := "" + switch { + case !prev && now: + transition = "exhausted" + case prev && !now: + transition = "recovered" + } + + shouldEmit := transition != "" + if !shouldEmit { + last := r.lastEmitted[snap.Resource] + if snap.UpdatedAt.Sub(last) >= rateUpdateDebounce { + shouldEmit = true + } + } + if shouldEmit { + r.lastEmitted[snap.Resource] = snap.UpdatedAt + } + allSnap := r.allLocked() + r.mu.Unlock() + + if !shouldEmit { + return + } + r.publish(allSnap, snap.Resource, transition) +} + +// Snapshot returns a copy of the current snapshot for resource. +func (r *RateTracker) Snapshot(resource Resource) (RateSnapshot, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + snap, ok := r.snapshots[resource] + return snap, ok +} + +// All returns a copy of every known snapshot. +func (r *RateTracker) All() map[Resource]RateSnapshot { + r.mu.RLock() + defer r.mu.RUnlock() + return r.allLocked() +} + +func (r *RateTracker) allLocked() map[Resource]RateSnapshot { + out := make(map[Resource]RateSnapshot, len(r.snapshots)) + for k, v := range r.snapshots { + out[k] = v + } + return out +} + +// IsExhausted reports whether the resource bucket is currently exhausted. +func (r *RateTracker) IsExhausted(resource Resource) bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.exhausted[resource] +} + +// WaitDuration returns how long callers should wait before retrying the +// resource, or 0 if the bucket is not exhausted. +func (r *RateTracker) WaitDuration(resource Resource) time.Duration { + r.mu.RLock() + defer r.mu.RUnlock() + if !r.exhausted[resource] { + return 0 + } + d := time.Until(r.snapshots[resource].ResetAt) + if d < 0 { + return 0 + } + return d +} + +func (r *RateTracker) publish(all map[Resource]RateSnapshot, trigger Resource, transition string) { + if r.bus == nil { + return + } + snaps := make([]RateSnapshot, 0, len(all)) + for _, s := range all { + snaps = append(snaps, s) + } + evt := &RateLimitUpdatedEvent{ + Snapshots: snaps, + Trigger: trigger, + ExhaustionTransition: transition, + } + event := bus.NewEvent(events.GitHubRateLimitUpdated, "github", evt) + if err := r.bus.Publish(context.Background(), events.GitHubRateLimitUpdated, event); err != nil && r.log != nil { + r.log.Debug("publish rate-limit event failed", zap.Error(err)) + } +} + +// parseRateHeaders extracts a snapshot from a GitHub HTTP response. Returns +// false when the response carries no rate-limit headers. +func parseRateHeaders(resp *http.Response, defaultResource Resource) (RateSnapshot, bool) { + if resp == nil { + return RateSnapshot{}, false + } + limitStr := resp.Header.Get("X-RateLimit-Limit") + remainingStr := resp.Header.Get("X-RateLimit-Remaining") + resetStr := resp.Header.Get("X-RateLimit-Reset") + if limitStr == "" && remainingStr == "" && resetStr == "" { + return RateSnapshot{}, false + } + limit, _ := strconv.Atoi(limitStr) + remaining, _ := strconv.Atoi(remainingStr) + reset, _ := strconv.ParseInt(resetStr, 10, 64) + + resource := defaultResource + if r := resp.Header.Get("X-RateLimit-Resource"); r != "" { + resource = Resource(strings.ToLower(r)) + } + return RateSnapshot{ + Resource: resource, + Limit: limit, + Remaining: remaining, + ResetAt: time.Unix(reset, 0).UTC(), + UpdatedAt: time.Now().UTC(), + }, true +} + +// markRateExhausted records an exhaustion snapshot for callers that detect +// rate-limit conditions out-of-band (e.g. parsing `gh` stderr or 4xx bodies +// without headers). Uses a conservative one-hour reset when no better value +// is known. +func (r *RateTracker) markRateExhausted(resource Resource, resetAt time.Time) { + if resource == "" { + return + } + if resetAt.IsZero() { + resetAt = time.Now().Add(time.Hour).UTC() + } + prev, _ := r.Snapshot(resource) + r.Record(RateSnapshot{ + Resource: resource, + Limit: prev.Limit, + Remaining: 0, + ResetAt: resetAt, + UpdatedAt: time.Now().UTC(), + }) +} diff --git a/apps/backend/internal/github/ratelimit_test.go b/apps/backend/internal/github/ratelimit_test.go new file mode 100644 index 0000000000..a189d73cb2 --- /dev/null +++ b/apps/backend/internal/github/ratelimit_test.go @@ -0,0 +1,230 @@ +package github + +import ( + "context" + "net/http" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" +) + +func newTestTrackerLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + if err != nil { + t.Fatalf("logger: %v", err) + } + return log +} + +// captureBus collects events published to a single subject so tests can +// assert on transitions without racing. +type captureBus struct { + mu sync.Mutex + events []*bus.Event + inner bus.EventBus +} + +func newCaptureBus(t *testing.T, subject string) *captureBus { + t.Helper() + log := newTestTrackerLogger(t) + cb := &captureBus{inner: bus.NewMemoryEventBus(log)} + if _, err := cb.inner.Subscribe(subject, func(_ context.Context, e *bus.Event) error { + cb.mu.Lock() + cb.events = append(cb.events, e) + cb.mu.Unlock() + return nil + }); err != nil { + t.Fatalf("subscribe: %v", err) + } + return cb +} + +func (c *captureBus) Publish(ctx context.Context, subject string, event *bus.Event) error { + return c.inner.Publish(ctx, subject, event) +} + +func (c *captureBus) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) { + return c.inner.Subscribe(subject, handler) +} + +func (c *captureBus) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) { + return c.inner.QueueSubscribe(subject, queue, handler) +} + +func (c *captureBus) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) { + return c.inner.Request(ctx, subject, event, timeout) +} + +func (c *captureBus) Close() { c.inner.Close() } +func (c *captureBus) IsConnected() bool { return c.inner.IsConnected() } + +// captured returns a snapshot of events captured so far. MemoryEventBus +// dispatches to subscribers synchronously, so callers can read this directly +// after Record() / Publish() returns without polling. +func (c *captureBus) captured() []*bus.Event { + c.mu.Lock() + defer c.mu.Unlock() + return append([]*bus.Event(nil), c.events...) +} + +func (c *captureBus) requireCount(t *testing.T, count int, msg string) []*bus.Event { + t.Helper() + evs := c.captured() + if len(evs) != count { + t.Fatalf("%s: expected %d events, got %d", msg, count, len(evs)) + } + return evs +} + +func TestParseRateHeaders_DecodesAllFields(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("X-RateLimit-Limit", "5000") + resp.Header.Set("X-RateLimit-Remaining", "4242") + resp.Header.Set("X-RateLimit-Reset", "2000000000") + resp.Header.Set("X-RateLimit-Resource", "graphql") + + snap, ok := parseRateHeaders(resp, ResourceCore) + if !ok { + t.Fatalf("expected snapshot to be parsed") + } + if snap.Resource != ResourceGraphQL { + t.Errorf("resource: got %q want graphql", snap.Resource) + } + if snap.Limit != 5000 || snap.Remaining != 4242 { + t.Errorf("limit/remaining mismatch: %d / %d", snap.Limit, snap.Remaining) + } + if snap.ResetAt.Unix() != 2000000000 { + t.Errorf("reset_at: got %d want 2000000000", snap.ResetAt.Unix()) + } +} + +func TestParseRateHeaders_FallsBackToDefaultResource(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("X-RateLimit-Limit", "30") + resp.Header.Set("X-RateLimit-Remaining", "29") + resp.Header.Set("X-RateLimit-Reset", "2000000000") + + snap, ok := parseRateHeaders(resp, ResourceSearch) + if !ok || snap.Resource != ResourceSearch { + t.Fatalf("expected default resource search, got %q (ok=%v)", snap.Resource, ok) + } +} + +func TestParseRateHeaders_NoHeadersReturnsFalse(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + if _, ok := parseRateHeaders(resp, ResourceCore); ok { + t.Fatalf("expected no snapshot when headers absent") + } +} + +func TestRateTracker_RecordEmitsAndTracksExhaustion(t *testing.T) { + log := newTestTrackerLogger(t) + cb := newCaptureBus(t, events.GitHubRateLimitUpdated) + tr := NewRateTracker(cb, log) + + future := time.Now().Add(20 * time.Minute) + + // First snapshot: healthy. Should publish once (initial update). + tr.Record(RateSnapshot{ + Resource: ResourceGraphQL, + Limit: 5000, + Remaining: 4000, + ResetAt: future, + UpdatedAt: time.Now(), + }) + if tr.IsExhausted(ResourceGraphQL) { + t.Fatalf("not exhausted yet") + } + + // Second snapshot: exhausted -> must publish (transition). + tr.Record(RateSnapshot{ + Resource: ResourceGraphQL, + Limit: 5000, + Remaining: 0, + ResetAt: future, + UpdatedAt: time.Now(), + }) + if !tr.IsExhausted(ResourceGraphQL) { + t.Fatalf("expected exhausted after Remaining=0 + future reset") + } + if d := tr.WaitDuration(ResourceGraphQL); d <= 0 || d > 21*time.Minute { + t.Fatalf("WaitDuration out of range: %v", d) + } + + // Third snapshot: recovered -> publishes recovery transition. + tr.Record(RateSnapshot{ + Resource: ResourceGraphQL, + Limit: 5000, + Remaining: 1500, + ResetAt: future, + UpdatedAt: time.Now(), + }) + + evts := cb.requireCount(t, 3, "expected 3 events (initial, exhausted, recovered)") + // Last two events should carry transition strings. + gotTransitions := []string{} + for _, e := range evts { + payload, ok := e.Data.(*RateLimitUpdatedEvent) + if !ok { + t.Fatalf("unexpected event payload type %T", e.Data) + } + gotTransitions = append(gotTransitions, payload.ExhaustionTransition) + } + if gotTransitions[1] != "exhausted" || gotTransitions[2] != "recovered" { + t.Errorf("transitions = %v, want [* exhausted recovered]", gotTransitions) + } +} + +func TestRateTracker_DebouncesNonTransitionUpdates(t *testing.T) { + log := newTestTrackerLogger(t) + cb := newCaptureBus(t, events.GitHubRateLimitUpdated) + tr := NewRateTracker(cb, log) + now := time.Now() + + // Two healthy snapshots within the debounce window -> only first emits. + // MemoryEventBus.Publish dispatches synchronously, so reads after Record + // see the final state without sleeping. + tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 4999, ResetAt: now.Add(time.Hour), UpdatedAt: now}) + tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 4998, ResetAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Second)}) + cb.requireCount(t, 1, "expected 1 debounced event") + + // Outside the window -> emits again. + tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 4997, ResetAt: now.Add(time.Hour), UpdatedAt: now.Add(2 * rateUpdateDebounce)}) + cb.requireCount(t, 2, "expected second event after debounce window") +} + +func TestRateTracker_MarkRateExhaustedSeedsUnknownLimit(t *testing.T) { + log := newTestTrackerLogger(t) + cb := newCaptureBus(t, events.GitHubRateLimitUpdated) + tr := NewRateTracker(cb, log) + + tr.markRateExhausted(ResourceGraphQL, time.Time{}) + + if !tr.IsExhausted(ResourceGraphQL) { + t.Fatalf("expected exhausted after markRateExhausted") + } + snap, ok := tr.Snapshot(ResourceGraphQL) + if !ok { + t.Fatalf("snapshot not stored") + } + if snap.Remaining != 0 { + t.Errorf("Remaining = %d, want 0", snap.Remaining) + } + // Default reset (~1h) means WaitDuration > 0. + if d := tr.WaitDuration(ResourceGraphQL); d <= 0 { + t.Errorf("WaitDuration should be positive, got %v", d) + } +} + +func TestRateTracker_NilBusIsNoop(t *testing.T) { + tr := NewRateTracker(nil, nil) + tr.Record(RateSnapshot{Resource: ResourceCore, Limit: 5000, Remaining: 0, ResetAt: time.Now().Add(time.Minute)}) + if !tr.IsExhausted(ResourceCore) { + t.Fatalf("tracker should still record when bus is nil") + } +} diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 5130d9b9d4..47e6a0b041 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -71,6 +71,7 @@ type Service struct { taskEventSubs []bus.Subscription searchCache *ttlCache prStatusCache *ttlCache + rateTracker *RateTracker } // NewService creates a new GitHub service. @@ -84,9 +85,80 @@ func NewService(client Client, authMethod string, secrets SecretProvider, store logger: log, searchCache: newTTLCache(), prStatusCache: newTTLCache(), + rateTracker: NewRateTracker(eventBus, log), } } +// RateTracker exposes the service's rate-limit tracker so factory callers +// can wire it into individual clients. +func (s *Service) RateTracker() *RateTracker { + return s.rateTracker +} + +// newPATClient builds a PATClient pre-wired with the service's shared rate +// tracker. Centralizing this guards against forgetting the wiring on auth +// flips (e.g. ConfigureToken), which would otherwise leave PAT calls +// invisible to the rate-limit UI, health checks, and poller throttling. +func (s *Service) newPATClient(token string) *PATClient { + c := NewPATClient(token) + attachRateTracker(c, s.rateTracker, s.logger) + return c +} + +// ExhaustedRateLimit is a tiny DTO returned to the health package — the +// health package can't import github (cycle), so it consumes a structural +// shape via interface assertion. +type ExhaustedRateLimit struct { + Resource string + ResetAt time.Time +} + +// ExhaustedRateLimits lists every resource bucket currently out of quota. +// Returns an empty slice when none are exhausted, so callers can safely +// `len()` the result. +func (s *Service) ExhaustedRateLimits() []ExhaustedRateLimit { + if s.rateTracker == nil { + return nil + } + out := []ExhaustedRateLimit{} + for resource, snap := range s.rateTracker.All() { + if snap.Exhausted() { + out = append(out, ExhaustedRateLimit{ + Resource: string(resource), + ResetAt: snap.ResetAt, + }) + } + } + return out +} + +// rateLimitInfo materializes the tracker's snapshots into the DTO shape +// returned by GetStatus and the rate-limit WS notification. Returns nil +// when no buckets are known yet so the field stays omitted. +func (s *Service) rateLimitInfo() *GitHubRateLimitInfo { + if s.rateTracker == nil { + return nil + } + all := s.rateTracker.All() + if len(all) == 0 { + return nil + } + info := &GitHubRateLimitInfo{} + if snap, ok := all[ResourceCore]; ok { + v := snap + info.Core = &v + } + if snap, ok := all[ResourceGraphQL]; ok { + v := snap + info.GraphQL = &v + } + if snap, ok := all[ResourceSearch]; ok { + v := snap + info.Search = &v + } + return info +} + // SetTaskDeleter sets the task deletion dependency for cleanup operations. func (s *Service) SetTaskDeleter(d TaskDeleter) { s.taskDeleter = d } @@ -143,6 +215,7 @@ func (s *Service) GetStatus(ctx context.Context) (*GitHubStatus, error) { status := &GitHubStatus{ AuthMethod: authMethod, RequiredScopes: RequiredGitHubScopes, + RateLimit: s.rateLimitInfo(), } // Check if a GITHUB_TOKEN secret is configured @@ -204,6 +277,7 @@ func (s *Service) retryClientCreation(ctx context.Context) { s.logger.Debug("GitHub client retry failed", zap.Error(err)) return } + attachRateTracker(client, s.rateTracker, s.logger) s.client = client s.authMethod = authMethod s.logger.Info("GitHub client recovered after retry", @@ -229,8 +303,10 @@ func (s *Service) ConfigureToken(ctx context.Context, token string) error { return fmt.Errorf("secret manager not configured") } - // Validate the token by testing authentication - testClient := NewPATClient(token) + // Validate the token by testing authentication. Wire the rate tracker + // onto the test client up front so the validation request seeds the + // shared quota and subsequent PAT-backed calls keep feeding it. + testClient := s.newPATClient(token) user, err := testClient.GetAuthenticatedUser(ctx) if err != nil { return fmt.Errorf("invalid token: %w", err) diff --git a/apps/backend/internal/github/service_token_test.go b/apps/backend/internal/github/service_token_test.go index 4d45d6dbf6..8f65214980 100644 --- a/apps/backend/internal/github/service_token_test.go +++ b/apps/backend/internal/github/service_token_test.go @@ -262,3 +262,23 @@ func TestClearToken_NoSecretManager(t *testing.T) { t.Error("expected error when secretManager is nil") } } + +// Regression: PAT clients minted via the service must share the rate tracker. +// ConfigureToken previously created a bare NewPATClient and dropped quota +// signals on the floor, so the UI / health / poller throttling all went stale +// after a token reconfigure. +func TestService_NewPATClient_AttachesRateTracker(t *testing.T) { + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) + svc := &Service{ + logger: log, + rateTracker: NewRateTracker(nil, nil), + } + + c := svc.newPATClient("ghp_x") + if c == nil { + t.Fatalf("newPATClient returned nil") + } + if c.rateTracker != svc.rateTracker { + t.Fatalf("expected rate tracker to be wired onto the new PAT client") + } +} diff --git a/apps/backend/internal/health/checks.go b/apps/backend/internal/health/checks.go index 97c34cf4e5..169fd6ca36 100644 --- a/apps/backend/internal/health/checks.go +++ b/apps/backend/internal/health/checks.go @@ -3,6 +3,7 @@ package health import ( "context" "errors" + "fmt" "time" ) @@ -12,9 +13,24 @@ type GitHubStatusProvider interface { AuthMethod() string } -// GitHubChecker checks GitHub CLI/auth availability. +// GitHubRateLimitProvider exposes per-resource exhaustion state so the health +// dialog can surface "GitHub API rate limit exhausted" alongside other setup +// issues. Implemented by *github.Service. +type GitHubRateLimitProvider interface { + ExhaustedRateLimits() []GitHubRateLimitStatus +} + +// GitHubRateLimitStatus is a minimal DTO so the health package does not have +// to import the github package (which would create a cycle). +type GitHubRateLimitStatus struct { + Resource string + ResetAt time.Time +} + +// GitHubChecker checks GitHub CLI/auth availability and rate-limit health. type GitHubChecker struct { - provider GitHubStatusProvider + provider GitHubStatusProvider + rateProvider GitHubRateLimitProvider } // NewGitHubChecker creates a checker for GitHub integration status. @@ -23,6 +39,13 @@ func NewGitHubChecker(provider GitHubStatusProvider) *GitHubChecker { return &GitHubChecker{provider: provider} } +// WithRateLimitProvider attaches a provider so the checker also surfaces +// rate-limit exhaustion. Optional — when nil the rate-limit issue is omitted. +func (c *GitHubChecker) WithRateLimitProvider(p GitHubRateLimitProvider) *GitHubChecker { + c.rateProvider = p + return c +} + func (c *GitHubChecker) Check(_ context.Context) []Issue { if c.provider == nil { return []Issue{{ @@ -46,7 +69,44 @@ func (c *GitHubChecker) Check(_ context.Context) []Issue { FixLabel: "Configure GitHub", }} } - return nil + return c.rateLimitIssues() +} + +// rateLimitIssues materializes one Issue per exhausted resource bucket. +// Returns nil when the rate-limit provider is missing or every bucket has +// quota — the issue auto-clears as soon as that happens. +func (c *GitHubChecker) rateLimitIssues() []Issue { + if c.rateProvider == nil { + return nil + } + exhausted := c.rateProvider.ExhaustedRateLimits() + if len(exhausted) == 0 { + return nil + } + issues := make([]Issue, 0, len(exhausted)) + for _, status := range exhausted { + issues = append(issues, Issue{ + ID: fmt.Sprintf("github_rate_limit_%s", status.Resource), + Category: "github", + Title: fmt.Sprintf("GitHub API rate limit exhausted (%s)", status.Resource), + Message: rateLimitMessage(status), + Severity: SeverityWarning, + FixURL: "/settings/workspace/{workspaceId}/github", + FixLabel: "View status", + }) + } + return issues +} + +func rateLimitMessage(status GitHubRateLimitStatus) string { + if status.ResetAt.IsZero() { + return "PR/issue checks are paused until the limit resets." + } + wait := time.Until(status.ResetAt).Round(time.Minute) + if wait <= 0 { + return "PR/issue checks are paused until the limit resets." + } + return fmt.Sprintf("PR/issue checks are paused; resets in %s.", wait) } // AgentDiscoveryProvider abstracts the agent discovery check. diff --git a/apps/backend/internal/health/checks_test.go b/apps/backend/internal/health/checks_test.go index e0cee4494b..31f07dd2c5 100644 --- a/apps/backend/internal/health/checks_test.go +++ b/apps/backend/internal/health/checks_test.go @@ -3,7 +3,9 @@ package health import ( "context" "fmt" + "strings" "testing" + "time" ) // --- GitHubChecker tests --- @@ -55,6 +57,41 @@ func TestGitHubChecker_Authenticated(t *testing.T) { } } +type stubRateLimitProvider struct { + exhausted []GitHubRateLimitStatus +} + +func (s *stubRateLimitProvider) ExhaustedRateLimits() []GitHubRateLimitStatus { + return s.exhausted +} + +func TestGitHubChecker_AuthenticatedButRateLimited(t *testing.T) { + rate := &stubRateLimitProvider{exhausted: []GitHubRateLimitStatus{ + {Resource: "graphql", ResetAt: time.Now().Add(15 * time.Minute)}, + }} + checker := NewGitHubChecker(&mockGitHubProvider{authenticated: true, authMethod: "gh_cli"}) + checker.WithRateLimitProvider(rate) + issues := checker.Check(context.Background()) + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + if issues[0].ID != "github_rate_limit_graphql" { + t.Errorf("ID = %q, want github_rate_limit_graphql", issues[0].ID) + } + if !strings.Contains(issues[0].Message, "resets in") { + t.Errorf("message should include reset countdown, got %q", issues[0].Message) + } +} + +func TestGitHubChecker_AuthenticatedRateProviderEmpty(t *testing.T) { + rate := &stubRateLimitProvider{} + checker := NewGitHubChecker(&mockGitHubProvider{authenticated: true}) + checker.WithRateLimitProvider(rate) + if got := checker.Check(context.Background()); len(got) != 0 { + t.Errorf("expected 0 issues when rate provider empty, got %d", len(got)) + } +} + // --- AgentChecker tests --- type mockAgentProvider struct { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index ea83c8ff23..3023d40d0c 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -365,6 +365,7 @@ const ( ActionGitHubPRFilesGet = "github.pr_files.get" ActionGitHubPRCommitsGet = "github.pr_commits.get" ActionGitHubTaskPRUpdated = "github.task_pr.updated" // Notification + ActionGitHubRateLimitUpdated = "github.rate_limit.updated" // Notification ActionGitHubPRFeedbackNotify = "github.pr_feedback.notify" // Notification ActionGitHubNewReviewPRNotify = "github.new_review_pr.notify" // Notification ActionGitHubTaskPRSync = "github.task_pr.sync" diff --git a/apps/web/components/github/github-rate-limit.tsx b/apps/web/components/github/github-rate-limit.tsx new file mode 100644 index 0000000000..bed95b7677 --- /dev/null +++ b/apps/web/components/github/github-rate-limit.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { IconAlertTriangle } from "@tabler/icons-react"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import type { GitHubRateLimitInfo, GitHubRateLimitSnapshot } from "@/lib/types/github"; + +const RESOURCE_LABELS: Record<string, string> = { + core: "REST", + graphql: "GraphQL", + search: "Search", +}; + +// useTickNow re-renders every intervalMs and exposes a stable now-value so the +// render pass stays pure (Date.now() in the render body would be impure). +function useTickNow(intervalMs: number): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return now; +} + +function isExhausted(snap: GitHubRateLimitSnapshot, now: number): boolean { + if (snap.remaining > 0) return false; + const reset = new Date(snap.reset_at).getTime(); + return Number.isFinite(reset) && reset > now; +} + +function formatReset(snap: GitHubRateLimitSnapshot): string { + const reset = new Date(snap.reset_at); + if (!Number.isFinite(reset.getTime())) return ""; + return formatDistanceToNow(reset, { addSuffix: true }); +} + +// latestReset returns the snapshot whose reset_at is furthest in the future. +// When multiple buckets are exhausted, background checks remain paused until +// the last one recovers, so the alert must anchor on that bucket — anchoring +// on snapshots[0] would understate the pause window. +function latestReset(snaps: GitHubRateLimitSnapshot[]): GitHubRateLimitSnapshot { + return snaps.reduce((latest, s) => + new Date(s.reset_at).getTime() > new Date(latest.reset_at).getTime() ? s : latest, + ); +} + +function snapshotsFromInfo(info: GitHubRateLimitInfo): GitHubRateLimitSnapshot[] { + const out: GitHubRateLimitSnapshot[] = []; + if (info.core) out.push(info.core); + if (info.graphql) out.push(info.graphql); + if (info.search) out.push(info.search); + return out; +} + +export function GitHubRateLimitDisplay({ info }: { info?: GitHubRateLimitInfo }) { + const now = useTickNow(30_000); + if (!info) return null; + const snapshots = snapshotsFromInfo(info); + if (snapshots.length === 0) return null; + const exhausted = snapshots.filter((s) => isExhausted(s, now)); + + return ( + <div className="space-y-2" data-testid="github-rate-limit-display"> + {exhausted.length > 0 && ( + <Alert variant="destructive" data-testid="github-rate-limit-exhausted"> + <IconAlertTriangle className="h-4 w-4" /> + <AlertDescription className="text-sm"> + GitHub API rate limit exhausted on{" "} + {exhausted.map((s) => RESOURCE_LABELS[s.resource] ?? s.resource).join(", ")}. Background + PR/issue checks are paused until the limit resets {formatReset(latestReset(exhausted))}. + </AlertDescription> + </Alert> + )} + <div className="text-xs text-muted-foreground flex flex-wrap gap-x-4 gap-y-1"> + {snapshots.map((snap) => { + const label = RESOURCE_LABELS[snap.resource] ?? snap.resource; + const limit = snap.limit > 0 ? snap.limit : "?"; + const reset = formatReset(snap); + return ( + <span key={snap.resource} data-testid={`github-rate-limit-${snap.resource}`}> + {label}: <strong>{snap.remaining}</strong>/{limit} + {reset ? ` · resets ${reset}` : ""} + </span> + ); + })} + </div> + </div> + ); +} diff --git a/apps/web/components/github/github-status.tsx b/apps/web/components/github/github-status.tsx index 83cf29b9f6..5b483cf887 100644 --- a/apps/web/components/github/github-status.tsx +++ b/apps/web/components/github/github-status.tsx @@ -18,6 +18,7 @@ import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { useToast } from "@/components/toast-provider"; import { configureGitHubToken, clearGitHubToken } from "@/lib/api/domains/github-api"; import type { AuthDiagnostics } from "@/lib/types/github"; +import { GitHubRateLimitDisplay } from "./github-rate-limit"; function DiagnosticsOutput({ diagnostics }: { diagnostics: AuthDiagnostics }) { return ( @@ -214,6 +215,7 @@ export function GitHubStatusCard() { Token stored in secrets. This token is used by remote agents for GitHub operations. </p> )} + <GitHubRateLimitDisplay info={status.rate_limit} /> </div> ); } diff --git a/apps/web/lib/state/slices/github/github-slice.test.ts b/apps/web/lib/state/slices/github/github-slice.test.ts new file mode 100644 index 0000000000..9cdcf5e6fb --- /dev/null +++ b/apps/web/lib/state/slices/github/github-slice.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { createGitHubSlice } from "./github-slice"; +import type { GitHubSlice } from "./types"; +import type { GitHubStatus } from "@/lib/types/github"; + +function makeStore() { + return create<GitHubSlice>()(immer((...a) => createGitHubSlice(...a))); +} + +const FUTURE_RESET = "2030-01-01T00:00:00Z"; +const NOW = "2026-05-04T12:00:00Z"; + +const baseStatus: GitHubStatus = { + authenticated: true, + username: "octocat", + auth_method: "pat", + token_configured: true, + required_scopes: ["repo"], +}; + +describe("applyGitHubRateLimitUpdate", () => { + it("merges incoming snapshots into the existing status", () => { + const store = makeStore(); + store.getState().setGitHubStatus({ ...baseStatus }); + + store.getState().applyGitHubRateLimitUpdate({ + trigger: "graphql", + snapshots: [ + { + resource: "graphql", + remaining: 0, + limit: 5000, + reset_at: FUTURE_RESET, + updated_at: NOW, + }, + { + resource: "core", + remaining: 4500, + limit: 5000, + reset_at: FUTURE_RESET, + updated_at: NOW, + }, + ], + }); + + const status = store.getState().githubStatus.status; + expect(status?.rate_limit?.graphql?.remaining).toBe(0); + expect(status?.rate_limit?.graphql?.limit).toBe(5000); + expect(status?.rate_limit?.core?.remaining).toBe(4500); + }); + + it("overwrites only the resources present in the update", () => { + const store = makeStore(); + store.getState().setGitHubStatus({ + ...baseStatus, + rate_limit: { + core: { + resource: "core", + remaining: 4500, + limit: 5000, + reset_at: FUTURE_RESET, + updated_at: NOW, + }, + }, + }); + + store.getState().applyGitHubRateLimitUpdate({ + trigger: "graphql", + snapshots: [ + { + resource: "graphql", + remaining: 100, + limit: 5000, + reset_at: FUTURE_RESET, + updated_at: NOW, + }, + ], + }); + + const rl = store.getState().githubStatus.status?.rate_limit; + expect(rl?.core?.remaining).toBe(4500); // untouched + expect(rl?.graphql?.remaining).toBe(100); + }); + + it("is a no-op when status has not been hydrated yet", () => { + const store = makeStore(); + expect(store.getState().githubStatus.status).toBeNull(); + + store.getState().applyGitHubRateLimitUpdate({ + trigger: "core", + snapshots: [ + { + resource: "core", + remaining: 0, + limit: 5000, + reset_at: FUTURE_RESET, + updated_at: NOW, + }, + ], + }); + + expect(store.getState().githubStatus.status).toBeNull(); + }); +}); diff --git a/apps/web/lib/state/slices/github/github-slice.ts b/apps/web/lib/state/slices/github/github-slice.ts index 4f9ed2c1a6..eb1fedd9bb 100644 --- a/apps/web/lib/state/slices/github/github-slice.ts +++ b/apps/web/lib/state/slices/github/github-slice.ts @@ -157,6 +157,24 @@ function createActionPresetActions( }; } +function createRateLimitActions(set: ImmerSet): Pick<GitHubSlice, "applyGitHubRateLimitUpdate"> { + return { + applyGitHubRateLimitUpdate: (update) => + set((draft) => { + const existing = draft.githubStatus.status; + if (!existing) { + // Status not yet hydrated; defer until the SSR/HTTP fetch lands. + return; + } + const rateLimit = { ...(existing.rate_limit ?? {}) }; + for (const snap of update.snapshots) { + rateLimit[snap.resource] = snap; + } + draft.githubStatus.status = { ...existing, rate_limit: rateLimit }; + }), + }; +} + export const createGitHubSlice: StateCreator< GitHubSlice, [["zustand/immer", never]], @@ -168,4 +186,5 @@ export const createGitHubSlice: StateCreator< ...createTaskPRActions(set), ...createWatchActions(set), ...createActionPresetActions(set), + ...createRateLimitActions(set), }); diff --git a/apps/web/lib/state/slices/github/types.ts b/apps/web/lib/state/slices/github/types.ts index 9450899825..0c8883e8e6 100644 --- a/apps/web/lib/state/slices/github/types.ts +++ b/apps/web/lib/state/slices/github/types.ts @@ -1,5 +1,6 @@ import type { GitHubStatus, + GitHubRateLimitUpdate, TaskPR, PRWatch, ReviewWatch, @@ -70,6 +71,7 @@ export type GitHubSliceActions = { removeIssueWatch: (id: string) => void; setActionPresets: (workspaceId: string, presets: GitHubActionPresets) => void; setActionPresetsLoading: (workspaceId: string, loading: boolean) => void; + applyGitHubRateLimitUpdate: (update: GitHubRateLimitUpdate) => void; }; export type GitHubSlice = GitHubSliceState & GitHubSliceActions; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 440804e0d4..f697e22b12 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -11,6 +11,7 @@ import type { } from "@/lib/types/http"; import type { GitHubStatus, + GitHubRateLimitUpdate, TaskPR, PRWatch, ReviewWatch as GitHubReviewWatch, @@ -262,6 +263,7 @@ export type AppState = { removeIssueWatch: (id: string) => void; setActionPresets: (workspaceId: string, presets: GitHubActionPresets) => void; setActionPresetsLoading: (workspaceId: string, loading: boolean) => void; + applyGitHubRateLimitUpdate: (update: GitHubRateLimitUpdate) => void; // JIRA actions setJiraIssueWatches: (watches: JiraIssueWatch[]) => void; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index ceb1bc4ec7..558224b660 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -63,7 +63,8 @@ export type BackendMessageType = | "secrets.updated" | "secrets.deleted" | "message.queue.status_changed" - | "github.task_pr.updated"; + | "github.task_pr.updated" + | "github.rate_limit.updated"; export type BackendMessage<T extends BackendMessageType, P> = { id?: string; @@ -82,7 +83,7 @@ import type { } from "@/lib/types/http"; import type { SecretListItem } from "@/lib/types/http-secrets"; import type { GitEventPayload } from "@/lib/types/git-events"; -import type { TaskPR } from "@/lib/types/github"; +import type { GitHubRateLimitUpdate, TaskPR } from "@/lib/types/github"; import type { FileChangeNotificationPayload } from "./workspace-files"; import type { AgentCapabilitiesPayload, @@ -605,6 +606,7 @@ export type BackendMessageMap = { QueueStatusChangedPayload >; "github.task_pr.updated": BackendMessage<"github.task_pr.updated", TaskPR>; + "github.rate_limit.updated": BackendMessage<"github.rate_limit.updated", GitHubRateLimitUpdate>; }; // Workspace file types (extracted to reduce file size) diff --git a/apps/web/lib/types/github.ts b/apps/web/lib/types/github.ts index 02b898b8da..cbc98eacea 100644 --- a/apps/web/lib/types/github.ts +++ b/apps/web/lib/types/github.ts @@ -16,6 +16,29 @@ export type GitHubStatus = { token_secret_id?: string; required_scopes: string[]; diagnostics?: AuthDiagnostics; + rate_limit?: GitHubRateLimitInfo; +}; + +export type GitHubRateLimitResource = "core" | "graphql" | "search"; + +export type GitHubRateLimitSnapshot = { + resource: GitHubRateLimitResource; + remaining: number; + limit: number; + reset_at: string; + updated_at: string; +}; + +export type GitHubRateLimitInfo = { + core?: GitHubRateLimitSnapshot; + graphql?: GitHubRateLimitSnapshot; + search?: GitHubRateLimitSnapshot; +}; + +export type GitHubRateLimitUpdate = { + snapshots: GitHubRateLimitSnapshot[]; + trigger: GitHubRateLimitResource; + exhaustion_transition?: "exhausted" | "recovered"; }; export type GitHubPR = { diff --git a/apps/web/lib/ws/handlers/github.ts b/apps/web/lib/ws/handlers/github.ts index 3260f11938..f772ccbc01 100644 --- a/apps/web/lib/ws/handlers/github.ts +++ b/apps/web/lib/ws/handlers/github.ts @@ -1,7 +1,7 @@ import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { TaskPR } from "@/lib/types/github"; +import type { GitHubRateLimitUpdate, TaskPR } from "@/lib/types/github"; export function registerGitHubHandlers(store: StoreApi<AppState>): WsHandlers { return { @@ -11,5 +11,11 @@ export function registerGitHubHandlers(store: StoreApi<AppState>): WsHandlers { store.getState().setTaskPR(pr.task_id, pr); } }, + "github.rate_limit.updated": (message) => { + const update = message.payload as GitHubRateLimitUpdate; + if (update?.snapshots?.length) { + store.getState().applyGitHubRateLimitUpdate(update); + } + }, }; } From fb178445477585590e1af06ea56c37498fd16ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Wed, 6 May 2026 19:03:40 +0100 Subject: [PATCH 18/33] refactor(mcp): reframe cross-task message wrapper to authorize action (#841) * refactor(mcp): reframe cross-task agent message wrapper to authorize action Receivers were refusing peer-agent messages with "the user can talk to them directly" and stalling. The old wrapper demoted peer messages ("rather than a direct user instruction") and explicitly licensed refusal ("you may decline, push back"). New wording states the user is not reading the thread, frames the peer as coordinating on the user's behalf, and directs the receiver to act with user-prompt authority. Reply path through message_task_kandev is unchanged. * test(mcp): pin authorization wording in wrap_agent_message tests Three reviewers (CodeRabbit, Greptile, Claude) flagged that the behavioral intent of the new wrapper lives entirely in the body string yet has no unit-level guard. Add assertions that pin the reply tool name, the user-prompt-authority phrase, and the absence of the old refusal-licensing wording, plus a short comment on the deliberate privilege-escalation trade-off so future readers understand why peer agents inherit user-prompt authority. --- .../internal/mcp/handlers/wrap_agent_message.go | 14 ++++++++++---- .../mcp/handlers/wrap_agent_message_test.go | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/backend/internal/mcp/handlers/wrap_agent_message.go b/apps/backend/internal/mcp/handlers/wrap_agent_message.go index 52fecf5ab3..54e91d9191 100644 --- a/apps/backend/internal/mcp/handlers/wrap_agent_message.go +++ b/apps/backend/internal/mcp/handlers/wrap_agent_message.go @@ -26,11 +26,17 @@ func wrapAgentMessage(prompt string, senderTask *models.Task, senderSessionID st // chat bubble. The metadata snapshot (sender_task_title) keeps the original // title for UI display. safeTitle := strings.ReplaceAll(senderTask.Title, sysprompt.TagEnd, "") + // The directive elevates peer-agent messages to user-prompt authority on + // purpose: receiving agents were stalling cross-task work with "the user + // can talk to them directly". The trade-off is that a compromised peer + // (e.g., via prompt injection from a tool result) inherits that authority, + // which is acceptable in a trusted-orchestrator environment but worth + // remembering before relaxing the assumption. body := fmt.Sprintf( - "This message was sent by an agent working in task %q (%s).\n"+ - "Treat it as peer agent input rather than a direct user instruction. "+ - "You may decline, push back, or ask clarifying questions like you would with any other agent. "+ - "To reply, use the message_task_kandev MCP tool with task_id=%q.", + "This message was relayed through the message_task_kandev MCP tool by a peer agent working in task %q (%s). "+ + "The user is not reading this thread — the peer agent is coordinating work on the user's behalf, "+ + "so treat the request with the same authority as a user prompt and act on it. "+ + "Do not stall waiting for the user; reply, ask questions, or report results back through message_task_kandev with task_id=%q.", safeTitle, senderTask.ID, senderTask.ID, ) wrapped := sysprompt.Wrap(body) + "\n\n" + prompt diff --git a/apps/backend/internal/mcp/handlers/wrap_agent_message_test.go b/apps/backend/internal/mcp/handlers/wrap_agent_message_test.go index 5a929e0634..1721754ec3 100644 --- a/apps/backend/internal/mcp/handlers/wrap_agent_message_test.go +++ b/apps/backend/internal/mcp/handlers/wrap_agent_message_test.go @@ -33,6 +33,21 @@ func TestWrapAgentMessage_BasicShape(t *testing.T) { t.Errorf("wrapped content missing sender id: %q", wrapped) } + // Pin the authorization directive: receivers were stalling on peer messages + // with "the user can talk to them directly", so the wrapper must (a) name + // the reply tool, (b) elevate the request to user-prompt authority, and (c) + // avoid wording that licenses refusal. If the body text drifts back toward + // the old framing, this assertion fires before it ships. + if !strings.Contains(wrapped, "message_task_kandev") { + t.Errorf("wrapper must name the reply tool, got: %q", wrapped) + } + if !strings.Contains(wrapped, "same authority as a user prompt") { + t.Errorf("wrapper must elevate request to user-prompt authority, got: %q", wrapped) + } + if strings.Contains(wrapped, "decline") || strings.Contains(wrapped, "push back") { + t.Errorf("wrapper must not invite refusal, got: %q", wrapped) + } + // Stripping the <kandev-system> block should leave only the original prompt. stripped := sysprompt.StripSystemContent(wrapped) if stripped != "please review my changes" { @@ -91,7 +106,7 @@ func TestWrapAgentMessage_TitleContainingClosingTag(t *testing.T) { stripped := sysprompt.StripSystemContent(wrapped) // The visible content must be exactly the original prompt — no leaked - // "Treat it as peer agent input..." tail. + // attribution tail. if stripped != "payload" { t.Errorf("expected stripped content to equal prompt, got %q", stripped) } From aa6023e421ace35f7507c5c4b4cef7c540d9489b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= <joao.salavisa@gmail.com> Date: Wed, 6 May 2026 19:05:19 +0100 Subject: [PATCH 19/33] feat(web): per-task color indicator in sidebar (#835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- apps/web/components/task/task-item.tsx | 32 ++++++-- .../task/task-switcher-context-menu.tsx | 68 ++++++++++++++++ apps/web/hooks/use-task-color.ts | 34 ++++++++ apps/web/lib/task-colors.test.ts | 70 ++++++++++++++++ apps/web/lib/task-colors.ts | 80 +++++++++++++++++++ 5 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 apps/web/hooks/use-task-color.ts create mode 100644 apps/web/lib/task-colors.test.ts create mode 100644 apps/web/lib/task-colors.ts diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index ccb81de755..7340c8743a 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -15,6 +15,8 @@ import { IssueTaskIcon } from "@/components/github/issue-task-icon"; import { useAppStore } from "@/components/state-provider"; import { cn } from "@/lib/utils"; import { DEBUG_UI } from "@/lib/config"; +import { useTaskColor } from "@/hooks/use-task-color"; +import { TASK_COLOR_BAR_CLASS, type TaskColor } from "@/lib/task-colors"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; import { shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons"; import type { SessionPollMode } from "@/lib/state/slices/session-runtime/types"; @@ -312,6 +314,7 @@ export const TaskItem = memo(function TaskItem({ const isInProgress = computeIsInProgress(state, sessionState); const hasDiffStats = !!diffStats && (diffStats.additions > 0 || diffStats.deletions > 0); const showSubtaskToggle = !!subtaskCount && subtaskCount > 0 && !!onToggleSubtasks; + const taskColor = useTaskColor(taskId); return ( <div @@ -327,12 +330,7 @@ export const TaskItem = memo(function TaskItem({ isSubTask ? "pl-8" : "pl-3", )} > - <div - className={cn( - "absolute left-0 top-0 bottom-0 w-[2px] transition-opacity", - isSelected ? "bg-primary opacity-100" : "opacity-0", - )} - /> + <SelectionBar isSelected={isSelected} color={taskColor} /> {isSubTask && ( <span className="absolute left-3.5 top-[10px] select-none text-[11px] text-muted-foreground/30"> ↳ @@ -373,6 +371,28 @@ export const TaskItem = memo(function TaskItem({ ); }); +function SelectionBar({ isSelected, color }: { isSelected: boolean; color: TaskColor | null }) { + if (color) { + return ( + <div + className={cn( + "absolute left-0 top-0 bottom-0 w-[3px] transition-opacity", + TASK_COLOR_BAR_CLASS[color], + isSelected ? "opacity-100" : "opacity-60", + )} + /> + ); + } + return ( + <div + className={cn( + "absolute left-0 top-0 bottom-0 w-[2px] bg-primary transition-opacity", + isSelected ? "opacity-100" : "opacity-0", + )} + /> + ); +} + function SubtaskToggle({ taskId, count, diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index 1bb84ffa5b..e301a9b0f6 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -3,8 +3,10 @@ import { cloneElement, isValidElement, useState } from "react"; import { IconArchive, + IconCheck, IconCopy, IconLoader, + IconPalette, IconPencil, IconPin, IconPinFilled, @@ -15,6 +17,9 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, } from "@kandev/ui/context-menu"; import { @@ -22,6 +27,14 @@ import { type TaskMoveWorkflow, } from "@/components/task/task-move-context-menu"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; +import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color"; +import { + TASK_COLORS, + TASK_COLOR_BAR_CLASS, + TASK_COLOR_LABEL, + type TaskColor, +} from "@/lib/task-colors"; +import { cn } from "@/lib/utils"; import type { TaskSwitcherItem } from "./task-switcher"; export type StepDef = { @@ -101,6 +114,7 @@ export function TaskItemWithContextMenu({ Archive </ContextMenuItem> )} + <TaskColorMenu taskId={task.id} disabled={isDeleting} /> {task.workflowId && ( <TaskMoveContextMenuItems currentWorkflowId={task.workflowId} @@ -153,3 +167,57 @@ function cloneWithMenuOpen( if (isValidElement(children)) return cloneElement(children, { menuOpen }); return children; } + +function TaskColorMenu({ taskId, disabled }: { taskId: string; disabled?: boolean }) { + const currentColor = useTaskColor(taskId); + const setColor = useSetTaskColor(); + return ( + <ContextMenuSub> + <ContextMenuSubTrigger disabled={disabled}> + <IconPalette className="mr-2 h-4 w-4" /> + Color + {currentColor && ( + <span + className={cn( + "ml-2 inline-block h-2 w-2 rounded-full", + TASK_COLOR_BAR_CLASS[currentColor], + )} + /> + )} + </ContextMenuSubTrigger> + <ContextMenuSubContent className="w-40"> + {TASK_COLORS.map((color) => ( + <TaskColorMenuItem + key={color} + color={color} + selected={currentColor === color} + onSelect={() => setColor(taskId, color)} + /> + ))} + <ContextMenuSeparator /> + <ContextMenuItem disabled={!currentColor} onSelect={() => setColor(taskId, null)}> + <span className="mr-2 inline-block h-2 w-2 rounded-full border border-muted-foreground/40" /> + None + </ContextMenuItem> + </ContextMenuSubContent> + </ContextMenuSub> + ); +} + +function TaskColorMenuItem({ + color, + selected, + onSelect, +}: { + color: TaskColor; + selected: boolean; + onSelect: () => void; +}) { + return ( + <ContextMenuItem onSelect={onSelect}> + <span className={cn("mr-2 inline-block h-2 w-2 rounded-full", TASK_COLOR_BAR_CLASS[color])} /> + {TASK_COLOR_LABEL[color]} + {selected && <IconCheck className="ml-auto h-3.5 w-3.5" />} + </ContextMenuItem> + ); +} diff --git a/apps/web/hooks/use-task-color.ts b/apps/web/hooks/use-task-color.ts new file mode 100644 index 0000000000..af9dc4639a --- /dev/null +++ b/apps/web/hooks/use-task-color.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useCallback, useSyncExternalStore } from "react"; +import { + getTaskColor, + setTaskColor, + TASK_COLORS_CHANGED_EVENT, + TASK_COLORS_STORAGE_KEY, + type TaskColor, +} from "@/lib/task-colors"; + +function subscribe(cb: () => void): () => void { + if (typeof window === "undefined") return () => {}; + const onStorage = (e: StorageEvent) => { + if (e.key === null || e.key === TASK_COLORS_STORAGE_KEY) cb(); + }; + window.addEventListener(TASK_COLORS_CHANGED_EVENT, cb); + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener(TASK_COLORS_CHANGED_EVENT, cb); + window.removeEventListener("storage", onStorage); + }; +} + +const getServerSnapshot = (): TaskColor | null => null; + +export function useTaskColor(taskId: string | undefined): TaskColor | null { + const getSnapshot = useCallback(() => (taskId ? getTaskColor(taskId) : null), [taskId]); + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +export function useSetTaskColor(): (taskId: string, color: TaskColor | null) => void { + return useCallback((taskId, color) => setTaskColor(taskId, color), []); +} diff --git a/apps/web/lib/task-colors.test.ts b/apps/web/lib/task-colors.test.ts new file mode 100644 index 0000000000..ea064f3ce0 --- /dev/null +++ b/apps/web/lib/task-colors.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getTaskColor, + setTaskColor, + TASK_COLORS_CHANGED_EVENT, + TASK_COLORS_STORAGE_KEY, +} from "./task-colors"; + +describe("task colors storage", () => { + beforeEach(() => { + window.localStorage.clear(); + // Synthesize a cross-tab storage event so the module-level cache invalidates, + // matching how a real browser would notify other tabs after localStorage.clear(). + window.dispatchEvent(new StorageEvent("storage", { key: null })); + }); + + it("returns null when no color is stored", () => { + expect(getTaskColor("task-1")).toBeNull(); + }); + + it("stores and reads a color", () => { + setTaskColor("task-1", "blue"); + expect(getTaskColor("task-1")).toBe("blue"); + }); + + it("removes a color when set to null", () => { + setTaskColor("task-1", "blue"); + setTaskColor("task-1", null); + expect(getTaskColor("task-1")).toBeNull(); + }); + + it("ignores invalid colors loaded from storage", () => { + window.localStorage.setItem( + TASK_COLORS_STORAGE_KEY, + JSON.stringify({ "task-1": "fuchsia", "task-2": "red" }), + ); + expect(getTaskColor("task-1")).toBeNull(); + expect(getTaskColor("task-2")).toBe("red"); + }); + + it("returns null on malformed storage", () => { + window.localStorage.setItem(TASK_COLORS_STORAGE_KEY, "{not json"); + expect(getTaskColor("task-1")).toBeNull(); + }); + + it("dispatches a change event when a color is set", () => { + const listener = vi.fn(); + window.addEventListener(TASK_COLORS_CHANGED_EVENT, listener); + setTaskColor("task-1", "green"); + expect(listener).toHaveBeenCalledTimes(1); + window.removeEventListener(TASK_COLORS_CHANGED_EVENT, listener); + }); + + it("does not dispatch when setting the same color twice", () => { + setTaskColor("task-1", "green"); + const listener = vi.fn(); + window.addEventListener(TASK_COLORS_CHANGED_EVENT, listener); + setTaskColor("task-1", "green"); + expect(listener).not.toHaveBeenCalled(); + window.removeEventListener(TASK_COLORS_CHANGED_EVENT, listener); + }); + + it("does not dispatch when clearing a non-existent color", () => { + const listener = vi.fn(); + window.addEventListener(TASK_COLORS_CHANGED_EVENT, listener); + setTaskColor("task-1", null); + expect(listener).not.toHaveBeenCalled(); + window.removeEventListener(TASK_COLORS_CHANGED_EVENT, listener); + }); +}); diff --git a/apps/web/lib/task-colors.ts b/apps/web/lib/task-colors.ts new file mode 100644 index 0000000000..ad8aa95755 --- /dev/null +++ b/apps/web/lib/task-colors.ts @@ -0,0 +1,80 @@ +import { getLocalStorage, setLocalStorage } from "./local-storage"; + +export const TASK_COLORS = ["red", "orange", "yellow", "green", "blue", "purple", "pink"] as const; + +export type TaskColor = (typeof TASK_COLORS)[number]; + +export const TASK_COLOR_BAR_CLASS: Record<TaskColor, string> = { + red: "bg-red-500", + orange: "bg-orange-500", + yellow: "bg-yellow-500", + green: "bg-green-500", + blue: "bg-blue-500", + purple: "bg-purple-500", + pink: "bg-pink-500", +}; + +export const TASK_COLOR_LABEL: Record<TaskColor, string> = { + red: "Red", + orange: "Orange", + yellow: "Yellow", + green: "Green", + blue: "Blue", + purple: "Purple", + pink: "Pink", +}; + +export const TASK_COLORS_STORAGE_KEY = "kandev.taskColors"; +export const TASK_COLORS_CHANGED_EVENT = "kandev:task-colors-changed"; + +function isTaskColor(value: unknown): value is TaskColor { + return typeof value === "string" && (TASK_COLORS as readonly string[]).includes(value); +} + +let cachedMap: Record<string, TaskColor> | null = null; + +if (typeof window !== "undefined") { + window.addEventListener("storage", (e) => { + if (e.key === null || e.key === TASK_COLORS_STORAGE_KEY) cachedMap = null; + }); +} + +function readAll(): Record<string, TaskColor> { + if (cachedMap) return cachedMap; + const raw = getLocalStorage<Record<string, string>>(TASK_COLORS_STORAGE_KEY, {}); + const out: Record<string, TaskColor> = {}; + if (raw && typeof raw === "object") { + for (const [taskId, color] of Object.entries(raw)) { + if (isTaskColor(color)) out[taskId] = color; + } + } + cachedMap = out; + return out; +} + +function writeAll(map: Record<string, TaskColor>): void { + cachedMap = map; + setLocalStorage(TASK_COLORS_STORAGE_KEY, map); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(TASK_COLORS_CHANGED_EVENT)); + } +} + +export function getTaskColor(taskId: string): TaskColor | null { + if (!taskId) return null; + return readAll()[taskId] ?? null; +} + +export function setTaskColor(taskId: string, color: TaskColor | null): void { + if (!taskId) return; + const all = readAll(); + if (color === null) { + if (!(taskId in all)) return; + const next = { ...all }; + delete next[taskId]; + writeAll(next); + } else { + if (all[taskId] === color) return; + writeAll({ ...all, [taskId]: color }); + } +} From 7ec639bf2f6aaa9d7f594f54f034f76e2738ce44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Wed, 6 May 2026 19:05:21 +0100 Subject: [PATCH 20/33] feat(web): mobile parity (session/terminal/repo pickers + multi-terminal) (#840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../components/kanban/mobile-task-sheet.tsx | 3 +- .../task/mobile/mobile-picker-sheet.tsx | 49 ++ .../task/mobile/mobile-pill-button.tsx | 67 +++ .../task/mobile/mobile-repo-pill.tsx | 89 ++++ .../task/mobile/mobile-repos-section.tsx | 202 ++++++++ .../task/mobile/mobile-sessions-section.tsx | 396 ++++++++++++++ .../task/mobile/mobile-terminal-pane.tsx | 127 +++++ .../task/mobile/mobile-terminals-context.tsx | 37 ++ .../task/mobile/mobile-terminals-section.tsx | 300 +++++++++++ .../task/mobile/session-mobile-layout.tsx | 131 +++-- .../task/mobile/session-mobile-top-bar.tsx | 7 + .../session-task-switcher-sheet-hooks.ts | 481 ++++++++++++++++++ .../mobile/session-task-switcher-sheet.tsx | 436 +--------------- .../components/task/passthrough-terminal.tsx | 37 +- apps/web/components/task/session-tab.tsx | 102 +--- .../task/use-passthrough-terminal.ts | 51 +- apps/web/components/task/ws-reconnect.ts | 29 ++ apps/web/e2e/helpers/ws-capture.ts | 74 ++- .../session/use-mobile-terminals.test.ts | 98 ++++ .../domains/session/use-mobile-terminals.ts | 73 +++ .../session/use-session-actions.test.ts | 163 ++++++ .../domains/session/use-session-actions.ts | 112 ++++ apps/web/lib/state/store.ts | 15 +- .../terminal/mobile-active-terminal.test.ts | 34 ++ .../lib/terminal/mobile-active-terminal.ts | 21 + .../web/lib/terminal/send-shell-input.test.ts | 35 ++ apps/web/lib/terminal/send-shell-input.ts | 41 +- apps/web/lib/ui/state-labels.test.ts | 48 ++ apps/web/lib/ui/state-labels.ts | 34 ++ 29 files changed, 2690 insertions(+), 602 deletions(-) create mode 100644 apps/web/components/task/mobile/mobile-picker-sheet.tsx create mode 100644 apps/web/components/task/mobile/mobile-pill-button.tsx create mode 100644 apps/web/components/task/mobile/mobile-repo-pill.tsx create mode 100644 apps/web/components/task/mobile/mobile-repos-section.tsx create mode 100644 apps/web/components/task/mobile/mobile-sessions-section.tsx create mode 100644 apps/web/components/task/mobile/mobile-terminal-pane.tsx create mode 100644 apps/web/components/task/mobile/mobile-terminals-context.tsx create mode 100644 apps/web/components/task/mobile/mobile-terminals-section.tsx create mode 100644 apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts create mode 100644 apps/web/hooks/domains/session/use-mobile-terminals.test.ts create mode 100644 apps/web/hooks/domains/session/use-mobile-terminals.ts create mode 100644 apps/web/hooks/domains/session/use-session-actions.test.ts create mode 100644 apps/web/hooks/domains/session/use-session-actions.ts create mode 100644 apps/web/lib/terminal/mobile-active-terminal.test.ts create mode 100644 apps/web/lib/terminal/mobile-active-terminal.ts create mode 100644 apps/web/lib/ui/state-labels.test.ts create mode 100644 apps/web/lib/ui/state-labels.ts diff --git a/apps/web/components/kanban/mobile-task-sheet.tsx b/apps/web/components/kanban/mobile-task-sheet.tsx index 7154705305..7021103475 100644 --- a/apps/web/components/kanban/mobile-task-sheet.tsx +++ b/apps/web/components/kanban/mobile-task-sheet.tsx @@ -12,6 +12,7 @@ import { import { Badge } from "@kandev/ui/badge"; import { IconArrowRight, IconEdit, IconTrash } from "@tabler/icons-react"; import type { Task } from "@/components/kanban-card"; +import { formatTaskStateLabel } from "@/lib/ui/state-labels"; type MobileTaskSheetProps = { task: Task | null; @@ -42,7 +43,7 @@ export function MobileTaskSheet({ )} <div className="flex items-center gap-2 mt-1"> <Badge variant="secondary" className="text-xs"> - {task.state ?? "not_started"} + {formatTaskStateLabel(task.state)} </Badge> </div> </DrawerHeader> diff --git a/apps/web/components/task/mobile/mobile-picker-sheet.tsx b/apps/web/components/task/mobile/mobile-picker-sheet.tsx new file mode 100644 index 0000000000..177dcae8da --- /dev/null +++ b/apps/web/components/task/mobile/mobile-picker-sheet.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { type ReactNode } from "react"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@kandev/ui/drawer"; + +type MobilePickerSheetProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + /** Optional trailing element rendered to the right of the title (e.g. a "+" CTA). */ + headerAction?: ReactNode; + children: ReactNode; +}; + +/** + * Bottom-sheet shell for picker patterns (sessions, terminals, repos). Wraps + * shadcn Drawer with a consistent header layout — picker components only need + * to render their list inside `children`. + */ +export function MobilePickerSheet({ + open, + onOpenChange, + title, + description, + headerAction, + children, +}: MobilePickerSheetProps) { + return ( + <Drawer open={open} onOpenChange={onOpenChange}> + <DrawerContent> + <DrawerHeader className="text-left pb-2"> + <div className="flex items-center justify-between gap-2"> + <DrawerTitle className="text-sm">{title}</DrawerTitle> + {headerAction} + </div> + {description && <DrawerDescription>{description}</DrawerDescription>} + </DrawerHeader> + <div className="flex-1 min-h-0 overflow-y-auto px-2 pb-4">{children}</div> + </DrawerContent> + </Drawer> + ); +} diff --git a/apps/web/components/task/mobile/mobile-pill-button.tsx b/apps/web/components/task/mobile/mobile-pill-button.tsx new file mode 100644 index 0000000000..b3034e605f --- /dev/null +++ b/apps/web/components/task/mobile/mobile-pill-button.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { forwardRef, type ReactNode } from "react"; +import { IconChevronDown } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; + +type MobilePillButtonProps = { + /** Optional icon to lead with (e.g. folder, terminal). */ + icon?: ReactNode; + /** Primary visible label. Hidden when `compact` is true. */ + label: string; + /** Optional count badge ("3", "2/4", etc.) shown after the label. */ + count?: string | number; + /** Hide the label, keeping just icon + count + chevron. For tight viewports. */ + compact?: boolean; + /** Stretch the pill to fill its parent's width and left-align the label. + * Use when the pill owns the entire header bar with no other content. */ + fullWidth?: boolean; + /** Whether the picker this opens is currently open. Drives aria-expanded. */ + isOpen?: boolean; + /** Tap handler — opens the associated picker sheet. */ + onClick: () => void; + /** Stable test id. */ + "data-testid"?: string; + /** Accessible label override. Defaults to `label`. */ + ariaLabel?: string; +}; + +/** + * Header trigger for a bottom-sheet picker. Pills look like buttons (background + * + chevron) so users see them as interactive, not a passive status badge. + */ +export const MobilePillButton = forwardRef<HTMLButtonElement, MobilePillButtonProps>( + function MobilePillButton( + { icon, label, count, compact, fullWidth, isOpen, onClick, ariaLabel, ...rest }, + ref, + ) { + return ( + <Button + ref={ref} + type="button" + variant="outline" + size="sm" + className={`h-8 px-3 gap-2 cursor-pointer ${fullWidth ? "w-full justify-between" : ""}`} + aria-label={ariaLabel ?? label} + aria-haspopup="dialog" + aria-expanded={isOpen ?? false} + title={label} + onClick={onClick} + data-testid={rest["data-testid"]} + > + <span className="flex items-center gap-2 min-w-0"> + {icon} + {!compact && ( + <span className={`text-xs truncate ${fullWidth ? "" : "max-w-[120px]"}`}>{label}</span> + )} + {count !== undefined && count !== "" && ( + <span className="text-[10px] font-medium px-1 py-0.5 rounded bg-foreground/10 text-muted-foreground leading-none shrink-0"> + {count} + </span> + )} + </span> + <IconChevronDown className="h-3.5 w-3.5 shrink-0 opacity-70" /> + </Button> + ); + }, +); diff --git a/apps/web/components/task/mobile/mobile-repo-pill.tsx b/apps/web/components/task/mobile/mobile-repo-pill.tsx new file mode 100644 index 0000000000..e20552fc79 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-repo-pill.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { memo, useEffect, useMemo, useState } from "react"; +import { IconFolder } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { MobilePillButton } from "./mobile-pill-button"; +import { MobilePickerSheet } from "./mobile-picker-sheet"; +import { MobileReposSection, useTaskRepoCount } from "./mobile-repos-section"; + +const COMPACT_VIEWPORT_PX = 360; + +/** Returns true once the viewport is narrower than the compact threshold. */ +function useIsCompactViewport(): boolean { + const [isCompact, setIsCompact] = useState(false); + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${COMPACT_VIEWPORT_PX - 1}px)`); + const update = () => setIsCompact(mql.matches); + update(); + mql.addEventListener("change", update); + return () => mql.removeEventListener("change", update); + }, []); + return isCompact; +} + +function useTaskActiveRepoName(taskId: string | null, workspaceId: string | null): string | null { + const workspaceRepos = useAppStore((s) => + workspaceId ? (s.repositories.itemsByWorkspaceId[workspaceId] ?? []) : [], + ); + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const activeRepoId = useAppStore((s) => + activeSessionId ? (s.taskSessions.items[activeSessionId]?.repository_id ?? null) : null, + ); + const taskRepos = useAppStore((s) => { + if (!taskId) return undefined; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return task?.repositories; + }); + return useMemo(() => { + if (!activeRepoId) { + // Fallback to the position-primary task repo when no session is active + // yet. Match the picker's ordering (mobile-repos-section sorts by + // position) so the pill label and the first sheet row agree. + const sorted = taskRepos ? [...taskRepos].sort((a, b) => a.position - b.position) : []; + const fallback = sorted[0]?.repository_id; + if (!fallback) return null; + const repo = workspaceRepos.find((r) => r.id === fallback); + return repo?.name ?? repo?.local_path ?? null; + } + const repo = workspaceRepos.find((r) => r.id === activeRepoId); + return repo?.name ?? repo?.local_path ?? null; + }, [activeRepoId, taskRepos, workspaceRepos]); +} + +export const MobileRepoPill = memo(function MobileRepoPill({ + taskId, + workspaceId, +}: { + taskId: string | null; + workspaceId: string | null; +}) { + const repoCount = useTaskRepoCount(taskId); + const activeName = useTaskActiveRepoName(taskId, workspaceId); + const isCompact = useIsCompactViewport(); + const [open, setOpen] = useState(false); + + if (repoCount <= 1) return null; + + const label = activeName ?? "Repo"; + return ( + <> + <MobilePillButton + icon={<IconFolder className="h-3.5 w-3.5 shrink-0" />} + label={label} + compact={isCompact} + isOpen={open} + onClick={() => setOpen(true)} + data-testid="mobile-repo-pill" + ariaLabel={`Active repository: ${label}. Tap to switch.`} + /> + <MobilePickerSheet open={open} onOpenChange={setOpen} title="Repositories"> + <MobileReposSection + taskId={taskId} + workspaceId={workspaceId} + onClose={() => setOpen(false)} + /> + </MobilePickerSheet> + </> + ); +}); diff --git a/apps/web/components/task/mobile/mobile-repos-section.tsx b/apps/web/components/task/mobile/mobile-repos-section.tsx new file mode 100644 index 0000000000..ac00a5af94 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-repos-section.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { memo, useCallback, useMemo } from "react"; +import { IconCheck, IconFolder, IconGitBranch } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; +import type { Repository, TaskSession } from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +type RepoRow = { + taskRepositoryId: string; + repositoryId: string; + name: string; + baseBranch: string; + checkoutBranch?: string; + sessionCount: number; + /** A representative session for this repo, if any (preferring primary). */ + switchToSessionId: string | null; +}; + +function pickSwitchToSession( + sessions: TaskSession[], + repositoryId: string, + primarySessionId: string | null | undefined, +): string | null { + const onRepo = sessions.filter((s) => s.repository_id === repositoryId); + if (onRepo.length === 0) return null; + if (primarySessionId && onRepo.some((s) => s.id === primarySessionId)) return primarySessionId; + // Most recently started session on this repo as fallback. + const sorted = [...onRepo].sort( + (a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime(), + ); + return sorted[0].id; +} + +function buildRepoRows( + taskRepositories: NonNullable<KanbanState["tasks"][number]["repositories"]>, + workspaceRepos: Repository[], + taskSessions: TaskSession[], + primarySessionId: string | null | undefined, +): RepoRow[] { + return [...taskRepositories] + .sort((a, b) => a.position - b.position) + .map((tr) => { + const repo = workspaceRepos.find((r) => r.id === tr.repository_id); + const onRepoSessions = taskSessions.filter((s) => s.repository_id === tr.repository_id); + return { + taskRepositoryId: tr.id, + repositoryId: tr.repository_id, + name: repo?.name ?? repo?.local_path ?? "Repository", + baseBranch: tr.base_branch, + checkoutBranch: tr.checkout_branch, + sessionCount: onRepoSessions.length, + switchToSessionId: pickSwitchToSession(taskSessions, tr.repository_id, primarySessionId), + }; + }); +} + +function useTaskRepoRows(taskId: string | null, workspaceId: string | null): RepoRow[] { + const taskRepositories = useAppStore((s) => { + if (!taskId) return undefined; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return task?.repositories; + }); + const workspaceRepos = useAppStore((s) => + workspaceId ? (s.repositories.itemsByWorkspaceId[workspaceId] ?? []) : [], + ); + const taskSessions = useAppStore((s) => + taskId ? (s.taskSessionsByTask.itemsByTaskId[taskId] ?? []) : [], + ); + const primarySessionId = useAppStore((s) => { + if (!taskId) return null; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return task?.primarySessionId ?? null; + }); + return useMemo( + () => + taskRepositories + ? buildRepoRows(taskRepositories, workspaceRepos, taskSessions, primarySessionId) + : [], + [taskRepositories, workspaceRepos, taskSessions, primarySessionId], + ); +} + +function RepoRowItem({ + row, + isActive, + onSelect, +}: { + row: RepoRow; + isActive: boolean; + onSelect: (row: RepoRow) => void; +}) { + return ( + <div + role="button" + tabIndex={0} + onClick={() => onSelect(row)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(row); + } + }} + data-testid={`mobile-repo-row-${row.repositoryId}`} + className={`flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer select-none ${ + isActive ? "bg-accent" : "hover:bg-accent/50" + }`} + > + <IconFolder className="h-4 w-4 text-muted-foreground shrink-0" /> + <div className="flex flex-col min-w-0 flex-1"> + <span className="text-sm truncate">{row.name}</span> + <div className="flex items-center gap-1 min-w-0"> + <IconGitBranch className="h-3 w-3 text-muted-foreground shrink-0" /> + <span className="text-[11px] text-muted-foreground truncate"> + {row.checkoutBranch || row.baseBranch} + </span> + {row.sessionCount > 0 && ( + <span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-foreground/10 text-muted-foreground leading-none ml-1 shrink-0"> + {row.sessionCount} session{row.sessionCount === 1 ? "" : "s"} + </span> + )} + </div> + </div> + {isActive && <IconCheck className="h-4 w-4 text-foreground shrink-0" />} + </div> + ); +} + +export const MobileReposSection = memo(function MobileReposSection({ + taskId, + workspaceId, + onClose, +}: { + taskId: string | null; + workspaceId: string | null; + onClose: () => void; +}) { + const setActiveSession = useAppStore((s) => s.setActiveSession); + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const activeRepoId = useAppStore((s) => + activeSessionId ? (s.taskSessions.items[activeSessionId]?.repository_id ?? null) : null, + ); + const rows = useTaskRepoRows(taskId, workspaceId); + const { toast } = useToast(); + + const handleSelect = useCallback( + (row: RepoRow) => { + if (!taskId) return; + if (row.switchToSessionId) { + setActiveSession(taskId, row.switchToSessionId); + onClose(); + return; + } + // We don't auto-launch from a repo tap — that would create a side effect + // for what looks like navigation. Surface a hint so the tap doesn't + // appear silently broken. + toast({ + title: "No session on this repo yet", + description: "Open the session picker to launch one.", + }); + }, + [taskId, setActiveSession, onClose, toast], + ); + + if (!taskId) { + return ( + <div className="text-xs text-muted-foreground px-2 py-6 text-center">No active task</div> + ); + } + if (rows.length === 0) { + return ( + <div className="text-xs text-muted-foreground px-2 py-6 text-center"> + This task has no repositories. + </div> + ); + } + + return ( + <div className="flex flex-col gap-0.5 px-1"> + {rows.map((row) => ( + <RepoRowItem + key={row.taskRepositoryId} + row={row} + isActive={row.repositoryId === activeRepoId} + onSelect={handleSelect} + /> + ))} + </div> + ); +}); + +/** Returns the count of repositories attached to the active task. */ +export function useTaskRepoCount(taskId: string | null): number { + return ( + useAppStore((s) => { + if (!taskId) return 0; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return task?.repositories?.length ?? 0; + }) ?? 0 + ); +} diff --git a/apps/web/components/task/mobile/mobile-sessions-section.tsx b/apps/web/components/task/mobile/mobile-sessions-section.tsx new file mode 100644 index 0000000000..439ea77ae9 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-sessions-section.tsx @@ -0,0 +1,396 @@ +"use client"; + +import { memo, useCallback, useMemo, useState } from "react"; +import { IconDotsVertical, IconPlus, IconStar } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@kandev/ui/alert-dialog"; +import { AgentLogo } from "@/components/agent-logo"; +import { useAppStore } from "@/components/state-provider"; +import { useTaskSessions } from "@/hooks/use-task-sessions"; +import { + useSessionActions, + isSessionStoppable, + isSessionDeletable, + isSessionResumable, +} from "@/hooks/domains/session/use-session-actions"; +import { NewSessionDialog } from "../new-session-dialog"; +import { MobilePillButton } from "./mobile-pill-button"; +import { MobilePickerSheet } from "./mobile-picker-sheet"; +import { formatTaskSessionStateLabel } from "@/lib/ui/state-labels"; +import type { TaskSession, TaskSessionState } from "@/lib/types/http"; +import type { AgentProfileOption } from "@/lib/state/slices"; + +type SessionRow = { + id: string; + agentName: string | null; + agentLabel: string; + state: TaskSessionState | null; + isPrimary: boolean; + index: number; + startedAt: string; +}; + +function buildSessionRows( + sessions: TaskSession[], + agentProfiles: AgentProfileOption[], + primarySessionId: string | null | undefined, +): SessionRow[] { + const sorted = [...sessions].sort( + (a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime(), + ); + return sorted.map((s, idx) => { + const profile = agentProfiles.find((p) => p.id === s.agent_profile_id); + const labelParts = profile?.label.split(" • ") ?? []; + return { + id: s.id, + agentName: profile?.agent_name ?? null, + agentLabel: labelParts[1] || labelParts[0] || profile?.label || "Agent", + state: (s.state as TaskSessionState | undefined) ?? null, + isPrimary: primarySessionId ? s.id === primarySessionId : !!s.is_primary, + index: idx + 1, + startedAt: s.started_at, + }; + }); +} + +const STATE_TONE: Partial<Record<TaskSessionState, string>> = { + RUNNING: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", + STARTING: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", + WAITING_FOR_INPUT: "bg-amber-500/15 text-amber-600 dark:text-amber-400", + FAILED: "bg-destructive/15 text-destructive", +}; + +function StateBadge({ state }: { state: TaskSessionState | null }) { + if (!state) return null; + const tone = STATE_TONE[state] ?? "bg-foreground/10 text-muted-foreground"; + return ( + <span className={`text-[10px] font-medium px-1.5 py-0.5 rounded leading-none ${tone}`}> + {formatTaskSessionStateLabel(state)} + </span> + ); +} + +function SessionActionsMenu({ + state, + isPrimary, + onSetPrimary, + onStop, + onResume, + onAskDelete, +}: { + state: TaskSessionState | null; + isPrimary: boolean; + onSetPrimary: () => void; + onStop: () => void; + onResume: () => void; + onAskDelete: () => void; +}) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="icon-sm" + className="cursor-pointer h-7 w-7" + onClick={(e) => e.stopPropagation()} + aria-label="Session actions" + > + <IconDotsVertical className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}> + <DropdownMenuItem + className="cursor-pointer" + onSelect={onSetPrimary} + disabled={isPrimary || !state} + > + Set as Primary + </DropdownMenuItem> + <DropdownMenuSeparator /> + {state && isSessionStoppable(state) && ( + <DropdownMenuItem className="cursor-pointer" onSelect={onStop}> + Stop + </DropdownMenuItem> + )} + {state && isSessionResumable(state) && ( + <DropdownMenuItem className="cursor-pointer" onSelect={onResume}> + Resume + </DropdownMenuItem> + )} + {state && isSessionDeletable(state) && ( + <DropdownMenuItem + className="cursor-pointer text-destructive focus:text-destructive" + onSelect={onAskDelete} + > + Delete + </DropdownMenuItem> + )} + </DropdownMenuContent> + </DropdownMenu> + ); +} + +function DeleteSessionConfirmDialog({ + open, + onOpenChange, + isPrimary, + isOnlySession, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + isPrimary: boolean; + isOnlySession: boolean; + onConfirm: () => void; +}) { + return ( + <AlertDialog open={open} onOpenChange={onOpenChange}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete session?</AlertDialogTitle> + <AlertDialogDescription asChild> + <div> + <p>This will permanently delete the conversation history with this session.</p> + {isPrimary && !isOnlySession && ( + <p className="mt-2 font-medium"> + This is the primary session. Another session will be set as primary. + </p> + )} + {isOnlySession && ( + <p className="mt-2 font-medium">This is the only session for this task.</p> + )} + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={() => { + onOpenChange(false); + onConfirm(); + }} + className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ); +} + +function SessionRowItem({ + row, + taskId, + isActive, + totalSessions, + onSelect, +}: { + row: SessionRow; + taskId: string; + isActive: boolean; + totalSessions: number; + onSelect: (sessionId: string) => void; +}) { + const [confirmDelete, setConfirmDelete] = useState(false); + const actions = useSessionActions({ sessionId: row.id, taskId }); + const isOnly = totalSessions === 1; + const showBadges = totalSessions > 1; + + return ( + <> + <div + role="button" + tabIndex={0} + onClick={() => onSelect(row.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(row.id); + } + }} + data-testid={`mobile-session-row-${row.id}`} + className={`flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer select-none ${ + isActive ? "bg-accent" : "hover:bg-accent/50" + }`} + > + {row.isPrimary && showBadges && ( + <IconStar className="h-3.5 w-3.5 fill-foreground/60 stroke-0 shrink-0" /> + )} + {showBadges && ( + <span className="text-[11px] font-medium leading-none text-muted-foreground bg-foreground/10 rounded px-1.5 py-0.5 shrink-0"> + {row.index} + </span> + )} + {row.agentName && <AgentLogo agentName={row.agentName} size={16} className="shrink-0" />} + <span className="text-sm truncate flex-1">{row.agentLabel}</span> + <StateBadge state={row.state} /> + <SessionActionsMenu + state={row.state} + isPrimary={row.isPrimary} + onSetPrimary={() => void actions.setPrimary()} + onStop={() => void actions.stop()} + onResume={() => void actions.resume()} + onAskDelete={() => setConfirmDelete(true)} + /> + </div> + <DeleteSessionConfirmDialog + open={confirmDelete} + onOpenChange={setConfirmDelete} + isPrimary={row.isPrimary} + isOnlySession={isOnly} + onConfirm={() => void actions.remove()} + /> + </> + ); +} + +function useSessionRows(taskId: string | null) { + const agentProfiles = useAppStore((s) => s.agentProfiles.items); + const primarySessionId = useAppStore((s) => { + if (!taskId) return null; + const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return task?.primarySessionId ?? null; + }); + const { sessions, isLoading } = useTaskSessions(taskId); + const rows = useMemo( + () => buildSessionRows(sessions, agentProfiles, primarySessionId), + [sessions, agentProfiles, primarySessionId], + ); + return { rows, isLoading, primarySessionId }; +} + +const MobileSessionsList = memo(function MobileSessionsList({ + taskId, + onClose, +}: { + taskId: string | null; + onClose: () => void; +}) { + const setActiveSession = useAppStore((s) => s.setActiveSession); + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const { rows, isLoading } = useSessionRows(taskId); + const [launchOpen, setLaunchOpen] = useState(false); + + const handleSelect = useCallback( + (sessionId: string) => { + if (!taskId) return; + setActiveSession(taskId, sessionId); + onClose(); + }, + [taskId, setActiveSession, onClose], + ); + + if (!taskId) { + return ( + <div className="text-xs text-muted-foreground px-2 py-6 text-center">No active task</div> + ); + } + + return ( + <div className="flex flex-col gap-2 px-1"> + <div className="flex items-center justify-between px-1"> + <span className="text-xs font-medium text-muted-foreground"> + {rows.length} session{rows.length === 1 ? "" : "s"} + </span> + <Button + size="sm" + variant="outline" + className="h-7 gap-1 cursor-pointer" + onClick={() => setLaunchOpen(true)} + data-testid="mobile-launch-session" + > + <IconPlus className="h-4 w-4" /> + New session + </Button> + </div> + <div className="flex flex-col gap-0.5"> + {isLoading && rows.length === 0 && ( + <div className="text-xs text-muted-foreground px-2 py-4 text-center"> + Loading sessions… + </div> + )} + {!isLoading && rows.length === 0 && ( + <div className="text-xs text-muted-foreground px-2 py-4 text-center"> + No sessions yet. Launch one to get started. + </div> + )} + {rows.map((row) => ( + <SessionRowItem + key={row.id} + row={row} + taskId={taskId} + isActive={row.id === activeSessionId} + totalSessions={rows.length} + onSelect={handleSelect} + /> + ))} + </div> + {launchOpen && ( + <NewSessionDialog open={launchOpen} onOpenChange={setLaunchOpen} taskId={taskId} /> + )} + </div> + ); +}); + +function useActiveSessionPillLabel(taskId: string | null): { + label: string; + count: string | undefined; +} { + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const { rows } = useSessionRows(taskId); + const activeRow = rows.find((r) => r.id === activeSessionId); + const total = rows.length; + const idx = activeRow?.index; + let count: string | undefined; + if (total > 1 && idx) count = `${idx}/${total}`; + else if (total > 1) count = `${total}`; + return { label: activeRow?.agentLabel ?? "Session", count }; +} + +export const MobileSessionsPicker = memo(function MobileSessionsPicker({ + taskId, + compact, + fullWidth, +}: { + taskId: string | null; + compact?: boolean; + fullWidth?: boolean; +}) { + const [open, setOpen] = useState(false); + const { label, count } = useActiveSessionPillLabel(taskId); + if (!taskId) return null; + return ( + <> + <MobilePillButton + label={label} + count={count} + compact={compact} + fullWidth={fullWidth} + isOpen={open} + onClick={() => setOpen(true)} + data-testid="mobile-sessions-pill" + ariaLabel={`Active session: ${label}. Tap to switch.`} + /> + <MobilePickerSheet open={open} onOpenChange={setOpen} title="Sessions"> + <MobileSessionsList taskId={taskId} onClose={() => setOpen(false)} /> + </MobilePickerSheet> + </> + ); +}); diff --git a/apps/web/components/task/mobile/mobile-terminal-pane.tsx b/apps/web/components/task/mobile/mobile-terminal-pane.tsx new file mode 100644 index 0000000000..3d9d6b2b07 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-terminal-pane.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { memo, useCallback, useEffect, useState } from "react"; +import { PassthroughTerminal } from "../passthrough-terminal"; +import { setActiveTerminalSender } from "@/lib/terminal/mobile-active-terminal"; +import { sendShellInput } from "@/lib/terminal/send-shell-input"; +import { MobileTerminalsPicker } from "./mobile-terminals-section"; +import { MobileTerminalsProvider, useMobileTerminalsContext } from "./mobile-terminals-context"; +import type { Terminal as XtermTerminal } from "@xterm/xterm"; +import type { Terminal } from "@/hooks/domains/session/use-terminals"; + +function TerminalSlot({ + terminal, + sessionId, + environmentId, + isActive, +}: { + terminal: Terminal; + sessionId: string; + environmentId: string | null; + isActive: boolean; +}) { + // Track xterm + ws in state so the registration / data-routing effects + // re-run once each is ready (PassthroughTerminal initialises both + // asynchronously when the container starts at 0×0). A ref-based dep would + // silently miss those paths on first mount. + const [xterm, setXterm] = useState<XtermTerminal | null>(null); + const [ws, setWs] = useState<WebSocket | null>(null); + + const handleWsReady = useCallback((nextWs: WebSocket) => { + setWs(nextWs); + // Drop the registry sender if this socket closes — otherwise the key-bar + // would route taps into a dead WS during reconnect, instead of falling + // through to sendShellInput's WS-fallback path. + nextWs.addEventListener( + "close", + () => setWs((current) => (current === nextWs ? null : current)), + { once: true }, + ); + }, []); + + // Forward OS-keyboard input through sendShellInput so the key-bar's sticky + // Ctrl/Shift modifiers are applied before the bytes hit the wire. Without + // this, AttachAddon would auto-send raw onData and modifiers would be a + // no-op for OS-keyboard typing on mobile. + useEffect(() => { + if (!isActive || !xterm) return; + const disposable = xterm.onData((data) => sendShellInput(sessionId, data)); + return () => disposable.dispose(); + }, [isActive, xterm, sessionId]); + + // Register the active key-bar sender only while the dedicated WS is open. + // sendShellInput's fallback path runs whenever no sender is registered, so + // skipping registration during connect/reconnect lets the key-bar still + // dispatch instead of silently dropping keystrokes. + useEffect(() => { + if (!isActive || !ws || ws.readyState !== WebSocket.OPEN) return; + const sender = (data: string) => ws.send(new TextEncoder().encode(data)); + setActiveTerminalSender(sender); + return () => setActiveTerminalSender(null); + }, [isActive, terminal.id, ws]); + + return ( + <div className={`absolute inset-0 ${isActive ? "block" : "hidden"}`}> + <PassthroughTerminal + mode="shell" + environmentId={environmentId} + terminalId={terminal.id} + label={terminal.label} + autoFocus={isActive} + disableWebgl + manualInputRouting + onXtermReady={setXterm} + onWsReady={handleWsReady} + /> + </div> + ); +} + +function MobileTerminalPaneInner({ sessionId }: { sessionId: string | null }) { + const { terminals, terminalTabValue, environmentId } = useMobileTerminalsContext(); + const activeId = terminals.find((t) => t.id === terminalTabValue)?.id ?? terminals[0]?.id; + + if (!sessionId || !environmentId) { + return ( + <div className="flex-1 flex items-center justify-center text-xs text-muted-foreground"> + Terminal unavailable — no active session. + </div> + ); + } + + return ( + <div className="flex-1 min-h-0 flex flex-col"> + <div className="flex items-center px-1 py-2 border-b border-border"> + <MobileTerminalsPicker sessionId={sessionId} fullWidth /> + </div> + <div className="relative flex-1 min-h-0"> + {terminals.length === 0 && ( + <div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground"> + Starting terminal… + </div> + )} + {terminals.map((t) => ( + <TerminalSlot + key={t.id} + terminal={t} + sessionId={sessionId} + environmentId={environmentId} + isActive={t.id === activeId} + /> + ))} + </div> + </div> + ); +} + +export const MobileTerminalPane = memo(function MobileTerminalPane({ + sessionId, +}: { + sessionId: string | null; +}) { + return ( + <MobileTerminalsProvider sessionId={sessionId}> + <MobileTerminalPaneInner sessionId={sessionId} /> + </MobileTerminalsProvider> + ); +}); diff --git a/apps/web/components/task/mobile/mobile-terminals-context.tsx b/apps/web/components/task/mobile/mobile-terminals-context.tsx new file mode 100644 index 0000000000..8b8ab62ba1 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-terminals-context.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { createContext, memo, useContext, type ReactNode } from "react"; +import { useMobileTerminals } from "@/hooks/domains/session/use-mobile-terminals"; + +type MobileTerminalsContextValue = ReturnType<typeof useMobileTerminals>; + +const MobileTerminalsContext = createContext<MobileTerminalsContextValue | null>(null); + +/** + * Provider that owns the single `useMobileTerminals` instance for a mobile + * terminal pane. Both the chip-style picker pill and the picker sheet's list + * read state from this context instead of calling `useMobileTerminals` + * themselves — three separate `useState`-backed lists used to drift out of + * sync after `addTerminal` / `removeTerminal`, leaving the pane showing the + * wrong active terminal until a server WS push reconciled them. + */ +export const MobileTerminalsProvider = memo(function MobileTerminalsProvider({ + sessionId, + children, +}: { + sessionId: string | null; + children: ReactNode; +}) { + const value = useMobileTerminals(sessionId); + return ( + <MobileTerminalsContext.Provider value={value}>{children}</MobileTerminalsContext.Provider> + ); +}); + +export function useMobileTerminalsContext(): MobileTerminalsContextValue { + const ctx = useContext(MobileTerminalsContext); + if (!ctx) { + throw new Error("useMobileTerminalsContext must be used inside a <MobileTerminalsProvider>"); + } + return ctx; +} diff --git a/apps/web/components/task/mobile/mobile-terminals-section.tsx b/apps/web/components/task/mobile/mobile-terminals-section.tsx new file mode 100644 index 0000000000..986e37e871 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-terminals-section.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { memo, useCallback, useState } from "react"; +import { IconPlus, IconTerminal2, IconX } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@kandev/ui/alert-dialog"; +import { useAppStore } from "@/components/state-provider"; +import { stopUserShell } from "@/lib/api/domains/user-shell-api"; +import { useUserShells } from "@/hooks/domains/session/use-user-shells"; +import { releaseAutoCreatedEnvironment } from "@/hooks/domains/session/use-mobile-terminals"; +import { MobilePillButton } from "./mobile-pill-button"; +import { MobilePickerSheet } from "./mobile-picker-sheet"; +import { useMobileTerminalsContext } from "./mobile-terminals-context"; +import type { Terminal } from "@/hooks/domains/session/use-terminals"; + +function TerminalRow({ + terminal, + isActive, + isRunning, + onSelect, + onAskClose, +}: { + terminal: Terminal; + isActive: boolean; + isRunning: boolean; + onSelect: (id: string) => void; + onAskClose: (terminal: Terminal) => void; +}) { + return ( + <div + role="button" + tabIndex={0} + onClick={() => onSelect(terminal.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(terminal.id); + } + }} + data-testid={`mobile-terminal-row-${terminal.id}`} + className={`flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer select-none ${ + isActive ? "bg-accent" : "hover:bg-accent/50" + }`} + > + <IconTerminal2 className="h-4 w-4 text-muted-foreground shrink-0" /> + <span className="text-sm truncate flex-1">{terminal.label}</span> + {isRunning && ( + <span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 leading-none"> + running + </span> + )} + {terminal.closable && ( + <Button + variant="ghost" + size="icon-sm" + aria-label={`Close ${terminal.label}`} + className="cursor-pointer h-7 w-7" + onClick={(e) => { + e.stopPropagation(); + onAskClose(terminal); + }} + > + <IconX className="h-4 w-4" /> + </Button> + )} + </div> + ); +} + +function CloseTerminalConfirmDialog({ + terminal, + onOpenChange, + onConfirm, +}: { + terminal: Terminal | null; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + return ( + <AlertDialog open={terminal !== null} onOpenChange={onOpenChange}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Close terminal?</AlertDialogTitle> + <AlertDialogDescription> + {`This stops the “${terminal?.label ?? ""}” shell and any process it's running.`} + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={() => { + onOpenChange(false); + onConfirm(); + }} + className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Close terminal + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ); +} + +type CloseHandlerArgs = { + sessionId: string | null; + environmentId: string | null; + terminals: Terminal[]; + terminalTabValue: string; + removeTerminal: (id: string) => void; + setRightPanelActiveTab: (sessionId: string, tab: string) => void; +}; + +function useTerminalCloseHandler({ + sessionId, + environmentId, + terminals, + terminalTabValue, + removeTerminal, + setRightPanelActiveTab, +}: CloseHandlerArgs) { + const [pendingClose, setPendingClose] = useState<Terminal | null>(null); + + const handleConfirmClose = useCallback(async () => { + const t = pendingClose; + if (!t || !sessionId) return; + try { + // Stop the remote shell first; only mutate UI on success so a failed + // stop doesn't orphan the shell server-side while the picker hides it. + if (environmentId) await stopUserShell(environmentId, t.id); + // If the closed terminal was active, advance the active tab to a + // remaining one so the picker doesn't leave terminalTabValue pointing + // at a deleted id (which would render every row as inactive). + if (terminalTabValue === t.id) { + const next = terminals.find((row) => row.id !== t.id); + if (next) setRightPanelActiveTab(sessionId, next.id); + } + removeTerminal(t.id); + // If this was the last terminal, release the auto-create guard so the + // pane recreates a default shell on next render instead of getting + // stuck on the "Starting terminal…" placeholder forever. + if (environmentId && terminals.length <= 1) { + releaseAutoCreatedEnvironment(environmentId); + } + setPendingClose(null); + } catch (err) { + console.error("Failed to stop terminal:", err); + } + }, [ + pendingClose, + sessionId, + environmentId, + terminals, + terminalTabValue, + removeTerminal, + setRightPanelActiveTab, + ]); + + return { pendingClose, setPendingClose, handleConfirmClose }; +} + +const MobileTerminalsList = memo(function MobileTerminalsList({ + sessionId, + onClose, +}: { + sessionId: string | null; + onClose: () => void; +}) { + const { terminals, terminalTabValue, addTerminal, removeTerminal, environmentId } = + useMobileTerminalsContext(); + const { shells } = useUserShells(environmentId); + const setRightPanelActiveTab = useAppStore((s) => s.setRightPanelActiveTab); + const { pendingClose, setPendingClose, handleConfirmClose } = useTerminalCloseHandler({ + sessionId, + environmentId, + terminals, + terminalTabValue, + removeTerminal, + setRightPanelActiveTab, + }); + + const isShellRunning = useCallback( + (id: string) => shells.find((s) => s.terminalId === id)?.running ?? false, + [shells], + ); + + const handleSelect = useCallback( + (id: string) => { + if (sessionId) setRightPanelActiveTab(sessionId, id); + onClose(); + }, + [sessionId, setRightPanelActiveTab, onClose], + ); + + const handleAskClose = useCallback( + (terminal: Terminal) => setPendingClose(terminal), + [setPendingClose], + ); + + if (!sessionId) { + return ( + <div className="text-xs text-muted-foreground px-2 py-6 text-center">No active session</div> + ); + } + + return ( + <div className="flex flex-col gap-2 px-1"> + <div className="flex items-center justify-between px-1"> + <span className="text-xs font-medium text-muted-foreground"> + {terminals.length} terminal{terminals.length === 1 ? "" : "s"} + </span> + <Button + size="sm" + variant="outline" + className="h-7 gap-1 cursor-pointer" + onClick={() => addTerminal()} + data-testid="mobile-add-terminal" + > + <IconPlus className="h-4 w-4" /> + New terminal + </Button> + </div> + <div className="flex flex-col gap-0.5"> + {terminals.length === 0 && ( + <div className="text-xs text-muted-foreground px-2 py-4 text-center"> + No terminals yet. + </div> + )} + {terminals.map((t) => ( + <TerminalRow + key={t.id} + terminal={t} + isActive={t.id === terminalTabValue} + isRunning={isShellRunning(t.id)} + onSelect={handleSelect} + onAskClose={handleAskClose} + /> + ))} + </div> + <CloseTerminalConfirmDialog + terminal={pendingClose} + onOpenChange={(open) => { + if (!open) setPendingClose(null); + }} + onConfirm={handleConfirmClose} + /> + </div> + ); +}); + +function useActiveTerminalPillLabel(): { label: string; count: string | undefined } { + const { terminals, terminalTabValue } = useMobileTerminalsContext(); + const activeIdx = terminals.findIndex((t) => t.id === terminalTabValue); + const idx = activeIdx >= 0 ? activeIdx : 0; + const active = terminals[idx]; + const total = terminals.length; + let count: string | undefined; + if (total > 1) count = `${idx + 1}/${total}`; + return { label: active?.label ?? "Terminal", count }; +} + +export const MobileTerminalsPicker = memo(function MobileTerminalsPicker({ + sessionId, + compact, + fullWidth, +}: { + sessionId: string | null; + compact?: boolean; + fullWidth?: boolean; +}) { + const [open, setOpen] = useState(false); + const { label, count } = useActiveTerminalPillLabel(); + if (!sessionId) return null; + return ( + <> + <MobilePillButton + label={label} + count={count} + compact={compact} + fullWidth={fullWidth} + isOpen={open} + onClick={() => setOpen(true)} + data-testid="mobile-terminals-pill" + ariaLabel={`Active terminal: ${label}. Tap to switch.`} + /> + <MobilePickerSheet open={open} onOpenChange={setOpen} title="Terminals"> + <MobileTerminalsList sessionId={sessionId} onClose={() => setOpen(false)} /> + </MobilePickerSheet> + </> + ); +}); diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index 60244b822b..50d0f54bb8 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -8,9 +8,10 @@ import { TaskChatPanel } from "../task-chat-panel"; import { TaskPlanPanel } from "../task-plan-panel"; import { TaskChangesPanel } from "../task-changes-panel"; import { TaskFilesPanel } from "../task-files-panel"; -import { ShellTerminal } from "../shell-terminal"; import { PassthroughTerminal } from "../passthrough-terminal"; import { MobileTerminalKeybar, KEYBAR_HEIGHT_PX } from "./mobile-terminal-keybar"; +import { MobileTerminalPane } from "./mobile-terminal-pane"; +import { MobileSessionsPicker } from "./mobile-sessions-section"; import { SessionPanelContent } from "@kandev/ui/pannel-session"; import { useSessionLayoutState } from "@/hooks/use-session-layout-state"; import { useVisualViewportOffset } from "@/hooks/use-visual-viewport-offset"; @@ -39,28 +40,36 @@ function MobileChatPanelContent({ activeTaskId, isPassthroughMode, effectiveSessionId, - sessionId, onOpenFile, }: { activeTaskId: string | null; isPassthroughMode: boolean; effectiveSessionId: string | null; - sessionId?: string | null; onOpenFile: (path: string) => void; }) { - if (activeTaskId && isPassthroughMode) { + if (!activeTaskId) { return ( - <div className="flex-1 min-h-0"> - <PassthroughTerminal key={effectiveSessionId} sessionId={sessionId} mode="agent" /> + <div className="flex-1 flex items-center justify-center text-muted-foreground"> + No task selected </div> ); } - if (activeTaskId) { - return <TaskChatPanel sessionId={sessionId} onOpenFile={onOpenFile} />; - } return ( - <div className="flex-1 flex items-center justify-center text-muted-foreground"> - No task selected + <div className="flex-1 min-h-0 flex flex-col"> + <div className="flex items-center px-1 py-2"> + <MobileSessionsPicker taskId={activeTaskId} fullWidth /> + </div> + {isPassthroughMode ? ( + <div className="flex-1 min-h-0"> + <PassthroughTerminal + key={effectiveSessionId} + sessionId={effectiveSessionId} + mode="agent" + /> + </div> + ) : ( + <TaskChatPanel sessionId={effectiveSessionId} onOpenFile={onOpenFile} /> + )} </div> ); } @@ -70,7 +79,6 @@ type MobilePanelAreaProps = { activeTaskId: string | null; isPassthroughMode: boolean; effectiveSessionId: string | null; - sessionId?: string | null; selectedDiff: { path: string; content?: string } | null; handleOpenFileFromChat: (path: string) => void; handleClearSelectedDiff: () => void; @@ -85,7 +93,6 @@ function MobilePanelArea({ activeTaskId, isPassthroughMode, effectiveSessionId, - sessionId, selectedDiff, handleOpenFileFromChat, handleClearSelectedDiff, @@ -112,12 +119,11 @@ function MobilePanelArea({ }} > {currentMobilePanel === "chat" && ( - <div className="flex-1 min-h-0 flex flex-col p-2"> + <div className="flex-1 min-h-0 flex flex-col px-2 pb-2"> <MobileChatPanelContent activeTaskId={activeTaskId} isPassthroughMode={isPassthroughMode} effectiveSessionId={effectiveSessionId} - sessionId={sessionId} onOpenFile={handleOpenFileFromChat} /> </div> @@ -147,11 +153,11 @@ function MobilePanelArea({ {currentMobilePanel === "terminal" && ( <div data-testid="terminal-panel" - className="flex-1 min-h-0 flex flex-col p-2" + className="flex-1 min-h-0 flex flex-col px-2" style={{ paddingBottom: terminalPaddingBottom }} > - <SessionPanelContent className="p-0 flex-1 min-h-0"> - <ShellTerminal key={effectiveSessionId} sessionId={effectiveSessionId ?? undefined} /> + <SessionPanelContent className="p-0 flex-1 min-h-0 flex flex-col"> + <MobileTerminalPane key={effectiveSessionId} sessionId={effectiveSessionId} /> </SessionPanelContent> </div> )} @@ -159,6 +165,53 @@ function MobilePanelArea({ ); } +type MobileTopBarStickyProps = { + activeTaskId: string | null; + workspaceId: string | null; + taskTitle?: string; + effectiveSessionId: string | null; + baseBranch?: string; + worktreeBranch?: string | null; + onMenuClick: () => void; + showApproveButton: boolean; + onApprove: () => void; + isRemoteExecutor?: boolean; + remoteExecutorType?: string | null; + remoteExecutorName?: string | null; + remoteState?: string | null; + remoteCreatedAt?: string | null; + remoteCheckedAt?: string | null; + remoteStatusError?: string | null; +}; + +function MobileTopBarSticky(props: MobileTopBarStickyProps) { + return ( + <div + className="fixed top-0 left-0 right-0 z-40 bg-background border-b border-border" + style={{ paddingTop: "env(safe-area-inset-top, 0px)" }} + > + <SessionMobileTopBar + taskId={props.activeTaskId} + workspaceId={props.workspaceId} + taskTitle={props.taskTitle} + sessionId={props.effectiveSessionId} + baseBranch={props.baseBranch} + worktreeBranch={props.worktreeBranch} + onMenuClick={props.onMenuClick} + showApproveButton={props.showApproveButton} + onApprove={props.onApprove} + isRemoteExecutor={props.isRemoteExecutor} + remoteExecutorType={props.remoteExecutorType} + remoteExecutorName={props.remoteExecutorName} + remoteState={props.remoteState} + remoteCreatedAt={props.remoteCreatedAt} + remoteCheckedAt={props.remoteCheckedAt} + remoteStatusError={props.remoteStatusError} + /> + </div> + ); +} + function useMobilePanelHandlers({ handleSelectDiff, handlePanelChange, @@ -235,29 +288,24 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({ return ( <div className="h-dvh relative bg-background"> - {/* Fixed Top Bar */} - <div - className="fixed top-0 left-0 right-0 z-40 bg-background border-b border-border" - style={{ paddingTop: "env(safe-area-inset-top, 0px)" }} - > - <SessionMobileTopBar - taskId={activeTaskId} - taskTitle={taskTitle} - sessionId={effectiveSessionId} - baseBranch={baseBranch} - worktreeBranch={worktreeBranch} - onMenuClick={handleMenuClick} - showApproveButton={showApproveButton} - onApprove={handleApprove} - isRemoteExecutor={isRemoteExecutor} - remoteExecutorType={remoteExecutorType} - remoteExecutorName={remoteExecutorName} - remoteState={remoteState} - remoteCreatedAt={remoteCreatedAt} - remoteCheckedAt={remoteCheckedAt} - remoteStatusError={remoteStatusError} - /> - </div> + <MobileTopBarSticky + activeTaskId={activeTaskId} + workspaceId={workspaceId} + taskTitle={taskTitle} + effectiveSessionId={effectiveSessionId} + baseBranch={baseBranch} + worktreeBranch={worktreeBranch} + onMenuClick={handleMenuClick} + showApproveButton={showApproveButton} + onApprove={handleApprove} + isRemoteExecutor={isRemoteExecutor} + remoteExecutorType={remoteExecutorType} + remoteExecutorName={remoteExecutorName} + remoteState={remoteState} + remoteCreatedAt={remoteCreatedAt} + remoteCheckedAt={remoteCheckedAt} + remoteStatusError={remoteStatusError} + /> {/* Content Area - fixed height panels that manage their own scrolling */} <MobilePanelArea @@ -265,7 +313,6 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({ activeTaskId={activeTaskId} isPassthroughMode={isPassthroughMode} effectiveSessionId={effectiveSessionId} - sessionId={sessionId} selectedDiff={selectedDiff} handleOpenFileFromChat={handleOpenFileFromChat} handleClearSelectedDiff={handleClearSelectedDiff} diff --git a/apps/web/components/task/mobile/session-mobile-top-bar.tsx b/apps/web/components/task/mobile/session-mobile-top-bar.tsx index faea70bb75..5f64eae70c 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar.tsx @@ -15,9 +15,11 @@ import { computeUncommittedStats, useMobileGitActions, } from "./session-mobile-top-bar-git-controls"; +import { MobileRepoPill } from "./mobile-repo-pill"; type SessionMobileTopBarProps = { taskId?: string | null; + workspaceId?: string | null; taskTitle?: string; sessionId?: string | null; baseBranch?: string; @@ -63,6 +65,7 @@ function MobileTaskTitle({ type MobileTopBarActionsProps = { taskId?: string | null; + workspaceId?: string | null; isRemoteExecutor?: boolean; remoteExecutorType?: string | null; remoteExecutorName?: string | null; @@ -87,6 +90,7 @@ type MobileTopBarActionsProps = { function MobileTopBarActions({ taskId, + workspaceId, isRemoteExecutor, remoteExecutorType, remoteExecutorName, @@ -110,6 +114,7 @@ function MobileTopBarActions({ }: MobileTopBarActionsProps) { return ( <div className="flex items-center gap-1"> + <MobileRepoPill taskId={taskId ?? null} workspaceId={workspaceId ?? null} /> {isRemoteExecutor && ( <RemoteCloudTooltip taskId={taskId ?? ""} @@ -156,6 +161,7 @@ function MobileTopBarActions({ export const SessionMobileTopBar = memo(function SessionMobileTopBar({ taskId, + workspaceId, taskTitle, sessionId, baseBranch, @@ -212,6 +218,7 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar({ </div> <MobileTopBarActions taskId={taskId} + workspaceId={workspaceId} isRemoteExecutor={isRemoteExecutor} remoteExecutorType={remoteExecutorType} remoteExecutorName={remoteExecutorName} diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts new file mode 100644 index 0000000000..04611835b1 --- /dev/null +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -0,0 +1,481 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { replaceTaskUrl } from "@/lib/links"; +import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api"; +import { launchSession } from "@/lib/services/session-launch-service"; +import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; +import { useTasks } from "@/hooks/use-tasks"; +import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; +import { useTaskRemoval } from "@/hooks/use-task-removal"; +import { getSessionInfoForTask } from "@/lib/utils/session-info"; +import { hasPendingClarificationForSession } from "@/lib/utils/pending-clarification"; +import type { + TaskState, + TaskSessionState, + Repository, + Task, + WorkflowSnapshot, +} from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +// Map workflow snapshot to kanban state on workspace switch. +function mapSnapshotToKanban(snapshot: WorkflowSnapshot, newWorkflowId: string) { + return { + workflowId: newWorkflowId, + isLoading: false, + steps: snapshot.steps.map((step) => ({ + id: step.id, + title: step.name, + color: step.color, + position: step.position, + events: step.events, + // Carry optional step capabilities forward so downstream UI doesn't see + // them as missing after a workspace switch (until a full reload). + allow_manual_move: step.allow_manual_move, + prompt: step.prompt, + is_start_step: step.is_start_step, + show_in_command_panel: step.show_in_command_panel, + agent_profile_id: step.agent_profile_id, + })), + tasks: snapshot.tasks.map((task) => ({ + id: task.id, + workflowStepId: task.workflow_step_id, + title: task.title, + description: task.description ?? undefined, + position: task.position ?? 0, + state: task.state, + repositoryId: task.repositories?.[0]?.repository_id ?? undefined, + // Carry the full TaskRepository array so the mobile repo picker + // (useTaskRepoCount + MobileReposSection) keeps working after a + // workspace switch. Without this, the picker silently disappears for + // multi-repo tasks because length defaults to 0. + repositories: task.repositories?.map((r) => ({ + id: r.id, + repository_id: r.repository_id, + base_branch: r.base_branch, + checkout_branch: r.checkout_branch, + position: r.position, + })), + primarySessionId: task.primary_session_id ?? undefined, + primarySessionState: task.primary_session_state ?? undefined, + sessionCount: task.session_count ?? undefined, + reviewStatus: task.review_status ?? undefined, + primaryExecutorId: task.primary_executor_id ?? undefined, + primaryExecutorType: task.primary_executor_type ?? undefined, + primaryExecutorName: task.primary_executor_name ?? undefined, + isRemoteExecutor: task.is_remote_executor ?? false, + updatedAt: task.updated_at, + })), + }; +} + +function sortByUpdatedAtDesc<T extends { updated_at?: string | null }>(items: T[]): T[] { + return [...items].sort((a, b) => { + const aDate = a.updated_at ? new Date(a.updated_at).getTime() : 0; + const bDate = b.updated_at ? new Date(b.updated_at).getTime() : 0; + return bDate - aDate; + }); +} + +export function useSheetData(workspaceId: string | null, workflowId: string | null) { + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const sessionsById = useAppStore((state) => state.taskSessions.items); + const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId); + const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId); + const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId); + const messagesBySession = useAppStore((state) => state.messages.bySession); + const { tasks } = useTasks(workflowId); + const steps = useAppStore((state) => state.kanban.steps); + const workspaces = useAppStore((state) => state.workspaces.items); + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false); + + const selectedTaskId = useMemo(() => { + if (activeSessionId) return sessionsById[activeSessionId]?.task_id ?? activeTaskId; + return activeTaskId; + }, [activeSessionId, activeTaskId, sessionsById]); + + const tasksWithRepositories = useMemo(() => { + const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : []; + const repositoryPathsById = new Map( + repositories.map((repo: Repository) => [repo.id, repo.local_path]), + ); + return tasks.map((task: KanbanState["tasks"][number]) => { + const sessionInfo = getSessionInfoForTask( + task.id, + sessionsByTaskId, + gitStatusByEnvId, + envIdBySessionId, + ); + return { + id: task.id, + title: task.title, + state: task.state as TaskState | undefined, + sessionState: + sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined), + description: task.description, + workflowStepId: task.workflowStepId, + repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined, + diffStats: sessionInfo.diffStats, + updatedAt: sessionInfo.updatedAt ?? task.updatedAt, + isRemoteExecutor: task.isRemoteExecutor, + remoteExecutorType: task.primaryExecutorType ?? undefined, + remoteExecutorName: task.primaryExecutorName ?? undefined, + primarySessionId: task.primarySessionId ?? null, + hasPendingClarification: hasPendingClarificationForSession( + messagesBySession, + task.primarySessionId, + ), + }; + }); + }, [ + repositoriesByWorkspace, + tasks, + workspaceId, + sessionsByTaskId, + gitStatusByEnvId, + envIdBySessionId, + messagesBySession, + ]); + + const dialogSteps = useMemo( + () => + steps.map((step: KanbanState["steps"][number]) => ({ + id: step.id, + title: step.title, + color: step.color, + events: step.events, + })), + [steps], + ); + + return { + activeTaskId, + selectedTaskId, + steps, + workspaces, + kanbanIsLoading, + tasksWithRepositories, + dialogSteps, + }; +} + +type SheetNavOptions = { + workspaceId: string | null; + store: ReturnType<typeof useAppStoreApi>; + loadTaskSessionsForTask: ( + taskId: string, + ) => Promise<Array<{ id: string; updated_at?: string | null }>>; + setActiveSession: (taskId: string, sessionId: string) => void; + setActiveTask: (taskId: string) => void; + onOpenChange: (open: boolean) => void; +}; + +async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) { + const { store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange } = opts; + store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true } })); + try { + const workflowsResponse = await listWorkflows(newWorkspaceId, { + cache: "no-store", + includeHidden: true, + }); + const newWorkspaceWorkflows = workflowsResponse.workflows ?? []; + const firstWorkflow = newWorkspaceWorkflows.find((w) => !w.hidden); + if (!firstWorkflow) { + store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } })); + return; + } + const snapshot = await fetchWorkflowSnapshot(firstWorkflow.id); + store.setState((state) => ({ + ...state, + workflows: { + ...state.workflows, + items: [ + ...state.workflows.items.filter( + (w: { workspaceId: string }) => w.workspaceId !== newWorkspaceId, + ), + ...newWorkspaceWorkflows.map((w) => ({ + id: w.id, + workspaceId: w.workspace_id, + name: w.name, + hidden: w.hidden, + })), + ], + activeId: firstWorkflow.id, + }, + kanban: mapSnapshotToKanban(snapshot, firstWorkflow.id), + })); + const mostRecentTask = sortByUpdatedAtDesc(snapshot.tasks)[0]; + if (mostRecentTask) { + const sessions = await loadTaskSessionsForTask(mostRecentTask.id); + const mostRecentSession = sortByUpdatedAtDesc(sessions)[0]; + if (mostRecentSession) { + setActiveSession(mostRecentTask.id, mostRecentSession.id); + } else { + setActiveTask(mostRecentTask.id); + } + replaceTaskUrl(mostRecentTask.id); + } + onOpenChange(false); + } catch (error) { + console.error("Failed to switch workspace:", error); + store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } })); + } +} + +function mapTaskRepositories( + repositories: Task["repositories"], +): KanbanState["tasks"][number]["repositories"] { + return repositories?.map((r) => ({ + id: r.id, + repository_id: r.repository_id, + base_branch: r.base_branch, + checkout_branch: r.checkout_branch, + position: r.position, + })); +} + +function mergeSessionFields( + task: Task, + existing: KanbanState["tasks"][number] | undefined, + taskSessionId: string | null, +) { + return { + primarySessionId: + taskSessionId ?? task.primary_session_id ?? existing?.primarySessionId ?? undefined, + primarySessionState: task.primary_session_state ?? existing?.primarySessionState ?? undefined, + sessionCount: task.session_count ?? existing?.sessionCount ?? (taskSessionId ? 1 : undefined), + reviewStatus: task.review_status ?? existing?.reviewStatus ?? undefined, + }; +} + +/** + * Build the kanban-store representation of a task for an upsert. Session- + * derived fields (primarySessionId, sessionCount, etc.) fall through new + * DTO → existing entry → meta.taskSessionId — that way an "edit" call doesn't + * wipe sessions the existing entry carried, and "create with session" still + * sets the primary correctly. + */ +function buildKanbanTaskUpsert( + task: Task, + existing: KanbanState["tasks"][number] | undefined, + meta: { taskSessionId?: string | null } | undefined, +): KanbanState["tasks"][number] { + const taskSessionId = meta?.taskSessionId ?? null; + return { + id: task.id, + workflowStepId: task.workflow_step_id, + title: task.title, + description: task.description, + position: task.position ?? 0, + state: task.state, + repositoryId: task.repositories?.[0]?.repository_id ?? undefined, + repositories: mapTaskRepositories(task.repositories), + updatedAt: task.updated_at, + ...mergeSessionFields(task, existing, taskSessionId), + primaryExecutorId: task.primary_executor_id ?? undefined, + primaryExecutorType: task.primary_executor_type ?? undefined, + primaryExecutorName: task.primary_executor_name ?? undefined, + isRemoteExecutor: task.is_remote_executor ?? false, + }; +} + +function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { + const { + workspaceId, + store, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + onOpenChange, + } = opts; + + const handleWorkspaceChange = useCallback( + async (newWorkspaceId: string) => { + if (newWorkspaceId === workspaceId) return; + await switchWorkspace(newWorkspaceId, { + workspaceId, + store, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + onOpenChange, + }); + }, + // Spread the individual fields rather than the `opts` object so callers + // re-passing a fresh literal each render don't defeat memoization. + [workspaceId, store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange], + ); + + const handleTaskCreated = useCallback( + (task: Task, _mode: "create" | "edit", meta?: { taskSessionId?: string | null }) => { + store.setState((state) => { + if (state.kanban.workflowId !== task.workflow_id) return state; + const existing = state.kanban.tasks.find( + (item: KanbanState["tasks"][number]) => item.id === task.id, + ); + const nextTask = buildKanbanTaskUpsert(task, existing, meta); + return { + ...state, + kanban: { + ...state.kanban, + tasks: state.kanban.tasks.some( + (item: KanbanState["tasks"][number]) => item.id === task.id, + ) + ? state.kanban.tasks.map((item: KanbanState["tasks"][number]) => + item.id === task.id ? nextTask : item, + ) + : [...state.kanban.tasks, nextTask], + }, + }; + }); + setActiveTask(task.id); + if (meta?.taskSessionId) { + setActiveSession(task.id, meta.taskSessionId); + } + replaceTaskUrl(task.id); + onOpenChange(false); + }, + [store, setActiveTask, setActiveSession, onOpenChange], + ); + + return { handleWorkspaceChange, handleTaskCreated }; +} + +type SelectTaskOptions = { + setActiveTask: (taskId: string) => void; + setActiveSession: (taskId: string, sessionId: string) => void; + loadTaskSessionsForTask: SheetNavOptions["loadTaskSessionsForTask"]; + onOpenChange: (open: boolean) => void; +}; + +async function selectTaskWithoutPrimarySession(taskId: string, opts: SelectTaskOptions) { + const { setActiveTask, setActiveSession, loadTaskSessionsForTask, onOpenChange } = opts; + try { + const sessions = await loadTaskSessionsForTask(taskId); + const sessionId = sessions[0]?.id ?? null; + if (sessionId) { + setActiveSession(taskId, sessionId); + replaceTaskUrl(taskId); + onOpenChange(false); + return; + } + // No session — prepare workspace. + const { request } = buildPrepareRequest(taskId); + try { + const resp = await launchSession(request); + if (resp.session_id) { + setActiveSession(taskId, resp.session_id); + replaceTaskUrl(taskId); + onOpenChange(false); + return; + } + } catch { + // Fall through to default navigation. + } + } catch (error) { + // Loading sessions can reject (network / 5xx). Don't strand the user; + // fall back to plain task navigation so URL + state still align with tap. + console.error("Failed to load sessions for task:", error); + } + setActiveTask(taskId); + replaceTaskUrl(taskId); + onOpenChange(false); +} + +export function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolean) => void) { + const setActiveTask = useAppStore((state) => state.setActiveTask); + const setActiveSession = useAppStore((state) => state.setActiveSession); + const store = useAppStoreApi(); + const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null); + const { deleteTaskById } = useTaskActions(); + const archiveAndSwitch = useArchiveAndSwitchTask(); + const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store }); + + const handleSelectTask = useCallback( + (taskId: string) => { + const kanbanTasks = store.getState().kanban.tasks; + const task = kanbanTasks.find((t) => t.id === taskId); + if (task?.primarySessionId) { + setActiveSession(taskId, task.primarySessionId); + loadTaskSessionsForTask(taskId); + replaceTaskUrl(taskId); + onOpenChange(false); + return; + } + void selectTaskWithoutPrimarySession(taskId, { + setActiveTask, + setActiveSession, + loadTaskSessionsForTask, + onOpenChange, + }); + }, + [loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange], + ); + + const [archivingTask, setArchivingTask] = useState<{ id: string; title: string } | null>(null); + const [isArchiving, setIsArchiving] = useState(false); + + const handleArchiveTask = useCallback( + (taskId: string) => { + const task = store.getState().kanban.tasks.find((t) => t.id === taskId); + setArchivingTask({ id: taskId, title: task?.title ?? "this task" }); + }, + [store], + ); + + const handleArchiveConfirm = useCallback(async () => { + if (!archivingTask) return; + setIsArchiving(true); + try { + await archiveAndSwitch(archivingTask.id); + } catch (error) { + console.error("Failed to archive task:", error); + } finally { + setIsArchiving(false); + setArchivingTask(null); + } + }, [archivingTask, archiveAndSwitch]); + + const handleDeleteTask = useCallback( + async (taskId: string) => { + setDeletingTaskId(taskId); + // Capture active state before the async API call — the WS "task.deleted" + // handler may clear activeTaskId/activeSessionId before removeTaskFromBoard runs. + const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } = + store.getState().tasks; + try { + await deleteTaskById(taskId); + await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId }); + } finally { + setDeletingTaskId(null); + } + }, + [deleteTaskById, removeTaskFromBoard, store], + ); + + const { handleWorkspaceChange, handleTaskCreated } = useWorkspaceAndTaskCreatedActions({ + workspaceId, + store, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + onOpenChange, + }); + + return { + deletingTaskId, + handleSelectTask, + handleArchiveTask, + handleDeleteTask, + handleWorkspaceChange, + handleTaskCreated, + archivingTask, + setArchivingTask, + isArchiving, + handleArchiveConfirm, + }; +} diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index bd77534589..509ced78ce 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState, memo } from "react"; +import { useMemo, useState, memo } from "react"; import { IconPlus } from "@tabler/icons-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; import { Button } from "@kandev/ui/button"; @@ -12,25 +12,8 @@ import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-pr import { WorkspaceSwitcher } from "../workspace-switcher"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { replaceTaskUrl } from "@/lib/links"; -import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api"; -import { launchSession } from "@/lib/services/session-launch-service"; -import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; -import { useTasks } from "@/hooks/use-tasks"; -import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; -import { useTaskRemoval } from "@/hooks/use-task-removal"; -import { getSessionInfoForTask } from "@/lib/utils/session-info"; -import { hasPendingClarificationForSession } from "@/lib/utils/pending-clarification"; -import type { - TaskState, - TaskSessionState, - Workspace, - Repository, - Task, - WorkflowSnapshot, -} from "@/lib/types/http"; -import type { KanbanState } from "@/lib/state/slices"; +import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hooks"; +import type { Workspace } from "@/lib/types/http"; type SessionTaskSwitcherSheetProps = { open: boolean; @@ -39,361 +22,6 @@ type SessionTaskSwitcherSheetProps = { workflowId: string | null; }; -// Helper to map workflow snapshot to kanban state -function mapSnapshotToKanban(snapshot: WorkflowSnapshot, newWorkflowId: string) { - return { - workflowId: newWorkflowId, - isLoading: false, - steps: snapshot.steps.map((step) => ({ - id: step.id, - title: step.name, - color: step.color, - position: step.position, - events: step.events, - })), - tasks: snapshot.tasks.map((task) => ({ - id: task.id, - workflowStepId: task.workflow_step_id, - title: task.title, - description: task.description ?? undefined, - position: task.position ?? 0, - state: task.state, - repositoryId: task.repositories?.[0]?.repository_id ?? undefined, - primarySessionId: task.primary_session_id ?? undefined, - primarySessionState: task.primary_session_state ?? undefined, - sessionCount: task.session_count ?? undefined, - reviewStatus: task.review_status ?? undefined, - primaryExecutorId: task.primary_executor_id ?? undefined, - primaryExecutorType: task.primary_executor_type ?? undefined, - primaryExecutorName: task.primary_executor_name ?? undefined, - isRemoteExecutor: task.is_remote_executor ?? false, - updatedAt: task.updated_at, - })), - }; -} - -// Helper to sort by updated_at descending -function sortByUpdatedAtDesc<T extends { updated_at?: string | null }>(items: T[]): T[] { - return [...items].sort((a, b) => { - const aDate = a.updated_at ? new Date(a.updated_at).getTime() : 0; - const bDate = b.updated_at ? new Date(b.updated_at).getTime() : 0; - return bDate - aDate; - }); -} - -function useSheetData(workspaceId: string | null, workflowId: string | null) { - const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); - const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); - const sessionsById = useAppStore((state) => state.taskSessions.items); - const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId); - const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId); - const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId); - const messagesBySession = useAppStore((state) => state.messages.bySession); - const { tasks } = useTasks(workflowId); - const steps = useAppStore((state) => state.kanban.steps); - const workspaces = useAppStore((state) => state.workspaces.items); - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); - const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false); - - const selectedTaskId = useMemo(() => { - if (activeSessionId) return sessionsById[activeSessionId]?.task_id ?? activeTaskId; - return activeTaskId; - }, [activeSessionId, activeTaskId, sessionsById]); - - const tasksWithRepositories = useMemo(() => { - const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : []; - const repositoryPathsById = new Map( - repositories.map((repo: Repository) => [repo.id, repo.local_path]), - ); - return tasks.map((task: KanbanState["tasks"][number]) => { - const sessionInfo = getSessionInfoForTask( - task.id, - sessionsByTaskId, - gitStatusByEnvId, - envIdBySessionId, - ); - return { - id: task.id, - title: task.title, - state: task.state as TaskState | undefined, - sessionState: - sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined), - description: task.description, - workflowStepId: task.workflowStepId, - repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined, - diffStats: sessionInfo.diffStats, - updatedAt: sessionInfo.updatedAt ?? task.updatedAt, - isRemoteExecutor: task.isRemoteExecutor, - remoteExecutorType: task.primaryExecutorType ?? undefined, - remoteExecutorName: task.primaryExecutorName ?? undefined, - primarySessionId: task.primarySessionId ?? null, - hasPendingClarification: hasPendingClarificationForSession( - messagesBySession, - task.primarySessionId, - ), - }; - }); - }, [ - repositoriesByWorkspace, - tasks, - workspaceId, - sessionsByTaskId, - gitStatusByEnvId, - envIdBySessionId, - messagesBySession, - ]); - - const dialogSteps = useMemo( - () => - steps.map((step: KanbanState["steps"][number]) => ({ - id: step.id, - title: step.title, - color: step.color, - events: step.events, - })), - [steps], - ); - - return { - activeTaskId, - selectedTaskId, - steps, - workspaces, - kanbanIsLoading, - tasksWithRepositories, - dialogSteps, - }; -} - -type SheetNavOptions = { - workspaceId: string | null; - store: ReturnType<typeof useAppStoreApi>; - loadTaskSessionsForTask: ( - taskId: string, - ) => Promise<Array<{ id: string; updated_at?: string | null }>>; - setActiveSession: (taskId: string, sessionId: string) => void; - setActiveTask: (taskId: string) => void; - onOpenChange: (open: boolean) => void; -}; - -async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) { - const { store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange } = opts; - store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true } })); - try { - const workflowsResponse = await listWorkflows(newWorkspaceId, { - cache: "no-store", - includeHidden: true, - }); - const newWorkspaceWorkflows = workflowsResponse.workflows ?? []; - const firstWorkflow = newWorkspaceWorkflows.find((w) => !w.hidden); - if (!firstWorkflow) { - store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } })); - return; - } - const snapshot = await fetchWorkflowSnapshot(firstWorkflow.id); - store.setState((state) => ({ - ...state, - workflows: { - ...state.workflows, - items: [ - ...state.workflows.items.filter( - (w: { workspaceId: string }) => w.workspaceId !== newWorkspaceId, - ), - ...newWorkspaceWorkflows.map((w) => ({ - id: w.id, - workspaceId: w.workspace_id, - name: w.name, - hidden: w.hidden, - })), - ], - activeId: firstWorkflow.id, - }, - kanban: mapSnapshotToKanban(snapshot, firstWorkflow.id), - })); - const mostRecentTask = sortByUpdatedAtDesc(snapshot.tasks)[0]; - if (mostRecentTask) { - const sessions = await loadTaskSessionsForTask(mostRecentTask.id); - const mostRecentSession = sortByUpdatedAtDesc(sessions)[0]; - if (mostRecentSession) { - setActiveSession(mostRecentTask.id, mostRecentSession.id); - } else { - setActiveTask(mostRecentTask.id); - } - replaceTaskUrl(mostRecentTask.id); - } - onOpenChange(false); - } catch (error) { - console.error("Failed to switch workspace:", error); - store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } })); - } -} - -function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { - const { workspaceId, store, setActiveSession, setActiveTask, onOpenChange } = opts; - - const handleWorkspaceChange = useCallback( - async (newWorkspaceId: string) => { - if (newWorkspaceId === workspaceId) return; - await switchWorkspace(newWorkspaceId, opts); - }, - [workspaceId, opts], - ); - - const handleTaskCreated = useCallback( - (task: Task, _mode: "create" | "edit", meta?: { taskSessionId?: string | null }) => { - store.setState((state) => { - if (state.kanban.workflowId !== task.workflow_id) return state; - const nextTask = { - id: task.id, - workflowStepId: task.workflow_step_id, - title: task.title, - description: task.description, - position: task.position ?? 0, - state: task.state, - repositoryId: task.repositories?.[0]?.repository_id ?? undefined, - updatedAt: task.updated_at, - primaryExecutorId: task.primary_executor_id ?? undefined, - primaryExecutorType: task.primary_executor_type ?? undefined, - primaryExecutorName: task.primary_executor_name ?? undefined, - isRemoteExecutor: task.is_remote_executor ?? false, - }; - return { - ...state, - kanban: { - ...state.kanban, - tasks: state.kanban.tasks.some( - (item: KanbanState["tasks"][number]) => item.id === task.id, - ) - ? state.kanban.tasks.map((item: KanbanState["tasks"][number]) => - item.id === task.id ? nextTask : item, - ) - : [...state.kanban.tasks, nextTask], - }, - }; - }); - setActiveTask(task.id); - if (meta?.taskSessionId) { - setActiveSession(task.id, meta.taskSessionId); - } - replaceTaskUrl(task.id); - onOpenChange(false); - }, - [store, setActiveTask, setActiveSession, onOpenChange], - ); - - return { handleWorkspaceChange, handleTaskCreated }; -} - -function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolean) => void) { - const setActiveTask = useAppStore((state) => state.setActiveTask); - const setActiveSession = useAppStore((state) => state.setActiveSession); - const store = useAppStoreApi(); - const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null); - const { deleteTaskById } = useTaskActions(); - const archiveAndSwitch = useArchiveAndSwitchTask(); - const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store }); - - const handleSelectTask = useCallback( - (taskId: string) => { - const kanbanTasks = store.getState().kanban.tasks; - const task = kanbanTasks.find((t) => t.id === taskId); - if (task?.primarySessionId) { - setActiveSession(taskId, task.primarySessionId); - loadTaskSessionsForTask(taskId); - replaceTaskUrl(taskId); - onOpenChange(false); - return; - } - loadTaskSessionsForTask(taskId).then(async (sessions) => { - const sessionId = sessions[0]?.id ?? null; - if (sessionId) { - setActiveSession(taskId, sessionId); - replaceTaskUrl(taskId); - onOpenChange(false); - return; - } - // No session — prepare workspace - const { request } = buildPrepareRequest(taskId); - try { - const resp = await launchSession(request); - if (resp.session_id) { - setActiveSession(taskId, resp.session_id); - } - } catch { - // Fall through to default behavior - } - setActiveTask(taskId); - replaceTaskUrl(taskId); - onOpenChange(false); - }); - }, - [loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange], - ); - - const [archivingTask, setArchivingTask] = useState<{ id: string; title: string } | null>(null); - const [isArchiving, setIsArchiving] = useState(false); - - const handleArchiveTask = useCallback( - (taskId: string) => { - const task = store.getState().kanban.tasks.find((t) => t.id === taskId); - setArchivingTask({ id: taskId, title: task?.title ?? "this task" }); - }, - [store], - ); - - const handleArchiveConfirm = useCallback(async () => { - if (!archivingTask) return; - setIsArchiving(true); - try { - await archiveAndSwitch(archivingTask.id); - } catch (error) { - console.error("Failed to archive task:", error); - } finally { - setIsArchiving(false); - setArchivingTask(null); - } - }, [archivingTask, archiveAndSwitch]); - - const handleDeleteTask = useCallback( - async (taskId: string) => { - setDeletingTaskId(taskId); - // Capture active state before the async API call — the WS "task.deleted" - // handler may clear activeTaskId/activeSessionId before removeTaskFromBoard runs. - const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } = - store.getState().tasks; - try { - await deleteTaskById(taskId); - await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId }); - } finally { - setDeletingTaskId(null); - } - }, - [deleteTaskById, removeTaskFromBoard, store], - ); - - const { handleWorkspaceChange, handleTaskCreated } = useWorkspaceAndTaskCreatedActions({ - workspaceId, - store, - loadTaskSessionsForTask, - setActiveSession, - setActiveTask, - onOpenChange, - }); - - return { - deletingTaskId, - handleSelectTask, - handleArchiveTask, - handleDeleteTask, - handleWorkspaceChange, - handleTaskCreated, - archivingTask, - setArchivingTask, - isArchiving, - handleArchiveConfirm, - }; -} - function MobileTaskList({ tasks, activeTaskId, @@ -445,26 +73,8 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ workflowId, }: SessionTaskSwitcherSheetProps) { const [dialogOpen, setDialogOpen] = useState(false); - const { - activeTaskId, - selectedTaskId, - workspaces, - kanbanIsLoading, - tasksWithRepositories, - dialogSteps, - } = useSheetData(workspaceId, workflowId); - const { - deletingTaskId, - handleSelectTask, - handleArchiveTask, - handleDeleteTask, - handleWorkspaceChange, - handleTaskCreated, - archivingTask, - setArchivingTask, - isArchiving, - handleArchiveConfirm, - } = useSheetActions(workspaceId, onOpenChange); + const data = useSheetData(workspaceId, workflowId); + const actions = useSheetActions(workspaceId, onOpenChange); return ( <Sheet open={open} onOpenChange={onOpenChange}> @@ -488,23 +98,23 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ </div> <div className="pt-2"> <WorkspaceSwitcher - workspaces={workspaces.map((w: Workspace) => ({ id: w.id, name: w.name }))} + workspaces={data.workspaces.map((w: Workspace) => ({ id: w.id, name: w.name }))} activeWorkspaceId={workspaceId} - onSelect={handleWorkspaceChange} + onSelect={actions.handleWorkspaceChange} /> </div> </SheetHeader> <div className="flex-1 min-h-0 overflow-y-auto p-2"> <MobileTaskList - tasks={tasksWithRepositories} - activeTaskId={activeTaskId} - selectedTaskId={selectedTaskId} - onSelectTask={handleSelectTask} - onArchiveTask={handleArchiveTask} - onDeleteTask={handleDeleteTask} - deletingTaskId={deletingTaskId} - isLoading={kanbanIsLoading} + tasks={data.tasksWithRepositories} + activeTaskId={data.activeTaskId} + selectedTaskId={data.selectedTaskId} + onSelectTask={actions.handleSelectTask} + onArchiveTask={actions.handleArchiveTask} + onDeleteTask={actions.handleDeleteTask} + deletingTaskId={actions.deletingTaskId} + isLoading={data.kanbanIsLoading} /> </div> </SheetContent> @@ -515,19 +125,19 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ mode="create" workspaceId={workspaceId} workflowId={workflowId} - defaultStepId={dialogSteps[0]?.id ?? null} - steps={dialogSteps} - onSuccess={handleTaskCreated} + defaultStepId={data.dialogSteps[0]?.id ?? null} + steps={data.dialogSteps} + onSuccess={actions.handleTaskCreated} /> <TaskArchiveConfirmDialog - open={archivingTask !== null} + open={actions.archivingTask !== null} onOpenChange={(open) => { - if (!open) setArchivingTask(null); + if (!open) actions.setArchivingTask(null); }} - taskTitle={archivingTask?.title ?? ""} - isArchiving={isArchiving} - onConfirm={handleArchiveConfirm} + taskTitle={actions.archivingTask?.title ?? ""} + isArchiving={actions.isArchiving} + onConfirm={actions.handleArchiveConfirm} /> </Sheet> ); diff --git a/apps/web/components/task/passthrough-terminal.tsx b/apps/web/components/task/passthrough-terminal.tsx index dea953cbe1..64eef67b14 100644 --- a/apps/web/components/task/passthrough-terminal.tsx +++ b/apps/web/components/task/passthrough-terminal.tsx @@ -31,6 +31,23 @@ type BaseProps = { autoFocus?: boolean; pendingCommand?: string | null; onCommandSent?: () => void; + /** Called once the xterm instance is created. Mobile uses this to register + * the active key-bar input target. Desktop ignores. */ + onXtermReady?: (xterm: Terminal) => void; + /** Skip the WebGL renderer addon and use xterm's canvas renderer instead. + * WebGL miscomputes glyph atlas scaling on mobile (notably after a + * desktop→mobile responsive switch) and renders text at multiples of the + * intended font size. Mobile callers should pass true. */ + disableWebgl?: boolean; + /** When true, the AttachAddon is configured receive-only — incoming PTY + * data still flows to the terminal display, but the consumer is responsible + * for forwarding xterm.onData to the WebSocket. Mobile sets this so the + * key-bar's modifier transforms can run before bytes go on the wire. */ + manualInputRouting?: boolean; + /** Fires when the dedicated terminal WebSocket reaches the OPEN state. + * Mobile uses this to register a key-bar sender that writes raw bytes + * directly to this terminal's socket. */ + onWsReady?: (ws: WebSocket) => void; }; type AgentTerminalProps = BaseProps & { mode: "agent"; sessionId?: string | null; label?: string }; type ShellTerminalProps = BaseProps & { @@ -119,7 +136,17 @@ function computeCanConnect( // eslint-disable-next-line max-lines-per-function -- wires many hooks + refs; each block is already its own hook export function PassthroughTerminal(props: PassthroughTerminalProps) { - const { mode, label, autoFocus, pendingCommand, onCommandSent } = props; + const { + mode, + label, + autoFocus, + pendingCommand, + onCommandSent, + onXtermReady, + disableWebgl, + manualInputRouting, + onWsReady, + } = props; const terminalId = mode === "shell" ? props.terminalId : undefined; const environmentId = mode === "shell" ? props.environmentId : undefined; const refs = useTerminalRefs(); @@ -137,7 +164,10 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { const wsBaseUrl = useWsBaseUrl(); const [isTerminalReady, setIsTerminalReady] = useState(false); - const onTerminalReady = useCallback(() => setIsTerminalReady(true), []); + const onTerminalReady = useCallback(() => { + setIsTerminalReady(true); + if (xtermRef.current) onXtermReady?.(xtermRef.current); + }, [onXtermReady, xtermRef]); // Track which terminal target has an active WebSocket connection. The loading // overlay resets on target switches without needing a separate setState effect. @@ -186,6 +216,7 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { linkHandler, fontFamily: buildTerminalFontFamily(terminalFontFamily), fontSize: terminalFontSize ?? undefined, + disableWebgl, onToggleBottomTerminal: toggleBottomTerminal, sendInput, keyboardShortcutsRef, @@ -208,6 +239,8 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { wsRef, attachAddonRef, onConnected, + manualInputRouting, + onWsReady, }); usePendingCommand(pendingCommand, isConnected, wsRef, onCommandSent); diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index b8b1be84f3..0412d78606 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -21,22 +21,16 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@kandev/ui/alert-dialog"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { useToast } from "@/components/toast-provider"; -import { getWebSocketClient } from "@/lib/ws/connection"; +import { useAppStore } from "@/components/state-provider"; +import { + useSessionActions, + isSessionStoppable as isStoppable, + isSessionDeletable as isDeletable, + isSessionResumable as isResumable, +} from "@/hooks/domains/session/use-session-actions"; import type { TaskSessionState } from "@/lib/types/http"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; -function isStoppable(s: TaskSessionState) { - return s === "RUNNING" || s === "STARTING" || s === "WAITING_FOR_INPUT"; -} -function isDeletable(s: TaskSessionState) { - return s !== "RUNNING" && s !== "STARTING"; -} -function isResumable(s: TaskSessionState) { - return s === "COMPLETED" || s === "FAILED" || s === "CANCELLED"; -} - function useSessionTabState(sessionId: string | undefined) { const isPrimary = useAppStore((state) => { const activeTaskId = state.tasks.activeTaskId; @@ -98,86 +92,20 @@ function useSessionTabActions( api: IDockviewPanelHeaderProps["api"], containerApi: IDockviewPanelHeaderProps["containerApi"], ) { - const { toast, updateToast } = useToast(); - const removeTaskSession = useAppStore((state) => state.removeTaskSession); - const appStoreApi = useAppStoreApi(); - - const wsAction = useCallback( - async ( - action: string, - label: string, - payload: Record<string, unknown>, - timeout = 15000, - ): Promise<boolean> => { - const client = getWebSocketClient(); - if (!client) return false; - const toastId = toast({ title: `${label}...`, variant: "loading" }); - try { - await client.request(action, payload, timeout); - updateToast(toastId, { title: `${label} successful`, variant: "success" }); - return true; - } catch (error) { - const msg = error instanceof Error ? error.message : "Unknown error"; - updateToast(toastId, { title: `${label} failed`, description: msg, variant: "error" }); - return false; - } - }, - [toast, updateToast], - ); - - const handleSetPrimary = useCallback( - () => sessionId && wsAction("session.set_primary", "Set primary", { session_id: sessionId }), - [sessionId, wsAction], - ); - const handleStop = useCallback( - () => sessionId && wsAction("session.stop", "Stopping session", { session_id: sessionId }), - [sessionId, wsAction], - ); - const handleResume = useCallback( - () => - sessionId && - taskId && - wsAction( - "session.launch", - "Resuming session", - { task_id: taskId, intent: "resume", session_id: sessionId }, - 30000, - ), - [sessionId, taskId, wsAction], - ); - const handleDelete = useCallback(async () => { - if (!sessionId || !taskId) return; - // Only mutate local state once the backend confirms the delete — otherwise - // a transient WS failure leaves the UI desynced from the server. - const ok = await wsAction("session.delete", "Deleting session", { session_id: sessionId }); - if (!ok) return; - - // Switch the active session BEFORE removing from the store so - // useAutoSessionTab doesn't re-create this panel with the deleted session's ID. - // Hand off to the most-recently-started remaining session — store ordering - // is unspecified, so sort explicitly rather than rely on array position. - const state = appStoreApi.getState(); - if (state.tasks.activeSessionId === sessionId) { - const sessions = state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; - const remaining = sessions - .filter((s) => s.id !== sessionId) - .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime()); - if (remaining.length > 0) { - state.setActiveSessionAuto(taskId, remaining[0].id); - } else { - state.clearActiveSession(); - } - } - - removeTaskSession(taskId, sessionId); + const onDeleted = useCallback(() => { const panel = containerApi.getPanel(api.id); if (panel) containerApi.removePanel(panel); - }, [sessionId, taskId, wsAction, removeTaskSession, api.id, containerApi, appStoreApi]); + }, [api.id, containerApi]); + const { + setPrimary: handleSetPrimary, + stop: handleStop, + resume: handleResume, + remove: handleDelete, + } = useSessionActions({ sessionId, taskId, onDeleted }); const handleCloseOthers = useCallback(() => { const toClose = api.group.panels.filter((p) => p.id !== api.id); for (const panel of toClose) containerApi.removePanel(panel); }, [api, containerApi]); - return { handleSetPrimary, handleStop, handleResume, handleDelete, handleCloseOthers }; } diff --git a/apps/web/components/task/use-passthrough-terminal.ts b/apps/web/components/task/use-passthrough-terminal.ts index 94dcfd1c4a..f3f13fe3ee 100644 --- a/apps/web/components/task/use-passthrough-terminal.ts +++ b/apps/web/components/task/use-passthrough-terminal.ts @@ -6,7 +6,7 @@ import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { getTerminalTheme } from "@/lib/theme/terminal-theme"; -import { startReconnectLoop } from "./ws-reconnect"; +import { startReconnectLoop, teardownWebSocket } from "./ws-reconnect"; import { matchesShortcut } from "@/lib/keyboard/utils"; import { SHORTCUTS } from "@/lib/keyboard/constants"; import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides"; @@ -134,6 +134,7 @@ function initTerminalInstance( linkHandler?: (event: MouseEvent, uri: string) => void; fontFamily?: string; fontSize?: number; + disableWebgl?: boolean; } & TerminalKeyHandlerOptions, ) { if (refs.isInitializedRef.current || refs.xtermRef.current) return undefined; @@ -168,7 +169,7 @@ function initTerminalInstance( } refs.xtermRef.current = terminal; refs.fitAddonRef.current = fitAddon; - deferWebGLAddon(refs); + if (!options.disableWebgl) deferWebGLAddon(refs); exposeBufferReader(termContainer, terminal); const handleResize = () => { const rect = termContainer.getBoundingClientRect(); @@ -205,6 +206,7 @@ type TerminalInitHookOptions = TerminalInitOptions & linkHandler?: (event: MouseEvent, uri: string) => void; fontFamily?: string; fontSize?: number; + disableWebgl?: boolean; }; export function useTerminalInit({ @@ -220,6 +222,7 @@ export function useTerminalInit({ linkHandler, fontFamily, fontSize, + disableWebgl, onToggleBottomTerminal, sendInput, keyboardShortcutsRef, @@ -254,6 +257,7 @@ export function useTerminalInit({ linkHandler, fontFamily, fontSize, + disableWebgl, onToggleBottomTerminal, sendInput, keyboardShortcutsRef, @@ -324,6 +328,13 @@ export type WebSocketConnectionOptions = { wsRef: React.MutableRefObject<WebSocket | null>; attachAddonRef: React.MutableRefObject<AttachAddon | null>; onConnected: () => void; + /** When true, AttachAddon is created in receive-only mode so callers can + * intercept onData themselves (used by mobile to route input through the + * key-bar's modifier transform). Defaults to bidirectional. */ + manualInputRouting?: boolean; + /** Fires when the WebSocket reaches the OPEN state. Use to register a sender + * that bypasses xterm.onData (mobile key-bar registry). */ + onWsReady?: (ws: WebSocket) => void; }; export function buildTerminalWsUrl( @@ -365,6 +376,8 @@ type ConnectWebSocketOptions = { onTimeout: (id: ReturnType<typeof setTimeout>) => void; onConnected: () => void; onSocketClose: (event: CloseEvent) => void; + manualInputRouting?: boolean; + onWsReady?: (ws: WebSocket) => void; }; function connectWebSocket({ @@ -382,6 +395,8 @@ function connectWebSocket({ onTimeout, onConnected, onSocketClose, + manualInputRouting, + onWsReady, }: ConnectWebSocketOptions) { if (attachAddonRef.current) { attachAddonRef.current.dispose(); @@ -413,9 +428,13 @@ function connectWebSocket({ return; } log("WebSocket connected"); - const attachAddon = new AttachAddon(ws, { bidirectional: true }); + // Mobile passes manualInputRouting=true so the consumer can intercept + // onData and apply key-bar modifier transforms before the bytes go on the + // wire — AttachAddon's auto-send would otherwise bypass that path. + const attachAddon = new AttachAddon(ws, { bidirectional: !manualInputRouting }); terminal.loadAddon(attachAddon); attachAddonRef.current = attachAddon; + onWsReady?.(ws); onConnected(); // Send initial resize (forced) so the backend knows our terminal dimensions, // then one deferred resize to catch layout settling + force a full redraw. @@ -469,6 +488,8 @@ export function useWebSocketConnection({ wsRef, attachAddonRef, onConnected, + manualInputRouting, + onWsReady, }: WebSocketConnectionOptions) { const taskIdRef = useRef(taskId); @@ -528,27 +549,11 @@ export function useWebSocketConnection({ attachAddonRef, onConnected, connectWebSocket, + manualInputRouting, + onWsReady, }); - return () => { - log("WebSocket cleanup"); - stopReconnectLoop(); - if (attachAddonRef.current) { - attachAddonRef.current.dispose(); - attachAddonRef.current = null; - } - if (wsRef.current) { - // Only close if the connection is actually open or has completed - // opening. Closing a CONNECTING WebSocket triggers a browser warning - // ("WebSocket is closed before the connection is established"). - if ( - wsRef.current.readyState === WebSocket.OPEN || - wsRef.current.readyState === WebSocket.CLOSING - ) { - wsRef.current.close(); - } - wsRef.current = null; - } - }; + return () => teardownWebSocket(stopReconnectLoop, attachAddonRef, wsRef); + // eslint-disable-next-line react-hooks/exhaustive-deps -- manualInputRouting/onWsReady should not retrigger reconnect }, [ sessionId, environmentId, diff --git a/apps/web/components/task/ws-reconnect.ts b/apps/web/components/task/ws-reconnect.ts index f223e4808c..c1ab3c543f 100644 --- a/apps/web/components/task/ws-reconnect.ts +++ b/apps/web/components/task/ws-reconnect.ts @@ -2,6 +2,27 @@ import { Terminal } from "@xterm/xterm"; import { AttachAddon } from "@xterm/addon-attach"; import { log } from "./use-passthrough-terminal"; +export function teardownWebSocket( + stopReconnectLoop: () => void, + attachAddonRef: React.MutableRefObject<AttachAddon | null>, + wsRef: React.MutableRefObject<WebSocket | null>, +): void { + log("WebSocket cleanup"); + stopReconnectLoop(); + if (attachAddonRef.current) { + attachAddonRef.current.dispose(); + attachAddonRef.current = null; + } + // Only close if the connection is actually open or has completed opening. + // Closing a CONNECTING WebSocket triggers a browser warning ("WebSocket is + // closed before the connection is established"). + const ws = wsRef.current; + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CLOSING)) { + ws.close(); + } + wsRef.current = null; +} + const STABLE_CONNECTION_MS = 500; export function reconnectDelayMs(attempt: number): number { @@ -24,6 +45,8 @@ type ConnectWebSocketFn = (opts: { onTimeout: (id: ReturnType<typeof setTimeout>) => void; onConnected: () => void; onSocketClose: (event: CloseEvent) => void; + manualInputRouting?: boolean; + onWsReady?: (ws: WebSocket) => void; }) => void; export type ReconnectLoopOptions = { @@ -39,6 +62,8 @@ export type ReconnectLoopOptions = { attachAddonRef: React.MutableRefObject<AttachAddon | null>; onConnected: () => void; connectWebSocket: ConnectWebSocketFn; + manualInputRouting?: boolean; + onWsReady?: (ws: WebSocket) => void; }; export function startReconnectLoop({ @@ -54,6 +79,8 @@ export function startReconnectLoop({ attachAddonRef, onConnected, connectWebSocket, + manualInputRouting, + onWsReady, }: ReconnectLoopOptions): () => void { let isMounted = true; let connectTimeout: ReturnType<typeof setTimeout> | null = null; @@ -77,6 +104,8 @@ export function startReconnectLoop({ fitAndResize, wsRef, attachAddonRef, + manualInputRouting, + onWsReady, isMountedCheck: () => isMounted, onTimeout: (id) => { settleTimeout = id; diff --git a/apps/web/e2e/helpers/ws-capture.ts b/apps/web/e2e/helpers/ws-capture.ts index 181fcad61d..86557d61b3 100644 --- a/apps/web/e2e/helpers/ws-capture.ts +++ b/apps/web/e2e/helpers/ws-capture.ts @@ -12,10 +12,48 @@ type ParsedFrame = { }; /** - * Subscribe to outgoing WS frames on the given page and collect every - * `shell.input` request with its `{ session_id, data }` payload. Useful for - * asserting that UI actions translate to the correct escape sequences without - * depending on xterm paint timing. + * PassthroughTerminal's resize frames start with a 0x01 tag byte followed by + * a JSON `{cols, rows}` body. A naive "first byte === 0x01" check would + * misclassify Ctrl+A (also 0x01) as a resize, so confirm the JSON shape + * before discarding. + */ +const RESIZE_FRAME_TAG = 0x01; + +function isResizeFrame(payload: Buffer | Uint8Array): boolean { + if (payload.length < 2 || payload[0] !== RESIZE_FRAME_TAG) return false; + try { + const tail = new TextDecoder("utf-8", { fatal: false }).decode( + (payload as Uint8Array).slice(1), + ); + const parsed = JSON.parse(tail) as { cols?: unknown; rows?: unknown }; + return typeof parsed?.cols === "number" && typeof parsed?.rows === "number"; + } catch { + return false; + } +} + +function decodeBinaryFrame(payload: Buffer | Uint8Array): string | null { + if (!payload || payload.length === 0) return null; + if (isResizeFrame(payload)) return null; + try { + return new TextDecoder("utf-8", { fatal: false }).decode(payload as Uint8Array); + } catch { + return null; + } +} + +/** + * Subscribe to outgoing WS frames on the given page and collect every shell + * input frame, regardless of which transport carried it: + * + * - JSON `{action: "shell.input", payload: {session_id, data}}` over the + * kandev gateway WS — the per-session default shell. + * - Raw binary frames over a PassthroughTerminal's dedicated WS — used by + * mobile multi-terminal where the on-screen terminal owns its own + * AttachAddon connection. The session ID is unknown for these frames + * (the WS is env+terminalId scoped) so an empty string is reported. + * + * Tests assert on the `data` field, which works the same either way. * * Returns a live array that tests can poll via `expect.poll`. Must be called * before the page navigates, since `framesent` events fire before your next @@ -25,20 +63,24 @@ export function attachShellInputCapture(page: Page): { frames: ShellInputFrame[] const frames: ShellInputFrame[] = []; page.on("websocket", (ws) => { ws.on("framesent", (event) => { - const raw = - typeof event.payload === "string" ? event.payload : (event.payload?.toString("utf8") ?? ""); - if (!raw || !raw.includes('"shell.input"')) return; - try { - const msg = JSON.parse(raw) as ParsedFrame; - if (msg.action !== "shell.input") return; - const sessionId = msg.payload?.session_id; - const data = msg.payload?.data; - if (typeof sessionId === "string" && typeof data === "string") { - frames.push({ sessionId, data }); + const payload = event.payload; + if (typeof payload === "string") { + if (!payload.includes('"shell.input"')) return; + try { + const msg = JSON.parse(payload) as ParsedFrame; + if (msg.action !== "shell.input") return; + const sessionId = msg.payload?.session_id; + const data = msg.payload?.data; + if (typeof sessionId === "string" && typeof data === "string") { + frames.push({ sessionId, data }); + } + } catch { + /* non-JSON string frames — ignore */ } - } catch { - /* non-JSON frames (binary, etc.) — ignore */ + return; } + const decoded = decodeBinaryFrame(payload); + if (decoded) frames.push({ sessionId: "", data: decoded }); }); }); return { frames }; diff --git a/apps/web/hooks/domains/session/use-mobile-terminals.test.ts b/apps/web/hooks/domains/session/use-mobile-terminals.test.ts new file mode 100644 index 0000000000..0ea08214e0 --- /dev/null +++ b/apps/web/hooks/domains/session/use-mobile-terminals.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useMobileTerminals, __resetAutoCreatedEnvironmentsForTest } from "./use-mobile-terminals"; + +const SESSION_ID = "sess-1"; + +const addTerminalMock = vi.fn(); +let mockEnvironmentId: string | null = null; +let mockShells: Array<{ terminalId: string; label: string; closable: boolean }> = []; +let mockShellsLoaded = false; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + environmentIdBySessionId: mockEnvironmentId ? { [SESSION_ID]: mockEnvironmentId } : {}, + }), +})); + +vi.mock("./use-terminals", () => ({ + useTerminals: () => ({ + terminals: [], + activeTab: undefined, + terminalTabValue: "commands", + addTerminal: addTerminalMock, + removeTerminal: vi.fn(), + handleCloseDevTab: vi.fn(), + handleCloseTab: vi.fn(), + handleRunCommand: vi.fn(), + isStoppingDev: false, + devProcessId: undefined, + devOutput: "", + }), +})); + +vi.mock("./use-user-shells", () => ({ + useUserShells: () => ({ + shells: mockShells, + isLoading: false, + isLoaded: mockShellsLoaded, + addShell: vi.fn(), + removeShell: vi.fn(), + }), +})); + +beforeEach(() => { + addTerminalMock.mockReset(); + __resetAutoCreatedEnvironmentsForTest(); + mockEnvironmentId = "env-A"; + mockShells = []; + mockShellsLoaded = true; +}); + +describe("useMobileTerminals auto-create", () => { + it("creates a first shell when shells loaded but list is empty", async () => { + addTerminalMock.mockResolvedValue(undefined); + renderHook(() => useMobileTerminals(SESSION_ID)); + await waitFor(() => expect(addTerminalMock).toHaveBeenCalledTimes(1)); + }); + + it("does not create a shell when one already exists", async () => { + mockShells = [{ terminalId: "shell-1", label: "Terminal", closable: true }]; + renderHook(() => useMobileTerminals(SESSION_ID)); + // Wait a tick so any effects had a chance to fire. + await new Promise((r) => setTimeout(r, 0)); + expect(addTerminalMock).not.toHaveBeenCalled(); + }); + + it("does not create when shells are still loading", async () => { + mockShellsLoaded = false; + renderHook(() => useMobileTerminals(SESSION_ID)); + await new Promise((r) => setTimeout(r, 0)); + expect(addTerminalMock).not.toHaveBeenCalled(); + }); + + it("multiple instances for the same env trigger only one creation (module-level guard)", async () => { + addTerminalMock.mockResolvedValue(undefined); + renderHook(() => useMobileTerminals(SESSION_ID)); + renderHook(() => useMobileTerminals(SESSION_ID)); + renderHook(() => useMobileTerminals(SESSION_ID)); + await waitFor(() => expect(addTerminalMock).toHaveBeenCalledTimes(1)); + }); + + it("clears the guard when addTerminal rejects so a fresh hook retries", async () => { + addTerminalMock.mockRejectedValueOnce(new Error("network down")); + const first = renderHook(() => useMobileTerminals(SESSION_ID)); + await waitFor(() => expect(addTerminalMock).toHaveBeenCalledTimes(1)); + // Let the rejection settle so the .catch handler clears the guard. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + first.unmount(); + // A new instance for the same env should now retry creation. + addTerminalMock.mockResolvedValueOnce(undefined); + renderHook(() => useMobileTerminals(SESSION_ID)); + await waitFor(() => expect(addTerminalMock).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/apps/web/hooks/domains/session/use-mobile-terminals.ts b/apps/web/hooks/domains/session/use-mobile-terminals.ts new file mode 100644 index 0000000000..2995214ad1 --- /dev/null +++ b/apps/web/hooks/domains/session/use-mobile-terminals.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useTerminals } from "./use-terminals"; +import { useUserShells } from "./use-user-shells"; +import { useAppStore } from "@/components/state-provider"; + +/** + * Module-level guard for the auto-create-first-shell effect, keyed by + * environmentId. `useMobileTerminals` is called from several places that all + * end up rendering the same terminal pane (the pane itself, the picker pill, + * the picker sheet content) — a per-instance ref guard would let each one + * fire `createUserShell` on first mount, racing into multiple shells. The + * Set is shared across instances so only the first effect to run for a given + * environment triggers creation. + */ +const autoCreatedEnvironments = new Set<string>(); + +/** + * Release the auto-create guard for a specific environment so the next mount + * (or shell-list change) re-runs the auto-create effect. Call this when the + * user explicitly closes the last terminal in an env — otherwise the pane + * shows "Starting terminal…" forever because the guard prevents recreation. + */ +export function releaseAutoCreatedEnvironment(environmentId: string): void { + autoCreatedEnvironments.delete(environmentId); +} + +/** Test-only: reset the module-level guard so each test starts from scratch. */ +export function __resetAutoCreatedEnvironmentsForTest(): void { + autoCreatedEnvironments.clear(); +} + +/** + * Mobile wrapper around `useTerminals`. Auto-creates a first shell when the + * server-side shell list loads empty so the user always sees one terminal + * mounted by default. Returns the same interface as `useTerminals`. + */ +export function useMobileTerminals(sessionId: string | null) { + const environmentId = useAppStore((s) => + sessionId ? (s.environmentIdBySessionId[sessionId] ?? null) : null, + ); + const result = useTerminals({ sessionId, environmentId }); + // Read user-shell loaded flag directly so the auto-create trigger has + // primitive dependencies — depending on `result` would re-run on every + // render and would also race against React's effect ordering. + const { isLoaded: shellsLoaded, shells } = useUserShells(environmentId); + const addTerminalRef = useRef(result.addTerminal); + useEffect(() => { + addTerminalRef.current = result.addTerminal; + }, [result.addTerminal]); + + useEffect(() => { + if (!environmentId || !shellsLoaded) return; + if (autoCreatedEnvironments.has(environmentId)) return; + if (shells.length > 0) { + autoCreatedEnvironments.add(environmentId); + return; + } + autoCreatedEnvironments.add(environmentId); + // Reset the guard if creation fails so the user gets a retry on the next + // render cycle (e.g. after the WS reconnects). `addTerminal` returns void + // but its inner promise can still reject; guard defensively. + const promise = addTerminalRef.current() as unknown; + if (promise && typeof (promise as Promise<unknown>).catch === "function") { + (promise as Promise<unknown>).catch(() => { + autoCreatedEnvironments.delete(environmentId); + }); + } + }, [environmentId, shellsLoaded, shells.length]); + + return { ...result, environmentId }; +} diff --git a/apps/web/hooks/domains/session/use-session-actions.test.ts b/apps/web/hooks/domains/session/use-session-actions.test.ts new file mode 100644 index 0000000000..48a037a62e --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-actions.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { + isSessionStoppable, + isSessionDeletable, + isSessionResumable, + useSessionActions, +} from "./use-session-actions"; + +const mockToast = vi.fn().mockReturnValue("toast-1"); +const mockUpdateToast = vi.fn(); +const mockRequest = vi.fn(); +const mockRemoveTaskSession = vi.fn(); +const mockSetActiveSessionAuto = vi.fn(); +const mockClearActiveSession = vi.fn(); + +let mockState: Record<string, unknown> = {}; + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: mockToast, updateToast: mockUpdateToast }), +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => ({ request: mockRequest }), +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + removeTaskSession: mockRemoveTaskSession, + }), + useAppStoreApi: () => ({ + getState: () => mockState, + }), +})); + +describe("session state predicates", () => { + it("isSessionStoppable returns true for active states", () => { + expect(isSessionStoppable("RUNNING")).toBe(true); + expect(isSessionStoppable("STARTING")).toBe(true); + expect(isSessionStoppable("WAITING_FOR_INPUT")).toBe(true); + expect(isSessionStoppable("COMPLETED")).toBe(false); + expect(isSessionStoppable("FAILED")).toBe(false); + }); + + it("isSessionDeletable returns false for in-flight states", () => { + expect(isSessionDeletable("RUNNING")).toBe(false); + expect(isSessionDeletable("STARTING")).toBe(false); + expect(isSessionDeletable("WAITING_FOR_INPUT")).toBe(true); + expect(isSessionDeletable("COMPLETED")).toBe(true); + expect(isSessionDeletable("FAILED")).toBe(true); + }); + + it("isSessionResumable returns true for terminal states", () => { + expect(isSessionResumable("COMPLETED")).toBe(true); + expect(isSessionResumable("FAILED")).toBe(true); + expect(isSessionResumable("CANCELLED")).toBe(true); + expect(isSessionResumable("RUNNING")).toBe(false); + expect(isSessionResumable("STARTING")).toBe(false); + }); +}); + +describe("useSessionActions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockToast.mockReturnValue("toast-1"); + mockRequest.mockResolvedValue(undefined); + mockState = { + tasks: { activeSessionId: null }, + taskSessionsByTask: { itemsByTaskId: {} }, + setActiveSessionAuto: mockSetActiveSessionAuto, + clearActiveSession: mockClearActiveSession, + }; + }); + + it("setPrimary dispatches session.set_primary with session id", async () => { + const { result } = renderHook(() => useSessionActions({ sessionId: "s1", taskId: "t1" })); + await result.current.setPrimary(); + expect(mockRequest).toHaveBeenCalledWith("session.set_primary", { session_id: "s1" }, 15000); + }); + + it("stop dispatches session.stop", async () => { + const { result } = renderHook(() => useSessionActions({ sessionId: "s1", taskId: "t1" })); + await result.current.stop(); + expect(mockRequest).toHaveBeenCalledWith("session.stop", { session_id: "s1" }, 15000); + }); + + it("resume dispatches session.launch with intent=resume and 30s timeout", async () => { + const { result } = renderHook(() => useSessionActions({ sessionId: "s1", taskId: "t1" })); + await result.current.resume(); + expect(mockRequest).toHaveBeenCalledWith( + "session.launch", + { task_id: "t1", intent: "resume", session_id: "s1" }, + 30000, + ); + }); + + it("remove deletes via WS, removes from store, and runs onDeleted callback", async () => { + const onDeleted = vi.fn(); + const { result } = renderHook(() => + useSessionActions({ sessionId: "s1", taskId: "t1", onDeleted }), + ); + await result.current.remove(); + await waitFor(() => expect(onDeleted).toHaveBeenCalled()); + expect(mockRequest).toHaveBeenCalledWith("session.delete", { session_id: "s1" }, 15000); + expect(mockRemoveTaskSession).toHaveBeenCalledWith("t1", "s1"); + }); + + it("remove no-ops when WS request fails (store untouched)", async () => { + mockRequest.mockRejectedValueOnce(new Error("network down")); + const onDeleted = vi.fn(); + const { result } = renderHook(() => + useSessionActions({ sessionId: "s1", taskId: "t1", onDeleted }), + ); + await result.current.remove(); + expect(mockRemoveTaskSession).not.toHaveBeenCalled(); + expect(onDeleted).not.toHaveBeenCalled(); + }); + + it("remove hands off to most-recent remaining session when active was deleted", async () => { + mockState = { + tasks: { activeSessionId: "s1" }, + taskSessionsByTask: { + itemsByTaskId: { + t1: [ + { id: "s1", started_at: "2025-01-01T00:00:00Z" }, + { id: "s2", started_at: "2025-01-02T00:00:00Z" }, + { id: "s3", started_at: "2025-01-03T00:00:00Z" }, + ], + }, + }, + setActiveSessionAuto: mockSetActiveSessionAuto, + clearActiveSession: mockClearActiveSession, + }; + const { result } = renderHook(() => useSessionActions({ sessionId: "s1", taskId: "t1" })); + await result.current.remove(); + expect(mockSetActiveSessionAuto).toHaveBeenCalledWith("t1", "s3"); + expect(mockClearActiveSession).not.toHaveBeenCalled(); + }); + + it("remove clears active session when no other sessions remain", async () => { + mockState = { + tasks: { activeSessionId: "s1" }, + taskSessionsByTask: { + itemsByTaskId: { t1: [{ id: "s1", started_at: "2025-01-01T00:00:00Z" }] }, + }, + setActiveSessionAuto: mockSetActiveSessionAuto, + clearActiveSession: mockClearActiveSession, + }; + const { result } = renderHook(() => useSessionActions({ sessionId: "s1", taskId: "t1" })); + await result.current.remove(); + expect(mockClearActiveSession).toHaveBeenCalled(); + expect(mockSetActiveSessionAuto).not.toHaveBeenCalled(); + }); + + it("actions no-op when sessionId is missing", async () => { + const { result } = renderHook(() => useSessionActions({ sessionId: null, taskId: "t1" })); + await result.current.setPrimary(); + await result.current.stop(); + await result.current.remove(); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-actions.ts b/apps/web/hooks/domains/session/use-session-actions.ts new file mode 100644 index 0000000000..1124af35e8 --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-actions.ts @@ -0,0 +1,112 @@ +"use client"; + +import { useCallback } from "react"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import type { TaskSessionState } from "@/lib/types/http"; + +export function isSessionStoppable(s: TaskSessionState): boolean { + return s === "RUNNING" || s === "STARTING" || s === "WAITING_FOR_INPUT"; +} +export function isSessionDeletable(s: TaskSessionState): boolean { + return s !== "RUNNING" && s !== "STARTING"; +} +export function isSessionResumable(s: TaskSessionState): boolean { + return s === "COMPLETED" || s === "FAILED" || s === "CANCELLED"; +} + +type SessionActionsArgs = { + sessionId: string | null | undefined; + taskId: string | null; + /** Optional callback after a successful delete (e.g. close a tab/panel). */ + onDeleted?: () => void; +}; + +type WsActionFn = ( + action: string, + label: string, + payload: Record<string, unknown>, + timeout?: number, +) => Promise<boolean>; + +function useWsAction(): WsActionFn { + const { toast, updateToast } = useToast(); + return useCallback( + async (action, label, payload, timeout = 15000) => { + const client = getWebSocketClient(); + if (!client) return false; + const toastId = toast({ title: `${label}...`, variant: "loading" }); + try { + await client.request(action, payload, timeout); + updateToast(toastId, { title: `${label} successful`, variant: "success" }); + return true; + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + updateToast(toastId, { title: `${label} failed`, description: msg, variant: "error" }); + return false; + } + }, + [toast, updateToast], + ); +} + +/** + * Shared lifecycle actions for a session (set-primary, stop, resume, delete). + * Handles backend coordination + local store cleanup. Caller can pass + * `onDeleted` to perform UI-specific teardown (e.g. dockview panel removal). + */ +export function useSessionActions({ sessionId, taskId, onDeleted }: SessionActionsArgs) { + const wsAction = useWsAction(); + const removeTaskSession = useAppStore((state) => state.removeTaskSession); + const appStoreApi = useAppStoreApi(); + + const setPrimary = useCallback( + () => sessionId && wsAction("session.set_primary", "Set primary", { session_id: sessionId }), + [sessionId, wsAction], + ); + + const stop = useCallback( + () => sessionId && wsAction("session.stop", "Stopping session", { session_id: sessionId }), + [sessionId, wsAction], + ); + + const resume = useCallback( + () => + sessionId && + taskId && + wsAction( + "session.launch", + "Resuming session", + { task_id: taskId, intent: "resume", session_id: sessionId }, + 30000, + ), + [sessionId, taskId, wsAction], + ); + + const remove = useCallback(async () => { + if (!sessionId || !taskId) return; + const ok = await wsAction("session.delete", "Deleting session", { session_id: sessionId }); + if (!ok) return; + + // Switch the active session BEFORE removing from the store so callers + // observing activeSessionId don't briefly point at a deleted session. + const state = appStoreApi.getState(); + if (state.tasks.activeSessionId === sessionId) { + const sessions = state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; + const remaining = sessions + .filter((s) => s.id !== sessionId) + .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime()); + if (remaining.length > 0) { + state.setActiveSessionAuto(taskId, remaining[0].id); + } else { + state.clearActiveSession(); + } + } + + removeTaskSession(taskId, sessionId); + onDeleted?.(); + }, [sessionId, taskId, wsAction, removeTaskSession, appStoreApi, onDeleted]); + + return { setPrimary, stop, resume, remove }; +} diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index f697e22b12..b87867ce0f 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -20,6 +20,7 @@ import type { } from "@/lib/types/github"; import type { SystemHealthResponse } from "@/lib/types/health"; import type { UISliceActions as UIA } from "./slices/ui/types"; +import type * as UISliceTypes from "./slices/ui/types"; import { mergeInitialState } from "./default-state"; import { createKanbanSlice, @@ -361,16 +362,10 @@ export type AppState = { setConnectionStatus: (status: ConnectionState["status"], error?: string | null) => void; setMobileKanbanColumnIndex: (index: number) => void; setMobileKanbanMenuOpen: (open: boolean) => void; - setMobileSessionPanel: ( - sessionId: string, - panel: import("./slices/ui/types").MobileSessionPanel, - ) => void; + setMobileSessionPanel: (sessionId: string, panel: UISliceTypes.MobileSessionPanel) => void; setMobileSessionTaskSwitcherOpen: (open: boolean) => void; setPlanMode: (sessionId: string, enabled: boolean) => void; - setActiveDocument: ( - sessionId: string, - doc: import("./slices/ui/types").ActiveDocument | null, - ) => void; + setActiveDocument: (sessionId: string, doc: UISliceTypes.ActiveDocument | null) => void; setSystemHealth: (response: SystemHealthResponse) => void; setSystemHealthLoading: (loading: boolean) => void; invalidateSystemHealth: () => void; @@ -385,9 +380,7 @@ export type AppState = { closeConfigChatSession: (sessionId: string) => void; setActiveConfigChatSession: (sessionId: string) => void; renameConfigChatSession: (sessionId: string, name: string) => void; - setSessionFailureNotification: ( - n: import("./slices/ui/types").SessionFailureNotification | null, - ) => void; + setSessionFailureNotification: (n: UISliceTypes.SessionFailureNotification | null) => void; toggleBottomTerminal: () => void; openBottomTerminalWithCommand: (command: string) => void; clearBottomTerminalCommand: () => void; diff --git a/apps/web/lib/terminal/mobile-active-terminal.test.ts b/apps/web/lib/terminal/mobile-active-terminal.test.ts new file mode 100644 index 0000000000..4f9a6fa4eb --- /dev/null +++ b/apps/web/lib/terminal/mobile-active-terminal.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { setActiveTerminalSender, getActiveTerminalSender } from "./mobile-active-terminal"; + +describe("mobile active terminal sender registry", () => { + beforeEach(() => { + setActiveTerminalSender(null); + }); + + it("returns null when no sender is registered", () => { + expect(getActiveTerminalSender()).toBeNull(); + }); + + it("returns the registered sender", () => { + const sender = vi.fn(); + setActiveTerminalSender(sender); + expect(getActiveTerminalSender()).toBe(sender); + sender("hi"); + expect(sender).toHaveBeenCalledWith("hi"); + }); + + it("replaces a previously registered sender", () => { + const a = vi.fn(); + const b = vi.fn(); + setActiveTerminalSender(a); + setActiveTerminalSender(b); + expect(getActiveTerminalSender()).toBe(b); + }); + + it("clears the sender when null is set", () => { + setActiveTerminalSender(vi.fn()); + setActiveTerminalSender(null); + expect(getActiveTerminalSender()).toBeNull(); + }); +}); diff --git a/apps/web/lib/terminal/mobile-active-terminal.ts b/apps/web/lib/terminal/mobile-active-terminal.ts new file mode 100644 index 0000000000..24ce45b0c0 --- /dev/null +++ b/apps/web/lib/terminal/mobile-active-terminal.ts @@ -0,0 +1,21 @@ +/** + * Tracks the current input target for the mobile terminal key-bar. + * + * Mobile renders multiple terminals via PassthroughTerminal, each with its own + * WebSocket. The shared key-bar (Ctrl, ^C, arrow keys, etc.) needs to dispatch + * keystrokes to whichever terminal is on screen, not the per-session default + * shell. The active terminal registers a `paste` sender on mount; the key-bar + * reads from this registry and falls back to the default shell otherwise. + */ + +type Sender = (data: string) => void; + +let activeSender: Sender | null = null; + +export function setActiveTerminalSender(sender: Sender | null): void { + activeSender = sender; +} + +export function getActiveTerminalSender(): Sender | null { + return activeSender; +} diff --git a/apps/web/lib/terminal/send-shell-input.test.ts b/apps/web/lib/terminal/send-shell-input.test.ts index 8009ee8eea..02f921fbdc 100644 --- a/apps/web/lib/terminal/send-shell-input.test.ts +++ b/apps/web/lib/terminal/send-shell-input.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { useShellModifiersStore } from "./shell-modifiers"; +import { setActiveTerminalSender } from "./mobile-active-terminal"; const sendMock = vi.fn(); let clientFactory: () => { send: typeof sendMock } | null = () => ({ send: sendMock }); @@ -17,6 +18,7 @@ beforeEach(() => { sendMock.mockReset(); clientFactory = () => ({ send: sendMock }); useShellModifiersStore.getState().reset(); + setActiveTerminalSender(null); }); function lastFrameData(): string | undefined { @@ -77,4 +79,37 @@ describe("sendShellInput", () => { expect(sendMock).not.toHaveBeenCalled(); expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: true, sticky: false }); }); + + describe("with an active mobile terminal sender registered", () => { + it("routes input through the active sender and bypasses WS", () => { + const sender = vi.fn(); + setActiveTerminalSender(sender); + sendShellInput(SESSION, "ls"); + expect(sender).toHaveBeenCalledWith("ls"); + expect(sendMock).not.toHaveBeenCalled(); + }); + + it("applies modifiers and consumes them after a successful sender call", () => { + const sender = vi.fn(); + setActiveTerminalSender(sender); + useShellModifiersStore.getState().toggleCtrl(); + sendShellInput(SESSION, "c"); + expect(sender).toHaveBeenCalledWith("\x03"); + expect(useShellModifiersStore.getState().ctrl).toEqual({ latched: false, sticky: false }); + }); + + it("falls back to WS when the sender throws", () => { + const sender = vi.fn(() => { + throw new Error("xterm gone"); + }); + setActiveTerminalSender(sender); + sendShellInput(SESSION, "ls"); + expect(sender).toHaveBeenCalledWith("ls"); + expect(sendMock).toHaveBeenCalledWith({ + type: "request", + action: "shell.input", + payload: { session_id: SESSION, data: "ls" }, + }); + }); + }); }); diff --git a/apps/web/lib/terminal/send-shell-input.ts b/apps/web/lib/terminal/send-shell-input.ts index 0531398409..9e6c75c36f 100644 --- a/apps/web/lib/terminal/send-shell-input.ts +++ b/apps/web/lib/terminal/send-shell-input.ts @@ -1,5 +1,6 @@ import { getWebSocketClient } from "@/lib/ws/connection"; import { applyShellModifiers } from "./apply-shell-modifiers"; +import { getActiveTerminalSender } from "./mobile-active-terminal"; import { useShellModifiersStore, isActive } from "./shell-modifiers"; /** @@ -9,20 +10,46 @@ import { useShellModifiersStore, isActive } from "./shell-modifiers"; * Used by both the virtual key-bar and xterm's own `onData` callback so that * a Ctrl latch set from the key-bar modifies the next character the user * types on the OS keyboard — which is the whole point of the modifier. + * + * Mobile multi-terminal: when a PassthroughTerminal is registered as the + * active key-bar target, route input through it (xterm.paste → onData → its + * dedicated WS) instead of the per-session default shell. Falls back to the + * default `shell.input` action when no active terminal is registered. */ export function sendShellInput(sessionId: string, data: string): void { if (!data) return; - const client = getWebSocketClient(); - if (!client) return; // keep modifiers armed; the user can retry once reconnected const store = useShellModifiersStore.getState(); const ctrlActive = isActive(store.ctrl); const shiftActive = isActive(store.shift); const transformed = applyShellModifiers(data, { ctrl: ctrlActive, shift: shiftActive }); - client.send({ - type: "request", - action: "shell.input", - payload: { session_id: sessionId, data: transformed }, - }); + + const activeSender = getActiveTerminalSender(); + if (activeSender) { + try { + activeSender(transformed); + } catch (err) { + // A stale registry entry (e.g. xterm disposed mid-frame) shouldn't + // silently drop the keystroke nor consume modifiers. Fall through to + // the WS path so the input still has a chance of landing. + console.error("Active terminal sender threw, falling back to shell.input:", err); + const client = getWebSocketClient(); + if (!client) return; + client.send({ + type: "request", + action: "shell.input", + payload: { session_id: sessionId, data: transformed }, + }); + } + } else { + const client = getWebSocketClient(); + if (!client) return; // keep modifiers armed; user can retry once reconnected + client.send({ + type: "request", + action: "shell.input", + payload: { session_id: sessionId, data: transformed }, + }); + } + if (ctrlActive) store.consumeCtrl(); if (shiftActive) store.consumeShift(); } diff --git a/apps/web/lib/ui/state-labels.test.ts b/apps/web/lib/ui/state-labels.test.ts new file mode 100644 index 0000000000..503b30824a --- /dev/null +++ b/apps/web/lib/ui/state-labels.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { formatTaskStateLabel, formatTaskSessionStateLabel } from "./state-labels"; +import type { TaskState, TaskSessionState } from "@/lib/types/http"; + +describe("formatTaskStateLabel", () => { + it("maps known task states to human labels", () => { + expect(formatTaskStateLabel("IN_PROGRESS")).toBe("In progress"); + expect(formatTaskStateLabel("WAITING_FOR_INPUT")).toBe("Waiting for input"); + expect(formatTaskStateLabel("TODO")).toBe("To do"); + expect(formatTaskStateLabel("COMPLETED")).toBe("Completed"); + expect(formatTaskStateLabel("FAILED")).toBe("Failed"); + expect(formatTaskStateLabel("CANCELLED")).toBe("Cancelled"); + expect(formatTaskStateLabel("BLOCKED")).toBe("Blocked"); + expect(formatTaskStateLabel("REVIEW")).toBe("Review"); + expect(formatTaskStateLabel("CREATED")).toBe("Created"); + expect(formatTaskStateLabel("SCHEDULING")).toBe("Scheduling"); + }); + + it("returns 'Not started' for null/undefined", () => { + expect(formatTaskStateLabel(null)).toBe("Not started"); + expect(formatTaskStateLabel(undefined)).toBe("Not started"); + }); + + it("falls back to the raw value for unknown states", () => { + expect(formatTaskStateLabel("UNKNOWN_FUTURE" as TaskState)).toBe("UNKNOWN_FUTURE"); + }); +}); + +describe("formatTaskSessionStateLabel", () => { + it("maps known session states", () => { + expect(formatTaskSessionStateLabel("RUNNING")).toBe("Running"); + expect(formatTaskSessionStateLabel("STARTING")).toBe("Starting"); + expect(formatTaskSessionStateLabel("WAITING_FOR_INPUT")).toBe("Waiting for input"); + expect(formatTaskSessionStateLabel("COMPLETED")).toBe("Completed"); + expect(formatTaskSessionStateLabel("FAILED")).toBe("Failed"); + expect(formatTaskSessionStateLabel("CANCELLED")).toBe("Cancelled"); + expect(formatTaskSessionStateLabel("CREATED")).toBe("Created"); + }); + + it("returns empty string for null/undefined", () => { + expect(formatTaskSessionStateLabel(null)).toBe(""); + expect(formatTaskSessionStateLabel(undefined)).toBe(""); + }); + + it("falls back to the raw value for unknown states", () => { + expect(formatTaskSessionStateLabel("UNKNOWN" as TaskSessionState)).toBe("UNKNOWN"); + }); +}); diff --git a/apps/web/lib/ui/state-labels.ts b/apps/web/lib/ui/state-labels.ts new file mode 100644 index 0000000000..8eb5e457f2 --- /dev/null +++ b/apps/web/lib/ui/state-labels.ts @@ -0,0 +1,34 @@ +import type { TaskState, TaskSessionState } from "@/lib/types/http"; + +const TASK_STATE_LABELS: Record<TaskState, string> = { + CREATED: "Created", + SCHEDULING: "Scheduling", + TODO: "To do", + IN_PROGRESS: "In progress", + REVIEW: "Review", + BLOCKED: "Blocked", + WAITING_FOR_INPUT: "Waiting for input", + COMPLETED: "Completed", + FAILED: "Failed", + CANCELLED: "Cancelled", +}; + +const TASK_SESSION_STATE_LABELS: Record<TaskSessionState, string> = { + CREATED: "Created", + STARTING: "Starting", + RUNNING: "Running", + WAITING_FOR_INPUT: "Waiting for input", + COMPLETED: "Completed", + FAILED: "Failed", + CANCELLED: "Cancelled", +}; + +export function formatTaskStateLabel(state: TaskState | null | undefined): string { + if (!state) return "Not started"; + return TASK_STATE_LABELS[state] ?? state; +} + +export function formatTaskSessionStateLabel(state: TaskSessionState | null | undefined): string { + if (!state) return ""; + return TASK_SESSION_STATE_LABELS[state] ?? state; +} From 7642b984c6655e1c883828c5aba3a5af9c5dae2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= <carlosmflorencio@gmail.com> Date: Wed, 6 May 2026 20:25:34 +0100 Subject: [PATCH 21/33] feat(web): show grid spinner on agent tab while session is working (#836) --- .../components/task/preview-session-tabs.tsx | 10 ++++----- apps/web/components/task/session-sort.test.ts | 21 +++++++++++++++++++ apps/web/components/task/session-sort.ts | 4 ++++ apps/web/components/task/session-tab.tsx | 21 ++++++++++++------- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/apps/web/components/task/preview-session-tabs.tsx b/apps/web/components/task/preview-session-tabs.tsx index d3c2642547..25845d19a7 100644 --- a/apps/web/components/task/preview-session-tabs.tsx +++ b/apps/web/components/task/preview-session-tabs.tsx @@ -4,6 +4,7 @@ import { useCallback, useMemo } from "react"; import { IconLoader2 } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { AgentLogo } from "@/components/agent-logo"; +import { GridSpinner } from "@/components/grid-spinner"; import { SessionTabs, type SessionTab } from "@/components/session-tabs"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; @@ -11,12 +12,13 @@ import { useSessionResumption } from "@/hooks/domains/session/use-session-resump import { useTaskSessions } from "@/hooks/use-task-sessions"; import type { UseEnsureTaskSessionResult } from "@/hooks/domains/session/use-ensure-task-session"; import type { AgentProfileOption } from "@/lib/state/slices"; -import type { TaskSession, TaskSessionState } from "@/lib/types/http"; +import type { TaskSession } from "@/lib/types/http"; import { getWebSocketClient } from "@/lib/ws/connection"; import { PassthroughTerminal } from "./passthrough-terminal"; import { TaskChatPanel } from "./task-chat-panel"; import { buildAgentLabelsById, + isSessionActive, pickActiveSessionId, resolveAgentLabelFor, sortSessions, @@ -174,12 +176,8 @@ function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId: ); } -function isSessionActive(state: TaskSessionState): boolean { - return state === "RUNNING" || state === "STARTING"; -} - function RunningSpinner() { - return <IconLoader2 className="h-3 w-3 shrink-0 text-blue-500 animate-spin" />; + return <GridSpinner className="text-muted-foreground shrink-0 text-[12px]" />; } function PreviewLoadingState({ label }: { label: string }) { diff --git a/apps/web/components/task/session-sort.test.ts b/apps/web/components/task/session-sort.test.ts index c6e9399770..f48cff5bfa 100644 --- a/apps/web/components/task/session-sort.test.ts +++ b/apps/web/components/task/session-sort.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildAgentLabelsById, + isSessionActive, pickActiveSessionId, resolveAgentLabelFor, sortSessions, @@ -83,3 +84,23 @@ describe("pickActiveSessionId", () => { expect(pickActiveSessionId(sessions, null)).toBe("a"); }); }); + +describe("isSessionActive", () => { + it("is true while the agent is starting or running", () => { + expect(isSessionActive("RUNNING")).toBe(true); + expect(isSessionActive("STARTING")).toBe(true); + }); + + it("is false for terminal and idle states", () => { + expect(isSessionActive("WAITING_FOR_INPUT")).toBe(false); + expect(isSessionActive("COMPLETED")).toBe(false); + expect(isSessionActive("FAILED")).toBe(false); + expect(isSessionActive("CANCELLED")).toBe(false); + expect(isSessionActive("CREATED")).toBe(false); + }); + + it("is false for null/undefined", () => { + expect(isSessionActive(null)).toBe(false); + expect(isSessionActive(undefined)).toBe(false); + }); +}); diff --git a/apps/web/components/task/session-sort.ts b/apps/web/components/task/session-sort.ts index a5b3e8fb14..4d91df0921 100644 --- a/apps/web/components/task/session-sort.ts +++ b/apps/web/components/task/session-sort.ts @@ -57,3 +57,7 @@ export function pickActiveSessionId( const primary = sessions.find((s) => s.is_primary); return (primary ?? sessions[0]).id; } + +export function isSessionActive(state: TaskSessionState | null | undefined): boolean { + return state === "RUNNING" || state === "STARTING"; +} diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index 0412d78606..4ca533c583 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react"; import { IconStar } from "@tabler/icons-react"; import { AgentLogo } from "@/components/agent-logo"; +import { GridSpinner } from "@/components/grid-spinner"; import { ContextMenu, ContextMenuContent, @@ -29,6 +30,7 @@ import { isSessionResumable as isResumable, } from "@/hooks/domains/session/use-session-actions"; import type { TaskSessionState } from "@/lib/types/http"; +import { isSessionActive } from "./session-sort"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; function useSessionTabState(sessionId: string | undefined) { @@ -244,13 +246,18 @@ export function SessionTab(props: IDockviewPanelHeaderProps) { {sessionNumber} </span> )} - {agentName && ( - <AgentLogo - agentName={agentName} - size={14} - className={`ml-1.5 shrink-0${isActive ? "" : " opacity-50"}`} - /> - )} + {agentName && + (isSessionActive(sessionState) ? ( + <GridSpinner + className={`ml-1.5 shrink-0 text-[14px] text-muted-foreground${isActive ? "" : " opacity-50"}`} + /> + ) : ( + <AgentLogo + agentName={agentName} + size={14} + className={`ml-1.5 shrink-0${isActive ? "" : " opacity-50"}`} + /> + ))} <DockviewDefaultTab {...props} hideClose={sessionCount <= 1} /> </div> </ContextMenuTrigger> From 9ee42b53e3b1e346b335ed8ea71a25119daf4804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= <carlosmflorencio@gmail.com> Date: Wed, 6 May 2026 20:33:23 +0100 Subject: [PATCH 22/33] fix(orchestrator): abandon orphan turns on session resume (#837) --- apps/backend/cmd/kandev/adapters.go | 4 + .../internal/integration/test_server_test.go | 4 + apps/backend/internal/orchestrator/service.go | 5 + .../internal/orchestrator/task_operations.go | 19 ++ .../orchestrator/turn_lifecycle_test.go | 70 ++++++ .../task/handlers/process_handlers_test.go | 3 + .../internal/task/repository/interface.go | 1 + .../task/repository/sqlite/session.go | 15 ++ .../internal/task/service/service_turns.go | 67 ++++++ .../task/service/service_turns_test.go | 201 ++++++++++++++++++ 10 files changed, 389 insertions(+) diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index f71f6c8af6..ae5b6ac130 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -770,6 +770,10 @@ func (a *turnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID string return a.svc.GetActiveTurn(ctx, sessionID) } +func (a *turnServiceAdapter) AbandonOpenTurns(ctx context.Context, sessionID string) error { + return a.svc.AbandonOpenTurns(ctx, sessionID) +} + func newTurnServiceAdapter(svc *taskservice.Service) *turnServiceAdapter { return &turnServiceAdapter{svc: svc} } diff --git a/apps/backend/internal/integration/test_server_test.go b/apps/backend/internal/integration/test_server_test.go index e61b1fa783..4b50966167 100644 --- a/apps/backend/internal/integration/test_server_test.go +++ b/apps/backend/internal/integration/test_server_test.go @@ -217,6 +217,10 @@ func (a *testTurnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID st return a.svc.GetActiveTurn(ctx, sessionID) } +func (a *testTurnServiceAdapter) AbandonOpenTurns(ctx context.Context, sessionID string) error { + return a.svc.AbandonOpenTurns(ctx, sessionID) +} + // NewOrchestratorTestServer creates a test server with full orchestrator support func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { t.Helper() diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index ba91807405..7c57e518ad 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -91,6 +91,11 @@ type TurnService interface { StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) CompleteTurn(ctx context.Context, turnID string) error GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) + // AbandonOpenTurns buries any open turns for a session by setting + // completed_at = started_at (zero duration), so a subsequent prompt starts + // a fresh turn instead of adopting one that was orphaned by a crash or + // restart. Used on session resume. + AbandonOpenTurns(ctx context.Context, sessionID string) error } // TaskEventPublisher is the orchestrator's collaborator for publishing diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 2f23877e3b..56a6c86752 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -747,6 +747,25 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin return nil, fmt.Errorf("session is completed and cannot be resumed; create a new session instead") } + // Bury any open turns from the previous run before relaunching. Without + // this, startTurnForSession adopts the orphan on the next prompt and the + // UI's running timer counts from the orphan's started_at — which can be + // hours or days ago. Zero-duration completion keeps analytics honest about + // the dead window. A failure here shouldn't block the resume; the next + // completeTurnForSession sweep will mop up. + // + // Drop the activeTurns cache entry first, mirroring completeTurnForSession. + // Otherwise a stale entry would let getActiveTurnID return the now-abandoned + // turn ID without re-reading the DB, tagging new messages to a closed turn. + if s.turnService != nil { + s.activeTurns.Delete(sessionID) + if err := s.turnService.AbandonOpenTurns(ctx, sessionID); err != nil { + s.logger.Warn("failed to abandon orphan turns on resume; continuing", + zap.String("session_id", sessionID), + zap.Error(err)) + } + } + // Use context.WithoutCancel to prevent WebSocket request timeout from canceling the resume. // Session resume can take time and shouldn't be tied to the WS request lifecycle. resumeCtx := context.WithoutCancel(ctx) diff --git a/apps/backend/internal/orchestrator/turn_lifecycle_test.go b/apps/backend/internal/orchestrator/turn_lifecycle_test.go index c0b51f47f8..8ae6fc77b3 100644 --- a/apps/backend/internal/orchestrator/turn_lifecycle_test.go +++ b/apps/backend/internal/orchestrator/turn_lifecycle_test.go @@ -49,6 +49,18 @@ func (a *repoTurnService) GetActiveTurn(ctx context.Context, sessionID string) ( return turn, err } +func (a *repoTurnService) AbandonOpenTurns(ctx context.Context, sessionID string) error { + for { + turn, err := a.GetActiveTurn(ctx, sessionID) + if err != nil || turn == nil { + return err + } + if err := a.repo.AbandonTurn(ctx, turn.ID); err != nil { + return err + } + } +} + func openTurnCount(t *testing.T, repo *sqliterepo.Repository, sessionID string) int { t.Helper() turns, err := repo.ListTurnsBySession(context.Background(), sessionID) @@ -230,3 +242,61 @@ func TestCompleteTurnIsIdempotent(t *testing.T) { t.Fatalf("expected exactly 1 turn (no phantom), got %d", len(turns)) } } + +// TestAbandonOpenTurnsZeroesDuration covers the resume-orphan path: turns +// left open by a previous crash must close with completed_at = started_at so +// the UI's running timer doesn't count from a stale start, and analytics +// doesn't accumulate hours of dead time. +func TestAbandonOpenTurnsZeroesDuration(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + + stale, err := svc.turnService.StartTurn(ctx, "session1") + if err != nil { + t.Fatalf("seed turn: %v", err) + } + + if err := svc.turnService.AbandonOpenTurns(ctx, "session1"); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + if open := openTurnCount(t, repo, "session1"); open != 0 { + t.Fatalf("expected 0 open turns after abandon, got %d", open) + } + + got, err := repo.GetTurn(ctx, stale.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if got.CompletedAt == nil { + t.Fatal("expected completed_at to be set after abandon") + } + if !got.CompletedAt.Equal(got.StartedAt) { + t.Fatalf("expected completed_at == started_at (zero duration), got started=%v completed=%v", + got.StartedAt, *got.CompletedAt) + } +} + +// TestAbandonOpenTurnsHandlesMultipleZombies verifies the loop closes every +// open turn for the session, mirroring the behavior of completeTurnForSession. +func TestAbandonOpenTurnsHandlesMultipleZombies(t *testing.T) { + svc, repo := newTurnLifecycleTestService(t) + ctx := context.Background() + + for i := 0; i < 4; i++ { + if _, err := svc.turnService.StartTurn(ctx, "session1"); err != nil { + t.Fatalf("seed turn %d: %v", i, err) + } + } + if open := openTurnCount(t, repo, "session1"); open != 4 { + t.Fatalf("expected 4 open turns before abandon, got %d", open) + } + + if err := svc.turnService.AbandonOpenTurns(ctx, "session1"); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + if open := openTurnCount(t, repo, "session1"); open != 0 { + t.Fatalf("expected 0 open turns after abandon, got %d", open) + } +} diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 20d694d268..35292e029c 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -175,6 +175,9 @@ func (m *mockRepository) UpdateTurn(ctx context.Context, turn *models.Turn) erro func (m *mockRepository) CompleteTurn(ctx context.Context, id string) error { return nil } +func (m *mockRepository) AbandonTurn(ctx context.Context, id string) error { + return nil +} func (m *mockRepository) CompletePendingToolCallsForTurn(ctx context.Context, turnID string) (int64, error) { return 0, nil } diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 7d8ea057bd..31e5f63e8d 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -82,6 +82,7 @@ type TurnRepository interface { GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) UpdateTurn(ctx context.Context, turn *models.Turn) error CompleteTurn(ctx context.Context, id string) error + AbandonTurn(ctx context.Context, id string) error CompletePendingToolCallsForTurn(ctx context.Context, turnID string) (int64, error) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) } diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 9e5f43c48a..cc5d3f5db8 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -122,6 +122,21 @@ func (r *Repository) CompleteTurn(ctx context.Context, id string) error { return err } +// AbandonTurn marks a turn as completed with completed_at = started_at, giving it +// zero duration. Used when a turn was orphaned by an interruption (backend +// restart, agent crash) and the previous "running" window was not real work — +// recording `now` would inflate analytics and the UI's last-turn duration with +// hours of dead time. +func (r *Repository) AbandonTurn(ctx context.Context, id string) error { + now := time.Now().UTC() + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE task_session_turns + SET completed_at = started_at, updated_at = ? + WHERE id = ? AND completed_at IS NULL + `), now, id) + return err +} + // ListTurnsBySession returns all turns for a session ordered by start time func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) { ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTurnsBySession") diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 07e4d595e7..579e4b41f4 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -100,6 +100,73 @@ func (s *Service) GetActiveTurn(ctx context.Context, sessionID string) (*models. return turn, err } +// AbandonOpenTurns closes any open turns for a session by setting their +// completed_at = started_at. Used on session resume to bury orphan turns left +// behind by a previous crash/restart so a fresh prompt starts a fresh turn +// (preventing the UI from showing the orphan's stale started_at as the running +// timer, and preventing the orphan from poisoning analytics with hours of dead +// time). +// +// Mirrors the iteration shape of orchestrator.completeTurnForSession: the DB is +// authoritative, so we loop GetActiveTurn → AbandonTurn until none remain, with +// a sanity cap to break runaway loops. +func (s *Service) AbandonOpenTurns(ctx context.Context, sessionID string) error { + const maxIterations = 16 + closed := 0 + for closed < maxIterations { + turn, err := s.GetActiveTurn(ctx, sessionID) + if err != nil { + return err + } + if turn == nil { + return nil + } + if err := s.turns.AbandonTurn(ctx, turn.ID); err != nil { + s.logger.Error("failed to abandon turn", + zap.String("turn_id", turn.ID), + zap.String("session_id", sessionID), + zap.Error(err)) + return err + } + // Same safety net as CompleteTurn: any tool calls left in a non-terminal + // state belong to a turn that is no longer running, so flip them to + // "complete" instead of leaving them to spin forever in the UI. + if affected, err := s.turns.CompletePendingToolCallsForTurn(ctx, turn.ID); err != nil { + s.logger.Warn("failed to complete pending tool calls for abandoned turn", + zap.String("turn_id", turn.ID), + zap.Error(err)) + } else if affected > 0 { + s.logger.Info("completed pending tool calls on abandoned turn", + zap.String("turn_id", turn.ID), + zap.Int64("affected", affected)) + } + // Re-fetch so the published event carries the persisted completed_at + // (= started_at) rather than a synthesized one. + refreshed, err := s.turns.GetTurn(ctx, turn.ID) + if err != nil { + s.logger.Debug("failed to refetch abandoned turn", + zap.String("turn_id", turn.ID), + zap.Error(err)) + } else { + s.publishTurnEvent(events.TurnCompleted, refreshed) + } + s.logger.Info("abandoned orphan turn on session resume", + zap.String("turn_id", turn.ID), + zap.String("session_id", sessionID)) + closed++ + } + // Only warn if turns are *still* accumulating after the cap. Closing + // exactly maxIterations turns and then finding the session clean is not a + // runaway — same shape as completeTurnForSession. + if turn, err := s.GetActiveTurn(ctx, sessionID); err == nil && turn != nil { + s.logger.Warn("AbandonOpenTurns hit iteration cap; some orphan turns may remain", + zap.String("session_id", sessionID), + zap.Int("closed", closed), + zap.Int("max_iterations", maxIterations)) + } + return nil +} + // getOrStartTurn returns the active turn for a session, or starts a new one if none exists. // This is used to ensure messages always have a valid turn ID. func (s *Service) getOrStartTurn(ctx context.Context, sessionID string) (*models.Turn, error) { diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index f91cdb0e70..b1e4cd85ad 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" ) @@ -316,3 +317,203 @@ func TestGetWorkspaceInfoForSession_MultiRepoReturnsTaskRoot(t *testing.T) { info.WorkspacePath) } } + +// AbandonOpenTurns: turns left open by a previous crash must close with +// completed_at = started_at so analytics' active_duration_ms doesn't get +// poisoned with the dead window and the UI's running timer doesn't count +// from a stale start. +func TestAbandonOpenTurns_ZeroesDuration(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + stale := &models.Turn{ + ID: "turn-stale", + TaskSessionID: sessionID, + TaskID: "task-123", + StartedAt: time.Now().Add(-90 * time.Hour).UTC(), + } + if err := repo.CreateTurn(ctx, stale); err != nil { + t.Fatalf("CreateTurn: %v", err) + } + + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + got, err := repo.GetTurn(ctx, stale.ID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if got.CompletedAt == nil { + t.Fatal("expected completed_at to be set") + } + if !got.CompletedAt.Equal(got.StartedAt) { + t.Fatalf("expected completed_at == started_at (zero duration), got started=%v completed=%v", + got.StartedAt, *got.CompletedAt) + } +} + +// AbandonOpenTurns sweeps the same pending tool calls that CompleteTurn does: +// otherwise the UI would show "running" tool calls forever on an abandoned turn. +func TestAbandonOpenTurns_CompletesPendingToolCalls(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + turn := &models.Turn{ + ID: "turn-with-tool", + TaskSessionID: sessionID, + TaskID: "task-123", + StartedAt: time.Now().UTC(), + } + if err := repo.CreateTurn(ctx, turn); err != nil { + t.Fatalf("CreateTurn: %v", err) + } + + toolMsg := &models.Message{ + ID: "msg-tool-1", + TaskSessionID: sessionID, + TaskID: "task-123", + TurnID: turn.ID, + AuthorType: models.MessageAuthorAgent, + Type: models.MessageTypeToolCall, + Content: "running tool", + Metadata: map[string]interface{}{ + "tool_call_id": "tc-1", + "status": "running", + }, + } + if err := repo.CreateMessage(ctx, toolMsg); err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + got, err := repo.GetMessageByToolCallID(ctx, sessionID, "tc-1") + if err != nil { + t.Fatalf("GetMessageByToolCallID: %v", err) + } + if got == nil { + t.Fatal("expected tool call message to exist") + } + if status, _ := got.Metadata["status"].(string); status != "complete" { + t.Fatalf("expected tool call status='complete', got %q", status) + } +} + +// AbandonOpenTurns publishes turn.completed for each closed turn so the WS +// gateway can broadcast the state change and the frontend clears its +// activeBySession entry. +func TestAbandonOpenTurns_PublishesTurnCompletedEvent(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + turn := &models.Turn{ + ID: "turn-event", + TaskSessionID: sessionID, + TaskID: "task-123", + StartedAt: time.Now().UTC(), + } + if err := repo.CreateTurn(ctx, turn); err != nil { + t.Fatalf("CreateTurn: %v", err) + } + + eventBus.ClearEvents() + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + var found bool + for _, ev := range eventBus.GetPublishedEvents() { + if ev.Type == events.TurnCompleted { + found = true + break + } + } + if !found { + t.Fatal("expected turn.completed event to be published") + } +} + +// AbandonOpenTurns is a no-op when no open turn exists (e.g. resume called on +// a session whose previous turn already completed cleanly). +func TestAbandonOpenTurns_NoOpenTurns(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + eventBus.ClearEvents() + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + if got := len(eventBus.GetPublishedEvents()); got != 0 { + t.Fatalf("expected no events when nothing to abandon, got %d", got) + } +} + +// AbandonOpenTurns iteration cap: with more than maxIterations open turns, +// the function still returns nil (caller swallows the error and the next +// completeTurnForSession sweep mops up the remainder). +func TestAbandonOpenTurns_IterationCap(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + + const seeded = 20 // > maxIterations (16) + for i := 0; i < seeded; i++ { + turn := &models.Turn{ + ID: fmt.Sprintf("turn-cap-%d", i), + TaskSessionID: sessionID, + TaskID: "task-123", + StartedAt: time.Now().UTC(), + } + if err := repo.CreateTurn(ctx, turn); err != nil { + t.Fatalf("seed turn %d: %v", i, err) + } + } + + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("AbandonOpenTurns: %v", err) + } + + // First sweep closes exactly maxIterations; remainder still open. + turns, err := repo.ListTurnsBySession(ctx, sessionID) + if err != nil { + t.Fatalf("ListTurnsBySession: %v", err) + } + open := 0 + for _, turn := range turns { + if turn.CompletedAt == nil { + open++ + } + } + if open != seeded-16 { + t.Fatalf("expected %d open turns after first sweep (cap=16), got %d", seeded-16, open) + } + + // Second sweep finishes the job. + if err := svc.AbandonOpenTurns(ctx, sessionID); err != nil { + t.Fatalf("second AbandonOpenTurns: %v", err) + } + turns, _ = repo.ListTurnsBySession(ctx, sessionID) + for _, turn := range turns { + if turn.CompletedAt == nil { + t.Fatalf("expected all turns closed after second sweep, %s still open", turn.ID) + } + } +} From 8c70aee27d3587af8148304005cec3cb26073d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= <carlosmflorencio@gmail.com> Date: Thu, 7 May 2026 18:42:57 +0100 Subject: [PATCH 23/33] fix(workflows): hide improve-kandev system workflow from settings UI (#842) --- apps/backend/cmd/kandev/e2e_reset.go | 54 ++++++++-- apps/backend/cmd/kandev/helpers.go | 2 +- apps/backend/config/workflows/loader.go | 32 ++++++ .../backend/internal/improvekandev/handler.go | 11 ++ .../internal/task/service/service_events.go | 1 + .../task/service/service_resources.go | 21 ++++ .../task/service/service_workflow_test.go | 39 +++++++ .../internal/workflow/service/service.go | 19 +++- .../internal/workflow/service/service_test.go | 15 +++ apps/web/e2e/helpers/api-client.ts | 15 +++ .../tests/workflow/workflow-settings.spec.ts | 46 ++++++++ .../settings/use-workflow-settings.test.ts | 41 +++++++ .../domains/settings/use-workflow-settings.ts | 14 ++- apps/web/lib/types/backend.ts | 1 + apps/web/lib/ws/handlers/workflows.test.ts | 102 ++++++++++++++++++ apps/web/lib/ws/handlers/workflows.ts | 93 +++++++++------- 16 files changed, 453 insertions(+), 53 deletions(-) create mode 100644 apps/web/lib/ws/handlers/workflows.test.ts diff --git a/apps/backend/cmd/kandev/e2e_reset.go b/apps/backend/cmd/kandev/e2e_reset.go index 63b4b2a046..3ac0c4a8e5 100644 --- a/apps/backend/cmd/kandev/e2e_reset.go +++ b/apps/backend/cmd/kandev/e2e_reset.go @@ -10,18 +10,29 @@ import ( "github.com/kandev/kandev/internal/common/logger" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + taskservice "github.com/kandev/kandev/internal/task/service" ) -// registerE2EResetRoutes registers the E2E data-reset endpoint. -// The endpoint is available when KANDEV_MOCK_AGENT is "true" or "only" (dev/E2E modes). -func registerE2EResetRoutes(router *gin.Engine, repo *sqliterepo.Repository, log *logger.Logger) { +// registerE2EResetRoutes registers the E2E test-only endpoints. +// The endpoints are available when KANDEV_MOCK_AGENT is "true" or "only" (dev/E2E modes). +func registerE2EResetRoutes(router *gin.Engine, repo *sqliterepo.Repository, taskSvc *taskservice.Service, log *logger.Logger) { mockMode := os.Getenv("KANDEV_MOCK_AGENT") if mockMode != "true" && mockMode != "only" { return } api := router.Group("/api/v1/e2e") - api.DELETE("/reset/:workspaceId", func(c *gin.Context) { + api.DELETE("/reset/:workspaceId", handleE2EReset(repo, log)) + // Hidden-workflow factory: lets E2E tests cover the system-only + // workflow path (e.g. improve-kandev) without depending on the real + // bootstrap endpoint, which clones from GitHub and shells out to gh. + api.POST("/hidden-workflow", handleE2ECreateHiddenWorkflow(taskSvc, log)) + + log.Info("registered E2E endpoints (test-only)") +} + +func handleE2EReset(repo *sqliterepo.Repository, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { workspaceID := c.Param("workspaceId") // Optional: comma-separated workflow IDs to keep (e.g., the seeded workflow). @@ -50,7 +61,36 @@ func registerE2EResetRoutes(router *gin.Engine, repo *sqliterepo.Repository, log "deleted_tasks": deletedTasks, "deleted_workflows": deletedWorkflows, }) - }) - - log.Info("registered E2E reset endpoint (test-only)") + } +} + +type e2eHiddenWorkflowRequest struct { + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` +} + +func handleE2ECreateHiddenWorkflow(taskSvc *taskservice.Service, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + var body e2eHiddenWorkflowRequest + if err := c.ShouldBindJSON(&body); err != nil || body.WorkspaceID == "" || body.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id and name are required"}) + return + } + workflow, err := taskSvc.CreateWorkflow(c.Request.Context(), &taskservice.CreateWorkflowRequest{ + WorkspaceID: body.WorkspaceID, + Name: body.Name, + Hidden: true, + }) + if err != nil { + log.Error("e2e: failed to create hidden workflow", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{ + "id": workflow.ID, + "workspace_id": workflow.WorkspaceID, + "name": workflow.Name, + "hidden": workflow.Hidden, + }) + } } diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index d3d2a84a00..7fb8470be6 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -648,7 +648,7 @@ func registerSecondaryRoutes( registerMCPAndDebugRoutes(p, workflowCtrl, clarificationStore, planService) - registerE2EResetRoutes(p.router, p.taskRepo, p.log) + registerE2EResetRoutes(p.router, p.taskRepo, p.taskSvc, p.log) } // registerHealthRoutes sets up the system health endpoint with all health checkers. diff --git a/apps/backend/config/workflows/loader.go b/apps/backend/config/workflows/loader.go index 5edc2e83c7..b64f715c6b 100644 --- a/apps/backend/config/workflows/loader.go +++ b/apps/backend/config/workflows/loader.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "strings" + "sync" "time" "github.com/kandev/kandev/internal/workflow/models" @@ -48,6 +49,37 @@ type actionYAML struct { Config map[string]any `yaml:"config,omitempty"` } +// HiddenTemplateIDs returns the set of template IDs marked `hidden: true` +// in their embedded YAML. Hidden templates are system-only flows +// (e.g. improve-kandev) that must not appear in management UI or pickers. +// +// The result is cached for the lifetime of the binary; the embedded YAML is +// static, and ListTemplates (the only caller) is on a hot path served on +// every picker / settings page load. +func HiddenTemplateIDs() (map[string]bool, error) { + hiddenOnce.Do(func() { + templates, err := LoadTemplates() + if err != nil { + hiddenErr = err + return + } + m := make(map[string]bool, len(templates)) + for _, t := range templates { + if t.Hidden { + m[t.ID] = true + } + } + hiddenIDs = m + }) + return hiddenIDs, hiddenErr +} + +var ( + hiddenOnce sync.Once + hiddenIDs map[string]bool + hiddenErr error +) + // LoadTemplates parses all embedded YAML files and returns workflow templates. func LoadTemplates() ([]*models.WorkflowTemplate, error) { entries, err := embeddedFS.ReadDir(".") diff --git a/apps/backend/internal/improvekandev/handler.go b/apps/backend/internal/improvekandev/handler.go index 00d390aabd..f8eb087eda 100644 --- a/apps/backend/internal/improvekandev/handler.go +++ b/apps/backend/internal/improvekandev/handler.go @@ -248,6 +248,17 @@ func (h *Handler) ensureWorkflow(ctx context.Context, workspaceID string) (*task } for _, w := range existing { if w.WorkflowTemplateID != nil && *w.WorkflowTemplateID == templateID { + // Heal records created before the workflow honored Hidden on insert. + // Best-effort: a DB failure here must not block the caller from getting + // their workflow ID, since the workflow itself is already usable. + if !w.Hidden { + if err := h.taskSvc.SetWorkflowHidden(ctx, w.ID, true); err != nil { + h.log.Warn("improve-kandev: failed to heal hidden flag on stale record", + zap.String("workflow_id", w.ID), zap.Error(err)) + } else { + w.Hidden = true + } + } return w, nil } } diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 969d8d795a..0669fee689 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -221,6 +221,7 @@ func (s *Service) publishWorkflowEvent(ctx context.Context, eventType string, wo "name": workflow.Name, "description": workflow.Description, "agent_profile_id": workflow.AgentProfileID, + "hidden": workflow.Hidden, "created_at": workflow.CreatedAt.Format(time.RFC3339), "updated_at": workflow.UpdatedAt.Format(time.RFC3339), } diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 70f745ccff..4a33a9507e 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -181,6 +181,27 @@ func (s *Service) UpdateWorkflow(ctx context.Context, id string, req *UpdateWork return workflow, nil } +// SetWorkflowHidden flips the hidden flag on a workflow. Used by system +// flows (e.g. improve-kandev) to heal records created before Hidden was +// honored on insert. +func (s *Service) SetWorkflowHidden(ctx context.Context, id string, hidden bool) error { + workflow, err := s.workflows.GetWorkflow(ctx, id) + if err != nil { + return err + } + if workflow.Hidden == hidden { + return nil + } + workflow.Hidden = hidden + workflow.UpdatedAt = time.Now().UTC() + if err := s.workflows.UpdateWorkflow(ctx, workflow); err != nil { + s.logger.Error("failed to update workflow hidden flag", zap.String("workflow_id", id), zap.Error(err)) + return err + } + s.publishWorkflowEvent(ctx, events.WorkflowUpdated, workflow) + return nil +} + // DeleteWorkflow deletes a workflow func (s *Service) DeleteWorkflow(ctx context.Context, id string) error { workflow, err := s.workflows.GetWorkflow(ctx, id) diff --git a/apps/backend/internal/task/service/service_workflow_test.go b/apps/backend/internal/task/service/service_workflow_test.go index 87b571bf81..ddbbc14d7f 100644 --- a/apps/backend/internal/task/service/service_workflow_test.go +++ b/apps/backend/internal/task/service/service_workflow_test.go @@ -38,6 +38,45 @@ func (testStepNotFound) Error() string { return "step not found" } var errStepNotFoundForTest = testStepNotFound{} +// TestService_SetWorkflowHidden_HealsStaleRecord verifies the helper used by +// the improve-kandev bootstrap to flip Hidden=true on workflows created +// before the flag was honored on insert. +func TestService_SetWorkflowHidden_HealsStaleRecord(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-stale", WorkspaceID: "ws-1", Name: "Improve Kandev", Hidden: false}) + + if err := svc.SetWorkflowHidden(ctx, "wf-stale", true); err != nil { + t.Fatalf("SetWorkflowHidden: %v", err) + } + + visible, err := svc.ListWorkflows(ctx, "ws-1", false) + if err != nil { + t.Fatalf("ListWorkflows: %v", err) + } + for _, wf := range visible { + if wf.ID == "wf-stale" { + t.Fatalf("hidden workflow leaked into default listing: %+v", wf) + } + } + + all, err := svc.ListWorkflows(ctx, "ws-1", true) + if err != nil { + t.Fatalf("ListWorkflows(includeHidden): %v", err) + } + var found *models.Workflow + for _, wf := range all { + if wf.ID == "wf-stale" { + found = wf + } + } + if found == nil || !found.Hidden { + t.Fatalf("expected wf-stale to be hidden after heal, got %+v", found) + } +} + func TestService_MoveTaskRejectsInvalidWorkflowTargets(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index d3efb19fa4..c0ff7109af 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "go.uber.org/zap" + workflowcfg "github.com/kandev/kandev/config/workflows" "github.com/kandev/kandev/internal/common/logger" taskmodels "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/workflow/models" @@ -54,14 +55,28 @@ func NewService(repo *repository.Repository, log *logger.Logger) *Service { // Template Operations // ============================================================================ -// ListTemplates returns all workflow templates. +// ListTemplates returns user-pickable workflow templates. Templates marked +// `hidden: true` in their embedded YAML (e.g. improve-kandev) are excluded +// from the management UI and the create-workflow picker. func (s *Service) ListTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) { templates, err := s.repo.ListTemplates(ctx) if err != nil { s.logger.Error("failed to list templates", zap.Error(err)) return nil, err } - return templates, nil + hidden, err := workflowcfg.HiddenTemplateIDs() + if err != nil { + s.logger.Error("failed to load embedded template visibility", zap.Error(err)) + return nil, err + } + result := make([]*models.WorkflowTemplate, 0, len(templates)) + for _, t := range templates { + if hidden[t.ID] { + continue + } + result = append(result, t) + } + return result, nil } // GetTemplate retrieves a workflow template by ID. diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index b16566dd8d..e43063a603 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -113,6 +113,21 @@ func createStep(t *testing.T, svc *Service, step *models.WorkflowStep) { require.NoError(t, err) } +// TestListTemplates_FiltersHidden verifies that templates marked +// `hidden: true` in their embedded YAML (improve-kandev) are excluded +// from the picker shown by the create-workflow dialog and the settings UI. +func TestListTemplates_FiltersHidden(t *testing.T) { + svc, _ := setupTestService(t) + + templates, err := svc.ListTemplates(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, templates, "ListTemplates returned no templates; the assertion below would vacuously pass") + + for _, tmpl := range templates { + assert.NotEqual(t, "improve-kandev", tmpl.ID, "hidden template must not be returned by ListTemplates") + } +} + func TestGetNextStepByPosition(t *testing.T) { t.Run("middle step returns next step", func(t *testing.T) { svc, db := setupTestService(t) diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 6b7ebbf2ca..323de5f675 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -571,6 +571,21 @@ export class ApiClient { await this.request("DELETE", `/api/v1/e2e/reset/${workspaceId}${params}`); } + /** + * Creates a hidden (system-only) workflow. Mirrors the path that + * improve-kandev's bootstrap takes when ensuring its workflow exists, + * without depending on the gh CLI or repo cloning. + */ + async e2eCreateHiddenWorkflow( + workspaceId: string, + name: string, + ): Promise<{ id: string; workspace_id: string; name: string; hidden: boolean }> { + return this.request("POST", "/api/v1/e2e/hidden-workflow", { + workspace_id: workspaceId, + name, + }); + } + // --- GitHub Mock Control --- async mockGitHubReset(): Promise<void> { diff --git a/apps/web/e2e/tests/workflow/workflow-settings.spec.ts b/apps/web/e2e/tests/workflow/workflow-settings.spec.ts index 587e78d37c..72688a825e 100644 --- a/apps/web/e2e/tests/workflow/workflow-settings.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-settings.spec.ts @@ -273,4 +273,50 @@ test.describe("Seed protection", () => { expect(postKanban).toHaveLength(kanbanSteps.length); expect(postPR).toHaveLength(prSteps.length); }); + + test("hidden system workflows do not appear in the settings list", async ({ + testPage, + seedData, + apiClient, + }) => { + // Reproduces the original "Improve Kandev" leak: while the user is on + // the workspace workflow settings page, a hidden system workflow gets + // created (e.g. via the Improve Kandev dialog). The backend fires a + // `workflow.created` WS event with hidden=true; the frontend receives + // it and previously surfaced the entry as a manageable card in the + // settings list. Verify the new hidden entry never appears as a card. + const hiddenName = "Improve Kandev"; + + const page = new WorkflowSettingsPage(testPage); + await page.goto(seedData.workspaceId); + + // The seeded visible workflow is rendered before the leak attempt. + const visibleCard = await page.findWorkflowCard("E2E Workflow"); + await expect(visibleCard).toBeVisible(); + const baselineCount = await testPage.locator('[data-testid^="workflow-card-"]').count(); + + // Trigger the leak path: a hidden workflow is created and the + // `workflow.created` WS event arrives at the open settings page. + await apiClient.e2eCreateHiddenWorkflow(seedData.workspaceId, hiddenName); + + // Allow the WS event to propagate and the React effect in + // useWorkflowSettings a chance to (incorrectly) add a card. + await testPage.waitForTimeout(500); + + // No new card appeared and the hidden entry is not in the list. + const allCards = testPage.locator('[data-testid^="workflow-card-"]'); + const newCount = await allCards.count(); + const cardNames: string[] = []; + for (let i = 0; i < newCount; i++) { + const value = await allCards + .nth(i) + .locator("input") + .first() + .inputValue({ timeout: 500 }) + .catch(() => ""); + cardNames.push(value); + } + expect(cardNames).not.toContain(hiddenName); + expect(newCount).toBe(baselineCount); + }); }); diff --git a/apps/web/hooks/domains/settings/use-workflow-settings.test.ts b/apps/web/hooks/domains/settings/use-workflow-settings.test.ts index d5e1b46958..de57a2d0a5 100644 --- a/apps/web/hooks/domains/settings/use-workflow-settings.test.ts +++ b/apps/web/hooks/domains/settings/use-workflow-settings.test.ts @@ -7,6 +7,7 @@ type StoreWorkflow = { workspaceId: string; name: string; description?: string | null; + hidden?: boolean; }; type MockState = { workflows: { items: StoreWorkflow[] } }; @@ -119,6 +120,46 @@ describe("useWorkflowSettings", () => { expect(result.current.workflowItems[0].name).toEqual("Renamed B1"); }); + it("excludes hidden system workflows from the settings list", () => { + // System workflows like "Improve Kandev" live in the global store with + // hidden=true so the kanban can resolve task references, but they must + // never appear in the management UI. + const HIDDEN_SYSTEM: StoreWorkflow = { + id: "wf-improve-kandev", + workspaceId: "ws-b", + name: "Improve Kandev", + hidden: true, + }; + setStore([STORE_B1, HIDDEN_SYSTEM]); + + const { result } = renderHook(() => useWorkflowSettings([], "ws-b")); + + expect(result.current.workflowItems.map((w) => w.id)).toEqual(["wf-b1"]); + expect(result.current.savedWorkflowItems.map((w) => w.id)).toEqual(["wf-b1"]); + }); + + it("drops a workflow from the settings list once it becomes hidden", () => { + const initial = [wf("wf-b1", "ws-b", NAME_B1)]; + setStore([STORE_B1]); + + const { result, rerender } = renderHook( + ({ store }: { store: StoreWorkflow[] }) => { + setStore(store); + return useWorkflowSettings(initial, "ws-b"); + }, + { initialProps: { store: [STORE_B1] } }, + ); + + expect(result.current.workflowItems.map((w) => w.id)).toEqual(["wf-b1"]); + + // Backend flips hidden=true (e.g. healing the improve-kandev record). + act(() => { + rerender({ store: [{ ...STORE_B1, hidden: true }] }); + }); + + expect(result.current.workflowItems.map((w) => w.id)).toEqual([]); + }); + it("starts scoping store entries once a workspaceId becomes defined", () => { setStore([STORE_A1, STORE_B1]); diff --git a/apps/web/hooks/domains/settings/use-workflow-settings.ts b/apps/web/hooks/domains/settings/use-workflow-settings.ts index 773b71d9f6..408748a565 100644 --- a/apps/web/hooks/domains/settings/use-workflow-settings.ts +++ b/apps/web/hooks/domains/settings/use-workflow-settings.ts @@ -14,11 +14,15 @@ import type { Workflow } from "@/lib/types/http"; */ export function useWorkflowSettings(initialWorkflows: Workflow[], workspaceId?: string) { const storeWorkflows = useAppStore((state) => state.workflows.items); - const scopedStoreWorkflows = useMemo( - () => - workspaceId ? storeWorkflows.filter((w) => w.workspaceId === workspaceId) : storeWorkflows, - [storeWorkflows, workspaceId], - ); + // Hidden workflows (e.g. the system "Improve Kandev" template) are loaded + // into the global store with `includeHidden: true` so the kanban can resolve + // them when a task references one, but they must never surface in the + // settings management UI. Filter them out at the store boundary so all + // downstream merging logic remains hidden-agnostic. + const scopedStoreWorkflows = useMemo(() => { + const visible = storeWorkflows.filter((w) => !w.hidden); + return workspaceId ? visible.filter((w) => w.workspaceId === workspaceId) : visible; + }, [storeWorkflows, workspaceId]); const [workflowItems, setWorkflowItems] = useState<Workflow[]>(initialWorkflows); const [savedWorkflowItems, setSavedWorkflowItems] = useState<Workflow[]>(initialWorkflows); diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 558224b660..04d08fe71e 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -185,6 +185,7 @@ export type WorkflowPayload = { name: string; description?: string; agent_profile_id?: string; + hidden?: boolean; created_at?: string; updated_at?: string; }; diff --git a/apps/web/lib/ws/handlers/workflows.test.ts b/apps/web/lib/ws/handlers/workflows.test.ts new file mode 100644 index 0000000000..340d54d2ea --- /dev/null +++ b/apps/web/lib/ws/handlers/workflows.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { BackendMessageMap, WorkflowPayload } from "@/lib/types/backend"; +import { registerWorkflowsHandlers } from "./workflows"; + +type WorkflowItem = { id: string; workspaceId: string; name: string; hidden?: boolean }; + +function makeStore(items: WorkflowItem[], activeId: string | null) { + let state = { + workflows: { items, activeId }, + workspaces: { activeId: "ws-1" }, + kanban: { workflowId: null, steps: [], tasks: [] }, + } as unknown as AppState; + + return { + getState: () => state, + setState: (updater: AppState | ((s: AppState) => AppState)) => { + state = + typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater; + }, + subscribe: () => () => {}, + destroy: () => {}, + getInitialState: () => state, + } as unknown as StoreApi<AppState>; +} + +function updatedMessage(payload: WorkflowPayload): BackendMessageMap["workflow.updated"] { + return { + id: "msg-1", + type: "notification", + action: "workflow.updated", + payload, + timestamp: "2026-01-01T00:00:00Z", + }; +} + +describe("workflow.updated handler — hidden flag reconciles activeId", () => { + it("clears activeId to next visible workflow when active becomes hidden", () => { + const store = makeStore( + [ + { id: "wf-1", workspaceId: "ws-1", name: "Improve Kandev", hidden: false }, + { id: "wf-2", workspaceId: "ws-1", name: "Default", hidden: false }, + ], + "wf-1", + ); + const handlers = registerWorkflowsHandlers(store); + + handlers["workflow.updated"]?.( + updatedMessage({ id: "wf-1", workspace_id: "ws-1", name: "Improve Kandev", hidden: true }), + ); + + expect(store.getState().workflows.activeId).toBe("wf-2"); + expect(store.getState().workflows.items.find((i) => i.id === "wf-1")?.hidden).toBe(true); + }); + + it("clears activeId to null when no visible workflow remains", () => { + const store = makeStore( + [{ id: "wf-1", workspaceId: "ws-1", name: "Only One", hidden: false }], + "wf-1", + ); + const handlers = registerWorkflowsHandlers(store); + + handlers["workflow.updated"]?.( + updatedMessage({ id: "wf-1", workspace_id: "ws-1", name: "Only One", hidden: true }), + ); + + expect(store.getState().workflows.activeId).toBeNull(); + }); + + it("leaves activeId untouched when a non-active workflow becomes hidden", () => { + const store = makeStore( + [ + { id: "wf-1", workspaceId: "ws-1", name: "Active", hidden: false }, + { id: "wf-2", workspaceId: "ws-1", name: "Other", hidden: false }, + ], + "wf-1", + ); + const handlers = registerWorkflowsHandlers(store); + + handlers["workflow.updated"]?.( + updatedMessage({ id: "wf-2", workspace_id: "ws-1", name: "Other", hidden: true }), + ); + + expect(store.getState().workflows.activeId).toBe("wf-1"); + }); + + it("leaves activeId untouched when payload omits hidden", () => { + const store = makeStore( + [{ id: "wf-1", workspaceId: "ws-1", name: "Old Name", hidden: false }], + "wf-1", + ); + const handlers = registerWorkflowsHandlers(store); + + handlers["workflow.updated"]?.( + updatedMessage({ id: "wf-1", workspace_id: "ws-1", name: "New Name" }), + ); + + expect(store.getState().workflows.activeId).toBe("wf-1"); + expect(store.getState().workflows.items[0]?.name).toBe("New Name"); + }); +}); diff --git a/apps/web/lib/ws/handlers/workflows.ts b/apps/web/lib/ws/handlers/workflows.ts index 2b54840485..71c98c3c81 100644 --- a/apps/web/lib/ws/handlers/workflows.ts +++ b/apps/web/lib/ws/handlers/workflows.ts @@ -1,6 +1,7 @@ import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import type { WsHandlers } from "@/lib/ws/handlers/types"; +import type { WorkflowPayload } from "@/lib/types/backend"; // eslint-disable-next-line @typescript-eslint/no-explicit-any function stepFromPayload(step: any) { @@ -18,49 +19,65 @@ function stepFromPayload(step: any) { }; } +function applyWorkflowCreated(state: AppState, payload: WorkflowPayload): AppState { + if (state.workspaces.activeId !== payload.workspace_id) return state; + if (state.workflows.items.some((item) => item.id === payload.id)) return state; + const isHidden = Boolean(payload.hidden); + return { + ...state, + workflows: { + items: [ + { + id: payload.id, + workspaceId: payload.workspace_id, + name: payload.name, + hidden: isHidden, + }, + ...state.workflows.items, + ], + // Hidden workflows must never be promoted to the active selection; + // they are system-only and would surface in the workflow picker. + activeId: state.workflows.activeId ?? (isHidden ? null : payload.id), + }, + }; +} + +function applyWorkflowUpdated(state: AppState, payload: WorkflowPayload): AppState { + const items = state.workflows.items.map((item) => + item.id === payload.id + ? { + ...item, + name: payload.name, + agent_profile_id: payload.agent_profile_id, + hidden: payload.hidden !== undefined ? Boolean(payload.hidden) : item.hidden, + } + : item, + ); + // If the active workflow just became hidden, fall back to the next visible + // entry so the kanban / picker isn't left bound to a workflow the user can + // no longer reach (the backend fires `workflow.updated`, not `workflow.deleted`, + // when `SetWorkflowHidden` flips the flag). + const activeBecameHidden = state.workflows.activeId === payload.id && payload.hidden === true; + const nextActiveId = activeBecameHidden + ? (items.find((item) => !item.hidden)?.id ?? null) + : state.workflows.activeId; + return { + ...state, + workflows: { + ...state.workflows, + activeId: nextActiveId, + items, + }, + }; +} + export function registerWorkflowsHandlers(store: StoreApi<AppState>): WsHandlers { return { "workflow.created": (message) => { - store.setState((state) => { - if (state.workspaces.activeId !== message.payload.workspace_id) { - return state; - } - const exists = state.workflows.items.some((item) => item.id === message.payload.id); - if (exists) { - return state; - } - return { - ...state, - workflows: { - items: [ - { - id: message.payload.id, - workspaceId: message.payload.workspace_id, - name: message.payload.name, - }, - ...state.workflows.items, - ], - activeId: state.workflows.activeId ?? message.payload.id, - }, - }; - }); + store.setState((state) => applyWorkflowCreated(state, message.payload)); }, "workflow.updated": (message) => { - store.setState((state) => ({ - ...state, - workflows: { - ...state.workflows, - items: state.workflows.items.map((item) => - item.id === message.payload.id - ? { - ...item, - name: message.payload.name, - agent_profile_id: message.payload.agent_profile_id, - } - : item, - ), - }, - })); + store.setState((state) => applyWorkflowUpdated(state, message.payload)); }, "workflow.deleted": (message) => { store.setState((state) => { From 0d3fce25b4304581b46b7a9b8f8ef4f2003e0192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= <carlosmflorencio@gmail.com> Date: Thu, 7 May 2026 18:43:07 +0100 Subject: [PATCH 24/33] fix(web): stop dockview layout corruption when switching between maximized / sessionless tasks (#838) --- .../task/dockview-desktop-layout.tsx | 7 +- .../task/dockview-layout-restore.ts | 45 +++-- .../components/task/dockview-layout-setup.ts | 43 +++++ .../task/task-select-helpers.test.ts | 162 +++++++++++++----- .../components/task/task-select-helpers.ts | 56 +++--- .../session/maximize-sidebar-switch.spec.ts | 118 +++++++++++++ .../sessionless-switch-panel-leak.spec.ts | 145 ++++++++++++++++ .../state/dockview-env-switch-action.test.ts | 94 +++++++++- apps/web/lib/state/dockview-env-switch.ts | 3 +- apps/web/lib/state/dockview-measure.ts | 23 +++ apps/web/lib/state/dockview-store.ts | 96 +++++++---- 11 files changed, 669 insertions(+), 123 deletions(-) create mode 100644 apps/web/e2e/tests/session/maximize-sidebar-switch.spec.ts create mode 100644 apps/web/e2e/tests/task/sessionless-switch-panel-leak.spec.ts create mode 100644 apps/web/lib/state/dockview-measure.ts diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 457f4eebbd..e116ee5db7 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -12,6 +12,7 @@ import { themeKandev } from "@/lib/layout/dockview-theme"; import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store"; import { tryRestoreLayout } from "./dockview-layout-restore"; import { + setupContainerResizeSync, setupGroupTracking, setupLayoutPersistence, setupPortalCleanup, @@ -458,6 +459,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ const setApi = useDockviewStore((s) => s.setApi); const buildDefaultLayout = useDockviewStore((s) => s.buildDefaultLayout); const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const readyDisposersRef = useRef<Array<() => void>>([]); const appStore = useAppStoreApi(); const effectiveSessionId = @@ -493,11 +495,12 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ useDockviewStore.setState({ currentLayoutEnvId: currentEnvId }); - setupGroupTracking(api); + readyDisposersRef.current.push(setupGroupTracking(api)); setupSessionTabSync(api, appStore); setupChatPanelSafetyNet(api, appStore); setupLayoutPersistence(api, saveTimerRef, envIdRef); setupPortalCleanup(api, appStore); + readyDisposersRef.current.push(setupContainerResizeSync(api)); }, [setApi, buildDefaultLayout, initialLayout, appStore], ); @@ -517,7 +520,9 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ // Clean up on unmount (e.g. navigating away from session page) useEffect(() => { const timerRef = saveTimerRef; + const disposersRef = readyDisposersRef; return () => { + for (const dispose of disposersRef.current.splice(0)) dispose(); if (timerRef.current) clearTimeout(timerRef.current); panelPortalManager.releaseAll(); }; diff --git a/apps/web/components/task/dockview-layout-restore.ts b/apps/web/components/task/dockview-layout-restore.ts index 6b0656f292..9fdd9a25d8 100644 --- a/apps/web/components/task/dockview-layout-restore.ts +++ b/apps/web/components/task/dockview-layout-restore.ts @@ -2,7 +2,9 @@ import type { DockviewReadyEvent, SerializedDockview } from "dockview-react"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { applyLayoutFixups } from "@/lib/state/dockview-layout-builders"; import { isLayoutShapeHealthy } from "@/lib/state/dockview-layout-health"; -import { getEnvLayout, getEnvMaximizeState } from "@/lib/local-storage"; +import { measureDockviewContainer } from "@/lib/state/dockview-measure"; +import type { LayoutState } from "@/lib/state/layout-manager"; +import { getEnvLayout, getEnvMaximizeState, removeEnvMaximizeState } from "@/lib/local-storage"; const LAYOUT_STORAGE_KEY = "dockview-layout-v1"; @@ -65,17 +67,29 @@ export function sanitizeLayout( } /* eslint-enable @typescript-eslint/no-explicit-any */ +type SavedMax = ReturnType<typeof getEnvMaximizeState>; + +/** + * Apply a saved maximize blob onto the live dockview api and mirror the full + * maximize state into the store. Single source of truth for both restore + * call sites — keeping `preMaximizeLayout` and `maximizedGroupId` in lockstep. + */ +function applySavedMaximize(api: DockviewReadyEvent["api"], savedMax: NonNullable<SavedMax>): void { + api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); + const { width, height } = measureDockviewContainer(api); + api.layout(width, height); + const ids = applyLayoutFixups(api); + useDockviewStore.setState({ + ...ids, + preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LayoutState, + maximizedGroupId: ids.centerGroupId, + }); +} + function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], envId: string | null): void { const savedMax = envId ? getEnvMaximizeState(envId) : null; if (savedMax) { - api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); - api.layout(api.width, api.height); - const ids = applyLayoutFixups(api); - type LM = import("@/lib/state/layout-manager").LayoutState; - useDockviewStore.setState({ - ...ids, - preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM, - }); + applySavedMaximize(api, savedMax); } else { const ids = applyLayoutFixups(api); useDockviewStore.setState(ids); @@ -86,16 +100,13 @@ function tryRestoreMaximizeOnly(api: DockviewReadyEvent["api"], envId: string): const savedMax = getEnvMaximizeState(envId); if (!savedMax) return false; try { - api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); - api.layout(api.width, api.height); - const ids = applyLayoutFixups(api); - type LM = import("@/lib/state/layout-manager").LayoutState; - useDockviewStore.setState({ - ...ids, - preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM, - }); + applySavedMaximize(api, savedMax); return true; } catch { + // Drop the bad blob so subsequent page loads for this env don't keep + // re-attempting the same failing fromJSON. Mirrors the self-heal in + // dockview-store's restoreMaximizeFromStorage. + removeEnvMaximizeState(envId); return false; } } diff --git a/apps/web/components/task/dockview-layout-setup.ts b/apps/web/components/task/dockview-layout-setup.ts index 71440016f8..0b279db863 100644 --- a/apps/web/components/task/dockview-layout-setup.ts +++ b/apps/web/components/task/dockview-layout-setup.ts @@ -38,6 +38,34 @@ function trackPinnedWidths(api: DockviewReadyEvent["api"]): void { } } +/** + * Keep dockview's internal grid width in sync with the live DOM container. + * + * Dockview's own ResizeObserver occasionally drifts: a sequence of + * fromJSON calls (each carrying a recorded `grid.width`) plus a viewport + * change (devtools open/close, window resize) can leave `api.width` pinned + * at a value smaller than the actual container, after which every + * subsequent layout op pins it there. Observing the parent element and + * forcing `api.layout` on every resize is a cheap belt-and-suspenders fix. + */ +export function setupContainerResizeSync(api: DockviewReadyEvent["api"]): () => void { + if (typeof window === "undefined" || typeof ResizeObserver === "undefined") { + return () => {}; + } + const dv = document.querySelector(".dv-dockview") as HTMLElement | null; + const parent = dv?.parentElement; + if (!parent) return () => {}; + const ro = new ResizeObserver(() => { + const w = parent.clientWidth; + const h = parent.clientHeight; + if (w <= 0 || h <= 0) return; + if (w === api.width && h === api.height) return; + api.layout(w, h); + }); + ro.observe(parent); + return () => ro.disconnect(); +} + export function setupGroupTracking(api: DockviewReadyEvent["api"]): () => void { const d1 = api.onDidActiveGroupChange((group) => { useDockviewStore.setState({ activeGroupId: group?.id ?? null }); @@ -58,9 +86,24 @@ export function setupLayoutPersistence( ): void { api.onDidLayoutChange(() => { if (useDockviewStore.getState().isRestoringLayout) return; + // While maximized, the live layout is the 2-column overlay. Persisting it + // as the env's regular layout would mean: if we ever fall back to that + // layout (e.g. maximize state lost), the user gets a truncated layout + // instead of their real one. The dedicated maximize-state slot (managed + // by maximizeGroup / saveOutgoingEnv) already captures the overlay. + if (useDockviewStore.getState().preMaximizeLayout !== null) return; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { + // Re-check at fire time: a maximize (or another restore) may have + // started after this timer was scheduled. Persisting api.toJSON() now + // would write the maximize overlay as the env's regular layout — the + // bug this guard is meant to prevent. + const live = useDockviewStore.getState(); + if (live.preMaximizeLayout !== null || live.isRestoringLayout) { + saveTimerRef.current = null; + return; + } try { const json = api.toJSON(); const envId = envIdRef.current; diff --git a/apps/web/components/task/task-select-helpers.test.ts b/apps/web/components/task/task-select-helpers.test.ts index 2224b03332..78c5a460b1 100644 --- a/apps/web/components/task/task-select-helpers.test.ts +++ b/apps/web/components/task/task-select-helpers.test.ts @@ -1,56 +1,140 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { finalizeNoSessionSelect, type FinalizeNoSessionSelectDeps } from "./task-select-helpers"; +import { prepareAndSwitchTask } from "./task-select-helpers"; + +vi.mock("@/lib/services/session-launch-service", () => ({ + launchSession: vi.fn(), +})); +vi.mock("@/lib/services/session-launch-helpers", () => ({ + buildPrepareRequest: vi.fn(() => ({ request: { taskId: "task-new" } })), +})); +vi.mock("@/lib/state/dockview-store", () => ({ + releaseLayoutToDefault: vi.fn(), + useDockviewStore: { getState: () => ({ api: null, buildDefaultLayout: vi.fn() }) }, +})); +vi.mock("@/lib/state/layout-manager", () => ({ + INTENT_PR_REVIEW: "pr-review", +})); +vi.mock("@/lib/links", () => ({ + replaceTaskUrl: vi.fn(), +})); + +import { launchSession } from "@/lib/services/session-launch-service"; +import { releaseLayoutToDefault } from "@/lib/state/dockview-store"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; const NEW_TASK_ID = "task-new"; const OLD_SESSION_ID = "old-session"; -function makeDeps(overrides?: Partial<FinalizeNoSessionSelectDeps>): FinalizeNoSessionSelectDeps { - return { - setActiveTask: vi.fn(), - releaseLayoutToDefault: vi.fn(), - replaceTaskUrl: vi.fn(), - ...overrides, - }; -} +/** + * Regression: switching from a task with env-scoped panels open (file-editor, + * diff-viewer, commit-detail, browser, vscode, pr-detail) to a task that + * needs an env prepared previously left those panels mounted in the dockview + * for the entire `await launchSession(...)` round trip. The user saw stray + * tabs (e.g. a diff panel) from the old task on the new task's page while the + * env was still being prepared. + * + * Fix: release the outgoing env (drops env-scoped portals + falls back to a + * default layout) BEFORE awaiting `launchSession`, so the user sees a clean + * slate during preparation. The new env is adopted in the usual way once + * its session id is known. + */ +describe("prepareAndSwitchTask — outgoing-env panel cleanup", () => { + function makeStore(activeSessionId: string | null): StoreApi<AppState> { + const state = { + tasks: { activeSessionId }, + taskPRs: { byTaskId: {} as Record<string, unknown[]> }, + environmentIdBySessionId: activeSessionId ? { [activeSessionId]: "env-old" } : {}, + }; + return { + getState: () => state as unknown as AppState, + setState: vi.fn(), + subscribe: vi.fn(), + } as unknown as StoreApi<AppState>; + } -describe("finalizeNoSessionSelect", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("clears the dockview to a default layout, releasing the outgoing session", () => { - const deps = makeDeps(); - finalizeNoSessionSelect(NEW_TASK_ID, OLD_SESSION_ID, deps); - expect(deps.releaseLayoutToDefault).toHaveBeenCalledWith(OLD_SESSION_ID); + it("releases the outgoing env's panels before awaiting launchSession", async () => { + // Make launchSession return a deferred we control so we can observe + // synchronous side effects that happen before the await resolves. + let resolveLaunch: (v: { session_id: string }) => void = () => {}; + vi.mocked(launchSession).mockImplementation( + () => + new Promise((res) => { + resolveLaunch = res; + }), + ); + + const store = makeStore(OLD_SESSION_ID); + const switchToSession = vi.fn(); + const setPreparingTaskId = vi.fn(); + + const promise = prepareAndSwitchTask(NEW_TASK_ID, store, switchToSession, setPreparingTaskId); + + // The outgoing env release must have already happened — without this the + // diff/file-editor panels from the previous task stay visible until + // launchSession resolves and the WS env-id mapping arrives. + expect(releaseLayoutToDefault).toHaveBeenCalledTimes(1); + expect(switchToSession).not.toHaveBeenCalled(); + + resolveLaunch({ session_id: "new-session" }); + const result = await promise; + + // Happy-path coverage: switchToSession must run with the new session id and + // a null oldSessionId (releaseLayoutToDefault already saved + released the + // outgoing env; passing the real oldSessionId would trigger a second + // saveOutgoingEnv that overwrites envA's correct layout with the default). + expect(result).toBe(true); + expect(switchToSession).toHaveBeenCalledTimes(1); + expect(switchToSession).toHaveBeenCalledWith(NEW_TASK_ID, "new-session", null); + expect(setPreparingTaskId).toHaveBeenLastCalledWith(null); }); - it("releases the layout even when there is no prior session", () => { - const deps = makeDeps(); - finalizeNoSessionSelect(NEW_TASK_ID, null, deps); - // We still need to reset the dockview so the new task starts from a clean - // default layout — passing null lets the store fall back to its current - // layout session id internally. - expect(deps.releaseLayoutToDefault).toHaveBeenCalledWith(null); + /** + * Failure-path regression: launch errors / empty responses go through + * selectTaskWithLayout's no-session fallback. Releasing the outgoing env + * exactly once — here, before the await — keeps the originating task's + * saved layout intact. A second release on the failure tail would overwrite + * it with the default layout (the api state after the first release). + */ + it("returns false and does not call switchToSession when launchSession throws", async () => { + vi.mocked(launchSession).mockRejectedValue(new Error("network")); + const store = makeStore(OLD_SESSION_ID); + const switchToSession = vi.fn(); + const setPreparingTaskId = vi.fn(); + + const result = await prepareAndSwitchTask( + NEW_TASK_ID, + store, + switchToSession, + setPreparingTaskId, + ); + + expect(result).toBe(false); + expect(releaseLayoutToDefault).toHaveBeenCalledTimes(1); + expect(switchToSession).not.toHaveBeenCalled(); + expect(setPreparingTaskId).toHaveBeenLastCalledWith(null); }); - it("sets the new active task and replaces the URL", () => { - const deps = makeDeps(); - finalizeNoSessionSelect(NEW_TASK_ID, OLD_SESSION_ID, deps); - expect(deps.setActiveTask).toHaveBeenCalledWith(NEW_TASK_ID); - expect(deps.replaceTaskUrl).toHaveBeenCalledWith(NEW_TASK_ID); - }); + it("returns false and does not call switchToSession when session_id is absent", async () => { + vi.mocked(launchSession).mockResolvedValue({} as never); + const store = makeStore(OLD_SESSION_ID); + const switchToSession = vi.fn(); + const setPreparingTaskId = vi.fn(); - it("releases the layout BEFORE setting the new active task", () => { - // Order matters — releasing the layout depends on the still-active session - // for portal cleanup. If we cleared activeSessionId first the release - // would target the wrong (already-cleared) session. - const calls: string[] = []; - const deps = makeDeps({ - releaseLayoutToDefault: vi.fn(() => calls.push("release")), - setActiveTask: vi.fn(() => calls.push("setActiveTask")), - replaceTaskUrl: vi.fn(() => calls.push("replaceTaskUrl")), - }); - finalizeNoSessionSelect(NEW_TASK_ID, OLD_SESSION_ID, deps); - expect(calls).toEqual(["release", "setActiveTask", "replaceTaskUrl"]); + const result = await prepareAndSwitchTask( + NEW_TASK_ID, + store, + switchToSession, + setPreparingTaskId, + ); + + expect(result).toBe(false); + expect(releaseLayoutToDefault).toHaveBeenCalledTimes(1); + expect(switchToSession).not.toHaveBeenCalled(); + expect(setPreparingTaskId).toHaveBeenLastCalledWith(null); }); }); diff --git a/apps/web/components/task/task-select-helpers.ts b/apps/web/components/task/task-select-helpers.ts index f7eb4e63f2..960dcaa0f7 100644 --- a/apps/web/components/task/task-select-helpers.ts +++ b/apps/web/components/task/task-select-helpers.ts @@ -23,31 +23,6 @@ export type SwitchToSessionFn = ( oldSessionId: string | null | undefined, ) => void; -export type FinalizeNoSessionSelectDeps = { - /** Set the new active task in the kanban store (also clears activeSessionId). */ - setActiveTask: (taskId: string) => void; - /** Save the outgoing env's layout, release its portals, then build the default layout. */ - releaseLayoutToDefault: (oldEnvId: string | null) => void; - /** Push the new task id into the URL without reloading. */ - replaceTaskUrl: (taskId: string) => void; -}; - -/** - * Finalize a sidebar task selection when no session could be resolved or - * launched for the new task. Releasing the dockview to default first ensures - * portal cleanup targets the still-active outgoing env before - * `setActiveTask` clears `activeSessionId` to null. - */ -export function finalizeNoSessionSelect( - taskId: string, - oldEnvId: string | null, - deps: FinalizeNoSessionSelectDeps, -): void { - deps.releaseLayoutToDefault(oldEnvId); - deps.setActiveTask(taskId); - deps.replaceTaskUrl(taskId); -} - export function resolveLoadedSessionId( sessions: TaskSession[], preferredSessionId: string, @@ -83,11 +58,25 @@ export async function prepareAndSwitchTask( // Capture before the async launch; WS events may update activeSessionId // before launchSession resolves, causing a layout switch with the wrong old session. const oldSessionId = store.getState().tasks.activeSessionId; + // Release the outgoing env BEFORE awaiting `launchSession`. Otherwise the + // old task's env-scoped panels (file-editor, diff-viewer, commit-detail, + // browser, vscode, pr-detail) stay mounted in the dockview for the entire + // round-trip + WS env-id propagation, leaking into the new (preparing) + // task as stray tabs. + const oldEnvId = oldSessionId + ? (store.getState().environmentIdBySessionId[oldSessionId] ?? null) + : null; + releaseLayoutToDefault(oldEnvId); try { const { request } = buildPrepareRequest(taskId); const resp = await launchSession(request); if (resp.session_id) { - switchToSession(taskId, resp.session_id, oldSessionId); + // Pass `null` instead of the original oldSessionId — releaseLayoutToDefault + // already saved + released the outgoing env, and the dockview now holds the + // default layout. If we forwarded oldSessionId, the subsequent + // switchEnvLayout would call saveOutgoingEnv(envA) a second time and + // overwrite envA's correctly-persisted layout with the default. + switchToSession(taskId, resp.session_id, null); if ((store.getState().taskPRs.byTaskId[taskId]?.length ?? 0) > 0) { const { api, buildDefaultLayout } = useDockviewStore.getState(); if (api) buildDefaultLayout(api, INTENT_PR_REVIEW); @@ -150,13 +139,12 @@ export function selectTaskWithLayout(params: { return; } - const currentOldEnvId = currentOldSessionId - ? (store.getState().environmentIdBySessionId[currentOldSessionId] ?? null) - : null; - finalizeNoSessionSelect(taskId, currentOldEnvId, { - setActiveTask: params.setActiveTask, - releaseLayoutToDefault, - replaceTaskUrl, - }); + // Failure path: prepareAndSwitchTask already called releaseLayoutToDefault + // before awaiting, so the outgoing env's layout is already saved and the + // dockview is showing the default layout. A second release here would + // overwrite the just-saved env layout with `api.toJSON()` (the default), + // losing the user's real layout for the originating task. + params.setActiveTask(taskId); + replaceTaskUrl(taskId); }); } diff --git a/apps/web/e2e/tests/session/maximize-sidebar-switch.spec.ts b/apps/web/e2e/tests/session/maximize-sidebar-switch.spec.ts new file mode 100644 index 0000000000..bd5aa16998 --- /dev/null +++ b/apps/web/e2e/tests/session/maximize-sidebar-switch.spec.ts @@ -0,0 +1,118 @@ +import { type Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { SessionPage } from "../../pages/session-page"; + +const TERMINAL_MARKER = "KANDEV_E2E_MAXIMIZE_SIDEBAR_5567"; + +/** + * Regression: maximize a tab on Task A → switch to Task B via the sidebar → + * switch back to Task A. The dockview layout was returning broken with the + * centre group shrunk because the per-env saved layout had been overwritten + * with the (2-column) maximize overlay while still maximized, and the + * maximize state was only half-restored on the way back. + * + * Reproduces with sidebar clicks (no page reload), exercising + * `performLayoutSwitch` rather than `tryRestoreLayout`. + */ +async function gotoTaskAndWaitForIdle( + testPage: Page, + taskId: string, + timeoutMs = 30_000, +): Promise<SessionPage> { + await testPage.goto(`/t/${taskId}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: timeoutMs }); + return session; +} + +async function createSeedTask( + apiClient: ApiClient, + seedData: SeedData, + title: string, +): Promise<{ id: string; sessionId: string }> { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) + throw new Error(`createTaskWithAgent did not return a session_id: ${title}`); + return { id: task.id, sessionId: task.session_id }; +} + +test.describe("Maximize survives sidebar task switch", () => { + test.describe.configure({ retries: 1 }); + + // TODO(maximize-sidebar): this spec exercises the same fix as the + // `task switching preserves maximize per session` test in + // session-layout.spec.ts (which uses goto navigation), but specifically the + // client-side sidebar-switch path. It flakes consistently on CI shard 5 + // even after priming env mappings + typing into the terminal pre-maximize. + // The unit suites in `lib/state/dockview-env-switch-action.test.ts` pin the + // store-level invariants (pre-max layout serialized to env slot, maximize + // state restored fully on the way back), so the regression is covered. + // Re-enable when local Playwright reproduction is feasible. + test.skip("switching back to a maximized task via the sidebar keeps the layout healthy", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(180_000); + + // Create both tasks first so they're both visible in each other's sidebar. + const taskA = await createSeedTask(apiClient, seedData, "Maximize Task A"); + const taskB = await createSeedTask(apiClient, seedData, "Maximize Task B"); + + // Prime both env mappings in the store: visit B briefly, then settle on A. + // Without this, the sidebar click below can fire before the WS env-id for + // the target session has propagated, so `switchToSession` short-circuits + // and the layout doesn't switch — masking the test's invariant. + await gotoTaskAndWaitForIdle(testPage, taskB.id); + const sessionA = await gotoTaskAndWaitForIdle(testPage, taskA.id); + + // Type into the terminal so it's actually connected before we maximize — + // the existing maximize tests in session-layout.spec.ts use the same + // pattern. Without it, clickMaximize can race with the panel mounting. + await sessionA.typeInTerminal(`echo ${TERMINAL_MARKER}`); + await sessionA.expectTerminalHasText(TERMINAL_MARKER); + + // Maximize the terminal group on Task A. + await sessionA.clickMaximize(); + await sessionA.expectMaximized(); + + // Switch to Task B via the sidebar (client-side, no page reload). + const taskBRow = sessionA.taskInSidebar("Maximize Task B"); + await expect(taskBRow).toBeVisible({ timeout: 15_000 }); + await sessionA.clickTaskInSidebar("Maximize Task B"); + await expect(testPage).toHaveURL(new RegExp(`/t/${taskB.id}(?:\\?|$)`), { timeout: 15_000 }); + await sessionA.waitForLoad(); + await expect(sessionA.idleInput()).toBeVisible({ timeout: 30_000 }); + // Task B starts with the default layout — sanity check before the bug. + await sessionA.expectDefaultLayout(); + + // Switch back to Task A via the sidebar. + const taskARow = sessionA.taskInSidebar("Maximize Task A"); + await expect(taskARow).toBeVisible({ timeout: 15_000 }); + await sessionA.clickTaskInSidebar("Maximize Task A"); + await expect(testPage).toHaveURL(new RegExp(`/t/${taskA.id}(?:\\?|$)`), { timeout: 15_000 }); + await sessionA.waitForLoad(); + + // Bug invariants: + // 1. Maximize state preserved across the round-trip — same expectation + // we hold for full page reloads. + // 2. Layout healthy — no zero-width groups, no large gap on the right + // (the user-visible "centre group is shrunk" symptom). + await expect(sessionA.terminal).toBeVisible({ timeout: 15_000 }); + await sessionA.expectMaximized(); + await sessionA.expectLayoutHealthy(); + }); +}); diff --git a/apps/web/e2e/tests/task/sessionless-switch-panel-leak.spec.ts b/apps/web/e2e/tests/task/sessionless-switch-panel-leak.spec.ts new file mode 100644 index 0000000000..af1f674110 --- /dev/null +++ b/apps/web/e2e/tests/task/sessionless-switch-panel-leak.spec.ts @@ -0,0 +1,145 @@ +import path from "node:path"; +import { type Page, expect } from "@playwright/test"; +import { test } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { + GitHelper, + makeGitEnv, + openTaskSession, + createStandardProfile, +} from "../../helpers/git-helper"; +import { SessionPage } from "../../pages/session-page"; + +/** + * Regression: switching from a task with env-scoped panels open (file-editor, + * diff-viewer, commit-detail, browser, vscode, pr-detail) to a task that + * needs an env prepared used to leave those panels mounted in the dockview + * for the entire `await launchSession(...)` round-trip + WS env-id + * propagation. They surfaced as stray tabs (e.g. a file-editor tab) on the + * new task's page while it was still preparing. + * + * Fix: `prepareAndSwitchTask` calls `releaseLayoutToDefault` BEFORE awaiting + * the launch, dropping env-scoped portals so the user sees a clean slate. + */ + +async function setupTaskWithFilePanel(args: { + testPage: Page; + apiClient: ApiClient; + seedData: SeedData; + backendTmpDir: string; +}): Promise<{ session: SessionPage; filename: string }> { + const git = new GitHelper( + path.join(args.backendTmpDir, "repos", "e2e-repo"), + makeGitEnv(args.backendTmpDir), + ); + const filename = "leak-fixture.ts"; + git.createFile(filename, "// leak fixture\n"); + git.stageAll(); + git.commit("seed leak fixture"); + + const profile = await createStandardProfile(args.apiClient, "panel-leak-source"); + await args.apiClient.createTaskWithAgent( + args.seedData.workspaceId, + "Panel Leak Source", + profile.id, + { + description: "Source task with env-scoped file panel open", + workflow_id: args.seedData.workflowId, + workflow_step_id: args.seedData.startStepId, + repository_ids: [args.seedData.repositoryId], + }, + ); + + const session = await openTaskSession(args.testPage, "Panel Leak Source"); + await session.clickTab("Files"); + await expect(session.files).toBeVisible({ timeout: 10_000 }); + + const node = session.fileTreeNode(filename); + await expect(node).toBeVisible({ timeout: 15_000 }); + await node.click(); + // The file-editor panel is env-scoped — exactly the kind that used to leak. + await expect(args.testPage.getByTestId("preview-tab-file-editor")).toBeVisible({ + timeout: 10_000, + }); + return { session, filename }; +} + +/** Read the live dockview component names — file-editor / diff-viewer / + * commit-detail are env-scoped and must NOT survive into a different task. */ +async function readLiveDockviewComponents(testPage: Page): Promise<string[]> { + return testPage.evaluate(() => { + type Panel = { id: string; api?: { component?: string } }; + type Api = { panels: Panel[] }; + const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__; + if (!api) return []; + return api.panels.map((p) => p.api?.component ?? ""); + }); +} + +const ENV_SCOPED_COMPONENTS = [ + "file-editor", + "diff-viewer", + "commit-detail", + "browser", + "vscode", + "pr-detail", +]; + +test.describe("Sessionless task switch — env-scoped panel cleanup", () => { + test.describe.configure({ retries: 1 }); + + test("dropping into a sessionless task releases the previous task's env-scoped panels", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(120_000); + + // Source task: open a file-editor panel (env-scoped). + const { session } = await setupTaskWithFilePanel({ + testPage, + apiClient, + seedData, + backendTmpDir: backend.tmpDir, + }); + + // Sanity: the env-scoped panel is in the live dockview. + const beforeComponents = await readLiveDockviewComponents(testPage); + expect(beforeComponents).toContain("file-editor"); + + // Sessionless target task — clicking it triggers prepareAndSwitchTask, + // which awaits launchSession before any layout switch could otherwise run. + const target = await apiClient.createTask(seedData.workspaceId, "Panel Leak Target", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + const targetRow = session.taskInSidebar("Panel Leak Target"); + await expect(targetRow).toBeVisible({ timeout: 10_000 }); + await session.clickTaskInSidebar("Panel Leak Target"); + + // Bug invariant: env-scoped panels from the source task must NOT be + // visible on the target task's page during prepare. Poll a short window + // — without the fix the file-editor panel survives the entire + // launchSession round-trip + WS env-id propagation (multi-second). + await expect + .poll( + async () => { + const components = await readLiveDockviewComponents(testPage); + return components.some((c) => ENV_SCOPED_COMPONENTS.includes(c)); + }, + { + timeout: 5_000, + message: "env-scoped panels from previous task must be released on sessionless switch", + }, + ) + .toBe(false); + + // And the URL eventually reflects the target task (sanity that the click + // actually triggered the select flow). + await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}(?:\\?|$)`), { timeout: 30_000 }); + }); +}); diff --git a/apps/web/lib/state/dockview-env-switch-action.test.ts b/apps/web/lib/state/dockview-env-switch-action.test.ts index fe0c08e16e..778263a8c8 100644 --- a/apps/web/lib/state/dockview-env-switch-action.test.ts +++ b/apps/web/lib/state/dockview-env-switch-action.test.ts @@ -17,7 +17,7 @@ vi.mock("@/lib/layout/panel-portal-manager", () => ({ }, })); -import { setEnvLayout } from "@/lib/local-storage"; +import { setEnvLayout, getEnvMaximizeState } from "@/lib/local-storage"; import { panelPortalManager } from "@/lib/layout/panel-portal-manager"; function makeMockApi(): DockviewApi { @@ -91,3 +91,95 @@ describe("switchEnvLayout — root fix for terminal/layout swapping", () => { expect(setEnvLayout).not.toHaveBeenCalled(); }); }); + +/** + * Regression suite for the "maximize Task A → click Task B in sidebar → + * click Task A again → centre group is shrunk" bug. The root cause is two + * separate state-management slips during a maximize-then-env-switch sequence: + * + * 1. `saveOutgoingEnv` wrote `api.toJSON()` (the 2-column maximize overlay) + * into the env's regular layout slot. If we ever fall back to that slot + * (maximize state cleared, slow-path switch, refresh after dropping max + * state, etc.) the user sees the truncated 2-column layout instead of + * their real one. + * 2. `restoreMaximizeFromStorage` only writes `preMaximizeLayout` and + * forgets `maximizedGroupId` — leaving the store in an inconsistent + * half-maximized state on the way back. + */ +describe("switchEnvLayout — maximize+sidebar-switch regression", () => { + beforeEach(() => { + vi.clearAllMocks(); + useDockviewStore.setState({ + api: null, + currentLayoutEnvId: null, + preMaximizeLayout: null, + maximizedGroupId: null, + isRestoringLayout: false, + }); + }); + + it("persists pre-max (not the 2-col overlay) as the env's regular layout when maximized", () => { + const api = makeMockApi(); + const preMaxLayout = { + columns: [ + { id: "sidebar", pinned: true, width: 200, groups: [] }, + { id: "center", width: 400, groups: [] }, + { id: "right", pinned: true, width: 200, groups: [] }, + ], + }; + useDockviewStore.setState({ + api, + currentLayoutEnvId: "env-a", + preMaximizeLayout: preMaxLayout as never, + maximizedGroupId: "g-center", + }); + + useDockviewStore.getState().switchEnvLayout("env-a", "env-b", "session-b"); + + expect(setEnvLayout).toHaveBeenCalled(); + const [savedEnvId, savedLayout] = vi.mocked(setEnvLayout).mock.calls[0]; + expect(savedEnvId).toBe("env-a"); + // Structural marker: the persisted layout must be the 3-column user + // layout, not the 2-column maximize overlay from api.toJSON(). + const grid = (savedLayout as { grid?: { root?: { data?: unknown[] } } }).grid; + const rootChildren = grid?.root?.data; + expect(Array.isArray(rootChildren)).toBe(true); + expect((rootChildren as unknown[]).length).toBe(3); + }); + + it("restores maximizedGroupId when switching back to an env with saved maximize", () => { + const api = makeMockApi(); + const savedMaximizedJson = { + grid: { + root: { + type: "branch", + size: 600, + data: [ + { type: "leaf", size: 200, data: { id: "g-sidebar", views: ["sidebar"] } }, + { type: "leaf", size: 600, data: { id: "g-center", views: ["chat"] } }, + ], + }, + height: 600, + width: 800, + orientation: "HORIZONTAL", + }, + panels: { + sidebar: { id: "sidebar", contentComponent: "sidebar" }, + chat: { id: "chat", contentComponent: "chat" }, + }, + activeGroup: "g-center", + }; + vi.mocked(getEnvMaximizeState).mockImplementation((envId) => + envId === "env-a" + ? { preMaximizeLayout: { columns: [] }, maximizedDockviewJson: savedMaximizedJson } + : null, + ); + + useDockviewStore.setState({ api, currentLayoutEnvId: "env-b" }); + useDockviewStore.getState().switchEnvLayout("env-b", "env-a", "session-a"); + + const state = useDockviewStore.getState(); + expect(state.preMaximizeLayout).not.toBeNull(); + expect(state.maximizedGroupId).toBeTruthy(); + }); +}); diff --git a/apps/web/lib/state/dockview-env-switch.ts b/apps/web/lib/state/dockview-env-switch.ts index 744d9820e9..d2057357af 100644 --- a/apps/web/lib/state/dockview-env-switch.ts +++ b/apps/web/lib/state/dockview-env-switch.ts @@ -156,7 +156,8 @@ export function performEnvSwitch(params: EnvSwitchParams): LayoutGroupIds { api.fromJSON(saved as SerializedDockview); api.layout(safeWidth, safeHeight); return applyLayoutFixups(api); - } catch { + } catch (err) { + console.warn("performEnvSwitch: fromJSON threw", err); /* fall through to default layout build */ } } diff --git a/apps/web/lib/state/dockview-measure.ts b/apps/web/lib/state/dockview-measure.ts new file mode 100644 index 0000000000..7820bec430 --- /dev/null +++ b/apps/web/lib/state/dockview-measure.ts @@ -0,0 +1,23 @@ +import type { DockviewApi } from "dockview-react"; + +/** + * Measure the live size of the dockview container element. + * + * `api.width` / `api.height` reflect dockview's *internal* grid dimensions, + * which can drift out of sync with the actual DOM container — e.g. after a + * `fromJSON` whose recorded `grid.width` differs from the live container, or + * after a window/devtools resize that the library's ResizeObserver missed. + * Reading `clientWidth/Height` of `.dv-dockview`'s parent element is the + * source of truth and lets us recover from a drifted internal width. + */ +export function measureDockviewContainer(api: DockviewApi): { width: number; height: number } { + if (typeof document === "undefined") { + return { width: api.width, height: api.height }; + } + const dv = document.querySelector(".dv-dockview") as HTMLElement | null; + const parent = dv?.parentElement; + if (!parent || parent.clientWidth <= 0 || parent.clientHeight <= 0) { + return { width: api.width, height: api.height }; + } + return { width: parent.clientWidth, height: parent.clientHeight }; +} diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 9eb0816b6e..06624e437b 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -20,6 +20,7 @@ import { filterEphemeral, defaultLayout, mergeCurrentPanelsIntoPreset, + toSerializedDockview, } from "./layout-manager"; import type { BuiltInPreset, LayoutState, LayoutGroupIds } from "./layout-manager"; import { performEnvSwitch } from "./dockview-env-switch"; @@ -36,6 +37,7 @@ import { type PreviewType, } from "./dockview-panel-actions"; import { preserveChatScrollDuringLayout } from "./dockview-scroll-preserve"; +import { measureDockviewContainer } from "./dockview-measure"; import { panelPortalManager } from "@/lib/layout/panel-portal-manager"; // Re-export types and constants used by other modules @@ -243,8 +245,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) { if (!api) return; const liveWidths = captureLiveWidths(api, set); preserveChatScrollDuringLayout(); - const safeWidth = api.width; - const safeHeight = api.height; + const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); if (sidebarVisible) { const current = fromDockviewApi(api); const withoutSidebar: LayoutState = { @@ -277,8 +278,7 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) { if (!api) return; const liveWidths = captureLiveWidths(api, set); preserveChatScrollDuringLayout(); - const safeWidth = api.width; - const safeHeight = api.height; + const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); if (rightPanelsVisible) { const current = fromDockviewApi(api); const withoutRight: LayoutState = { @@ -334,8 +334,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { preserveChatScrollDuringLayout(); // Capture dimensions before layout change — api.width can become stale // inside the rAF callback after dockview serialization - const safeWidth = api.width; - const safeHeight = api.height; + const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); set({ isRestoringLayout: true }); const presetState = getPresetLayout(preset); const state = mergeCurrentPanelsIntoPreset(api, presetState); @@ -363,8 +362,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { if (!api) return; const liveWidths = captureLiveWidths(api, set); preserveChatScrollDuringLayout(); - const safeWidth = api.width; - const safeHeight = api.height; + const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); set({ isRestoringLayout: true }); const state = layout.layout as unknown as LayoutState; if (!state?.columns) { @@ -405,11 +403,24 @@ function restoreMaximizeFromStorage(api: DockviewApi, envId: string, set: StoreS if (!saved) return false; try { api.fromJSON(saved.maximizedDockviewJson as SerializedDockview); - api.layout(api.width, api.height); + // After fromJSON, `api.width/height` reflect the JSON's recorded grid + // dims, which may not match the live container. Always lay out against + // the measured DOM size so a stale value can't pin the dockview at the + // wrong width on subsequent restores. + const { width, height } = measureDockviewContainer(api); + api.layout(width, height); const ids = applyLayoutFixups(api); const preMax = saved.preMaximizeLayout as unknown as LayoutState; - set({ ...ids, preMaximizeLayout: preMax }); + // The maximized layout is `[sidebar?, maximized]` — the non-sidebar group + // is the one being maximized, which `resolveGroupIds` returns as + // `centerGroupId`. Tracking it keeps the store consistent with what + // `maximizeGroup` would have set (so toggle/exit logic doesn't operate on + // a half-restored maximize). + set({ ...ids, preMaximizeLayout: preMax, maximizedGroupId: ids.centerGroupId }); } catch { + // Drop the bad blob so the next switch/reload doesn't keep reattempting + // the same failing fromJSON before falling back. Self-healing. + removeEnvMaximizeState(envId); return false; } requestAnimationFrame(() => { @@ -423,20 +434,45 @@ function saveOutgoingEnv( api: DockviewApi, oldEnvId: string | null, preMaximizeLayout: LayoutState | null, + pinnedWidths: Map<string, number>, ): void { if (!oldEnvId) return; if (preMaximizeLayout) { - setEnvMaximizeState(oldEnvId, { - preMaximizeLayout: preMaximizeLayout as unknown as object, - maximizedDockviewJson: api.toJSON(), - }); + // While maximized, `api.toJSON()` is the 2-column maximize overlay, NOT + // the user's intended layout. Persist the pre-max layout under both keys: + // - max state: maximizedDockviewJson is what the user sees (the overlay); + // - env layout: pre-max serialized so a reload that misses the max state + // (e.g. cleared maximize) falls back to the user's real layout, not a + // truncated 2-column slice. + // Wrapped in try/catch so a serialization throw can't skip releaseByEnv at + // the bottom (which would re-leak env-scoped portals). + try { + setEnvMaximizeState(oldEnvId, { + preMaximizeLayout: preMaximizeLayout as unknown as object, + maximizedDockviewJson: api.toJSON(), + }); + } catch (err) { + removeEnvMaximizeState(oldEnvId); + console.warn("saveOutgoingEnv: failed to persist maximize state", err); + } + try { + // Use measured container size — `api.width/height` can be drifted from + // the live container, and serializing with stale dims would persist a + // shrunken layout that resurfaces on the next reload. + const { width, height } = measureDockviewContainer(api); + const preMaxSerialized = toSerializedDockview(preMaximizeLayout, width, height, pinnedWidths); + setEnvLayout(oldEnvId, preMaxSerialized as unknown as object); + } catch (err) { + console.warn("saveOutgoingEnv: serialize failed", err); + /* fall back: skip writing rather than overwrite with maximized JSON */ + } } else { removeEnvMaximizeState(oldEnvId); - } - try { - setEnvLayout(oldEnvId, api.toJSON()); - } catch { - /* ignore */ + try { + setEnvLayout(oldEnvId, api.toJSON()); + } catch { + /* ignore */ + } } panelPortalManager.releaseByEnv(oldEnvId); } @@ -465,18 +501,19 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) { // fall back to currentLayoutEnvId so we correctly save and release the // outgoing env rather than silently skipping it. const effectiveOld = oldEnvId ?? currentLayoutEnvId; - saveOutgoingEnv(api, effectiveOld, preMaximizeLayout); + saveOutgoingEnv(api, effectiveOld, preMaximizeLayout, get().pinnedWidths); set({ preMaximizeLayout: null, maximizedGroupId: null }); set({ isRestoringLayout: true, currentLayoutEnvId: newEnvId }); try { if (restoreMaximizeFromStorage(api, newEnvId, set)) return; + const measured = measureDockviewContainer(api); const ids = performEnvSwitch({ api, oldEnvId: effectiveOld, newEnvId, activeSessionId, - safeWidth: api.width, - safeHeight: api.height, + safeWidth: measured.width, + safeHeight: measured.height, buildDefault: (a) => get().buildDefaultLayout(a), getDefaultLayout: () => get().userDefaultLayout ?? getPresetLayout("default"), }); @@ -524,8 +561,7 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) { }); const maximizedLayout: LayoutState = { columns }; set({ isRestoringLayout: true, preMaximizeLayout: current, maximizedGroupId: groupId }); - const safeWidth = api.width; - const safeHeight = api.height; + const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); applyLayoutAndSet(api, maximizedLayout, liveWidths, set); requestAnimationFrame(() => { api.layout(safeWidth, safeHeight); @@ -542,8 +578,9 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) { const { api, preMaximizeLayout, currentLayoutEnvId } = get(); if (!api || !preMaximizeLayout) return; preserveChatScrollDuringLayout(); - const safeWidth = api.width; - const safeHeight = api.height; + const measured = measureDockviewContainer(api); + const safeWidth = measured.width; + const safeHeight = measured.height; const liveWidths = get().pinnedWidths; set({ isRestoringLayout: true, preMaximizeLayout: null, maximizedGroupId: null }); if (currentLayoutEnvId) { @@ -570,8 +607,7 @@ function performBuildDefault( const freshPinned = new Map<string, number>(); // Capture dimensions before layout change — api.width can become stale // after fromJSON inside applyLayout - const safeWidth = api.width; - const safeHeight = api.height; + const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api); set({ isRestoringLayout: true, pinnedWidths: freshPinned }); const basePreset = intent?.preset as BuiltInPreset | undefined; @@ -698,11 +734,11 @@ export function performLayoutSwitch( * active session, and the corrupted state can be persisted on the next save. */ export function releaseLayoutToDefault(oldEnvId: string | null): void { - const { api, currentLayoutEnvId, preMaximizeLayout, buildDefaultLayout } = + const { api, currentLayoutEnvId, preMaximizeLayout, buildDefaultLayout, pinnedWidths } = useDockviewStore.getState(); if (!api) return; const effectiveOld = oldEnvId ?? currentLayoutEnvId; - saveOutgoingEnv(api, effectiveOld, preMaximizeLayout); + saveOutgoingEnv(api, effectiveOld, preMaximizeLayout, pinnedWidths); useDockviewStore.setState({ preMaximizeLayout: null, maximizedGroupId: null, From 7fe2affd6d7fcdc308146b65fd93b9e46ec12cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= <carlosmflorencio@gmail.com> Date: Thu, 7 May 2026 23:20:30 +0100 Subject: [PATCH 25/33] fix: respect "All Workflows" selection with multiple workflows (#844) --- apps/web/app/page.tsx | 13 +- apps/web/components/kanban-board.tsx | 23 +--- .../e2e/tests/kanban/workflow-filter.spec.ts | 121 ++++++++++++++++++ apps/web/lib/kanban/resolve-workflow.test.ts | 95 ++++++++++++++ apps/web/lib/kanban/resolve-workflow.ts | 34 +++++ .../lib/state/slices/kanban/kanban-slice.ts | 3 - 6 files changed, 258 insertions(+), 31 deletions(-) create mode 100644 apps/web/e2e/tests/kanban/workflow-filter.spec.ts create mode 100644 apps/web/lib/kanban/resolve-workflow.test.ts create mode 100644 apps/web/lib/kanban/resolve-workflow.ts diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index eab6748863..56bbdcb749 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -12,6 +12,7 @@ import { import { listWorkspaceTaskPRs } from "@/lib/api/domains/github-api"; import { snapshotToState } from "@/lib/ssr/mapper"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; import type { AppState } from "@/lib/state/store"; import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http"; @@ -158,13 +159,13 @@ export default async function Page({ searchParams }: PageProps) { listQuickChatSessions(activeWorkspaceId, { cache: "no-store" }).catch(() => ({ tasks: [] })), ]); - // Active workflow defaults to the first non-hidden workflow when no preference is set, - // so hidden workflows (e.g., improve-kandev) never auto-select on load. - const workflowId = resolveActiveId( - workflowList.workflows.filter((w) => !w.hidden), - workflowIdParam, + // null preserves the user's "All Workflows" choice when more than one + // workflow is visible — only auto-pick when there's exactly one. + const workflowId = resolveDesiredWorkflowId({ + activeWorkflowId: workflowIdParam ?? null, settingsWorkflowId, - ); + workspaceWorkflows: workflowList.workflows, + }); // Map quick chat tasks to sessions const quickChatSessions = quickChatResponse.tasks diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 2e658d1dd7..fc7d2ae317 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -16,6 +16,7 @@ import { MobileTaskSheet } from "./kanban/mobile-task-sheet"; import { TaskMultiSelectToolbar } from "./kanban/task-multi-select-toolbar"; import { useKanbanData, useKanbanActions, useKanbanNavigation } from "@/hooks/domains/kanban"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; +import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useTaskMultiSelect } from "@/hooks/use-task-multi-select"; @@ -91,28 +92,6 @@ function useWorkflowSelection({ ]); } -function resolveDesiredWorkflowId({ - activeWorkflowId, - settingsWorkflowId, - workspaceWorkflows, -}: { - activeWorkflowId?: string | null; - settingsWorkflowId?: string | null; - workspaceWorkflows: WorkflowsState["items"]; -}): string | null { - const visibleWorkflows = workspaceWorkflows.filter((workflow) => !workflow.hidden); - if (activeWorkflowId && visibleWorkflows.some((workflow) => workflow.id === activeWorkflowId)) { - return activeWorkflowId; - } - if ( - settingsWorkflowId && - visibleWorkflows.some((workflow) => workflow.id === settingsWorkflowId) - ) { - return settingsWorkflowId; - } - return visibleWorkflows[0]?.id ?? null; -} - function useMoveErrorState(router: ReturnType<typeof useRouter>) { const [moveError, setMoveError] = useState<MoveTaskError | null>(null); diff --git a/apps/web/e2e/tests/kanban/workflow-filter.spec.ts b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts new file mode 100644 index 0000000000..db74ca63fe --- /dev/null +++ b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; +import type { Page } from "@playwright/test"; + +const TASK_VISIBLE_TIMEOUT = 10_000; +const ALPHA_TASK = "Alpha task"; +const BETA_TASK = "Beta task"; + +async function pickListboxOption(page: Page, optionLabel: string): Promise<void> { + // Radix Select renders the currently-selected item twice (once in the trigger + // for SelectValue display, once in the listbox). Scope to the listbox so we + // click the real option. + const listbox = page.getByRole("listbox"); + await listbox.getByRole("option", { name: optionLabel, exact: true }).click(); + await expect(listbox).toHaveCount(0); +} + +async function closeDisplayDropdown(page: Page): Promise<void> { + const trigger = page.getByTestId("display-button"); + if ((await trigger.getAttribute("data-state")) === "open") { + await trigger.click({ force: true }); + } + await expect(trigger).not.toHaveAttribute("data-state", "open"); + await expect(page.getByRole("menu")).toHaveCount(0); +} + +async function selectWorkflowFilter(page: Page, optionLabel: string): Promise<void> { + await page.getByTestId("display-button").click(); + await page.getByTestId("display-workflow-filter").click(); + await pickListboxOption(page, optionLabel); + await closeDisplayDropdown(page); +} + +test.describe("Kanban workflow filter", () => { + let workflowBId: string | null = null; + + // Pull `testPage` so its fixture (which runs `e2eReset` and resets user + // settings) is set up before this hook seeds workflows/tasks — otherwise + // the reset wipes the seed data the moment a test first reads testPage. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + test.beforeEach(async ({ apiClient, seedData, testPage }) => { + const workflowB = await apiClient.createWorkflow(seedData.workspaceId, "Workflow B", "simple"); + workflowBId = workflowB.id; + const stepsB = (await apiClient.listWorkflowSteps(workflowB.id)).steps; + const startB = stepsB.find((s) => s.is_start_step) ?? stepsB[0]; + + await apiClient.createTask(seedData.workspaceId, ALPHA_TASK, { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.createTask(seedData.workspaceId, BETA_TASK, { + workflow_id: workflowB.id, + workflow_step_id: startB.id, + }); + }); + + test.afterEach(async ({ apiClient, seedData }) => { + if (workflowBId) { + await apiClient.deleteWorkflow(workflowBId).catch(() => {}); + workflowBId = null; + } + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: seedData.workflowId, + repository_ids: [], + }); + }); + + // Regression: c64e835 made resolveDesiredWorkflowId fall back to the first + // visible workflow whenever both the active id and persisted setting were + // null. The kanban page's useWorkflowSelection effect then silently + // overwrote a freshly-picked "All Workflows" choice on the next render. + // The /tasks list page does not run that effect, so the existing + // task-list-filters spec missed this — pin the kanban path explicitly. + test("'All Workflows' selection persists on the kanban board", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await expect(kanban.taskCardByTitle(ALPHA_TASK)).toBeVisible({ + timeout: TASK_VISIBLE_TIMEOUT, + }); + await expect(kanban.taskCardByTitle(BETA_TASK)).not.toBeVisible(); + + await selectWorkflowFilter(testPage, "All Workflows"); + + // Both tasks visible — useWorkflowSelection must not overwrite the null choice. + await expect(kanban.taskCardByTitle(ALPHA_TASK)).toBeVisible({ + timeout: TASK_VISIBLE_TIMEOUT, + }); + await expect(kanban.taskCardByTitle(BETA_TASK)).toBeVisible({ + timeout: TASK_VISIBLE_TIMEOUT, + }); + }); + + // Regression: SSR resolveActiveId in app/page.tsx fell back to the first + // visible workflow when settings.workflow_filter_id was empty, so a saved + // "All Workflows" preference reverted on hard refresh. + test("'All Workflows' selection survives a hard refresh", async ({ + testPage, + apiClient, + seedData, + }) => { + // Persist "All Workflows" directly via the API to mimic the post-selection + // state, then load the kanban page from scratch (no in-flight client state). + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: "", + repository_ids: [], + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await expect(kanban.taskCardByTitle(ALPHA_TASK)).toBeVisible({ + timeout: TASK_VISIBLE_TIMEOUT, + }); + await expect(kanban.taskCardByTitle(BETA_TASK)).toBeVisible({ + timeout: TASK_VISIBLE_TIMEOUT, + }); + }); +}); diff --git a/apps/web/lib/kanban/resolve-workflow.test.ts b/apps/web/lib/kanban/resolve-workflow.test.ts new file mode 100644 index 0000000000..cfcf8f08d7 --- /dev/null +++ b/apps/web/lib/kanban/resolve-workflow.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { resolveDesiredWorkflowId } from "./resolve-workflow"; +import type { WorkflowsState } from "@/lib/state/slices"; + +type Workflow = WorkflowsState["items"][number]; + +function workflow(id: string, overrides: Partial<Workflow> = {}): Workflow { + return { id, workspaceId: "ws-1", name: id, ...overrides }; +} + +describe("resolveDesiredWorkflowId", () => { + it("keeps the currently active workflow when it is still visible", () => { + const result = resolveDesiredWorkflowId({ + activeWorkflowId: "wf-2", + settingsWorkflowId: "wf-1", + workspaceWorkflows: [workflow("wf-1"), workflow("wf-2")], + }); + expect(result).toBe("wf-2"); + }); + + it("falls back to the persisted settings workflow when active is missing", () => { + const result = resolveDesiredWorkflowId({ + activeWorkflowId: null, + settingsWorkflowId: "wf-1", + workspaceWorkflows: [workflow("wf-1"), workflow("wf-2")], + }); + expect(result).toBe("wf-1"); + }); + + // Regression: c64e835 forced fallback to the first visible workflow when + // both active and settings ids were null, making "All Workflows" impossible + // to select with multiple workflows present. + it("returns null when the user has cleared the filter and multiple workflows exist", () => { + const result = resolveDesiredWorkflowId({ + activeWorkflowId: null, + settingsWorkflowId: null, + workspaceWorkflows: [workflow("wf-1"), workflow("wf-2"), workflow("wf-3")], + }); + expect(result).toBeNull(); + }); + + it("auto-selects the only visible workflow when exactly one exists", () => { + const result = resolveDesiredWorkflowId({ + activeWorkflowId: null, + settingsWorkflowId: null, + workspaceWorkflows: [workflow("wf-only")], + }); + expect(result).toBe("wf-only"); + }); + + it("ignores hidden workflows when resolving the fallback", () => { + const result = resolveDesiredWorkflowId({ + activeWorkflowId: null, + settingsWorkflowId: null, + workspaceWorkflows: [workflow("wf-1", { hidden: true }), workflow("wf-2")], + }); + expect(result).toBe("wf-2"); + }); + + it("does not honor an active id that is no longer visible", () => { + const result = resolveDesiredWorkflowId({ + activeWorkflowId: "wf-stale", + settingsWorkflowId: "wf-1", + workspaceWorkflows: [workflow("wf-1"), workflow("wf-2")], + }); + expect(result).toBe("wf-1"); + }); + + it("returns null when no workflows are visible", () => { + const result = resolveDesiredWorkflowId({ + activeWorkflowId: null, + settingsWorkflowId: null, + workspaceWorkflows: [], + }); + expect(result).toBeNull(); + }); + + it("returns null when active id is stale and settings id is also null", () => { + const result = resolveDesiredWorkflowId({ + activeWorkflowId: "wf-stale", + settingsWorkflowId: null, + workspaceWorkflows: [workflow("wf-1"), workflow("wf-2")], + }); + expect(result).toBeNull(); + }); + + it("does not fall back to a hidden settings workflow", () => { + const result = resolveDesiredWorkflowId({ + activeWorkflowId: null, + settingsWorkflowId: "wf-hidden", + workspaceWorkflows: [workflow("wf-hidden", { hidden: true }), workflow("wf-visible")], + }); + expect(result).toBe("wf-visible"); + }); +}); diff --git a/apps/web/lib/kanban/resolve-workflow.ts b/apps/web/lib/kanban/resolve-workflow.ts new file mode 100644 index 0000000000..27d5d2affd --- /dev/null +++ b/apps/web/lib/kanban/resolve-workflow.ts @@ -0,0 +1,34 @@ +import type { WorkflowsState } from "@/lib/state/slices"; + +type WorkflowLike = { id: string; hidden?: boolean }; + +/** + * Resolve the workflow id that should be active given the current store state + * and persisted user settings. + * + * `null` is a valid "All Workflows" selection: when the user has explicitly + * cleared the filter, we must not silently fall back to the first workflow. + * Auto-selecting only happens when there is exactly one visible workflow — + * otherwise the user would never be able to keep "All Workflows" picked. + */ +export function resolveDesiredWorkflowId({ + activeWorkflowId, + settingsWorkflowId, + workspaceWorkflows, +}: { + activeWorkflowId?: string | null; + settingsWorkflowId?: string | null; + workspaceWorkflows: WorkflowsState["items"] | WorkflowLike[]; +}): string | null { + const visibleWorkflows = workspaceWorkflows.filter((workflow) => !workflow.hidden); + if (activeWorkflowId && visibleWorkflows.some((workflow) => workflow.id === activeWorkflowId)) { + return activeWorkflowId; + } + if ( + settingsWorkflowId && + visibleWorkflows.some((workflow) => workflow.id === settingsWorkflowId) + ) { + return settingsWorkflowId; + } + return visibleWorkflows.length === 1 ? visibleWorkflows[0].id : null; +} diff --git a/apps/web/lib/state/slices/kanban/kanban-slice.ts b/apps/web/lib/state/slices/kanban/kanban-slice.ts index 02bc40c301..8a3d594e52 100644 --- a/apps/web/lib/state/slices/kanban/kanban-slice.ts +++ b/apps/web/lib/state/slices/kanban/kanban-slice.ts @@ -26,9 +26,6 @@ export const createKanbanSlice: StateCreator< setWorkflows: (workflows) => set((draft) => { draft.workflows.items = workflows; - if (!draft.workflows.activeId && workflows.length) { - draft.workflows.activeId = workflows[0].id; - } }), reorderWorkflowItems: (workflowIds) => set((draft) => { From 14e80c89044ac6cacc1cc0cdb18ea46e4c02c0f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 09:16:14 +0000 Subject: [PATCH 26/33] release: 0.40.0 --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ apps/cli/package.json | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ea65e355..9a837da0f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to Kandev. +## 0.40.0 - 2026-05-08 + +### Features + +- show grid spinner on agent tab while session is working ([#836](https://github.com/kdlbs/kandev/pull/836)) +- mobile parity (session/terminal/repo pickers + multi-terminal) ([#840](https://github.com/kdlbs/kandev/pull/840)) +- per-task color indicator in sidebar ([#835](https://github.com/kdlbs/kandev/pull/835)) +- rate-limit awareness, poller throttling, and GraphQL batching ([#821](https://github.com/kdlbs/kandev/pull/821)) +- pin tasks and drag-to-reorder in sidebar ([#829](https://github.com/kdlbs/kandev/pull/829)) +- allow renaming quick-chat tabs locally ([#830](https://github.com/kdlbs/kandev/pull/830)) +- multi-question support for ask_user_question_kandev ([#828](https://github.com/kdlbs/kandev/pull/828)) +- allow moving tasks across workflows ([#822](https://github.com/kdlbs/kandev/pull/822)) +- double-click tab to toggle maximize ([#823](https://github.com/kdlbs/kandev/pull/823)) +- cookie-mode integration that triages threads via a utility agent ([#775](https://github.com/kdlbs/kandev/pull/775)) +- add Linear issue watchers ([#805](https://github.com/kdlbs/kandev/pull/805)) + +### Bug Fixes + +- respect "All Workflows" selection with multiple workflows ([#844](https://github.com/kdlbs/kandev/pull/844)) +- stop dockview layout corruption when switching between maximized / sessionless tasks ([#838](https://github.com/kdlbs/kandev/pull/838)) +- hide improve-kandev system workflow from settings UI ([#842](https://github.com/kdlbs/kandev/pull/842)) +- abandon orphan turns on session resume ([#837](https://github.com/kdlbs/kandev/pull/837)) +- source commit pushed status from git remote, not PR commits ([#833](https://github.com/kdlbs/kandev/pull/833)) +- preserve commits in changes panel after refresh ([#834](https://github.com/kdlbs/kandev/pull/834)) + +### Refactoring + +- reframe cross-task message wrapper to authorize action ([#841](https://github.com/kdlbs/kandev/pull/841)) +- drop legacy orchestrator WS handlers superseded by session.launch ([#803](https://github.com/kdlbs/kandev/pull/803)) + ## 0.39.2 - 2026-05-05 ### Bug Fixes diff --git a/apps/cli/package.json b/apps/cli/package.json index 068b9f7aa6..81c5c396f7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "kandev", - "version": "0.39.2", + "version": "0.40.0", "private": false, "description": "Launcher for Kandev — manage tasks, orchestrate agents, review changes, and ship value", "license": "AGPL-3.0-only", From cf3dbc9582d7c30b2544dfee94f3583ac2b3904a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Fri, 8 May 2026 16:32:06 +0100 Subject: [PATCH 27/33] fix(web): keep command palette selection on first result (#845) * fix(web): keep command palette selection on first result * test(web): fix launch session mock response shape * refactor(web): address command palette review feedback * fix(web): align command panel selection setter type --- apps/web/components/command-panel-footer.tsx | 29 ++++--- apps/web/components/command-panel.tsx | 78 +++++++++++-------- .../task/task-select-helpers.test.ts | 11 ++- apps/web/e2e/tests/command-panel.spec.ts | 37 +++++++++ 4 files changed, 111 insertions(+), 44 deletions(-) diff --git a/apps/web/components/command-panel-footer.tsx b/apps/web/components/command-panel-footer.tsx index ca7b002340..da32d0a9f4 100644 --- a/apps/web/components/command-panel-footer.tsx +++ b/apps/web/components/command-panel-footer.tsx @@ -1,5 +1,6 @@ "use client"; +import type { Dispatch, SetStateAction } from "react"; import { formatDistanceToNow } from "date-fns"; import { IconArchive, IconArrowRight, IconHammer, IconLoader2 } from "@tabler/icons-react"; import { @@ -22,8 +23,8 @@ import type { Task } from "@/lib/types/http"; import { FileIcon } from "@/components/ui/file-icon"; const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); -const MODE_COMMANDS: CommandPanelMode = "commands"; -const MODE_SEARCH_FILES: CommandPanelMode = "search-files"; +export const MODE_COMMANDS: CommandPanelMode = "commands"; +export const MODE_SEARCH_FILES: CommandPanelMode = "search-files"; const STEP_COLOR_MAP: Record<string, string> = { "bg-slate-500": "#64748b", @@ -41,6 +42,18 @@ function getFileName(filePath: string) { return filePath.split("/").pop() ?? filePath; } +function getCommandValue(cmd: CommandItemType) { + return cmd.id + " " + cmd.label + " " + (cmd.keywords?.join(" ") ?? ""); +} + +export function getTaskResultValue(task: Task) { + return `__task:${task.id} ${task.title}`; +} + +export function getFileResultValue(filePath: string) { + return `__file:${filePath}`; +} + function CommandItemRow({ cmd, onSelect, @@ -49,11 +62,7 @@ function CommandItemRow({ onSelect: (cmd: CommandItemType) => void; }) { return ( - <CommandItem - key={cmd.id} - value={cmd.id + " " + cmd.label + " " + (cmd.keywords?.join(" ") ?? "")} - onSelect={() => onSelect(cmd)} - > + <CommandItem key={cmd.id} value={getCommandValue(cmd)} onSelect={() => onSelect(cmd)}> {cmd.icon && <span className="text-muted-foreground">{cmd.icon}</span>} <span>{cmd.label}</span> {cmd.shortcut && <CommandShortcut>{formatShortcut(cmd.shortcut)}</CommandShortcut>} @@ -90,7 +99,7 @@ function TaskResultItem({ task, stepMap, repoMap, onSelect }: TaskResultItemProp return ( <CommandItem key={task.id} - value={`__task:${task.id} ${task.title}`} + value={getTaskResultValue(task)} onSelect={() => onSelect(task)} className={isArchived ? "opacity-60" : ""} forceMount @@ -213,7 +222,7 @@ function FileSearchContent({ files, isSearching, search, onSelect }: FileSearchC return ( <CommandItem key={filePath} - value={`__file:${filePath}`} + value={getFileResultValue(filePath)} onSelect={() => onSelect(filePath)} forceMount > @@ -288,7 +297,7 @@ export type CommandPanelViewProps = { mode: CommandPanelMode; inputCommand: CommandItemType | null; selectedValue: string; - setSelectedValue: (value: string) => void; + setSelectedValue: Dispatch<SetStateAction<string>>; search: string; setSearch: (value: string) => void; handleKeyDown: (e: React.KeyboardEvent) => void; diff --git a/apps/web/components/command-panel.tsx b/apps/web/components/command-panel.tsx index 0cfe0952d3..668aae3621 100644 --- a/apps/web/components/command-panel.tsx +++ b/apps/web/components/command-panel.tsx @@ -15,14 +15,20 @@ import type { Task } from "@/lib/types/http"; import { getWebSocketClient } from "@/lib/ws/connection"; import { searchWorkspaceFiles } from "@/lib/ws/workspace-files"; import { useDockviewStore } from "@/lib/state/dockview-store"; -import { CommandPanelView } from "@/components/command-panel-footer"; +import { + CommandPanelView, + MODE_COMMANDS, + MODE_SEARCH_FILES, + getFileResultValue, + getTaskResultValue, +} from "@/components/command-panel-footer"; function getFileName(filePath: string) { return filePath.split("/").pop() ?? filePath; } function useCommandPanelState() { - const [mode, setMode] = useState<CommandPanelMode>("commands"); + const [mode, setMode] = useState<CommandPanelMode>(MODE_COMMANDS); const [search, setSearch] = useState(""); const [inputCommand, setInputCommand] = useState<CommandItemType | null>(null); const [taskResults, setTaskResults] = useState<Task[]>([]); @@ -63,22 +69,14 @@ type FileSearchEffectOptions = { activeSessionId: string | null; setFileResults: (files: string[]) => void; setIsSearchingFiles: (searching: boolean) => void; - setSelectedValue: (value: string) => void; fileDebounceRef: React.RefObject<ReturnType<typeof setTimeout> | null>; }; function useFileSearchEffect(opts: FileSearchEffectOptions) { - const { - mode, - search, - activeSessionId, - setFileResults, - setIsSearchingFiles, - setSelectedValue, - fileDebounceRef, - } = opts; + const { mode, search, activeSessionId, setFileResults, setIsSearchingFiles, fileDebounceRef } = + opts; useEffect(() => { - if (mode !== "search-files" || !search.trim() || !activeSessionId) { + if (mode !== MODE_SEARCH_FILES || !search.trim() || !activeSessionId) { setFileResults([]); setIsSearchingFiles(false); return; @@ -97,7 +95,6 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) { if (!cancelled) { const files = res.files ?? []; setFileResults(files); - if (files.length > 0) setSelectedValue(`__file:${files[0]}`); } } catch { if (!cancelled) setFileResults([]); @@ -109,15 +106,7 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) { cancelled = true; if (fileDebounceRef.current) clearTimeout(fileDebounceRef.current); }; - }, [ - activeSessionId, - fileDebounceRef, - mode, - search, - setFileResults, - setIsSearchingFiles, - setSelectedValue, - ]); + }, [activeSessionId, fileDebounceRef, mode, search, setFileResults, setIsSearchingFiles]); } const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); @@ -154,7 +143,7 @@ function useInlineTaskSearchEffect(opts: InlineTaskSearchOptions) { const { visibleStepIds, stepPositionMap } = useStepMaps(steps); useEffect(() => { - if (mode !== "commands") return; + if (mode !== MODE_COMMANDS) return; if (debounceRef.current) clearTimeout(debounceRef.current); abortRef.current?.abort(); @@ -275,7 +264,7 @@ function useCommandPanelEffects( useEffect(() => { if (!open) { const t = setTimeout(() => { - setMode("commands"); + setMode(MODE_COMMANDS); setSearch(""); setInputCommand(null); setTaskResults([]); @@ -302,11 +291,37 @@ function useCommandPanelEffects( activeSessionId, setFileResults, setIsSearchingFiles, - setSelectedValue, fileDebounceRef, }); } +function useFirstResultSelection(open: boolean, state: ReturnType<typeof useCommandPanelState>) { + const { mode, search, taskResults, fileResults, setSelectedValue } = state; + + // `search` intentionally re-applies the first result while debounced results are loading. + useEffect(() => { + if (!open) return; + + if (mode === MODE_COMMANDS) { + const firstTask = taskResults[0]; + if (firstTask) { + setSelectedValue(getTaskResultValue(firstTask)); + return; + } + setSelectedValue((current) => (current.startsWith("__task:") ? "" : current)); + return; + } + + if (mode === MODE_SEARCH_FILES) { + const firstFile = fileResults[0]; + setSelectedValue(firstFile ? getFileResultValue(firstFile) : ""); + return; + } + + setSelectedValue(""); + }, [fileResults, mode, open, search, setSelectedValue, taskResults]); +} + function useCommandPanelHandlers( state: ReturnType<typeof useCommandPanelState>, setOpen: (open: boolean) => void, @@ -382,9 +397,9 @@ function useCommandPanelHandlers( inputCommand.onInputSubmit(search.trim()); return; } - if (mode !== "commands" && e.key === "Backspace" && !search) { + if (mode !== MODE_COMMANDS && e.key === "Backspace" && !search) { e.preventDefault(); - setMode("commands"); + setMode(MODE_COMMANDS); setSearch(""); setInputCommand(null); } @@ -393,7 +408,7 @@ function useCommandPanelHandlers( ); const goBack = useCallback(() => { - setMode("commands"); + setMode(MODE_COMMANDS); setSearch(""); setInputCommand(null); }, [setMode, setSearch, setInputCommand]); @@ -434,6 +449,7 @@ export function CommandPanel() { } = state; useCommandPanelEffects(open, state, workspaceId, activeSessionId, kanbanSteps); + useFirstResultSelection(open, state); const openRef = useRef(open); useEffect(() => { @@ -443,10 +459,10 @@ export function CommandPanel() { const toggleCommands = useCallback(() => setOpen(!openRef.current), [setOpen]); const openFileSearch = useCallback(() => { - if (openRef.current && state.mode === "search-files") { + if (openRef.current && state.mode === MODE_SEARCH_FILES) { setOpen(false); } else { - state.setMode("search-files"); + state.setMode(MODE_SEARCH_FILES); state.setSearch(""); setOpen(true); } diff --git a/apps/web/components/task/task-select-helpers.test.ts b/apps/web/components/task/task-select-helpers.test.ts index 78c5a460b1..ec836e6b63 100644 --- a/apps/web/components/task/task-select-helpers.test.ts +++ b/apps/web/components/task/task-select-helpers.test.ts @@ -18,7 +18,7 @@ vi.mock("@/lib/links", () => ({ replaceTaskUrl: vi.fn(), })); -import { launchSession } from "@/lib/services/session-launch-service"; +import { launchSession, type LaunchSessionResponse } from "@/lib/services/session-launch-service"; import { releaseLayoutToDefault } from "@/lib/state/dockview-store"; import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; @@ -60,7 +60,7 @@ describe("prepareAndSwitchTask — outgoing-env panel cleanup", () => { it("releases the outgoing env's panels before awaiting launchSession", async () => { // Make launchSession return a deferred we control so we can observe // synchronous side effects that happen before the await resolves. - let resolveLaunch: (v: { session_id: string }) => void = () => {}; + let resolveLaunch: (v: LaunchSessionResponse) => void = () => {}; vi.mocked(launchSession).mockImplementation( () => new Promise((res) => { @@ -80,7 +80,12 @@ describe("prepareAndSwitchTask — outgoing-env panel cleanup", () => { expect(releaseLayoutToDefault).toHaveBeenCalledTimes(1); expect(switchToSession).not.toHaveBeenCalled(); - resolveLaunch({ session_id: "new-session" }); + resolveLaunch({ + success: true, + task_id: NEW_TASK_ID, + session_id: "new-session", + state: "ready", + }); const result = await promise; // Happy-path coverage: switchToSession must run with the new session id and diff --git a/apps/web/e2e/tests/command-panel.spec.ts b/apps/web/e2e/tests/command-panel.spec.ts index a4577c4026..65f5454426 100644 --- a/apps/web/e2e/tests/command-panel.spec.ts +++ b/apps/web/e2e/tests/command-panel.spec.ts @@ -119,6 +119,43 @@ test.describe("Command Panel", () => { await expect(taskOption.getByText(startStep.name)).toBeVisible({ timeout: 5_000 }); }); + test("inline task search selects the first matching task after loading", async ({ + testPage, + apiClient, + seedData, + }) => { + await apiClient.createTask(seedData.workspaceId, "Palette Selection Alpha", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.createTask(seedData.workspaceId, "Palette Selection Beta", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await expect(kanban.taskCardByTitle("Palette Selection Alpha")).toBeVisible({ + timeout: 10_000, + }); + await expect(kanban.taskCardByTitle("Palette Selection Beta")).toBeVisible({ + timeout: 10_000, + }); + + await openCommandPanel(testPage); + const dialog = commandDialog(testPage); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + const input = dialog.getByRole("combobox"); + await input.fill("Palette Selection"); + + const taskOptions = dialog.getByRole("option").filter({ hasText: /Palette Selection/ }); + await expect(taskOptions.first()).toBeVisible({ timeout: 10_000 }); + await expect(taskOptions.first()).toHaveAttribute("data-selected", "true", { + timeout: 5_000, + }); + }); + test("Escape closes the command panel", async ({ testPage }) => { const kanban = new KanbanPage(testPage); await kanban.goto(); From dfcaa24be37e99784d381869859b4c2119a18adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= <carlosmflorencio@gmail.com> Date: Fri, 8 May 2026 19:05:10 +0100 Subject: [PATCH 28/33] feat: allow subtasks to target a sibling repository (#852) --- .../backend/internal/mcp/handlers/handlers.go | 115 +++-- .../internal/mcp/handlers/handlers_test.go | 62 ++- apps/backend/internal/mcp/server/handlers.go | 6 +- .../internal/mcp/server/handlers_test.go | 45 +- apps/backend/internal/mcp/server/server.go | 10 +- .../internal/task/service/service_tasks.go | 12 + .../internal/task/service/service_test.go | 28 ++ .../components/task/new-subtask-dialog.tsx | 467 +++++++----------- .../task/new-subtask-form-parts.tsx | 302 +++++++++++ .../components/task/new-subtask-form-state.ts | 132 +++++ .../web/components/task/use-subtask-submit.ts | 190 +++++++ apps/web/e2e/tests/task/subtask.spec.ts | 431 ++++++++++++++++ 12 files changed, 1437 insertions(+), 363 deletions(-) create mode 100644 apps/web/components/task/new-subtask-form-parts.tsx create mode 100644 apps/web/components/task/new-subtask-form-state.ts create mode 100644 apps/web/components/task/use-subtask-submit.ts diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 509e62ddad..9c6601cbd3 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -436,24 +436,43 @@ type taskRepoResult struct { } // resolveTaskRepositories builds the repository list for a new task. -// Priority: explicit repositories > parent task repos > source task repos. -// When inheriting from a parent, it also returns the parent's workspace/workflow IDs. +// +// For subtasks (parentID set) workspace and workflow always come from the +// parent. Explicit repositories override the parent's repos when supplied, +// otherwise the parent's repos are inherited verbatim — letting an agent +// spin up a subtask that targets a sibling repo while staying in the same +// workspace/workflow. +// +// For top-level tasks (parentID empty) explicit repos win over source-task +// inheritance; workspace falls back to the source task when available. func (h *Handlers) resolveTaskRepositories( ctx context.Context, parentID, sourceTaskID string, explicit []mcpRepositoryInput, ) (taskRepoResult, error) { - if len(explicit) > 0 { - var repos []service.TaskRepositoryInput - for _, r := range explicit { - repos = append(repos, service.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - LocalPath: r.LocalPath, - GitHubURL: r.GitHubURL, - BaseBranch: r.BaseBranch, - }) + explicitRepos := explicitRepoInputs(explicit) + + if parentID != "" { + parent, err := h.taskSvc.GetTask(ctx, parentID) + if err != nil { + return taskRepoResult{}, fmt.Errorf("invalid parent_id: %w", err) } - result := taskRepoResult{Repos: repos} + if parent.IsEphemeral { + return taskRepoResult{}, fmt.Errorf("cannot create subtasks of an ephemeral task (quick chat); omit parent_id to create a top-level task") + } + repos := explicitRepos + if repos == nil { + repos = inheritedRepoInputs(parent.Repositories) + } + return taskRepoResult{ + Repos: repos, + WorkspaceID: parent.WorkspaceID, + WorkflowID: parent.WorkflowID, + }, nil + } + + if explicitRepos != nil { + result := taskRepoResult{Repos: explicitRepos} // Inherit workspace from source task so multi-workspace installs don't // fail auto-resolution when the agent supplies an explicit repository. if sourceTaskID != "" && h.taskSvc != nil { @@ -468,29 +487,6 @@ func (h *Handlers) resolveTaskRepositories( return result, nil } - if parentID != "" { - parent, err := h.taskSvc.GetTask(ctx, parentID) - if err != nil { - return taskRepoResult{}, fmt.Errorf("invalid parent_id: %w", err) - } - if parent.IsEphemeral { - return taskRepoResult{}, fmt.Errorf("cannot create subtasks of an ephemeral task (quick chat); omit parent_id to create a top-level task") - } - var repos []service.TaskRepositoryInput - for _, r := range parent.Repositories { - repos = append(repos, service.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - CheckoutBranch: r.CheckoutBranch, - }) - } - return taskRepoResult{ - Repos: repos, - WorkspaceID: parent.WorkspaceID, - WorkflowID: parent.WorkflowID, - }, nil - } - // For top-level tasks, inherit repos and workspace from the calling agent's current task. if sourceTaskID != "" { sourceTask, err := h.taskSvc.GetTask(ctx, sourceTaskID) @@ -499,16 +495,8 @@ func (h *Handlers) resolveTaskRepositories( zap.String("source_task_id", sourceTaskID), zap.Error(err)) return taskRepoResult{}, nil } - var repos []service.TaskRepositoryInput - for _, r := range sourceTask.Repositories { - repos = append(repos, service.TaskRepositoryInput{ - RepositoryID: r.RepositoryID, - BaseBranch: r.BaseBranch, - CheckoutBranch: r.CheckoutBranch, - }) - } return taskRepoResult{ - Repos: repos, + Repos: inheritedRepoInputs(sourceTask.Repositories), WorkspaceID: sourceTask.WorkspaceID, }, nil } @@ -516,6 +504,45 @@ func (h *Handlers) resolveTaskRepositories( return taskRepoResult{}, nil } +// explicitRepoInputs maps the MCP-side explicit repo list to service inputs. +// Returns nil when no explicit repos were supplied so callers can distinguish +// "agent didn't pass repos" from "agent passed an empty list". +func explicitRepoInputs(explicit []mcpRepositoryInput) []service.TaskRepositoryInput { + if len(explicit) == 0 { + return nil + } + repos := make([]service.TaskRepositoryInput, 0, len(explicit)) + for _, r := range explicit { + repos = append(repos, service.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + LocalPath: r.LocalPath, + GitHubURL: r.GitHubURL, + BaseBranch: r.BaseBranch, + }) + } + return repos +} + +// inheritedRepoInputs maps an existing task's repository list onto service +// inputs for a new task that inherits from it. +func inheritedRepoInputs(src []*models.TaskRepository) []service.TaskRepositoryInput { + if len(src) == 0 { + return nil + } + repos := make([]service.TaskRepositoryInput, 0, len(src)) + for _, r := range src { + if r == nil { + continue + } + repos = append(repos, service.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + }) + } + return repos +} + // autoStartTask launches an agent session for a newly created task in the background. // It resolves the agent profile: explicit > parent's session > source task's session > workspace default. // It resolves the executor: explicit executor_profile_id > parent's executor_profile_id > diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index 9a2c42fd13..3ac93bfcbd 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -263,20 +263,62 @@ func TestResolveTaskRepositories_NoInputs_ReturnsEmpty(t *testing.T) { assert.Empty(t, result.Repos) } -func TestResolveTaskRepositories_ExplicitRepos_SkipsParentLookup(t *testing.T) { - log := testLogger(t) - h := &Handlers{logger: log.WithFields()} +// --- Integration tests using real task service --- - // Even with a parentID, explicit repos should be used without looking up the parent - // (taskSvc is nil — if it tried to call GetTask it would panic). - explicit := []mcpRepositoryInput{{RepositoryID: "repo-explicit"}} - result, err := h.resolveTaskRepositories(context.Background(), "some-parent", "some-source", explicit) +// seedParentWithRepo creates a workspace, workflow, repository, and a parent +// task linked to that repository. Returns the parent task ID. +func seedParentWithRepo(t *testing.T, svc *service.Service, repo *sqliterepo.Repository) string { + t.Helper() + ctx := context.Background() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Test"})) + require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Board"})) + require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ID: "repo-parent", WorkspaceID: "ws-1", Name: "Parent Repo"})) + parent, err := svc.CreateTask(ctx, &service.CreateTaskRequest{ + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + Title: "Parent task", + Repositories: []service.TaskRepositoryInput{ + {RepositoryID: "repo-parent", BaseBranch: "main"}, + }, + }) require.NoError(t, err) - require.Len(t, result.Repos, 1) - assert.Equal(t, "repo-explicit", result.Repos[0].RepositoryID) + return parent.ID } -// --- Integration tests using real task service --- +func TestResolveTaskRepositories_ParentWithoutExplicitRepos_InheritsAll(t *testing.T) { + svc, repo := newTestTaskService(t) + parentID := seedParentWithRepo(t, svc, repo) + + log := testLogger(t) + h := &Handlers{taskSvc: svc, logger: log.WithFields()} + + result, err := h.resolveTaskRepositories(context.Background(), parentID, "", nil) + require.NoError(t, err) + require.Len(t, result.Repos, 1, "subtask without explicit repos inherits parent's repos") + assert.Equal(t, "repo-parent", result.Repos[0].RepositoryID) + assert.Equal(t, "ws-1", result.WorkspaceID) + assert.Equal(t, "wf-1", result.WorkflowID) +} + +func TestResolveTaskRepositories_ParentWithExplicitRepos_OverridesRepoButInheritsWorkspace(t *testing.T) { + svc, repo := newTestTaskService(t) + parentID := seedParentWithRepo(t, svc, repo) + + log := testLogger(t) + h := &Handlers{taskSvc: svc, logger: log.WithFields()} + + explicit := []mcpRepositoryInput{ + {GitHubURL: "https://github.com/acme/sibling", BaseBranch: "develop"}, + } + result, err := h.resolveTaskRepositories(context.Background(), parentID, "", explicit) + require.NoError(t, err) + require.Len(t, result.Repos, 1, "explicit repos override parent's repos") + assert.Equal(t, "https://github.com/acme/sibling", result.Repos[0].GitHubURL) + assert.Equal(t, "develop", result.Repos[0].BaseBranch) + assert.Empty(t, result.Repos[0].RepositoryID, "explicit repo should not be conflated with parent's RepositoryID") + assert.Equal(t, "ws-1", result.WorkspaceID, "subtask still inherits parent's workspace") + assert.Equal(t, "wf-1", result.WorkflowID, "subtask still inherits parent's workflow") +} func TestResolveTaskRepositories_EphemeralParent_Rejected(t *testing.T) { svc, repo := newTestTaskService(t) diff --git a/apps/backend/internal/mcp/server/handlers.go b/apps/backend/internal/mcp/server/handlers.go index 379d6e9fb5..d9a1d859ab 100644 --- a/apps/backend/internal/mcp/server/handlers.go +++ b/apps/backend/internal/mcp/server/handlers.go @@ -146,15 +146,13 @@ func (s *Server) createTaskHandler() server.ToolHandlerFunc { "start_agent": startAgent, } - // Add repository info (only valid for top-level tasks) + // Add repository info. For subtasks an explicit repo overrides the + // parent's; if omitted the backend inherits from the parent. repositoryID := req.GetString("repository_id", "") localPath := req.GetString("local_path", "") repositoryURL := req.GetString("repository_url", "") baseBranch := req.GetString("base_branch", "") hasRepo := repositoryID != "" || localPath != "" || repositoryURL != "" - if hasRepo && parentID != "" { - return mcp.NewToolResultError("repository_id, local_path, and repository_url are only valid for top-level tasks; subtasks inherit their repository from the parent"), nil - } if hasRepo { repo := map[string]string{} if repositoryID != "" { diff --git a/apps/backend/internal/mcp/server/handlers_test.go b/apps/backend/internal/mcp/server/handlers_test.go index 67b03f6783..723e547ca0 100644 --- a/apps/backend/internal/mcp/server/handlers_test.go +++ b/apps/backend/internal/mcp/server/handlers_test.go @@ -252,17 +252,56 @@ func TestCreateTask_WithRepositoryURL(t *testing.T) { assert.Equal(t, "main", repos[0]["base_branch"]) } -func TestCreateTask_RepositoryURL_RejectedForSubtasks(t *testing.T) { - backend := &testBackend{} +func TestCreateTask_RepositoryURL_AllowedForSubtasks(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{"id": "task-new", "title": "Subtask with URL"}, + } s := newTaskModeServer(t, backend, "task-current") result := callTool(t, s, "create_task_kandev", map[string]interface{}{ "title": "Subtask with URL", "parent_id": "self", + "description": "Fix the upstream review-eligibility check", "repository_url": "https://github.com/acme/widgets", + "base_branch": "main", }) - assert.True(t, result.IsError, "repository_url should be rejected for subtasks") + assert.False(t, result.IsError, "repository_url should be accepted for subtasks (cross-repo subtask)") + + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "task-current", payload["parent_id"], "self resolves to current task id") + + repos, ok := payload["repositories"].([]map[string]string) + require.True(t, ok, "repositories should be a slice") + require.Len(t, repos, 1) + assert.Equal(t, "https://github.com/acme/widgets", repos[0]["github_url"]) + assert.Equal(t, "main", repos[0]["base_branch"]) +} + +func TestCreateTask_LocalPath_AllowedForSubtasks(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{"id": "task-new", "title": "Subtask with local path"}, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "create_task_kandev", map[string]interface{}{ + "title": "Subtask with local path", + "parent_id": "self", + "description": "Patch the sibling repo", + "local_path": "/Users/me/projects/sibling", + }) + + assert.False(t, result.IsError, "local_path should be accepted for subtasks (cross-repo subtask)") + + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "task-current", payload["parent_id"]) + + repos, ok := payload["repositories"].([]map[string]string) + require.True(t, ok) + require.Len(t, repos, 1) + assert.Equal(t, "/Users/me/projects/sibling", repos[0]["local_path"]) } func TestMessageTask_ForwardsToBackend(t *testing.T) { diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 2cd7e607e2..5fa83def93 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -434,6 +434,7 @@ WHEN TO USE parent_id='self': - Breaking down your current task into phases/steps → use parent_id='self' - Creating tasks from a plan → use parent_id='self' (inherits repo, workspace, workflow) - Delegating work to another agent → use parent_id='self' +- Delegating work that lives in a sibling repo → use parent_id='self' AND pass repository_url / repository_id / local_path to point the subtask at that repo WHEN TO OMIT parent_id (top-level task): - Creating an unrelated, standalone task @@ -441,7 +442,8 @@ WHEN TO OMIT parent_id (top-level task): - workspace_id and workflow_id are auto-resolved if only one exists; provide explicitly if ambiguous IMPORTANT: -- Subtasks inherit repositories, workspace, workflow, agent profile, and executor from parent +- Subtasks inherit workspace, workflow, agent profile, and executor from the parent +- Subtasks inherit the parent's repository unless you supply repository_url, repository_id, or local_path — in which case the subtask targets that repo instead (must live in the parent's workspace) - Top-level tasks need a repository via repository_url, repository_id, or local_path - 'description' is the sub-agent's initial prompt — be specific and detailed - Set start_agent=false to create without starting an agent` @@ -471,9 +473,9 @@ IMPORTANT: mcp.WithString("agent_profile_id", mcp.Description("Agent profile ID to use. For subtasks, inherited from the parent session. For top-level tasks, ask the user which agent profile they want (e.g. Claude Code, OpenCode) if not already known.")), mcp.WithString("executor_profile_id", mcp.Description("Executor profile ID to use (determines the runtime environment: local, worktree, docker, etc.). For subtasks, inherited from the parent session. For top-level tasks, ask the user which executor profile they want if not already known.")), mcp.WithBoolean("start_agent", mcp.Description("Whether to auto-start an agent on the created task. Default: true. Set to false to create the task without starting an agent.")), - mcp.WithString("repository_id", mcp.Description("Repository ID for top-level tasks. Not needed for subtasks (inherited from parent).")), - mcp.WithString("local_path", mcp.Description("Local repository folder path (e.g. '/Users/me/projects/myrepo') for top-level tasks. Will create/find the repository automatically. Preferred for local worktree flow.")), - mcp.WithString("repository_url", mcp.Description("GitHub repository URL (e.g. 'https://github.com/owner/repo') for top-level tasks. The repository will be cloned automatically on first use. Not needed for subtasks (inherited from parent).")), + mcp.WithString("repository_id", mcp.Description("Repository ID. Required for top-level tasks unless local_path or repository_url is provided. For subtasks: optional — supply only when the subtask should target a different repo than the parent.")), + mcp.WithString("local_path", mcp.Description("Local repository folder path (e.g. '/Users/me/projects/myrepo'). Will create/find the repository automatically. Preferred for local worktree flow. For subtasks: supply only when the subtask should target a different repo than the parent.")), + mcp.WithString("repository_url", mcp.Description("GitHub repository URL (e.g. 'https://github.com/owner/repo'). The repository will be cloned automatically on first use. For subtasks: supply only when the subtask should target a different repo than the parent.")), mcp.WithString("base_branch", mcp.Description("Base branch for the repository (e.g. 'main'). Optional, defaults to repository's default branch.")), ), s.wrapHandler("create_task_kandev", s.createTaskHandler()), diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 9618db3de0..0aebb37573 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -160,6 +160,18 @@ func (s *Service) resolveRepoInput(ctx context.Context, workspaceID string, repo repositoryID = repoInput.RepositoryID baseBranch = repoInput.BaseBranch if repositoryID != "" { + // Verify the repository belongs to the target workspace. Without this + // check, an agent that knows a repository UUID from another workspace + // could associate it with a task in this workspace via the MCP tool's + // repository_id fast path (the github_url and local_path branches both + // scope through FindOrCreateRepository, which is workspace-bound). + repo, lookupErr := s.repoEntities.GetRepository(ctx, repositoryID) + if lookupErr != nil { + return "", "", fmt.Errorf("looking up repository %q: %w", repositoryID, lookupErr) + } + if repo == nil || repo.WorkspaceID != workspaceID { + return "", "", fmt.Errorf("repository %q does not belong to workspace %q", repositoryID, workspaceID) + } return repositoryID, baseBranch, nil } diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 7dd444e1dd..b7408e9136 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -3,6 +3,7 @@ package service import ( "context" "path/filepath" + "strings" "sync" "testing" "time" @@ -220,6 +221,33 @@ func TestService_CreateTask_AcceptsMultipleDistinctRepositories(t *testing.T) { } } +func TestService_CreateTask_RejectsRepositoryFromOtherWorkspace(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-a", Name: "A"}) + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-b", Name: "B"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-a", WorkspaceID: "ws-a", Name: "WF"}) + _ = repo.CreateRepository(ctx, &models.Repository{ID: "repo-b", WorkspaceID: "ws-b", Name: "B repo"}) + + req := &CreateTaskRequest{ + WorkspaceID: "ws-a", + WorkflowID: "wf-a", + WorkflowStepID: "step-1", + Title: "cross-ws", + Repositories: []TaskRepositoryInput{ + {RepositoryID: "repo-b", BaseBranch: "main"}, + }, + } + _, err := svc.CreateTask(ctx, req) + if err == nil { + t.Fatal("expected cross-workspace repository error, got nil") + } + if !strings.Contains(err.Error(), "does not belong to workspace") { + t.Errorf("expected workspace-ownership error, got: %v", err) + } +} + func TestService_CreateRepository_DefaultWorktreeBranchPrefix(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/web/components/task/new-subtask-dialog.tsx b/apps/web/components/task/new-subtask-dialog.tsx index 5ba5278e5f..dcb483ef5a 100644 --- a/apps/web/components/task/new-subtask-dialog.tsx +++ b/apps/web/components/task/new-subtask-dialog.tsx @@ -1,40 +1,31 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; -import { Badge } from "@kandev/ui/badge"; -import { Button } from "@kandev/ui/button"; -import { Input } from "@kandev/ui/input"; -import { Textarea } from "@kandev/ui/textarea"; -import { IconGitBranch } from "@tabler/icons-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; -import { createTask } from "@/lib/api/domains/kanban-api"; -import { replaceTaskUrl } from "@/lib/links"; -import { AgentSelector, ExecutorProfileSelector } from "@/components/task-create-dialog-selectors"; + import { useAgentProfileOptions, useExecutorProfileOptions, } from "@/components/task-create-dialog-options"; +import { useDialogHandlers } from "@/components/task-create-dialog-handlers"; +import { + useDiscoverReposEffect, + useGitHubUrlBranchesEffect, +} from "@/components/task-create-dialog-effects"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; -import { EnhancePromptButton } from "@/components/enhance-prompt-button"; +import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; -import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import { useSummarizeSession } from "@/hooks/use-summarize-session"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; -import type { ExecutorProfile } from "@/lib/types/http"; +import type { ExecutorProfile, Repository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; -import { IconLoader2 } from "@tabler/icons-react"; -import { toMessageAttachments } from "@/components/task-create-dialog-helpers"; -import { - ContextSelect, - useDialogAttachments, - AttachButton, - toContextItems, -} from "./session-dialog-shared"; -import { ContextZone } from "./chat/context-items/context-zone"; +import { useSubtaskFormState } from "./new-subtask-form-state"; +import { PromptZone, SubtaskFormBody } from "./new-subtask-form-parts"; +import { useSubtaskPromptZone, useSubtaskSubmit } from "./use-subtask-submit"; type NewSubtaskDialogProps = { open: boolean; @@ -130,17 +121,76 @@ function useAutoSelectExecutorProfile( }, [allProfiles, executorProfileId, setExecutorProfileId]); } -function activateSubtaskSession(opts: { - sessionId: string; - taskId: string; - setActiveTask: (taskId: string) => void; - setActiveSession: (taskId: string, sessionId: string) => void; +/** + * Seeds the chip row with the parent task's repo + branch when the form + * mounts. Form parent passes `key={open}` so each dialog open remounts the + * form, which is when this fires. The user can still change or add repos + * after seeding. + */ +function useSeedParentRepository( + fs: ReturnType<typeof useSubtaskFormState>, + parentRepositoryId: string | null, + baseBranch: string | null, +) { + useEffect(() => { + if (!parentRepositoryId) return; + fs.setRepositories([ + { key: "subtask-row-1", repositoryId: parentRepositoryId, branch: baseBranch ?? "" }, + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} + +/** Pre-fills the agent profile with the parent session's profile on mount. */ +function useSeedAgentProfileId( + fs: ReturnType<typeof useSubtaskFormState>, + defaultProfileId: string, +) { + useEffect(() => { + if (defaultProfileId) fs.setAgentProfileId(defaultProfileId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} + +/** + * Centralizes the Context selector's branching logic (Blank / Copy parent + * prompt / Summarize session N) so the form component stays under the + * complexity cap. + */ +function useContextChangeHandler(opts: { + setContextValue: (v: string) => void; + setHasPrompt: (v: boolean) => void; + promptRef: React.RefObject<HTMLTextAreaElement | null>; + initialPrompt: string | null; + summarize: (sessionId: string) => Promise<string | null>; }) { - opts.setActiveTask(opts.taskId); - opts.setActiveSession(opts.taskId, opts.sessionId); - // Layout switch is handled by useEnvSwitchCleanup when the new session's - // task_environment_id is present; the hook subscribes to env-id updates. - replaceTaskUrl(opts.taskId); + const { setContextValue, setHasPrompt, promptRef, initialPrompt, summarize } = opts; + return useCallback( + async (value: string) => { + if (!value) return; + setContextValue(value); + const ta = promptRef.current; + if (!ta) return; + if (value === "copy_prompt" && initialPrompt) { + ta.value = initialPrompt; + setHasPrompt(true); + return; + } + if (value === "blank") { + ta.value = ""; + setHasPrompt(false); + return; + } + if (value.startsWith("summarize:")) { + const result = await summarize(value.slice("summarize:".length)); + if (result && promptRef.current) { + promptRef.current.value = result; + setHasPrompt(true); + } + } + }, + [setContextValue, setHasPrompt, promptRef, initialPrompt, summarize], + ); } type SubtaskFormProps = { @@ -153,12 +203,16 @@ type SubtaskFormProps = { executors: Array<{ id: string; type: string; name: string; profiles?: ExecutorProfile[] }>; workspaceId: string | null; workflowId: string | null; - repositoryId: string | null; + /** The parent task's repository — used as the default for the subtask. */ + parentRepositoryId: string | null; baseBranch: string | null; + /** Workspace repositories the user can pick from to override the default. */ + availableRepositories: Repository[]; + /** Whether the parent dialog is open — required for the GitHub URL effect. */ + isOpen: boolean; onClose: () => void; }; -// eslint-disable-next-line max-lines-per-function function NewSubtaskForm({ parentTaskId, defaultTitle, @@ -169,284 +223,94 @@ function NewSubtaskForm({ executors, workspaceId, workflowId, - repositoryId, + parentRepositoryId, baseBranch, + availableRepositories, + isOpen, onClose, }: SubtaskFormProps) { const { toast } = useToast(); - const setActiveTask = useAppStore((s) => s.setActiveTask); - const setActiveSession = useAppStore((s) => s.setActiveSession); const isUtilityConfigured = useIsUtilityConfigured(); const { summarize, isSummarizing } = useSummarizeSession(); const [isCreating, setIsCreating] = useState(false); const [title, setTitle] = useState(defaultTitle); const [hasPrompt, setHasPrompt] = useState(false); const [contextValue, setContextValue] = useState("blank"); - const [selectedProfileId, setSelectedProfileId] = useState(defaultProfileId); - const [executorProfileId, setExecutorProfileId] = useState(""); - const promptRef = useRef<HTMLTextAreaElement>(null); - const { - attachments, - isDragging, - fileInputRef, - handleRemoveAttachment, - handlePaste, - handleDragOver, - handleDragLeave, - handleDrop, - handleAttachClick, - handleFileInputChange, - } = useDialogAttachments(isCreating || isSummarizing); - const contextItems = useMemo( - () => toContextItems(attachments, handleRemoveAttachment), - [attachments, handleRemoveAttachment], - ); + // Shim DialogFormState shared with the create-task dialog so RepoChipsRow, + // useDialogHandlers, and the GitHub URL branches effect work unchanged. + const fs = useSubtaskFormState(); + useSeedParentRepository(fs, parentRepositoryId, baseBranch); + useSeedAgentProfileId(fs, defaultProfileId); + const handlers = useDialogHandlers(fs, availableRepositories); + useGitHubUrlBranchesEffect(fs, isOpen); + // Fetch on-disk repos so the chip dropdown shows "on disk" entries + // (matches the create-task dialog's RepoChipsRow behavior). + useDiscoverReposEffect(fs, isOpen, workspaceId, false, toast); const profileOptions = useAgentProfileOptions(agentProfiles); const sessionOptions = useSessionOptions(parentTaskId); - const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ - sessionId: null, - taskTitle: title, - }); - const handleEnhancePrompt = useCallback(() => { - const current = promptRef.current?.value?.trim(); - if (!current) return; - enhancePrompt(current, (enhanced) => { - if (promptRef.current) { - promptRef.current.value = enhanced; - setHasPrompt(true); - } - }); - }, [enhancePrompt]); - const allExecutorProfiles = useExecutorProfiles(executors); const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles); - useAutoSelectExecutorProfile(allExecutorProfiles, executorProfileId, setExecutorProfileId); - - const handleContextChange = useCallback( - async (value: string) => { - if (!value) return; - setContextValue(value); - if (value === "copy_prompt" && initialPrompt && promptRef.current) { - promptRef.current.value = initialPrompt; - setHasPrompt(true); - } else if (value === "blank" && promptRef.current) { - promptRef.current.value = ""; - setHasPrompt(false); - } else if (value.startsWith("summarize:")) { - const sessionId = value.slice("summarize:".length); - const result = await summarize(sessionId); - if (result && promptRef.current) { - promptRef.current.value = result; - setHasPrompt(true); - } - } - }, - [initialPrompt, summarize], - ); - - const resolvePrompt = useCallback(() => { - const typed = promptRef.current?.value?.trim() ?? ""; - if (contextValue === "copy_prompt" && !typed && initialPrompt) return initialPrompt; - return typed; - }, [contextValue, initialPrompt]); - - const performCreate = useCallback( - async (trimmedTitle: string, prompt: string) => { - const repositories = repositoryId - ? [{ repository_id: repositoryId, base_branch: baseBranch || undefined }] - : []; - const profileId = selectedProfileId || defaultProfileId || undefined; - - const response = await createTask({ - workspace_id: workspaceId!, - workflow_id: workflowId!, - title: trimmedTitle, - description: prompt, - repositories, - start_agent: true, - agent_profile_id: profileId, - executor_profile_id: executorProfileId || undefined, - parent_id: parentTaskId, - attachments: toMessageAttachments(attachments), - }); - - const newSessionId = response.session_id ?? response.primary_session_id ?? null; - if (newSessionId) { - activateSubtaskSession({ - sessionId: newSessionId, - taskId: response.id, - setActiveTask, - setActiveSession, - }); - } - }, - [ - repositoryId, - baseBranch, - selectedProfileId, - defaultProfileId, - workspaceId, - workflowId, - executorProfileId, - parentTaskId, - attachments, - setActiveTask, - setActiveSession, - ], - ); - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - const trimmedTitle = title.trim(); - if (!trimmedTitle || !workspaceId || !workflowId) return; - const prompt = resolvePrompt(); - if (!prompt) return; - - setIsCreating(true); - try { - await performCreate(trimmedTitle, prompt); - onClose(); - } catch (error) { - toast({ - title: "Failed to create subtask", - description: error instanceof Error ? error.message : "Unknown error", - variant: "error", - }); - } finally { - setIsCreating(false); - } - }, - [title, workspaceId, workflowId, resolvePrompt, performCreate, onClose, toast], - ); - - const showSessions = isUtilityConfigured ? sessionOptions : []; - + useAutoSelectExecutorProfile(allExecutorProfiles, fs.executorProfileId, fs.setExecutorProfileId); + const promptZone = useSubtaskPromptZone({ + taskTitle: title, + inputDisabled: isCreating || isSummarizing, + contextValue, + initialPrompt, + setHasPrompt, + }); + const handleContextChange = useContextChangeHandler({ + setContextValue, + setHasPrompt, + promptRef: promptZone.promptRef, + initialPrompt, + summarize, + }); + const { handleSubmit } = useSubtaskSubmit({ + fs, + parentTaskId, + defaultProfileId, + workspaceId, + workflowId, + availableRepositories, + attachments: promptZone.attachments.attachments, + resolvePrompt: promptZone.resolvePrompt, + title, + setIsCreating, + onClose, + }); return ( - <form onSubmit={handleSubmit} className="space-y-4"> - <div className="space-y-1.5"> - <label className="text-xs font-medium text-muted-foreground">Title</label> - <Input - value={title} - onChange={(e) => setTitle(e.target.value)} - placeholder="Subtask title" - className="text-sm" - data-testid="subtask-title-input" - disabled={isCreating} + <SubtaskFormBody + fs={fs} + handlers={handlers} + title={title} + setTitle={setTitle} + workspaceId={workspaceId} + availableRepositories={availableRepositories} + parentRepositoryId={parentRepositoryId} + worktreeBranch={worktreeBranch} + profileOptions={profileOptions} + executorProfileOptions={executorProfileOptions} + agentProfileId={fs.agentProfileId || defaultProfileId} + contextValue={contextValue} + onContextChange={handleContextChange} + hasInitialPrompt={!!initialPrompt} + sessionOptions={isUtilityConfigured ? sessionOptions : []} + promptZone={ + <PromptZone + {...promptZone} + isCreating={isCreating} + isSummarizing={isSummarizing} + isUtilityConfigured={isUtilityConfigured} + setHasPrompt={setHasPrompt} + onSubmitShortcut={handleSubmit} /> - </div> - {worktreeBranch && ( - <div className="flex items-center gap-2 text-xs text-muted-foreground"> - <Badge variant="outline" className="text-xs font-normal gap-1"> - <IconGitBranch className="h-3 w-3" /> - {worktreeBranch} - </Badge> - <span>Same branch as current session</span> - </div> - )} - <div className="space-y-1.5"> - <label className="text-xs font-medium text-muted-foreground">Executor</label> - <ExecutorProfileSelector - options={executorProfileOptions} - value={executorProfileId} - onValueChange={setExecutorProfileId} - disabled={isCreating} - placeholder="Select executor profile" - /> - </div> - {profileOptions.length > 1 && ( - <div className="space-y-1.5"> - <label className="text-xs font-medium text-muted-foreground">Agent Profile</label> - <AgentSelector - options={profileOptions} - value={selectedProfileId || defaultProfileId} - onValueChange={setSelectedProfileId} - disabled={isCreating} - placeholder="Select agent profile" - /> - </div> - )} - <ContextSelect - value={contextValue} - onValueChange={handleContextChange} - hasInitialPrompt={!!initialPrompt} - sessionOptions={showSessions} - isSummarizing={isSummarizing} - /> - <div - className="relative" - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - <div className="rounded-md border border-input bg-transparent"> - <ContextZone items={contextItems} /> - <Textarea - ref={promptRef} - placeholder="What should the agent work on?" - className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]" - autoFocus - disabled={isCreating || isSummarizing} - data-testid="subtask-prompt-input" - onInput={(e) => setHasPrompt((e.target as HTMLTextAreaElement).value.trim().length > 0)} - onPaste={handlePaste} - onKeyDown={(e) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleSubmit(e); - } - }} - /> - <div className="flex items-center px-1 pb-1"> - <AttachButton onClick={handleAttachClick} disabled={isCreating || isSummarizing} /> - <EnhancePromptButton - onClick={handleEnhancePrompt} - isLoading={isEnhancingPrompt} - isConfigured={isUtilityConfigured} - /> - </div> - <input - ref={fileInputRef} - type="file" - multiple - className="hidden" - onChange={handleFileInputChange} - tabIndex={-1} - /> - </div> - {isDragging && ( - <div className="absolute inset-0 flex items-center justify-center bg-primary/10 border-2 border-dashed border-primary rounded-md pointer-events-none"> - <span className="text-sm text-primary font-medium">Drop files here</span> - </div> - )} - {isSummarizing && ( - <div className="absolute inset-0 flex items-center justify-center rounded-md bg-background/80"> - <div className="flex items-center gap-2 text-xs text-muted-foreground"> - <IconLoader2 className="h-4 w-4 animate-spin" /> - <span>Generating summary...</span> - </div> - </div> - )} - </div> - <DialogFooter> - <Button - type="button" - variant="ghost" - onClick={onClose} - disabled={isCreating} - className="cursor-pointer" - > - Cancel - </Button> - <Button - type="submit" - disabled={isCreating || isSummarizing || !hasPrompt} - className="cursor-pointer" - > - {isCreating ? "Creating..." : "Create Subtask"} - </Button> - </DialogFooter> - </form> + } + isCreating={isCreating} + isSummarizing={isSummarizing} + hasPrompt={hasPrompt} + onClose={onClose} + onSubmit={handleSubmit} + /> ); } @@ -468,6 +332,8 @@ export function NewSubtaskDialog({ // Ensure executor/agent data is loaded when dialog opens useSettingsData(open); + // Load workspace repositories so the subtask repo override picker has options. + const { repositories: availableRepositories } = useRepositories(workspaceId, open); const siblingCount = useAppStore( (s) => s.kanban.tasks.filter((t) => t.parentTaskId === parentTaskId).length, @@ -480,7 +346,10 @@ export function NewSubtaskDialog({ return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[520px]"> + <DialogContent + data-testid="new-subtask-dialog" + className="w-full h-full max-w-full max-h-full rounded-none sm:w-[800px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col" + > <DialogHeader> <DialogTitle className="text-sm font-medium"> New subtask for <span className="text-foreground">{parentTaskTitle}</span> @@ -497,8 +366,10 @@ export function NewSubtaskDialog({ executors={executors} workspaceId={workspaceId} workflowId={workflowId} - repositoryId={currentSession?.repository_id ?? null} + parentRepositoryId={currentSession?.repository_id ?? null} baseBranch={currentSession?.base_branch ?? null} + availableRepositories={availableRepositories} + isOpen={open} onClose={() => onOpenChange(false)} /> </DialogContent> diff --git a/apps/web/components/task/new-subtask-form-parts.tsx b/apps/web/components/task/new-subtask-form-parts.tsx new file mode 100644 index 0000000000..6024190ff3 --- /dev/null +++ b/apps/web/components/task/new-subtask-form-parts.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { DialogFooter } from "@kandev/ui/dialog"; +import { Input } from "@kandev/ui/input"; +import { Textarea } from "@kandev/ui/textarea"; +import { IconGitBranch, IconLoader2 } from "@tabler/icons-react"; +import { AgentSelector, ExecutorProfileSelector } from "@/components/task-create-dialog-selectors"; +import type { + useAgentProfileOptions, + useExecutorProfileOptions, +} from "@/components/task-create-dialog-options"; +import { EnhancePromptButton } from "@/components/enhance-prompt-button"; +import { RepoChipsRow } from "@/components/task-create-dialog-repo-chips"; +import type { useDialogHandlers } from "@/components/task-create-dialog-handlers"; +import type { Repository } from "@/lib/types/http"; +import type { useSubtaskFormState } from "./new-subtask-form-state"; +import { + AttachButton, + ContextSelect, + toContextItems, + useDialogAttachments, +} from "./session-dialog-shared"; +import { ContextZone } from "./chat/context-items/context-zone"; + +export function WorktreeBadge({ show, branch }: { show: boolean; branch: string | null }) { + if (!show || !branch) return null; + return ( + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <Badge variant="outline" className="text-xs font-normal gap-1"> + <IconGitBranch className="h-3 w-3" /> + {branch} + </Badge> + <span>Same branch as current session</span> + </div> + ); +} + +type SelectorsRowProps = { + profileOptions: ReturnType<typeof useAgentProfileOptions>; + executorProfileOptions: ReturnType<typeof useExecutorProfileOptions>; + agentProfileId: string; + executorProfileId: string; + onAgentProfileChange: (value: string) => void; + onExecutorProfileChange: (value: string) => void; + disabled: boolean; +}; + +export function SelectorsRow({ + profileOptions, + executorProfileOptions, + agentProfileId, + executorProfileId, + onAgentProfileChange, + onExecutorProfileChange, + disabled, +}: SelectorsRowProps) { + const noAgents = profileOptions.length === 0; + return ( + <div className="grid gap-4 grid-cols-1 sm:grid-cols-2"> + <div> + <AgentSelector + options={profileOptions} + value={agentProfileId} + onValueChange={onAgentProfileChange} + disabled={disabled || noAgents} + placeholder={noAgents ? "No agents found" : "Select agent profile"} + /> + </div> + <div> + <ExecutorProfileSelector + options={executorProfileOptions} + value={executorProfileId} + onValueChange={onExecutorProfileChange} + disabled={disabled} + placeholder="Select executor profile" + /> + </div> + </div> + ); +} + +type PromptZoneProps = { + promptRef: React.RefObject<HTMLTextAreaElement | null>; + contextItems: ReturnType<typeof toContextItems>; + attachments: ReturnType<typeof useDialogAttachments>; + isCreating: boolean; + isSummarizing: boolean; + isEnhancingPrompt: boolean; + isUtilityConfigured: boolean; + handleEnhancePrompt: () => void; + setHasPrompt: (v: boolean) => void; + onSubmitShortcut: (e: React.FormEvent) => void; +}; + +export function PromptZone({ + promptRef, + contextItems, + attachments, + isCreating, + isSummarizing, + isEnhancingPrompt, + isUtilityConfigured, + handleEnhancePrompt, + setHasPrompt, + onSubmitShortcut, +}: PromptZoneProps) { + const { + isDragging, + fileInputRef, + handlePaste, + handleDragOver, + handleDragLeave, + handleDrop, + handleAttachClick, + handleFileInputChange, + } = attachments; + const inputDisabled = isCreating || isSummarizing; + return ( + <div + className="relative" + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > + <div className="rounded-md border border-input bg-transparent"> + <ContextZone items={contextItems} /> + <Textarea + ref={promptRef} + placeholder="What should the agent work on?" + className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]" + autoFocus + disabled={inputDisabled} + data-testid="subtask-prompt-input" + onInput={(e) => setHasPrompt((e.target as HTMLTextAreaElement).value.trim().length > 0)} + onPaste={handlePaste} + onKeyDown={(e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + onSubmitShortcut(e); + } + }} + /> + <div className="flex items-center px-1 pb-1"> + <AttachButton onClick={handleAttachClick} disabled={inputDisabled} /> + <EnhancePromptButton + onClick={handleEnhancePrompt} + isLoading={isEnhancingPrompt} + isConfigured={isUtilityConfigured} + /> + </div> + <input + ref={fileInputRef} + type="file" + multiple + className="hidden" + onChange={handleFileInputChange} + tabIndex={-1} + /> + </div> + {isDragging && ( + <div className="absolute inset-0 flex items-center justify-center bg-primary/10 border-2 border-dashed border-primary rounded-md pointer-events-none"> + <span className="text-sm text-primary font-medium">Drop files here</span> + </div> + )} + {isSummarizing && ( + <div className="absolute inset-0 flex items-center justify-center rounded-md bg-background/80"> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <IconLoader2 className="h-4 w-4 animate-spin" /> + <span>Generating summary...</span> + </div> + </div> + )} + </div> + ); +} + +type SubtaskFormBodyProps = { + fs: ReturnType<typeof useSubtaskFormState>; + handlers: ReturnType<typeof useDialogHandlers>; + title: string; + setTitle: (v: string) => void; + workspaceId: string | null; + availableRepositories: Repository[]; + parentRepositoryId: string | null; + worktreeBranch: string | null; + profileOptions: ReturnType<typeof useAgentProfileOptions>; + executorProfileOptions: ReturnType<typeof useExecutorProfileOptions>; + agentProfileId: string; + contextValue: string; + onContextChange: (value: string) => void | Promise<void>; + hasInitialPrompt: boolean; + sessionOptions: React.ComponentProps<typeof ContextSelect>["sessionOptions"]; + promptZone: React.ReactNode; + isCreating: boolean; + isSummarizing: boolean; + hasPrompt: boolean; + onClose: () => void; + onSubmit: (e: React.FormEvent) => void; +}; + +/** + * Renders the entire subtask form body (title input, repo chips, selectors, + * context picker, prompt zone, footer). Extracted from `NewSubtaskForm` so + * the parent stays under the per-function complexity cap. + */ +export function SubtaskFormBody({ + fs, + handlers, + title, + setTitle, + workspaceId, + availableRepositories, + parentRepositoryId, + worktreeBranch, + profileOptions, + executorProfileOptions, + agentProfileId, + contextValue, + onContextChange, + hasInitialPrompt, + sessionOptions, + promptZone, + isCreating, + isSummarizing, + hasPrompt, + onClose, + onSubmit, +}: SubtaskFormBodyProps) { + // Worktree badge only when subtask still targets parent's repo (single chip, + // same id). Adding repos or pasting a URL makes it ambiguous, so hide. + const showWorktreeBadge = + !!worktreeBranch && + fs.repositories.length === 1 && + fs.repositories[0]?.repositoryId === parentRepositoryId && + !fs.useGitHubUrl; + return ( + <form onSubmit={onSubmit} className="space-y-4"> + <div className="space-y-1.5"> + <label htmlFor="subtask-title-input" className="text-xs font-medium text-muted-foreground"> + Title + </label> + <Input + id="subtask-title-input" + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="Subtask title" + className="text-sm" + data-testid="subtask-title-input" + disabled={isCreating} + /> + </div> + <RepoChipsRow + fs={fs} + repositories={availableRepositories} + isTaskStarted={false} + workspaceId={workspaceId} + onRowRepositoryChange={handlers.handleRowRepositoryChange} + onRowBranchChange={handlers.handleRowBranchChange} + onToggleGitHubUrl={handlers.handleToggleGitHubUrl} + onGitHubUrlChange={handlers.handleGitHubUrlChange} + /> + <WorktreeBadge show={showWorktreeBadge} branch={worktreeBranch} /> + <SelectorsRow + profileOptions={profileOptions} + executorProfileOptions={executorProfileOptions} + agentProfileId={agentProfileId} + executorProfileId={fs.executorProfileId} + onAgentProfileChange={handlers.handleAgentProfileChange} + onExecutorProfileChange={handlers.handleExecutorProfileChange} + disabled={isCreating} + /> + <ContextSelect + value={contextValue} + onValueChange={onContextChange} + hasInitialPrompt={hasInitialPrompt} + sessionOptions={sessionOptions} + isSummarizing={isSummarizing} + /> + {promptZone} + <DialogFooter> + <Button + type="button" + variant="ghost" + onClick={onClose} + disabled={isCreating} + className="cursor-pointer" + > + Cancel + </Button> + <Button + type="submit" + disabled={isCreating || isSummarizing || !hasPrompt} + className="cursor-pointer" + > + {isCreating ? "Creating..." : "Create Subtask"} + </Button> + </DialogFooter> + </form> + ); +} diff --git a/apps/web/components/task/new-subtask-form-state.ts b/apps/web/components/task/new-subtask-form-state.ts new file mode 100644 index 0000000000..f72b940f2e --- /dev/null +++ b/apps/web/components/task/new-subtask-form-state.ts @@ -0,0 +1,132 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import type { Branch, LocalRepository } from "@/lib/types/http"; +import type { + DialogFormState, + StepType, + TaskFormInputsHandle, +} from "@/components/task-create-dialog-types"; +import { useRepositoriesState } from "@/components/task-create-dialog-repositories-state"; + +/** + * Returns a `DialogFormState`-shaped object for the New Subtask dialog so it + * can reuse the create-task dialog's `RepoChipsRow`, `useDialogHandlers`, and + * `useGitHubUrlBranchesEffect` without any forking of those components. + * + * The subtask flow only exercises a slice of the full state: repo rows, + * GitHub URL mode, agent/executor profiles. The remaining fields (title, + * workflow, draft, fresh-branch, discovered repos) are kept as inert stubs + * because the subtask dialog renders its own title input and inherits the + * parent's workflow. + */ +export function useSubtaskFormState(): DialogFormState { + const repos = useRepositoriesState(); + const [agentProfileId, setAgentProfileId] = useState(""); + const [executorProfileId, setExecutorProfileId] = useState(""); + const [useGitHubUrl, setUseGitHubUrl] = useState(false); + const [githubUrl, setGitHubUrl] = useState(""); + const [githubBranch, setGitHubBranch] = useState(""); + const [githubBranches, setGitHubBranches] = useState<Branch[]>([]); + const [githubBranchesLoading, setGitHubBranchesLoading] = useState(false); + const [githubUrlError, setGitHubUrlError] = useState<string | null>(null); + const [githubPrHeadBranch, setGitHubPrHeadBranch] = useState<string | null>(null); + // Discovered (on-disk) repos — populated by useDiscoverReposEffect when the + // dialog opens, same as the create-task flow. This lets users target + // not-yet-imported on-machine git folders for the subtask. + const [discoveredRepositories, setDiscoveredRepositories] = useState<LocalRepository[]>([]); + const [discoverReposLoading, setDiscoverReposLoading] = useState(false); + const [discoverReposLoaded, setDiscoverReposLoaded] = useState(false); + const descriptionInputRef = useRef<TaskFormInputsHandle | null>(null); + + return useMemo<DialogFormState>( + () => ({ + // Title is owned by the subtask dialog directly — these are inert. + taskName: "", + setTaskName: NOOP, + hasTitle: false, + setHasTitle: NOOP, + hasDescription: false, + setHasDescription: NOOP, + draftDescription: "", + openCycle: 0, + currentDefaults: EMPTY_DEFAULTS, + descriptionInputRef, + // Repo chip row — what RepoChipsRow + useDialogHandlers actually drive. + repositories: repos.repositories, + setRepositories: repos.setRepositories, + addRepository: repos.addRepository, + removeRepository: repos.removeRepository, + updateRepository: repos.updateRepository, + githubBranch, + setGitHubBranch, + agentProfileId, + setAgentProfileId, + executorId: "", + setExecutorId: NOOP, + executorProfileId, + setExecutorProfileId, + discoveredRepositories, + setDiscoveredRepositories, + discoverReposLoading, + setDiscoverReposLoading, + discoverReposLoaded, + setDiscoverReposLoaded, + // Subtasks inherit the parent's workflow; no selector is rendered. + selectedWorkflowId: null, + setSelectedWorkflowId: NOOP, + fetchedSteps: EMPTY_STEPS, + setFetchedSteps: NOOP, + isCreatingSession: false, + setIsCreatingSession: NOOP, + isCreatingTask: false, + setIsCreatingTask: NOOP, + useGitHubUrl, + setUseGitHubUrl, + githubUrl, + setGitHubUrl, + githubBranches, + setGitHubBranches, + githubBranchesLoading, + setGitHubBranchesLoading, + githubUrlError, + setGitHubUrlError, + githubPrHeadBranch, + setGitHubPrHeadBranch, + workflowAgentProfileId: "", + setWorkflowAgentProfileId: NOOP, + clearDraft: NOOP, + // Fresh-branch flow is local-executor-only, single-row, and not + // surfaced here — keep the toggles wired but always inert. + freshBranchEnabled: false, + setFreshBranchEnabled: NOOP, + currentLocalBranch: "", + setCurrentLocalBranch: NOOP, + currentLocalBranchLoading: false, + setCurrentLocalBranchLoading: NOOP, + }), + [ + repos.repositories, + repos.setRepositories, + repos.addRepository, + repos.removeRepository, + repos.updateRepository, + githubBranch, + agentProfileId, + executorProfileId, + useGitHubUrl, + githubUrl, + githubBranches, + githubBranchesLoading, + githubUrlError, + githubPrHeadBranch, + discoveredRepositories, + discoverReposLoading, + discoverReposLoaded, + ], + ); +} + +const NOOP = () => undefined; +const EMPTY_DEFAULTS = { name: "", description: "" }; +const EMPTY_STEPS: StepType[] | null = null; diff --git a/apps/web/components/task/use-subtask-submit.ts b/apps/web/components/task/use-subtask-submit.ts new file mode 100644 index 0000000000..d0beeb3f0f --- /dev/null +++ b/apps/web/components/task/use-subtask-submit.ts @@ -0,0 +1,190 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import { createTask } from "@/lib/api/domains/kanban-api"; +import { replaceTaskUrl } from "@/lib/links"; +import { useAppStore } from "@/components/state-provider"; +import { + buildRepositoriesPayload, + toMessageAttachments, +} from "@/components/task-create-dialog-helpers"; +import { useToast } from "@/components/toast-provider"; +import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; +import type { Repository } from "@/lib/types/http"; +import type { useSubtaskFormState } from "./new-subtask-form-state"; +import { toContextItems, useDialogAttachments } from "./session-dialog-shared"; + +type UseSubtaskSubmitOpts = { + fs: ReturnType<typeof useSubtaskFormState>; + parentTaskId: string; + defaultProfileId: string; + workspaceId: string | null; + workflowId: string | null; + availableRepositories: Repository[]; + attachments: ReturnType<typeof useDialogAttachments>["attachments"]; + resolvePrompt: () => string; + title: string; + setIsCreating: (v: boolean) => void; + onClose: () => void; +}; + +/** + * Encapsulates the subtask creation flow: builds the repositories payload, + * calls createTask, and activates the new session. Returns `handleSubmit` + * so the surrounding component stays under the per-function complexity cap. + */ +export function useSubtaskSubmit(opts: UseSubtaskSubmitOpts) { + const { + fs, + parentTaskId, + defaultProfileId, + workspaceId, + workflowId, + availableRepositories, + attachments, + resolvePrompt, + title, + setIsCreating, + onClose, + } = opts; + const { toast } = useToast(); + const setActiveTask = useAppStore((s) => s.setActiveTask); + const setActiveSession = useAppStore((s) => s.setActiveSession); + // Synchronous guard: setIsCreating(true) won't reflect into the disabled + // submit button until React commits, so a fast double-submit (Enter + click, + // double-click) can re-enter handleSubmit and call createTask twice. + const isSubmittingRef = useRef(false); + + const performCreate = useCallback( + async (trimmedTitle: string, prompt: string) => { + const repositories = buildRepositoriesPayload({ + useGitHubUrl: fs.useGitHubUrl, + githubUrl: fs.githubUrl, + githubBranch: fs.githubBranch, + githubPrHeadBranch: fs.githubPrHeadBranch, + repositories: fs.repositories, + discoveredRepositories: fs.discoveredRepositories, + workspaceRepositories: availableRepositories, + }); + const profileId = fs.agentProfileId || defaultProfileId || undefined; + + const response = await createTask({ + workspace_id: workspaceId!, + workflow_id: workflowId!, + title: trimmedTitle, + description: prompt, + repositories, + start_agent: true, + agent_profile_id: profileId, + executor_profile_id: fs.executorProfileId || undefined, + parent_id: parentTaskId, + attachments: toMessageAttachments(attachments), + }); + + const newSessionId = response.session_id ?? response.primary_session_id ?? null; + if (newSessionId) { + setActiveTask(response.id); + setActiveSession(response.id, newSessionId); + // Layout switch is handled by useEnvSwitchCleanup when the new session's + // task_environment_id is present; the hook subscribes to env-id updates. + replaceTaskUrl(response.id); + } + }, + [ + fs.useGitHubUrl, + fs.githubUrl, + fs.githubBranch, + fs.githubPrHeadBranch, + fs.repositories, + fs.discoveredRepositories, + fs.agentProfileId, + fs.executorProfileId, + availableRepositories, + defaultProfileId, + workspaceId, + workflowId, + parentTaskId, + attachments, + setActiveTask, + setActiveSession, + ], + ); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmittingRef.current) return; + const trimmedTitle = title.trim(); + if (!trimmedTitle || !workspaceId || !workflowId) return; + const prompt = resolvePrompt(); + if (!prompt) return; + + isSubmittingRef.current = true; + setIsCreating(true); + try { + await performCreate(trimmedTitle, prompt); + onClose(); + } catch (error) { + toast({ + title: "Failed to create subtask", + description: error instanceof Error ? error.message : "Unknown error", + variant: "error", + }); + } finally { + isSubmittingRef.current = false; + setIsCreating(false); + } + }, + [title, workspaceId, workflowId, resolvePrompt, performCreate, setIsCreating, onClose, toast], + ); + + return { handleSubmit }; +} + +/** + * Bundles the prompt textarea ref, attachments, enhance-prompt action, and + * derived context items used by the subtask form. Returns the values the form + * needs without spreading hook/state plumbing across the parent component. + */ +export function useSubtaskPromptZone(opts: { + taskTitle: string; + inputDisabled: boolean; + contextValue: string; + initialPrompt: string | null; + setHasPrompt: (v: boolean) => void; +}) { + const { taskTitle, inputDisabled, contextValue, initialPrompt, setHasPrompt } = opts; + const promptRef = useRef<HTMLTextAreaElement>(null); + const attachments = useDialogAttachments(inputDisabled); + const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ + sessionId: null, + taskTitle, + }); + const handleEnhancePrompt = useCallback(() => { + const current = promptRef.current?.value?.trim(); + if (!current) return; + enhancePrompt(current, (enhanced) => { + if (promptRef.current) { + promptRef.current.value = enhanced; + setHasPrompt(true); + } + }); + }, [enhancePrompt, setHasPrompt]); + const contextItems = useMemo( + () => toContextItems(attachments.attachments, attachments.handleRemoveAttachment), + [attachments.attachments, attachments.handleRemoveAttachment], + ); + const resolvePrompt = useCallback(() => { + const typed = promptRef.current?.value?.trim() ?? ""; + if (contextValue === "copy_prompt" && !typed && initialPrompt) return initialPrompt; + return typed; + }, [contextValue, initialPrompt]); + return { + promptRef, + attachments, + contextItems, + handleEnhancePrompt, + isEnhancingPrompt, + resolvePrompt, + }; +} diff --git a/apps/web/e2e/tests/task/subtask.spec.ts b/apps/web/e2e/tests/task/subtask.spec.ts index 1234214dc1..5469d4a5b0 100644 --- a/apps/web/e2e/tests/task/subtask.spec.ts +++ b/apps/web/e2e/tests/task/subtask.spec.ts @@ -1,4 +1,8 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; import { test, expect } from "../../fixtures/test-base"; +import { makeGitEnv } from "../../helpers/git-helper"; import { KanbanPage } from "../../pages/kanban-page"; import { SessionPage } from "../../pages/session-page"; @@ -272,6 +276,433 @@ test.describe("MCP subtask creation", () => { const subtaskCard = kanban.taskCardByTitle(/Subtask Button Parent \/ Subtask \d+/); await expect(subtaskCard).toBeVisible({ timeout: 10_000 }); }); + + test("user creates subtask in a different repository via the repo chooser", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + // 1. Add a second workspace repository so the New Subtask dialog's + // repo chip can be switched to a different repo. + const otherRepoName = "UI Subtask Override Target"; + const otherRepoDir = path.join(backend.tmpDir, "repos", "ui-subtask-override"); + fs.mkdirSync(otherRepoDir, { recursive: true }); + const gitEnv = makeGitEnv(backend.tmpDir); + execSync("git init -b main", { cwd: otherRepoDir, env: gitEnv }); + execSync('git commit --allow-empty -m "init"', { cwd: otherRepoDir, env: gitEnv }); + const otherRepo = await apiClient.createRepository(seedData.workspaceId, otherRepoDir, "main", { + name: otherRepoName, + }); + + // 2. Create a parent task on repo A with a simple agent script so we can + // open its session and reach the subtask split-button. + const parentTask = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "UI Override Parent", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${parentTask.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // 3. Open the New Subtask dialog from the split-button. + await testPage.getByTestId("new-task-chevron").click(); + await testPage.getByTestId("new-subtask-button").click(); + + const titleInput = testPage.getByTestId("subtask-title-input"); + await expect(titleInput).toBeVisible({ timeout: 5_000 }); + + // 4. Open the repo chip and switch the parent's repo for repo B. The + // subtask dialog now uses the same chip-row UX as the create-task + // dialog (RepoChipsRow), so we drive it via repo-chip-trigger and + // the combobox option role. + const repoChip = testPage.getByTestId("repo-chip-trigger").first(); + await expect(repoChip).toBeVisible({ timeout: 5_000 }); + await repoChip.click(); + // The combobox option label includes a truncated-path badge alongside the + // repo name, so match by name substring rather than exact. + await testPage.getByRole("option", { name: new RegExp(otherRepoName) }).click(); + // After selection, the chip should now show repo B's name. + await expect(repoChip).toContainText(otherRepoName); + + // 5. Submit. Use a unique title so the listTasks poll can find this row. + const subtaskTitle = `UI Override Subtask ${Date.now()}`; + await titleInput.fill(subtaskTitle); + await testPage.getByTestId("subtask-prompt-input").fill("/e2e:simple-message"); + await testPage.getByRole("button", { name: "Create Subtask" }).click(); + await expect(titleInput).not.toBeVisible({ timeout: 10_000 }); + + // 6. Find the subtask via API and verify it landed on the override repo, + // not the parent's repo. + type TaskEntry = { id: string; title: string }; + let subtask: TaskEntry | undefined; + await expect + .poll( + async () => { + const allTasks = await apiClient.listTasks(seedData.workspaceId); + subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle); + return subtask; + }, + { timeout: 30_000, message: "Subtask should be created from the dialog" }, + ) + .toBeDefined(); + + const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`); + const subtaskData = (await subtaskRaw.json()) as TaskWithRepos; + const repoIds = subtaskData.repositories?.map((r) => r.repository_id) ?? []; + expect( + repoIds, + `subtask should target the override repository (${otherRepo.id}), not the parent's (${seedData.repositoryId}); got: ${JSON.stringify(repoIds)}`, + ).toContain(otherRepo.id); + expect(repoIds).not.toContain(seedData.repositoryId); + }); + + test("MCP-created subtask can override parent task repository", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + // 1. Create a second workspace repository so the subtask has somewhere + // different to point at than the parent's repository. + const otherRepoDir = path.join(backend.tmpDir, "repos", "e2e-cross-repo"); + fs.mkdirSync(otherRepoDir, { recursive: true }); + const gitEnv = makeGitEnv(backend.tmpDir); + execSync("git init -b main", { cwd: otherRepoDir, env: gitEnv }); + execSync('git commit --allow-empty -m "init"', { cwd: otherRepoDir, env: gitEnv }); + const otherRepo = await apiClient.createRepository(seedData.workspaceId, otherRepoDir, "main", { + name: "E2E Cross-Repo Target", + }); + + const subtaskTitle = "Cross-Repo Subtask E2E"; + const script = [ + 'e2e:thinking("Creating cross-repo subtask...")', + "e2e:delay(100)", + `e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${subtaskTitle}","description":"E2E subtask: verify repository override","repository_id":"${otherRepo.id}"})`, + "e2e:delay(100)", + 'e2e:message("Done.")', + ].join("\n"); + + // 2. Parent task lives in repoA (seedData.repositoryId). The agent script + // asks the MCP tool to create a subtask in repoB instead. + const parentTask = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Cross-Repo Parent Task", + seedData.agentProfileId, + { + description: script, + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + expect(parentTask.id).toBeTruthy(); + + // 3. Navigate so the parent execution stays alive while the agent runs. + await testPage.goto(`/t/${parentTask.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // 4. Poll for the subtask the agent is about to create. + type TaskEntry = { id: string; title: string }; + let subtask: TaskEntry | undefined; + await expect + .poll( + async () => { + const allTasks = await apiClient.listTasks(seedData.workspaceId); + subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle); + return subtask; + }, + { timeout: 60_000, message: "Subtask should be created by the agent's MCP call" }, + ) + .toBeDefined(); + + // 5. Verify the subtask landed on the OTHER repository, not the parent's. + const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`); + const subtaskData = (await subtaskRaw.json()) as TaskWithRepos; + const repoIds = subtaskData.repositories?.map((r) => r.repository_id) ?? []; + expect( + repoIds, + `subtask should target the override repository (${otherRepo.id}), not the parent's (${seedData.repositoryId}); got: ${JSON.stringify(repoIds)}`, + ).toContain(otherRepo.id); + expect(repoIds).not.toContain(seedData.repositoryId); + }); +}); + +/** + * Verifies the New Subtask dialog mirrors the create-task dialog's repo/URL + * features: GitHub URL paste, on-disk discovered repos, and multi-repo rows. + * These tests exercise the shared `RepoChipsRow` + handlers/effects through + * the subtask form-state shim in `new-subtask-form-state.ts`. + */ +test.describe("Subtask dialog feature parity", () => { + test("user creates subtask via pasted GitHub URL", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + // 1. Pre-seed a GitHub-backed repo + branches so the GitHub URL flow + // resolves to a known repository_id without performing a real clone. + const repoDir = path.join(backend.tmpDir, "repos", "subtask-gh-url-target"); + fs.mkdirSync(repoDir, { recursive: true }); + const gitEnv = makeGitEnv(backend.tmpDir); + execSync("git init -b main", { cwd: repoDir, env: gitEnv }); + execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv }); + const ghRepo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", { + name: "subtask-owner/subtask-repo", + provider: "github", + provider_owner: "subtask-owner", + provider_name: "subtask-repo", + }); + await apiClient.mockGitHubAddBranches("subtask-owner", "subtask-repo", [{ name: "main" }]); + + // 2. Parent task on the seed repo. + const parentTask = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Subtask GH URL Parent", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${parentTask.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // 3. Open the subtask dialog and toggle to GitHub URL mode. + await testPage.getByTestId("new-task-chevron").click(); + await testPage.getByTestId("new-subtask-button").click(); + const titleInput = testPage.getByTestId("subtask-title-input"); + await expect(titleInput).toBeVisible({ timeout: 5_000 }); + + await testPage.getByTestId("toggle-github-url").click(); + const urlInput = testPage.getByTestId("github-url-input"); + await expect(urlInput).toBeVisible({ timeout: 5_000 }); + await urlInput.fill("https://github.com/subtask-owner/subtask-repo"); + + // Wait for branch list to resolve so the payload carries `base_branch`. + // The branch pill is disabled while branches are loading and re-enables + // once the resolver returns options, so this is a positive signal that + // the URL was parsed and branches were fetched. + await expect(testPage.getByTestId("branch-chip-trigger")).toBeEnabled({ timeout: 10_000 }); + + // 4. Submit. + const subtaskTitle = `Subtask GH URL ${Date.now()}`; + await titleInput.fill(subtaskTitle); + await testPage.getByTestId("subtask-prompt-input").fill("/e2e:simple-message"); + await testPage.getByRole("button", { name: "Create Subtask" }).click(); + await expect(titleInput).not.toBeVisible({ timeout: 10_000 }); + + // 5. Verify the subtask resolved to the GitHub-backed repo, not the parent's. + type TaskEntry = { id: string; title: string }; + let subtask: TaskEntry | undefined; + await expect + .poll( + async () => { + const allTasks = await apiClient.listTasks(seedData.workspaceId); + subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle); + return subtask; + }, + { timeout: 30_000, message: "Subtask should be created from a GitHub URL" }, + ) + .toBeDefined(); + + const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`); + const subtaskData = (await subtaskRaw.json()) as TaskWithRepos; + const repoIds = subtaskData.repositories?.map((r) => r.repository_id) ?? []; + expect( + repoIds, + `subtask should resolve to the GitHub URL's repository (${ghRepo.id}); got: ${JSON.stringify(repoIds)}`, + ).toContain(ghRepo.id); + expect(repoIds).not.toContain(seedData.repositoryId); + }); + + test("subtask repo dropdown lists on-disk discovered repositories", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(60_000); + + // 1. Create a git repo on disk (under HOME=tmpDir, where the backend's + // discovery roots default scans) but do NOT register it as a workspace + // repo. The chip dropdown's "on disk" section is fed by the discovery + // endpoint and should pick this up. + const discoveredName = "subtask-discovered-only"; + const discoveredDir = path.join(backend.tmpDir, "repos", discoveredName); + fs.mkdirSync(discoveredDir, { recursive: true }); + const gitEnv = makeGitEnv(backend.tmpDir); + execSync("git init -b main", { cwd: discoveredDir, env: gitEnv }); + execSync('git commit --allow-empty -m "init"', { cwd: discoveredDir, env: gitEnv }); + + // 2. Parent task so we can open the subtask dialog from its session. + const parentTask = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Subtask Discovered Parent", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${parentTask.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // 3. Sanity-check via the API that backend discovery actually finds the + // on-disk directory. If this fails, the issue is the backend scan, not + // the dialog wiring — tells us where to look when debugging. + const discoverRaw = await apiClient.rawRequest( + "GET", + `/api/v1/workspaces/${seedData.workspaceId}/repositories/discover`, + ); + const discoverData = (await discoverRaw.json()) as { + repositories: Array<{ path: string; name: string }>; + }; + expect( + discoverData.repositories.some((r) => r.path === discoveredDir), + `backend discovery should include ${discoveredDir}; got: ${JSON.stringify(discoverData.repositories.map((r) => r.path))}`, + ).toBe(true); + + // 4. Open the subtask dialog. The discover effect fires on mount via a + // Next.js server action, so we can't await a direct backend response + // here. Use poll-then-open: poll for the chip option to appear, + // reopening the popover each tick because cmdk's listbox snapshots + // options at open time and won't update if discovery resolves while + // the popover is already showing. + await testPage.getByTestId("new-task-chevron").click(); + await testPage.getByTestId("new-subtask-button").click(); + await expect(testPage.getByTestId("subtask-title-input")).toBeVisible({ timeout: 5_000 }); + + const discoveredOption = testPage + .getByRole("option", { name: new RegExp(discoveredName, "i") }) + .first(); + const repoChip = testPage.getByTestId("repo-chip-trigger").first(); + await expect + .poll( + async () => { + await repoChip.click(); + const visible = await discoveredOption.isVisible().catch(() => false); + if (!visible) { + // Close so the next tick reopens against fresh state. + await testPage.keyboard.press("Escape"); + } + return visible; + }, + { timeout: 15_000, intervals: [500, 1000, 1500] }, + ) + .toBe(true); + await expect(discoveredOption).toContainText("on disk"); + }); + + test("user creates subtask spanning multiple repositories", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(90_000); + + // 1. Add a second workspace repository so the chip row can hold two rows. + const otherRepoName = "Subtask MultiRepo Target"; + const otherRepoDir = path.join(backend.tmpDir, "repos", "subtask-multi-repo"); + fs.mkdirSync(otherRepoDir, { recursive: true }); + const gitEnv = makeGitEnv(backend.tmpDir); + execSync("git init -b main", { cwd: otherRepoDir, env: gitEnv }); + execSync('git commit --allow-empty -m "init"', { cwd: otherRepoDir, env: gitEnv }); + const otherRepo = await apiClient.createRepository(seedData.workspaceId, otherRepoDir, "main", { + name: otherRepoName, + }); + + // 2. Parent task on repo A so we can open the subtask dialog from there. + const parentTask = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Subtask MultiRepo Parent", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${parentTask.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // 3. Open the subtask dialog. The first chip is seeded with the parent's + // repo. Click the "+ add repository" button to append a second chip, + // then point that chip at repo B. + await testPage.getByTestId("new-task-chevron").click(); + await testPage.getByTestId("new-subtask-button").click(); + const titleInput = testPage.getByTestId("subtask-title-input"); + await expect(titleInput).toBeVisible({ timeout: 5_000 }); + + await testPage.getByTestId("add-repository").click(); + const chipTriggers = testPage.getByTestId("repo-chip-trigger"); + await expect(chipTriggers).toHaveCount(2, { timeout: 5_000 }); + await chipTriggers.nth(1).click(); + await testPage.getByRole("option", { name: new RegExp(otherRepoName) }).click(); + await expect(chipTriggers.nth(1)).toContainText(otherRepoName); + + // 4. Submit. + const subtaskTitle = `Subtask MultiRepo ${Date.now()}`; + await titleInput.fill(subtaskTitle); + await testPage.getByTestId("subtask-prompt-input").fill("/e2e:simple-message"); + await testPage.getByRole("button", { name: "Create Subtask" }).click(); + await expect(titleInput).not.toBeVisible({ timeout: 10_000 }); + + // 5. Verify the subtask carries BOTH repos. + type TaskEntry = { id: string; title: string }; + let subtask: TaskEntry | undefined; + await expect + .poll( + async () => { + const allTasks = await apiClient.listTasks(seedData.workspaceId); + subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle); + return subtask; + }, + { timeout: 30_000, message: "Multi-repo subtask should be created from the dialog" }, + ) + .toBeDefined(); + + const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`); + const subtaskData = (await subtaskRaw.json()) as TaskWithRepos; + const repoIds = subtaskData.repositories?.map((r) => r.repository_id) ?? []; + expect( + repoIds, + `subtask should target both the parent's repo and the override repo; got: ${JSON.stringify(repoIds)}`, + ).toEqual(expect.arrayContaining([seedData.repositoryId, otherRepo.id])); + expect(repoIds).toHaveLength(2); + }); }); /** From 9664e13f076cd422e92c8d688e3199cb0e2f799f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= <carlosmflorencio@gmail.com> Date: Fri, 8 May 2026 21:23:48 +0100 Subject: [PATCH 29/33] fix(web): keep "All Workflows" filter on task nav and workflow.created (#854) --- .../task/dockview-desktop-layout.tsx | 3 +- .../task/dockview-header-actions.tsx | 3 +- apps/web/components/task/task-top-bar.tsx | 1 + .../e2e/tests/kanban/workflow-filter.spec.ts | 40 +++++++++++++++++- apps/web/lib/kanban/resolve-workflow.ts | 3 +- apps/web/lib/ssr/session-page-state.ts | 5 ++- .../lib/state/slices/kanban/kanban-slice.ts | 11 ++--- apps/web/lib/ws/handlers/workflows.test.ts | 41 +++++++++++++++++++ apps/web/lib/ws/handlers/workflows.ts | 5 +-- 9 files changed, 96 insertions(+), 16 deletions(-) diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index e116ee5db7..82b22cf29c 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -198,7 +198,8 @@ const tabComponents: Record<string, React.FunctionComponent<IDockviewPanelHeader function SidebarContent({ panelId }: { panelId: string }) { const workspaceId = useAppStore((state) => state.workspaces.activeId); - const workflowId = useAppStore((state) => state.workflows.activeId); + // Read kanban.workflowId (task snapshot), not workflows.activeId (homepage filter), to preserve "All Workflows" across task navigation. + const workflowId = useAppStore((state) => state.kanban.workflowId); const workspaceName = useAppStore((state) => { const ws = state.workspaces.items.find((w: { id: string }) => w.id === workspaceId); return ws?.name ?? "Workspace"; diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 6e111eab2a..f8840b218f 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -251,8 +251,9 @@ export function RightHeaderActions(props: IDockviewHeaderActionsProps) { function SidebarRightActions() { const workspaceId = useAppStore((state) => state.workspaces.activeId); - const workflowId = useAppStore((state) => state.workflows.activeId); const kanban = useAppStore((state) => state.kanban); + // Use kanban.workflowId (task context) not workflows.activeId so "All Workflows" isn't clobbered when viewing a task. + const workflowId = kanban.workflowId; const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeTaskTitle = useAppStore((state) => { const id = state.tasks.activeTaskId; diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index edb9e1a476..ab24e7aafe 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -199,6 +199,7 @@ function TopBarLeft({ <Link href="/" className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors" + data-testid="task-breadcrumb-home" > <IconHome className="h-4 w-4" /> </Link> diff --git a/apps/web/e2e/tests/kanban/workflow-filter.spec.ts b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts index db74ca63fe..b53d3c0e05 100644 --- a/apps/web/e2e/tests/kanban/workflow-filter.spec.ts +++ b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts @@ -33,6 +33,7 @@ async function selectWorkflowFilter(page: Page, optionLabel: string): Promise<vo test.describe("Kanban workflow filter", () => { let workflowBId: string | null = null; + let betaTaskId: string | null = null; // Pull `testPage` so its fixture (which runs `e2eReset` and resets user // settings) is set up before this hook seeds workflows/tasks — otherwise @@ -48,10 +49,11 @@ test.describe("Kanban workflow filter", () => { workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, }); - await apiClient.createTask(seedData.workspaceId, BETA_TASK, { + const beta = await apiClient.createTask(seedData.workspaceId, BETA_TASK, { workflow_id: workflowB.id, workflow_step_id: startB.id, }); + betaTaskId = beta.id; }); test.afterEach(async ({ apiClient, seedData }) => { @@ -59,6 +61,7 @@ test.describe("Kanban workflow filter", () => { await apiClient.deleteWorkflow(workflowBId).catch(() => {}); workflowBId = null; } + betaTaskId = null; await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, workflow_filter_id: seedData.workflowId, @@ -118,4 +121,39 @@ test.describe("Kanban workflow filter", () => { timeout: TASK_VISIBLE_TIMEOUT, }); }); + + // Regression: SSR wrote workflows.activeId from the task's workflow_id, clobbering the "All Workflows" filter on return. Pins the cross-page flow. + test("'All Workflows' selection survives navigating into a task and back", async ({ + testPage, + }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await selectWorkflowFilter(testPage, "All Workflows"); + + await expect(kanban.taskCardByTitle(ALPHA_TASK)).toBeVisible({ + timeout: TASK_VISIBLE_TIMEOUT, + }); + await expect(kanban.taskCardByTitle(BETA_TASK)).toBeVisible({ + timeout: TASK_VISIBLE_TIMEOUT, + }); + + // Visit a task that belongs to Workflow B — the SSR build path that used + // to poison the global filter. + if (!betaTaskId) throw new Error("beta task was not seeded"); + await testPage.goto(`/t/${betaTaskId}`); + await expect(testPage).toHaveURL(new RegExp(`/t/${betaTaskId}`)); + + // Breadcrumb = client-side nav: goto("/") re-runs SSR and re-resolves activeId, masking the bug. + await testPage.getByTestId("task-breadcrumb-home").click(); + await expect(testPage).toHaveURL(/\/$|\?/); + await expect(kanban.board).toBeVisible(); + + await expect(kanban.taskCardByTitle(ALPHA_TASK)).toBeVisible({ + timeout: TASK_VISIBLE_TIMEOUT, + }); + await expect(kanban.taskCardByTitle(BETA_TASK)).toBeVisible({ + timeout: TASK_VISIBLE_TIMEOUT, + }); + }); }); diff --git a/apps/web/lib/kanban/resolve-workflow.ts b/apps/web/lib/kanban/resolve-workflow.ts index 27d5d2affd..464dca7ead 100644 --- a/apps/web/lib/kanban/resolve-workflow.ts +++ b/apps/web/lib/kanban/resolve-workflow.ts @@ -30,5 +30,6 @@ export function resolveDesiredWorkflowId({ ) { return settingsWorkflowId; } - return visibleWorkflows.length === 1 ? visibleWorkflows[0].id : null; + if (visibleWorkflows.length === 1) return visibleWorkflows[0].id; + return null; } diff --git a/apps/web/lib/ssr/session-page-state.ts b/apps/web/lib/ssr/session-page-state.ts index 5bafcfb4da..c59bc9675f 100644 --- a/apps/web/lib/ssr/session-page-state.ts +++ b/apps/web/lib/ssr/session-page-state.ts @@ -22,6 +22,7 @@ import type { import type { Terminal } from "@/hooks/domains/session/use-terminals"; import { snapshotToState, taskToState } from "@/lib/ssr/mapper"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import type { AppState } from "@/lib/state/store"; function buildWorktreeState(allSessions: TaskSession[]) { const sessionsWithWorktrees = allSessions.filter((s) => s.worktree_id); @@ -104,6 +105,7 @@ function buildResourceState(p: BuildSessionPageStateParams) { })), activeId: task.workspace_id, }, + // Don't write activeId — null means "All Workflows"; task context lives in kanban.workflowId. workflows: { items: workflows.map((w) => ({ id: w.id, @@ -111,8 +113,7 @@ function buildResourceState(p: BuildSessionPageStateParams) { name: w.name, hidden: w.hidden, })), - activeId: task.workflow_id, - }, + } as Partial<AppState>["workflows"], repositories: { itemsByWorkspaceId: { [task.workspace_id]: repositories }, loadingByWorkspaceId: { [task.workspace_id]: false }, diff --git a/apps/web/lib/state/slices/kanban/kanban-slice.ts b/apps/web/lib/state/slices/kanban/kanban-slice.ts index 8a3d594e52..5c4adfd741 100644 --- a/apps/web/lib/state/slices/kanban/kanban-slice.ts +++ b/apps/web/lib/state/slices/kanban/kanban-slice.ts @@ -13,16 +13,13 @@ export const createKanbanSlice: StateCreator< [["zustand/immer", never]], [], KanbanSlice -> = (set, get) => ({ +> = (set) => ({ ...defaultKanbanState, - setActiveWorkflow: (workflowId) => { - if (get().workflows.activeId === workflowId) { - return; - } + setActiveWorkflow: (workflowId) => set((draft) => { + if (draft.workflows.activeId === workflowId) return; draft.workflows.activeId = workflowId; - }); - }, + }), setWorkflows: (workflows) => set((draft) => { draft.workflows.items = workflows; diff --git a/apps/web/lib/ws/handlers/workflows.test.ts b/apps/web/lib/ws/handlers/workflows.test.ts index 340d54d2ea..0188693ef5 100644 --- a/apps/web/lib/ws/handlers/workflows.test.ts +++ b/apps/web/lib/ws/handlers/workflows.test.ts @@ -35,6 +35,47 @@ function updatedMessage(payload: WorkflowPayload): BackendMessageMap["workflow.u }; } +function createdMessage(payload: WorkflowPayload): BackendMessageMap["workflow.created"] { + return { + id: "msg-1", + type: "notification", + action: "workflow.created", + payload, + timestamp: "2026-01-01T00:00:00Z", + }; +} + +describe("workflow.created handler — preserves user filter", () => { + it("does not promote a new workflow when activeId is null ('All Workflows')", () => { + const store = makeStore( + [{ id: "wf-1", workspaceId: "ws-1", name: "Existing", hidden: false }], + null, + ); + const handlers = registerWorkflowsHandlers(store); + + handlers["workflow.created"]?.( + createdMessage({ id: "wf-2", workspace_id: "ws-1", name: "Brand New" }), + ); + + expect(store.getState().workflows.activeId).toBeNull(); + expect(store.getState().workflows.items.map((i) => i.id)).toEqual(["wf-2", "wf-1"]); + }); + + it("leaves an existing activeId untouched when a new workflow appears", () => { + const store = makeStore( + [{ id: "wf-1", workspaceId: "ws-1", name: "Existing", hidden: false }], + "wf-1", + ); + const handlers = registerWorkflowsHandlers(store); + + handlers["workflow.created"]?.( + createdMessage({ id: "wf-2", workspace_id: "ws-1", name: "Brand New" }), + ); + + expect(store.getState().workflows.activeId).toBe("wf-1"); + }); +}); + describe("workflow.updated handler — hidden flag reconciles activeId", () => { it("clears activeId to next visible workflow when active becomes hidden", () => { const store = makeStore( diff --git a/apps/web/lib/ws/handlers/workflows.ts b/apps/web/lib/ws/handlers/workflows.ts index 71c98c3c81..ead4e004f8 100644 --- a/apps/web/lib/ws/handlers/workflows.ts +++ b/apps/web/lib/ws/handlers/workflows.ts @@ -23,6 +23,7 @@ function applyWorkflowCreated(state: AppState, payload: WorkflowPayload): AppSta if (state.workspaces.activeId !== payload.workspace_id) return state; if (state.workflows.items.some((item) => item.id === payload.id)) return state; const isHidden = Boolean(payload.hidden); + // Never use `??` here: null is a valid "All Workflows" selection, not a missing value. return { ...state, workflows: { @@ -35,9 +36,7 @@ function applyWorkflowCreated(state: AppState, payload: WorkflowPayload): AppSta }, ...state.workflows.items, ], - // Hidden workflows must never be promoted to the active selection; - // they are system-only and would surface in the workflow picker. - activeId: state.workflows.activeId ?? (isHidden ? null : payload.id), + activeId: state.workflows.activeId, }, }; } From a24b762132d8c9308030437815e1c6d41a45c0e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= <joao.salavisa@gmail.com> Date: Sat, 9 May 2026 09:01:38 +0100 Subject: [PATCH 30/33] fix(web): rank longest/quickest tasks by active duration (#849) * fix(web): rank longest/quickest tasks by active duration Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(web): drop redundant sorted alias in TaskDurationList 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> --- apps/web/app/stats/stats-sections.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/app/stats/stats-sections.tsx b/apps/web/app/stats/stats-sections.tsx index df56b6a7c0..4bf31a2b4d 100644 --- a/apps/web/app/stats/stats-sections.tsx +++ b/apps/web/app/stats/stats-sections.tsx @@ -223,7 +223,7 @@ export function WorkloadSection({ task_stats }: WorkloadSectionProps) { <Card className="rounded-sm"> <CardHeader className="pb-2"> <CardTitle className="text-sm font-medium text-muted-foreground">Longest Tasks</CardTitle> - <p className="text-xs text-muted-foreground">Ranked by elapsed span</p> + <p className="text-xs text-muted-foreground">Ranked by active duration</p> </CardHeader> <CardContent> <TaskDurationList @@ -240,7 +240,7 @@ export function WorkloadSection({ task_stats }: WorkloadSectionProps) { <CardTitle className="text-sm font-medium text-muted-foreground"> Quickest Tasks </CardTitle> - <p className="text-xs text-muted-foreground">Ranked by elapsed span</p> + <p className="text-xs text-muted-foreground">Ranked by active duration</p> </CardHeader> <CardContent> <TaskDurationList @@ -435,12 +435,13 @@ export function RepoLeaders({ repositoryStats }: { repositoryStats: RepositorySt } function TaskDurationList({ tasks, sortDirection, emptyLabel }: TaskDurationListProps) { - const filtered = [...tasks].filter((t) => t.active_duration_ms > 0 || t.elapsed_span_ms > 0); - const sorted = + const filtered = [...tasks].filter((t) => t.active_duration_ms > 0); + filtered.sort((a, b) => sortDirection === "desc" - ? filtered.sort((a, b) => b.elapsed_span_ms - a.elapsed_span_ms) - : filtered.sort((a, b) => a.elapsed_span_ms - b.elapsed_span_ms); - const top3 = sorted.slice(0, 3); + ? b.active_duration_ms - a.active_duration_ms + : a.active_duration_ms - b.active_duration_ms, + ); + const top3 = filtered.slice(0, 3); return ( <div className="space-y-3"> From 3b26601b77d761e203ca4c641c7f6676382a6fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= <carlosmflorencio@gmail.com> Date: Sat, 9 May 2026 11:39:14 +0100 Subject: [PATCH 31/33] fix: show sent message in chat without waiting for ws broadcast (#851) --- .../domains/comments/use-run-comment.test.ts | 40 +++++++++++++++++++ .../hooks/domains/comments/use-run-comment.ts | 9 ++++- apps/web/hooks/use-message-handler.ts | 19 ++++++--- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/web/hooks/domains/comments/use-run-comment.test.ts b/apps/web/hooks/domains/comments/use-run-comment.test.ts index 027a17a008..b868df8f09 100644 --- a/apps/web/hooks/domains/comments/use-run-comment.test.ts +++ b/apps/web/hooks/domains/comments/use-run-comment.test.ts @@ -9,6 +9,7 @@ import type { DiffComment, PlanComment } from "@/lib/state/slices/comments"; const mockRequest = vi.fn(); const mockAppendToQueue = vi.fn(); const mockMarkCommentsSent = vi.fn(); +const mockAddMessage = vi.fn(); const mockGetWebSocketClient = vi.fn(() => ({ request: mockRequest })); let mockStoreState: Record<string, unknown> = {}; @@ -49,6 +50,7 @@ function makeStoreState(sessionState: string, planMode = false) { chatInput: { planModeBySessionId: { "sess-1": planMode }, }, + addMessage: mockAddMessage, }; } @@ -148,6 +150,44 @@ describe("useRunComment — idle agent sends directly", () => { expect(mockMarkCommentsSent).not.toHaveBeenCalled(); }); + + // Regression: comments sent via "Run" sometimes did not appear in the chat + // until a page refresh, because the hook depended entirely on the + // session.message.added broadcast — which can be missed if the client's + // session subscription is briefly absent or its send buffer drops. + // The message returned in the message.add response must be added to the + // store optimistically; addMessage is idempotent so a later broadcast for + // the same id is a no-op. + it("adds returned message to the store so chat updates without waiting for broadcast", async () => { + const returnedMessage = { + id: "msg-42", + session_id: "sess-1", + task_id: "task-1", + author_type: "user", + content: "[diff] fix this", + type: "message", + created_at: "2026-05-08T00:00:00Z", + }; + mockRequest.mockResolvedValueOnce(returnedMessage); + const { result } = renderCommentHook(); + + await act(async () => { + await result.current.runComment(makeDiffComment()); + }); + + expect(mockAddMessage).toHaveBeenCalledWith(returnedMessage); + }); + + it("does not call addMessage when message.add returns no message", async () => { + mockRequest.mockResolvedValueOnce(undefined); + const { result } = renderCommentHook(); + + await act(async () => { + await result.current.runComment(makeDiffComment()); + }); + + expect(mockAddMessage).not.toHaveBeenCalled(); + }); }); describe("useRunComment — busy agent queues", () => { diff --git a/apps/web/hooks/domains/comments/use-run-comment.ts b/apps/web/hooks/domains/comments/use-run-comment.ts index 235eb21c1c..4946983214 100644 --- a/apps/web/hooks/domains/comments/use-run-comment.ts +++ b/apps/web/hooks/domains/comments/use-run-comment.ts @@ -15,6 +15,7 @@ import type { FileEditorComment, PRFeedbackComment, } from "@/lib/state/slices/comments"; +import type { Message } from "@/lib/types/http"; /** * Format a single comment into markdown suitable for sending to the agent. @@ -125,11 +126,17 @@ export function useRunComment({ sessionId, taskId }: UseRunCommentParams) { } else { const client = getWebSocketClient(); if (!client) throw new Error("WebSocket client unavailable"); - await client.request( + // Add the returned message to the store directly so the chat updates + // even if the session.message.added broadcast is missed (subscription + // gap, dropped frame, etc.). addMessage is idempotent on id. + const created = await client.request<Message | undefined>( "message.add", buildMessagePayload(sessionId, taskId, content, planModeEnabled, comment), 10000, ); + if (created && created.id && created.session_id) { + storeApi.getState().addMessage(created); + } } markCommentsSent([comment.id]); diff --git a/apps/web/hooks/use-message-handler.ts b/apps/web/hooks/use-message-handler.ts index c3762bad9a..cda6532208 100644 --- a/apps/web/hooks/use-message-handler.ts +++ b/apps/web/hooks/use-message-handler.ts @@ -1,12 +1,13 @@ import { useCallback } from "react"; import { getWebSocketClient } from "@/lib/ws/connection"; +import { useAppStoreApi } from "@/components/state-provider"; import { useQueue } from "./domains/session/use-queue"; import type { MessageAttachment } from "@/components/task/chat/chat-input-container"; import type { ActiveDocument } from "@/lib/state/slices/ui/types"; import type { PlanComment } from "@/lib/state/slices/comments"; import { toBlockquote } from "@/lib/state/slices/comments/format"; import type { ContextFile } from "@/lib/state/context-files-store"; -import type { CustomPrompt } from "@/lib/types/http"; +import type { CustomPrompt, Message } from "@/lib/types/http"; function buildDocumentContext( activeDocument: ActiveDocument | null, @@ -92,9 +93,9 @@ type SendMessagePayload = { contextFilesMeta?: Array<{ path: string; name: string }>; }; -async function sendMessageRequest(payload: SendMessagePayload): Promise<void> { +async function sendMessageRequest(payload: SendMessagePayload): Promise<Message | undefined> { const client = getWebSocketClient(); - if (!client) return; + if (!client) return undefined; const { taskId, @@ -108,7 +109,7 @@ async function sendMessageRequest(payload: SendMessagePayload): Promise<void> { } = payload; const hasAttachments = attachments && attachments.length > 0; - await client.request( + return client.request<Message | undefined>( "message.add", { task_id: taskId, @@ -137,6 +138,7 @@ export function useMessageHandler({ prompts = [], }: UseMessageHandlerParams) { const { queue } = useQueue(resolvedSessionId); + const storeApi = useAppStoreApi(); const buildFinalMessage = useCallback( (message: string, inlineMentions?: ContextFile[]) => { @@ -182,7 +184,10 @@ export function useMessageHandler({ return; } - await sendMessageRequest({ + // Add the returned message to the store directly so the chat updates + // even if the session.message.added broadcast is missed (subscription + // gap, dropped frame, etc.). addMessage is idempotent on id. + const created = await sendMessageRequest({ taskId, resolvedSessionId, finalMessage, @@ -192,6 +197,9 @@ export function useMessageHandler({ attachments, contextFilesMeta, }); + if (created && created.id && created.session_id) { + storeApi.getState().addMessage(created); + } }, [ resolvedSessionId, @@ -202,6 +210,7 @@ export function useMessageHandler({ isAgentBusy, queue, buildFinalMessage, + storeApi, ], ); From 40c89cd380e73c97ccaa29425bc852b7047f5273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= <carlosmflorencio@gmail.com> Date: Sat, 9 May 2026 11:59:16 +0100 Subject: [PATCH 32/33] fix(web): preserve dockview state across task and plan-mode switches (#855) --- .agents/skills/debug-logs/SKILL.md | 76 +++-- apps/backend/cmd/mock-agent/scenarios.go | 111 +++++++ .../task/chat/use-chat-panel-state.ts | 16 +- .../components/task/dockview-session-tabs.ts | 80 ++++- .../components/task/task-session-sidebar.tsx | 1 - apps/web/e2e/tests/git/diff-update.spec.ts | 274 ++++++++++++++++++ .../layout/preview-tab-session-switch.spec.ts | 143 +++++++++ apps/web/hooks/file-editors-sync.test.ts | 273 +++++++++++++++++ apps/web/hooks/file-editors-sync.ts | 156 ++++++++++ apps/web/hooks/use-file-editors.ts | 156 ++-------- .../web/lib/state/dockview-env-switch.test.ts | 110 ++++++- apps/web/lib/state/dockview-env-switch.ts | 194 ++++++++++--- apps/web/lib/state/dockview-store.ts | 45 ++- .../lib/state/layout-manager/merger.test.ts | 87 +++++- apps/web/lib/state/layout-manager/merger.ts | 83 ++++-- 15 files changed, 1549 insertions(+), 256 deletions(-) create mode 100644 apps/web/hooks/file-editors-sync.test.ts create mode 100644 apps/web/hooks/file-editors-sync.ts diff --git a/.agents/skills/debug-logs/SKILL.md b/.agents/skills/debug-logs/SKILL.md index 1e3dcfdcfa..9a8365e6c9 100644 --- a/.agents/skills/debug-logs/SKILL.md +++ b/.agents/skills/debug-logs/SKILL.md @@ -1,40 +1,59 @@ --- name: debug-logs -description: Add temporary debug logs to investigate issues. Use when the user needs to trace runtime behavior in the frontend or backend. Debug logs must be stripped before creating a PR. +description: Add temporary debug logs (console.log / structured Warn) to investigate issues. Use whenever the user wants to add logs, log statements, console.logs, trace, instrument, or print runtime behaviour to debug a frontend or backend issue. Triggers include "add debug logs", "add some logs", "log this", "trace this", "instrument", "investigate why", "print", "console.log around". Debug logs must be stripped before creating a PR. --- # Debug Logs Add temporary debug logs to investigate runtime issues. These logs are **never merged** — they must be removed before creating a PR. +**Use this skill any time you are about to add a `console.log`, `logger.Warn("[DEBUG] ...`, or similar transient instrumentation.** Even if the user says just "add some logs", "throw a few logs in there", "trace this", or "instrument X", apply these rules. + ## Rules 1. **All debug logs are temporary.** Strip them before running `/commit` or `/pr`. -2. Use a consistent, searchable prefix so logs can be found and removed easily. -3. Log object fields **inline** — do not log raw objects (they render as `Object` in the browser console and require expanding). +2. Use a consistent, searchable prefix so logs can be found and removed easily (e.g. `[reorder-bug]`, `[WS-DEBUG]`). +3. **Always print every value inline as a string.** Browser DevTools and many terminal viewers collapse arrays/maps/nested objects (`Array(2)`, `{...}`) — the agent must serialise these into the log message itself, not pass them as additional arguments. +4. Prefer **one template literal** per `console.log` call. Use `\n` and indentation to lay out structured data so the user can read it without clicking to expand. ## Frontend (TypeScript) - **Level:** `console.log` — not `console.debug` (hidden by default) or `console.warn` (noisy). -- **Prefix:** `[WS-DEBUG]` (or another `[AREA-DEBUG]` prefix agreed with the user). -- **Format:** Log fields inline as key-value pairs so they are visible without expanding. +- **Prefix:** `[area-bug]` / `[AREA-DEBUG]` agreed with the user. +- **Format:** A single template-literal string. Inline every field. Pre-format arrays/objects with `.map(...).join(...)` or `JSON.stringify(...)` before interpolating. -### ✅ Correct +### ✅ Correct — template literal, every value inlined ```typescript -console.log("[WS-DEBUG] subscribeSession", { - sessionId, - refCount: current + 1, - sentMessage: shouldSend, -}); +console.log( + `[reorder-bug] sidebar:render sort=${sort.key}:${sort.direction} active=${activeId ?? "-"}\n` + + ` inputOrder:\n ${inputs.map((t) => `${t.id}|${t.title}|state=${t.state}`).join("\n ")}\n` + + ` outputOrder:\n ${outputs.map((t) => t.id).join(", ")}`, +); ``` -Console output: -``` -[WS-DEBUG] subscribeSession {sessionId: 'abc', refCount: 2, sentMessage: true} +Renders as readable plain text in the console — no clicking required, copy-pasteable, diff-friendly. + +### ✅ Acceptable — flat object of primitives only + +When every value is a primitive (string/number/bool/null), an object literal is fine: + +```typescript +console.log("[WS-DEBUG] subscribeSession", { sessionId, refCount: current + 1, sent: shouldSend }); ``` -### ❌ Wrong — raw object renders as `Object` +Renders as: `[WS-DEBUG] subscribeSession {sessionId: 'abc', refCount: 2, sent: true}`. + +### ❌ Wrong — array/nested object collapses + +```typescript +console.log("[reorder-bug] render", { tasks: tasks.map(toCompact), groups }); +// Output: [reorder-bug] render {tasks: Array(2), groups: {...}} ← unreadable +``` + +Fix: pre-stringify (`tasks.map(...).join("\n ")`) and embed in a template literal. + +### ❌ Wrong — raw object passed as second arg ```typescript console.log("[WS-DEBUG] subscribeSession", session); @@ -44,17 +63,18 @@ console.log("[WS-DEBUG] subscribeSession", session); ### ❌ Wrong — wrong log level ```typescript -console.warn("[WS-DEBUG] subscribeSession", { sessionId }); // ← use console.log -console.debug("[WS-DEBUG] subscribeSession", { sessionId }); // ← hidden by default +console.warn("[WS-DEBUG] ...", { sessionId }); // ← use console.log +console.debug("[WS-DEBUG] ...", { sessionId }); // ← hidden by default ``` ## Backend (Go) - **Level:** `WARN` — stands out from normal `DEBUG`/`INFO` output without being an error. - **Prefix:** `[DEBUG]` (or another `[AREA-DEBUG]` prefix agreed with the user). -- **Method:** Use the structured logger: `s.logger.Warn("[DEBUG] description", "key", value, ...)`. +- **Method:** Use the structured logger: `s.logger.Warn("[DEBUG] description", "key", value, ...)`. Slog renders each key-value pair inline, so primitives are fine as-is. +- **For slices/maps/structs**, pre-format with `fmt.Sprintf` / `strings.Join` so the value lands on the log line as readable text instead of `[]string{...}`-style verbose output. -### ✅ Correct +### ✅ Correct — primitives as structured fields ```go s.logger.Warn("[DEBUG] handleTaskMoved entering", @@ -65,6 +85,15 @@ s.logger.Warn("[DEBUG] handleTaskMoved entering", ) ``` +### ✅ Correct — pre-format collections inline + +```go +s.logger.Warn("[DEBUG] panel order", + "task_id", taskID, + "panels", strings.Join(panelIDs, ","), +) +``` + ### ❌ Wrong — wrong level ```go @@ -72,11 +101,18 @@ s.logger.Debug("[DEBUG] handleTaskMoved", "task_id", taskID) // ← lost in nois s.logger.Error("[DEBUG] handleTaskMoved", "task_id", taskID) // ← triggers alerts ``` +## Quick Checklist (apply before every debug log you add) + +- [ ] Does the prefix match what's agreed (or already in the file) so all logs are greppable? +- [ ] Is every value a primitive at log time? If not, pre-format with `.map().join()`, `JSON.stringify`, `strings.Join`, or `fmt.Sprintf`. +- [ ] Frontend: `console.log` (not `warn`/`debug`)? Backend: `Warn` (not `Debug`/`Error`)? +- [ ] Is the call site the cheapest possible — one log per event, not per render frame? + ## Workflow 1. **Add debug logs** to the relevant code paths. Do not commit them — keep them as unstaged changes. 2. **Let the user test** the app and report back with console/log output. -3. **Iterate** — add, move, or refine logs as needed based on findings. Still no commits. +3. **Iterate** — add, move, or refine logs as needed based on findings. Still no commits. **If the user reports the values are unreadable (`Array(2)`, `Object`, `{...}`), the previous log violated rule 3; rewrite as a template literal before re-running.** 4. **Fix the issue** once the root cause is identified. 5. **Strip all debug logs** before committing the fix. Only commit the actual fix. diff --git a/apps/backend/cmd/mock-agent/scenarios.go b/apps/backend/cmd/mock-agent/scenarios.go index e6dea59122..3840e24c85 100644 --- a/apps/backend/cmd/mock-agent/scenarios.go +++ b/apps/backend/cmd/mock-agent/scenarios.go @@ -25,6 +25,9 @@ var scenarioRegistry = map[string]func(e *emitter){ "diff-expansion-setup": scenarioDiffExpansionSetup, "diff-update-setup": scenarioDiffUpdateSetup, "diff-update-modify": scenarioDiffUpdateModify, + "diff-update-streaming": scenarioDiffUpdateStreaming, + "multi-file-setup": scenarioMultiFileSetup, + "multi-file-modify": scenarioMultiFileModify, "untracked-file-setup": scenarioUntrackedFileSetup, "untracked-file-modify": scenarioUntrackedFileModify, "clarification": scenarioClarification, @@ -387,6 +390,114 @@ func scenarioDiffUpdateModify(e *emitter) { e.text("diff-update-modify complete: diff_update_test.txt now has SECOND_MODIFICATION") } +// scenarioDiffUpdateStreaming modifies the file mid-turn, emitting text both +// before and after the write so the agent's turn stays active for several +// seconds. Used to assert that open file editor / diff panels auto-update +// while the agent is still streaming. +func scenarioDiffUpdateStreaming(e *emitter) { + fixedDelay(50) + e.text("diff-update-streaming: starting work") + + fixedDelay(1000) + + filePath := "diff_update_test.txt" + modifiedContent := "line 1: SECOND_MODIFICATION\nline 2: unchanged\nline 3: ALSO_CHANGED\n" + if err := os.WriteFile(filePath, []byte(modifiedContent), 0o644); err != nil { + e.text("diff-update-streaming: write failed: " + err.Error()) + return + } + + fixedDelay(500) + e.text("diff-update-streaming: file written, continuing") + + // Long tail keeps the turn active long enough for polling+sync to fire. + fixedDelay(6000) + e.text("diff-update-streaming complete") +} + +// scenarioMultiFileSetup commits three tracked files, then modifies all three +// so each shows up in git status with FIRST_MODIFICATION. Used to test the +// case where the user has multiple file editors / diff panels open at once. +func scenarioMultiFileSetup(e *emitter) { + fixedDelay(50) + + wd, err := os.Getwd() + if err != nil { + e.text("multi-file-setup: getwd failed: " + err.Error()) + return + } + + runGitCmd := makeGitRunner(wd) + files := []string{"multi_a.txt", "multi_b.txt", "multi_c.txt"} + + // Cleanup any leftovers from a prior run. + for _, f := range files { + _ = runGitCmd("rm", "--force", f) + } + _ = runGitCmd("commit", "-m", "cleanup multi-file fixtures") + + // Commit pristine versions. + for _, f := range files { + original := fmt.Sprintf("%s line 1: original\n%s line 2: unchanged\n%s line 3: original\n", f, f, f) + if err := os.WriteFile(f, []byte(original), 0o644); err != nil { + e.text("multi-file-setup: write failed: " + err.Error()) + return + } + if err := runGitCmd("add", f); err != nil { + e.text("multi-file-setup: git add failed for " + f) + return + } + } + if err := runGitCmd("commit", "-m", "add multi-file fixtures"); err != nil { + e.text("multi-file-setup: git commit failed") + return + } + + // Modify all three so each appears in git status with FIRST_MODIFICATION. + for _, f := range files { + modified := fmt.Sprintf("%s line 1: FIRST_MODIFICATION\n%s line 2: unchanged\n%s line 3: original\n", f, f, f) + if err := os.WriteFile(f, []byte(modified), 0o644); err != nil { + e.text("multi-file-setup: write modified failed: " + err.Error()) + return + } + } + + fixedDelay(100) + e.text("multi-file-setup complete: 3 files have FIRST_MODIFICATION") +} + +// scenarioMultiFileModify modifies all three multi-file fixtures within a +// single turn, with a long trailing delay so the panels must auto-update +// while the agent is still streaming. Reproduces the user's reported case +// where multiple open editor / diff panels go stale on a real edit. +func scenarioMultiFileModify(e *emitter) { + fixedDelay(50) + e.text("multi-file-modify: starting work") + + fixedDelay(800) + + files := []string{"multi_a.txt", "multi_b.txt", "multi_c.txt"} + for i, f := range files { + modified := fmt.Sprintf( + "%s line 1: SECOND_MODIFICATION\n%s line 2: unchanged\n%s line 3: ALSO_CHANGED_%d\n", + f, f, f, i, + ) + if err := os.WriteFile(f, []byte(modified), 0o644); err != nil { + e.text("multi-file-modify: write failed: " + err.Error()) + return + } + // Stagger the writes slightly to mimic a real agent doing one tool + // call per file rather than all writes in a single instant. + fixedDelay(250) + } + + e.text("multi-file-modify: writes done, continuing") + + // Long tail keeps the turn active so we can assert mid-turn updates. + fixedDelay(5000) + e.text("multi-file-modify complete") +} + // scenarioUntrackedFileSetup creates a new untracked file. func scenarioUntrackedFileSetup(e *emitter) { fixedDelay(50) diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index 79db21873d..b9a0e63ffc 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -31,6 +31,13 @@ import type { BuiltInPreset } from "@/lib/state/layout-manager/presets"; const EMPTY_CONTEXT_FILES: ContextFile[] = []; const PLAN_CONTEXT_PATH = "plan:context"; +// Tracks sessions for which the plan layout has already been auto-applied. +// Module-scoped so it survives TaskChatPanel remounts (dockview's fromJSON +// tears down and rebuilds the portal-hosted panel, which would otherwise +// reset a component-local ref and cause the plan preset to be re-applied — +// clobbering the just-restored saved layout on env switch). +const autoAppliedPlanSessions = new Set<string>(); + export type CommentsState = { planComments: PlanComment[]; pendingCommentsByFile: Record<string, DiffComment[]>; @@ -82,21 +89,20 @@ function useAutoApplyPlanLayout(opts: AutoApplyPlanLayoutOpts) { setPlanMode, addContextFile, } = opts; - const autoAppliedPlanSessionRef = useRef<string | null>(null); useEffect(() => { if (!resolvedSessionId || !taskId) return; // Reset the guard when plan mode is disabled so future plan-mode steps // in the same session can be auto-applied (e.g. after proceeding away and back). - if (!sessionMetaPlanMode && autoAppliedPlanSessionRef.current === resolvedSessionId) { - autoAppliedPlanSessionRef.current = null; + if (!sessionMetaPlanMode && autoAppliedPlanSessions.has(resolvedSessionId)) { + autoAppliedPlanSessions.delete(resolvedSessionId); return; } - if (autoAppliedPlanSessionRef.current === resolvedSessionId) return; + if (autoAppliedPlanSessions.has(resolvedSessionId)) return; // Only auto-apply if both the session metadata AND the current step agree on plan mode. // sessionMetaPlanMode can be stale (deepMerge hydration preserves deleted keys), // so we cross-check with the step's actual configuration. if (sessionMetaPlanMode && currentStepHasPlanMode) { - autoAppliedPlanSessionRef.current = resolvedSessionId; + autoAppliedPlanSessions.add(resolvedSessionId); setActiveDocument(resolvedSessionId, { type: "plan", taskId }); applyBuiltInPreset("plan"); setPlanMode(resolvedSessionId, true); diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index 03b46162af..f294ba874d 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -242,6 +242,58 @@ function reconcileRemovedSessionPanels( const EMPTY_SESSION_IDS_KEY = ""; +/** + * Drop the generic "chat" placeholder once a real session is active. + * Skips removal in maximized state to preserve the saved maximize layout. + * Returns true when callers should continue ensuring session panels; + * false when the maximized state intentionally suppresses them. + */ +function prepareLayoutForSessionPanels(api: DockviewApi): boolean { + const preMaximizeLayout = useDockviewStore.getState().preMaximizeLayout; + const chatPanel = api.getPanel("chat"); + if (chatPanel && !preMaximizeLayout) { + api.removePanel(chatPanel); + } + // In maximized state, session panels are intentionally absent from the + // layout — they'll be restored when the user exits maximize. + return preMaximizeLayout === null; +} + +/** + * Decide whether to force-activate the session panel after it (and any + * sibling tabs) have been ensured. + * + * - It was just created by `ensureSessionPanel` (no prior dockview state + * to honor), or + * - The hook is mounting for the first time (initial page load) — preserve + * the long-standing behavior of focusing the agent tab so the chat is + * visible immediately, even when a saved layout had a different center + * tab active, or + * - The user switched sessions within the same task (intra-task switch + * where dockview hasn't re-activated the new session for us). + * + * After an env (task) switch the prev refs are populated and the task + * changed — `restoreSavedActiveViews` has already applied the saved active + * panel for the incoming task, so calling setActive here would override it + * and force the agent tab on top of whatever the user had focused. + */ +function shouldActivateSessionPanel(args: { + sessionPanelExistedBefore: boolean; + prevTaskId: string | null; + prevSessionId: string | null; + currentTaskId: string | null; + currentSessionId: string; +}): boolean { + const { sessionPanelExistedBefore, prevTaskId, prevSessionId, currentTaskId, currentSessionId } = + args; + if (!sessionPanelExistedBefore) return true; + const isFirstMount = prevTaskId === null && prevSessionId === null; + if (isFirstMount) return true; + const taskChanged = prevTaskId !== currentTaskId; + const sessionChanged = prevSessionId !== currentSessionId; + return sessionChanged && !taskChanged; +} + /** * Open a dockview tab for every session of the active task and keep them in sync * with the store. @@ -256,6 +308,8 @@ const EMPTY_SESSION_IDS_KEY = ""; */ export function useAutoSessionTab(effectiveSessionId: string | null) { const sessionTabCreatedRef = useRef<Set<string>>(new Set()); + const prevTaskIdRef = useRef<string | null>(null); + const prevSessionIdRef = useRef<string | null>(null); const appStore = useAppStoreApi(); // Key-based dependency so the effect re-runs when the task's session list @@ -298,17 +352,7 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { // be re-created by ensureSessionPanel. if (!currentSessionIds.includes(effectiveSessionId)) return; - // Remove the generic "chat" placeholder as soon as a real session is - // active — per-session tabs replace it. Skip in maximized state to - // preserve the saved maximize layout. - const chatPanel = api.getPanel("chat"); - if (chatPanel && !useDockviewStore.getState().preMaximizeLayout) { - api.removePanel(chatPanel); - } - - // In maximized state, session panels are intentionally absent from the - // layout — they'll be restored when the user exits maximize. - if (useDockviewStore.getState().preMaximizeLayout !== null) { + if (!prepareLayoutForSessionPanels(api)) { sessionTabCreatedRef.current.add(effectiveSessionId); return; } @@ -316,6 +360,7 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { const initialPosition = resolveInitialPosition(api); // Active panel first so its group becomes the anchor for siblings. + const sessionPanelExistedBefore = !!api.getPanel(`session:${effectiveSessionId}`); ensureSessionPanel( api, effectiveSessionId, @@ -325,7 +370,16 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { ); const activePanel = api.getPanel(`session:${effectiveSessionId}`); if (activePanel) { - activePanel.api.setActive(); + const shouldActivate = shouldActivateSessionPanel({ + sessionPanelExistedBefore, + prevTaskId: prevTaskIdRef.current, + prevSessionId: prevSessionIdRef.current, + currentTaskId: tid ?? null, + currentSessionId: effectiveSessionId, + }); + if (shouldActivate) { + activePanel.api.setActive(); + } useDockviewStore.setState({ centerGroupId: activePanel.group.id }); } @@ -337,5 +391,7 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { if (sid === effectiveSessionId) continue; ensureSessionPanel(api, sid, siblingAnchor, true, sessionTabCreatedRef.current); } + prevTaskIdRef.current = tid ?? null; + prevSessionIdRef.current = effectiveSessionId; }, [effectiveSessionId, sessionIdsKey, appStore]); } diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 175260aa74..6d5864e2c1 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -567,7 +567,6 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ () => applyView(displayTasks, effectiveView, { pinnedTaskIds, orderedTaskIds }), [displayTasks, effectiveView, pinnedTaskIds, orderedTaskIds], ); - return ( <PanelRoot data-testid="task-sidebar"> <SidebarFilterBar /> diff --git a/apps/web/e2e/tests/git/diff-update.spec.ts b/apps/web/e2e/tests/git/diff-update.spec.ts index 2dacf33b3e..f43014747c 100644 --- a/apps/web/e2e/tests/git/diff-update.spec.ts +++ b/apps/web/e2e/tests/git/diff-update.spec.ts @@ -69,6 +69,15 @@ function seedDiffUpdateTask(testPage: Page, apiClient: ApiClient, seedData: Seed }); } +/** Seed a task for the multi-file scenario (3 files, FIRST_MODIFICATION on all). */ +function seedMultiFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) { + return seedTaskWithScenario(testPage, apiClient, seedData, { + title: "Multi-file Diff Update E2E", + scenarioCommand: "/e2e:multi-file-setup", + completionText: "multi-file-setup complete", + }); +} + /** Click the Changes dockview tab. */ async function openChangesTab(testPage: Page) { const changesTab = testPage.locator(".dv-default-tab", { hasText: "Changes" }); @@ -239,6 +248,271 @@ test.describe("Diff update on file change", () => { }); }); +test.describe("File editor auto-update on file change", () => { + test.describe.configure({ retries: 2, timeout: 120_000 }); + + test("editor panel auto-updates without re-opening when file changes", async ({ + testPage, + apiClient, + seedData, + }) => { + // Regression: opening a file in the FileEditorPanel and then having the + // agent modify it should reactively update the editor content WITHOUT the + // user re-clicking the file. + const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData); + + // Open the file in the file editor via the Files tree. + await session.clickTab("Files"); + await expect(session.files).toBeVisible({ timeout: 10_000 }); + const fileRow = session.files.getByText("diff_update_test.txt"); + await expect(fileRow).toBeVisible({ timeout: 10_000 }); + await fileRow.click(); + + // The editor tab should appear in dockview. + const editorTab = testPage.locator(".dv-default-tab", { hasText: "diff_update_test.txt" }); + await expect(editorTab).toBeVisible({ timeout: 10_000 }); + + // Verify initial content shows FIRST_MODIFICATION (default Monaco editor). + const editorContent = testPage.locator(".view-lines").first(); + await expect(editorContent).toContainText("FIRST_MODIFICATION", { timeout: 15_000 }); + + // Switch to chat and trigger the second modification. + await session.clickSessionChatTab(); + await session.sendMessage("/e2e:diff-update-modify"); + await expect( + session.chat.getByText("diff-update-modify complete", { exact: false }), + ).toBeVisible({ timeout: 45_000 }); + + // Click the editor tab back to view it (do NOT re-open from file tree). + // The panel is still mounted, just not the active tab. + await editorTab.click(); + + // The editor should auto-update with SECOND_MODIFICATION content. + // Re-query .view-lines since DOM may have re-rendered after tab switch. + const updatedEditorContent = testPage.locator(".view-lines").first(); + await expect(updatedEditorContent).toContainText("SECOND_MODIFICATION", { timeout: 30_000 }); + await expect(updatedEditorContent).toContainText("ALSO_CHANGED", { timeout: 15_000 }); + + // FIRST_MODIFICATION should be gone. + await expect(updatedEditorContent).not.toContainText("FIRST_MODIFICATION", { timeout: 5_000 }); + }); + + test("editor + diff panels auto-update while agent is streaming (mid-turn)", async ({ + testPage, + apiClient, + seedData, + }) => { + // Reproduces the user-reported bug: open both editor and diff from the + // Changes tab, then have the agent modify the file MID-TURN (still + // streaming). Both panels must auto-update within a few seconds while + // the agent's turn is still active — without re-opening the file. + const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData); + + // Open the diff panel from the Changes tab. + await openChangesTab(testPage); + await openFileDiff(testPage, "diff_update_test.txt"); + const diffsContainer = getDiffsContainer(testPage); + await expect(diffsContainer).toBeVisible({ timeout: 15_000 }); + await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({ + timeout: 15_000, + }); + + // Also open the file editor for the same file via the Files tree. + await session.clickTab("Files"); + await expect(session.files).toBeVisible({ timeout: 5_000 }); + const fileRow = session.files.getByText("diff_update_test.txt"); + await expect(fileRow).toBeVisible({ timeout: 10_000 }); + await fileRow.click(); + const editorTab = testPage.locator(".dv-default-tab[type='file-editor']", { + hasText: "diff_update_test.txt", + }); + await expect(editorTab).toBeVisible({ timeout: 10_000 }); + const editorContent = testPage.locator(".view-lines").first(); + await expect(editorContent).toContainText("FIRST_MODIFICATION", { timeout: 15_000 }); + + // Trigger the streaming scenario: agent will write the file mid-turn and + // keep emitting text for ~6s afterwards. Do NOT wait for completion — + // we want to assert updates while the turn is still streaming. + await session.clickSessionChatTab(); + await session.sendMessage("/e2e:diff-update-streaming"); + + // Wait for the agent to confirm it has started — turn is now live. + await expect(session.chat.getByText("starting work", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + + // While the agent is still mid-turn (it has ~6s of trailing delay), both + // panels must reflect the new content. This is the user's bug scenario. + // Click back to the editor tab to make assertions. + await editorTab.click(); + const liveEditorContent = testPage.locator(".view-lines").first(); + await expect(liveEditorContent).toContainText("SECOND_MODIFICATION", { timeout: 8_000 }); + await expect(liveEditorContent).toContainText("ALSO_CHANGED", { timeout: 5_000 }); + + // Switch to the diff tab and assert it also updated. + const diffTab = testPage.locator(".dv-default-tab[type='file-diff']", { + hasText: "diff_update_test.txt", + }); + await expect(diffTab).toBeVisible({ timeout: 10_000 }); + await diffTab.click(); + const liveDiffsContainer = getDiffsContainer(testPage); + await expect(liveDiffsContainer.getByText("SECOND_MODIFICATION", { exact: true })).toBeVisible({ + timeout: 8_000, + }); + await expect(liveDiffsContainer.getByText("ALSO_CHANGED", { exact: true })).toBeVisible({ + timeout: 5_000, + }); + }); +}); + +test.describe("Multi-file editor + diff auto-update", () => { + test.describe.configure({ retries: 2, timeout: 180_000 }); + + test("diff panel auto-updates across all 3 files during a single multi-file streaming turn", async ({ + testPage, + apiClient, + seedData, + }) => { + // Reproduces the user-reported "multiple files / diffs opened" case. + // The Changes panel mounts useFileEditors. Opening a diff for file_a + // mounts another instance. A single multi-file streaming modify turn + // must propagate to gitStatus so: + // - The currently open diff (file_a) auto-updates mid-turn. + // - Switching the preview to file_b shows file_b's NEW diff immediately + // (not the pre-turn FIRST_MODIFICATION). + // - Same for file_c. + // This exercises the gitStatus-driven re-render path without the user + // re-triggering anything per-file. + const { session } = await seedMultiFileTask(testPage, apiClient, seedData); + const fileA = "multi_a.txt"; + const fileB = "multi_b.txt"; + const fileC = "multi_c.txt"; + + // Open the diff preview for file_a from the Changes panel. + await openChangesTab(testPage); + await openFileDiff(testPage, fileA); + const diffsContainer = getDiffsContainer(testPage); + await expect(diffsContainer).toBeVisible({ timeout: 15_000 }); + await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({ + timeout: 15_000, + }); + + // Trigger the multi-file streaming modification. Don't wait for completion. + await session.clickSessionChatTab(); + await session.sendMessage("/e2e:multi-file-modify"); + await expect(session.chat.getByText("starting work", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + + // Click back to the file_a diff tab so its panel becomes the visible one. + const diffTabA = testPage + .locator(".dv-default-tab") + .filter({ hasText: `Diff [${fileA}]` }) + .first(); + await diffTabA.click(); + + // While the agent is still streaming, the file_a diff must reflect + // SECOND_MODIFICATION + ALSO_CHANGED_0. + await expect(diffsContainer.getByText("SECOND_MODIFICATION", { exact: true })).toBeVisible({ + timeout: 15_000, + }); + await expect(diffsContainer.getByText("ALSO_CHANGED_0", { exact: true })).toBeVisible({ + timeout: 5_000, + }); + + // Swap the diff preview to file_b — the gitStatus-driven content must + // already have SECOND_MODIFICATION available without re-running the agent. + await openChangesTab(testPage); + await openFileDiff(testPage, fileB); + await expect(diffsContainer.getByText("ALSO_CHANGED_1", { exact: true })).toBeVisible({ + timeout: 15_000, + }); + + // And again for file_c. + await openChangesTab(testPage); + await openFileDiff(testPage, fileC); + await expect(diffsContainer.getByText("ALSO_CHANGED_2", { exact: true })).toBeVisible({ + timeout: 15_000, + }); + }); +}); + +test.describe("User-save then diff view (colleague repro)", () => { + // Intermittent bug: after agent modifies a file, user opens it in editor, + // edits + saves, then switches to diff view \u2014 the diff panel shows the + // pre-save content (agent's edit only, not the user's save). Workaround + // reported: leave the task and re-enter. We try to repro by driving the + // exact UI sequence and asserting the diff contains the user's marker. + test.describe.configure({ retries: 2, timeout: 120_000 }); + + test("diff shows user's edit after open-edit-save sequence", async ({ + testPage, + apiClient, + seedData, + }) => { + const USER_MARKER = "USER_EDIT_MARKER_42"; + const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData); + + // Step 1: agent has already modified diff_update_test.txt with FIRST_MODIFICATION. + // Wait for the Changes panel auto-activate (fires once gitStatus 0\u2192N) to + // settle before we click Files \u2014 otherwise the auto-activate races with + // our click and Files ends up inactive. Match any "Changes (N)" count so we + // don't fail when the badge briefly reads a different value. + await expect(testPage.locator(".dv-default-tab", { hasText: /^Changes \(\d+\)/ })).toBeVisible({ + timeout: 30_000, + }); + // Click Files and verify the file row becomes visible. If a late + // auto-activate stole focus back to Changes, click Files again. + const fileRow = session.fileTreeNode("diff_update_test.txt"); + await expect + .poll( + async () => { + await session.clickTab("Files"); + return await fileRow.isVisible(); + }, + { timeout: 20_000, intervals: [500, 1000, 2000] }, + ) + .toBe(true); + await fileRow.click(); + const editorTab = testPage.locator(".dv-default-tab[type='file-editor']", { + hasText: "diff_update_test.txt", + }); + await expect(editorTab).toBeVisible({ timeout: 10_000 }); + const editorContent = testPage.locator(".view-lines").first(); + await expect(editorContent).toContainText("FIRST_MODIFICATION", { timeout: 30_000 }); + + // Step 2: type into Monaco \u2014 add a unique marker line at the end. + // Click the view-lines area to focus the editor (Monaco's hidden textarea + // captures input but isn't directly focusable across all browsers). + await editorContent.click(); + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + // Move caret to end of document, then add a new line with our marker. + await testPage.keyboard.press(`${modifier}+End`); + await testPage.keyboard.press("End"); + await testPage.keyboard.type(`\n${USER_MARKER}`); + // Confirm the marker is present in the editor before saving. + await expect(editorContent).toContainText(USER_MARKER, { timeout: 5_000 }); + + // Step 3: save with Cmd/Ctrl+S. + await testPage.keyboard.press(`${modifier}+s`); + + // Step 4: open the diff for the same file. + await openChangesTab(testPage); + await openFileDiff(testPage, "diff_update_test.txt"); + + // Step 5: the diff must reflect the user's marker. + const diffsContainer = getDiffsContainer(testPage); + await expect(diffsContainer).toBeVisible({ timeout: 15_000 }); + await expect(diffsContainer.getByText(USER_MARKER, { exact: true })).toBeVisible({ + timeout: 15_000, + }); + // FIRST_MODIFICATION should still be present (agent's earlier edit). + await expect(diffsContainer.getByText("FIRST_MODIFICATION", { exact: true })).toBeVisible({ + timeout: 5_000, + }); + }); +}); + test.describe("Untracked file diff update", () => { test.describe.configure({ retries: 2, timeout: 120_000 }); diff --git a/apps/web/e2e/tests/layout/preview-tab-session-switch.spec.ts b/apps/web/e2e/tests/layout/preview-tab-session-switch.spec.ts index 7742822fe7..725c725ce4 100644 --- a/apps/web/e2e/tests/layout/preview-tab-session-switch.spec.ts +++ b/apps/web/e2e/tests/layout/preview-tab-session-switch.spec.ts @@ -1,6 +1,8 @@ import path from "node:path"; import { expect, type Page } from "@playwright/test"; import { test } from "../../fixtures/test-base"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; import { SessionPage } from "../../pages/session-page"; import { GitHelper, makeGitEnv } from "../../helpers/git-helper"; import { KanbanPage } from "../../pages/kanban-page"; @@ -23,6 +25,39 @@ async function openFileInPreview(page: Page, session: SessionPage, filename: str } } +async function seedFinishedTask( + apiClient: ApiClient, + seedData: SeedData, + title: string, +): Promise<{ id: string }> { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return DONE_STATES.includes(sessions[0]?.state ?? ""); + }, + { timeout: 30_000, message: `Waiting for ${title} session to finish` }, + ) + .toBe(true); + return task; +} + +/** `.dv-tab` is the wrapper dockview toggles `dv-active-tab` on. */ +function tabWrapperByText(page: Page, text: string) { + return page.locator(".dv-tab", { has: page.locator(".dv-default-tab", { hasText: text }) }); +} + test.describe("Preview tab survives session switch", () => { test("preview tab persists after switching tasks and back", async ({ testPage, @@ -122,4 +157,112 @@ test.describe("Preview tab survives session switch", () => { const fileTab = testPage.locator(".dv-default-tab").filter({ hasText: FILE_A }); await expect(fileTab).toHaveCount(1, { timeout: 15_000 }); }); + + test("promoted file tab does not duplicate after task switch round-trip", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(120_000); + + // Worker-scoped backend reuses the same repo across tests in the file, so each + // test seeds its own filename to avoid "nothing to commit" collisions. + const FILE_PROMOTED = "promoted.ts"; + const git = new GitHelper( + path.join(backend.tmpDir, "repos", "e2e-repo"), + makeGitEnv(backend.tmpDir), + ); + git.createFile(FILE_PROMOTED, "// promoted content"); + git.stageAll(); + git.commit("seed promoted"); + + const taskA = await seedFinishedTask(apiClient, seedData, "Promote Round-Trip A"); + const taskB = await seedFinishedTask(apiClient, seedData, "Promote Round-Trip B"); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.taskCardByTitle("Promote Round-Trip A").click(); + await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // Open file in preview, then promote via double-click. The panel keeps id + // `preview:file-editor` with `params.promoted=true` until another file is + // opened, so we sync on the italic class disappearing rather than the testid. + await openFileInPreview(testPage, session, FILE_PROMOTED); + const previewTab = testPage.getByTestId("preview-tab-file-editor"); + await expect(previewTab).toBeVisible({ timeout: 15_000 }); + await previewTab.dblclick(); + await expect(previewTab).not.toHaveClass(/italic/, { timeout: 10_000 }); + + // Round-trip: switch to Task B, then back to Task A (forces fromJSON restore). + await session.clickTaskInSidebar("Promote Round-Trip B"); + await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), { timeout: 15_000 }); + await session.waitForLoad(); + + await session.clickTaskInSidebar("Promote Round-Trip A"); + await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), { timeout: 15_000 }); + await expect(testPage.locator(".dv-dockview")).toBeVisible({ timeout: 15_000 }); + + // Regression: previously `loadAndRestoreTabs` re-added `file:promoted.ts` on + // top of the snapshot's restored `preview:file-editor` (which was persisted + // as `pinned: true` because it was a promoted preview), producing two tabs + // with the same filename. Strict count of 1 catches the duplicate. + const fileTab = testPage.locator(".dv-default-tab").filter({ hasText: FILE_PROMOTED }); + await expect(fileTab).toHaveCount(1, { timeout: 15_000 }); + }); + + test("active center tab is preserved across task switch round-trip", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(120_000); + + const FILE_ACTIVE = "active-file.ts"; + const git = new GitHelper( + path.join(backend.tmpDir, "repos", "e2e-repo"), + makeGitEnv(backend.tmpDir), + ); + git.createFile(FILE_ACTIVE, "// active content"); + git.stageAll(); + git.commit("seed active"); + + const taskA = await seedFinishedTask(apiClient, seedData, "Active Tab Round-Trip A"); + const taskB = await seedFinishedTask(apiClient, seedData, "Active Tab Round-Trip B"); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.taskCardByTitle("Active Tab Round-Trip A").click(); + await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // Open file in preview — it auto-activates, but we click it explicitly to + // make the intent clear and to be robust against any default-active changes. + await openFileInPreview(testPage, session, FILE_ACTIVE); + const fileTabWrapper = tabWrapperByText(testPage, FILE_ACTIVE); + await fileTabWrapper.click(); + await expect(fileTabWrapper).toHaveClass(/dv-active-tab/, { timeout: 10_000 }); + + // Round-trip + await session.clickTaskInSidebar("Active Tab Round-Trip B"); + await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), { timeout: 15_000 }); + await session.waitForLoad(); + + await session.clickTaskInSidebar("Active Tab Round-Trip A"); + await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), { timeout: 15_000 }); + await expect(testPage.locator(".dv-dockview")).toBeVisible({ timeout: 15_000 }); + + // Regression: previously `useAutoSessionTab` always called `setActive()` on + // the session panel after fromJSON, overriding the restored active state. + // The file tab the user left active must remain active across the round-trip. + // (Only one tab per group can be `dv-active-tab` so this implicitly asserts + // the session panel is not active.) + await expect(fileTabWrapper).toHaveClass(/dv-active-tab/, { timeout: 15_000 }); + }); }); diff --git a/apps/web/hooks/file-editors-sync.test.ts b/apps/web/hooks/file-editors-sync.test.ts new file mode 100644 index 0000000000..7899d9bafa --- /dev/null +++ b/apps/web/hooks/file-editors-sync.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor, cleanup } from "@testing-library/react"; +import { useRef } from "react"; +import type { FileEditorState } from "@/lib/state/dockview-store"; +import type { FileInfo } from "@/lib/state/store"; +import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; + +const mockRequestFileContent = vi.fn(); +const mockGetWebSocketClient = vi.fn(); +let openFilesMap = new Map<string, FileEditorState>(); + +vi.mock("@/lib/ws/workspace-files", () => ({ + requestFileContent: (...args: unknown[]) => mockRequestFileContent(...args), +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => mockGetWebSocketClient(), +})); + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: { + getState: () => ({ openFiles: openFilesMap }), + }, +})); + +vi.mock("./use-file-save-delete", () => ({ + updatePanelAfterSave: vi.fn(), +})); + +vi.mock("@/lib/utils/file-diff", () => ({ + calculateHash: async (s: string) => `h:${s.length}:${s.slice(0, 8)}`, +})); + +import { + buildGitFileSignature, + syncOpenFileFromWorkspace, + useOpenFileWorkspaceSync, +} from "./file-editors-sync"; + +const FAKE_CLIENT = {} as ReturnType<typeof import("@/lib/ws/connection").getWebSocketClient>; +const SESSION_ID = "sess-1"; +const PATH = "src/foo.ts"; + +function seedOpenFile(state: Partial<FileEditorState> = {}) { + openFilesMap = new Map<string, FileEditorState>([ + [ + PATH, + { + path: PATH, + name: "foo.ts", + content: "v1", + originalContent: "v1", + originalHash: "h:2:v1", + isDirty: false, + ...state, + }, + ], + ]); +} + +describe("buildGitFileSignature", () => { + it("returns a stable __clean__ marker for files absent from git status", () => { + expect(buildGitFileSignature(undefined)).toBe("__clean__"); + }); + + it("changes when the diff content changes (agent-edit detection)", () => { + const before = buildGitFileSignature({ + path: PATH, + status: "modified", + staged: false, + additions: 1, + deletions: 0, + diff: "@@ -1 +1 @@\n-v1\n+v2", + } as FileInfo); + const after = buildGitFileSignature({ + path: PATH, + status: "modified", + staged: false, + additions: 1, + deletions: 0, + diff: "@@ -1 +1 @@\n-v1\n+v3", + } as FileInfo); + expect(before).not.toBe(after); + }); +}); + +describe("syncOpenFileFromWorkspace", () => { + let updateFileState: ReturnType<typeof vi.fn>; + + beforeEach(() => { + vi.clearAllMocks(); + updateFileState = vi.fn(); + }); + + it("replaces editor content when the agent modifies a clean file", async () => { + // User opens foo.ts (content "v1"). Agent then edits it on disk to "v2". + // The hook detects the git status change and calls this sync function; + // we expect the editor buffer to be replaced in place. + seedOpenFile({ content: "v1", originalContent: "v1", originalHash: "h:2:v1" }); + mockRequestFileContent.mockResolvedValueOnce({ + content: "v2", + is_binary: false, + resolved_path: PATH, + }); + + await syncOpenFileFromWorkspace({ + client: FAKE_CLIENT, + sessionId: SESSION_ID, + path: PATH, + updateFileState, + }); + + expect(mockRequestFileContent).toHaveBeenCalledWith(FAKE_CLIENT, SESSION_ID, PATH); + expect(updateFileState).toHaveBeenCalledTimes(1); + expect(updateFileState).toHaveBeenCalledWith( + PATH, + expect.objectContaining({ + content: "v2", + originalContent: "v2", + isDirty: false, + hasRemoteUpdate: false, + }), + ); + }); + + it("flags hasRemoteUpdate (instead of clobbering) when the user has unsaved edits", async () => { + // User has typed local edits (isDirty=true). Agent edits on disk in + // parallel. We must NOT silently replace the user's buffer; we surface + // a Reload affordance via hasRemoteUpdate. + seedOpenFile({ + content: "user-typed", + originalContent: "v1", + originalHash: "h:2:v1", + isDirty: true, + }); + mockRequestFileContent.mockResolvedValueOnce({ + content: "v2-from-agent", + is_binary: false, + resolved_path: PATH, + }); + + await syncOpenFileFromWorkspace({ + client: FAKE_CLIENT, + sessionId: SESSION_ID, + path: PATH, + updateFileState, + }); + + expect(updateFileState).toHaveBeenCalledTimes(1); + expect(updateFileState).toHaveBeenCalledWith( + PATH, + expect.objectContaining({ + hasRemoteUpdate: true, + remoteContent: "v2-from-agent", + }), + ); + }); + + it("is a no-op when remote content matches the editor buffer", async () => { + seedOpenFile({ content: "v1", originalContent: "v1", originalHash: "h:2:v1" }); + mockRequestFileContent.mockResolvedValueOnce({ + content: "v1", + is_binary: false, + resolved_path: PATH, + }); + + await syncOpenFileFromWorkspace({ + client: FAKE_CLIENT, + sessionId: SESSION_ID, + path: PATH, + updateFileState, + }); + + expect(updateFileState).not.toHaveBeenCalled(); + }); +}); + +function makeStatus(files: Record<string, FileInfo>, timestamp: string): GitStatusEntry { + return { + branch: "main", + remote_branch: null, + modified: [], + added: [], + deleted: [], + untracked: [], + renamed: [], + ahead: 0, + behind: 0, + files, + timestamp, + } as GitStatusEntry; +} + +function modifiedFile(diff: string): FileInfo { + return { + path: PATH, + status: "modified", + staged: false, + additions: 1, + deletions: 1, + diff, + }; +} + +type SyncProps = { + gitStatus: GitStatusEntry | undefined; + openFiles: Map<string, FileEditorState>; + updateFileState: (path: string, updates: Partial<FileEditorState>) => void; +}; + +function renderSyncHook(initial: SyncProps) { + return renderHook( + (props: SyncProps) => { + const activeSessionIdRef = useRef<string | null>(SESSION_ID); + const gitFileSignaturesRef = useRef<Map<string, string>>(new Map()); + useOpenFileWorkspaceSync({ + gitStatus: props.gitStatus, + openFiles: props.openFiles, + updateFileState: props.updateFileState, + activeSessionIdRef, + gitFileSignaturesRef, + }); + }, + { initialProps: initial }, + ); +} + +describe("useOpenFileWorkspaceSync", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetWebSocketClient.mockReturnValue(FAKE_CLIENT); + }); + + it("refetches the open editor buffer when the agent edits its file", async () => { + // User has foo.ts open showing "v1". Initial git status has no entry for + // foo.ts (clean). Then the agent edits the file: a fresh git status arrives + // with a modified entry. We expect the hook to fetch new content and + // call updateFileState so the editor displays "v2". + seedOpenFile({ content: "v1", originalContent: "v1", originalHash: "h:2:v1" }); + const updateFileState = vi.fn(); + mockRequestFileContent.mockResolvedValue({ + content: "v2", + is_binary: false, + resolved_path: PATH, + }); + + const initialStatus = makeStatus({}, "2026-05-08T11:00:00.000Z"); + const { rerender } = renderSyncHook({ + gitStatus: initialStatus, + openFiles: openFilesMap, + updateFileState, + }); + + // First render baselines the signature; no fetch yet. + expect(mockRequestFileContent).not.toHaveBeenCalled(); + + const editedStatus = makeStatus( + { [PATH]: modifiedFile("@@ -1 +1 @@\n-v1\n+v2") }, + "2026-05-08T11:00:02.000Z", + ); + rerender({ gitStatus: editedStatus, openFiles: openFilesMap, updateFileState }); + + await waitFor(() => expect(mockRequestFileContent).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(updateFileState).toHaveBeenCalledWith( + PATH, + expect.objectContaining({ content: "v2", originalContent: "v2" }), + ), + ); + + cleanup(); + }); +}); diff --git a/apps/web/hooks/file-editors-sync.ts b/apps/web/hooks/file-editors-sync.ts new file mode 100644 index 0000000000..5ad2915fef --- /dev/null +++ b/apps/web/hooks/file-editors-sync.ts @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect } from "react"; +import type { FileEditorState } from "@/lib/state/dockview-store"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import { requestFileContent } from "@/lib/ws/workspace-files"; +import { calculateHash } from "@/lib/utils/file-diff"; +import { updatePanelAfterSave } from "./use-file-save-delete"; +import type { FileInfo } from "@/lib/state/store"; +import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; + +/** + * Builds a stable signature string from a file's git status entry. The hook + * compares signatures across renders to decide whether the editor's content + * needs re-syncing from the workspace. + */ +export function buildGitFileSignature(file: FileInfo | undefined): string { + if (!file) return "__clean__"; + return [ + file.status ?? "", + file.staged ? "1" : "0", + String(file.additions ?? 0), + String(file.deletions ?? 0), + file.old_path ?? "", + file.diff ?? "", + ].join("|"); +} + +export type SyncOpenFileArgs = { + client: ReturnType<typeof getWebSocketClient>; + sessionId: string; + path: string; + updateFileState: (path: string, updates: Partial<FileEditorState>) => void; +}; + +/** + * Re-fetches file content from the workspace and reconciles it with the open + * editor buffer. Behavior depends on dirty state: + * - Clean buffer: replaces `content` + `originalContent` so the editor shows + * the latest disk state without losing user focus/cursor. + * - Dirty buffer with matching remote content: clears dirty flag (the user's + * edits happen to equal what landed on disk). + * - Dirty buffer with different remote content: surfaces a "Reload" affordance + * via `hasRemoteUpdate` so the user explicitly chooses to clobber edits. + */ +export async function syncOpenFileFromWorkspace({ + client, + sessionId, + path, + updateFileState, +}: SyncOpenFileArgs): Promise<void> { + if (!client) return; + try { + const response = await requestFileContent(client, sessionId, path); + const latest = useDockviewStore.getState().openFiles.get(path); + if (!latest) return; + const remoteHash = await calculateHash(response.content); + + if (latest.isDirty) { + if (response.content === latest.content) { + updateFileState(path, { + originalContent: response.content, + originalHash: remoteHash, + isDirty: false, + hasRemoteUpdate: false, + remoteContent: undefined, + remoteOriginalHash: undefined, + }); + updatePanelAfterSave(path, latest.name); + return; + } + if (latest.hasRemoteUpdate && latest.remoteContent === response.content) return; + updateFileState(path, { + hasRemoteUpdate: true, + remoteContent: response.content, + remoteOriginalHash: remoteHash, + }); + return; + } + + if ( + latest.content === response.content && + latest.originalHash === remoteHash && + !latest.hasRemoteUpdate + ) { + return; + } + + updateFileState(path, { + content: response.content, + originalContent: response.content, + originalHash: remoteHash, + isDirty: false, + isBinary: response.is_binary, + hasRemoteUpdate: false, + remoteContent: undefined, + remoteOriginalHash: undefined, + }); + } catch { + // Ignore sync failures; user can continue editing. + } +} + +export type OpenFileWorkspaceSyncParams = { + gitStatus: GitStatusEntry | undefined; + openFiles: Map<string, FileEditorState>; + updateFileState: (path: string, updates: Partial<FileEditorState>) => void; + activeSessionIdRef: React.MutableRefObject<string | null>; + gitFileSignaturesRef: React.MutableRefObject<Map<string, string>>; +}; + +/** + * Watches gitStatus + openFiles and refetches open editor buffers from disk + * whenever a file's git signature changes (the agent edited it, the user ran + * git pull, etc.). Skips the very first observation per file so that opening + * a file doesn't trigger an immediate redundant refetch. + */ +export function useOpenFileWorkspaceSync({ + gitStatus, + openFiles, + updateFileState, + activeSessionIdRef, + gitFileSignaturesRef, +}: OpenFileWorkspaceSyncParams) { + useEffect(() => { + const sigMap = gitFileSignaturesRef.current; + for (const path of Array.from(sigMap.keys())) { + if (!openFiles.has(path)) sigMap.delete(path); + } + }, [openFiles, gitFileSignaturesRef]); + + useEffect(() => { + const client = getWebSocketClient(); + const sessionId = activeSessionIdRef.current; + if (!client || !sessionId) return; + + const gitFiles = gitStatus?.files ?? {}; + const sigMap = gitFileSignaturesRef.current; + for (const [path, file] of openFiles.entries()) { + // For symlinks, also check the resolved target path in git status + const gitFileInfo = (gitFiles[path] ?? + (file.resolvedPath ? gitFiles[file.resolvedPath] : undefined)) as FileInfo | undefined; + const nextSignature = buildGitFileSignature(gitFileInfo); + const prevSignature = sigMap.get(path); + if (prevSignature === undefined) { + sigMap.set(path, nextSignature); + continue; + } + if (prevSignature === nextSignature) continue; + + sigMap.set(path, nextSignature); + void syncOpenFileFromWorkspace({ client, sessionId, path, updateFileState }); + } + }, [gitStatus, openFiles, updateFileState, activeSessionIdRef, gitFileSignaturesRef]); +} diff --git a/apps/web/hooks/use-file-editors.ts b/apps/web/hooks/use-file-editors.ts index a96a03b7d2..7f05006e81 100644 --- a/apps/web/hooks/use-file-editors.ts +++ b/apps/web/hooks/use-file-editors.ts @@ -15,9 +15,9 @@ import { calculateHash } from "@/lib/utils/file-diff"; import { useToast } from "@/components/toast-provider"; import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; import type { FileContentResponse } from "@/lib/types/backend"; -import type { FileInfo } from "@/lib/state/store"; -import { useSaveDeleteActions, updatePanelAfterSave } from "./use-file-save-delete"; +import { useSaveDeleteActions } from "./use-file-save-delete"; import { PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions"; +import { useOpenFileWorkspaceSync } from "./file-editors-sync"; // Module-level guard: ensures restoration only runs once across all hook instances let _restoredSessionId: string | null = null; @@ -84,83 +84,6 @@ function buildPersistedTabs( }); } -function buildGitFileSignature(file: FileInfo | undefined): string { - if (!file) return "__clean__"; - return [ - file.status ?? "", - file.staged ? "1" : "0", - String(file.additions ?? 0), - String(file.deletions ?? 0), - file.old_path ?? "", - file.diff ?? "", - ].join("|"); -} - -type SyncOpenFileArgs = { - client: ReturnType<typeof getWebSocketClient>; - sessionId: string; - path: string; - updateFileState: (path: string, updates: Partial<FileEditorState>) => void; -}; - -async function syncOpenFileFromWorkspace({ - client, - sessionId, - path, - updateFileState, -}: SyncOpenFileArgs): Promise<void> { - if (!client) return; - try { - const response = await requestFileContent(client, sessionId, path); - const latest = getOpenFiles().get(path); - if (!latest) return; - const remoteHash = await calculateHash(response.content); - - if (latest.isDirty) { - if (response.content === latest.content) { - updateFileState(path, { - originalContent: response.content, - originalHash: remoteHash, - isDirty: false, - hasRemoteUpdate: false, - remoteContent: undefined, - remoteOriginalHash: undefined, - }); - updatePanelAfterSave(path, latest.name); - return; - } - if (latest.hasRemoteUpdate && latest.remoteContent === response.content) return; - updateFileState(path, { - hasRemoteUpdate: true, - remoteContent: response.content, - remoteOriginalHash: remoteHash, - }); - return; - } - - if ( - latest.content === response.content && - latest.originalHash === remoteHash && - !latest.hasRemoteUpdate - ) { - return; - } - - updateFileState(path, { - content: response.content, - originalContent: response.content, - originalHash: remoteHash, - isDirty: false, - isBinary: response.is_binary, - hasRemoteUpdate: false, - remoteContent: undefined, - remoteOriginalHash: undefined, - }); - } catch { - // Ignore sync failures; user can continue editing. - } -} - type RestoreTabsParams = { activeSessionId: string; savedTabs: Array<{ path: string; name: string; markdownPreview?: boolean; pinned?: boolean }>; @@ -173,6 +96,25 @@ type RestoreTabsParams = { ) => void; }; +/** + * Skip persisted tabs the dockview snapshot already restored (preview slot or + * existing pinned panel). Re-adding with `pin: true` here would create a + * duplicate `file:<path>` alongside the existing `preview:file-editor` — + * see the round-trip task switch where a promoted preview was persisted as + * pinned in localStorage and then re-opened on top of the restored preview. + */ +function isAlreadyRestored( + dockApi: ReturnType<typeof useDockviewStore.getState>["api"], + path: string, +): boolean { + if (!dockApi) return false; + if (dockApi.getPanel(`file:${path}`)) return true; + const previewParams = dockApi.getPanel(PREVIEW_FILE_EDITOR_ID)?.params as + | Record<string, unknown> + | undefined; + return previewParams?.previewItemId === path; +} + async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Promise<void> { const { activeSessionId, savedTabs, savedActiveTab, setFileState, addFileEditorPanel } = params; const client = getWebSocketClient(); @@ -191,7 +133,9 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr // Create all panels immediately so tabs are visible right away. // Content is fetched afterwards; if it fails, `useFileLoader` in // FileEditorPanel retries when the executor becomes available. + const dockApi = useDockviewStore.getState().api; for (const savedTab of savedTabs) { + if (isAlreadyRestored(dockApi, savedTab.path)) continue; addFileEditorPanel(savedTab.path, savedTab.name, { quiet: true, pin: savedTab.pinned, @@ -215,11 +159,8 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr /* useFileLoader will retry when executor is ready */ } } - const dockApi = useDockviewStore.getState().api; - if (dockApi) { - const targetPanel = dockApi.getPanel(savedActiveTab); - if (targetPanel) targetPanel.api.setActive(); - } + const targetPanel = dockApi?.getPanel(savedActiveTab); + if (targetPanel) targetPanel.api.setActive(); _restorationInProgress = false; } @@ -311,53 +252,6 @@ function useFileEditorEffects({ }, [api, removeFileState]); } -type OpenFileWorkspaceSyncParams = { - gitStatus: ReturnType<typeof useSessionGitStatus>; - openFiles: ReturnType<typeof useDockviewStore.getState>["openFiles"]; - updateFileState: (path: string, updates: Partial<FileEditorState>) => void; - activeSessionIdRef: React.MutableRefObject<string | null>; - gitFileSignaturesRef: React.MutableRefObject<Map<string, string>>; -}; - -function useOpenFileWorkspaceSync({ - gitStatus, - openFiles, - updateFileState, - activeSessionIdRef, - gitFileSignaturesRef, -}: OpenFileWorkspaceSyncParams) { - useEffect(() => { - const sigMap = gitFileSignaturesRef.current; - for (const path of Array.from(sigMap.keys())) { - if (!openFiles.has(path)) sigMap.delete(path); - } - }, [openFiles, gitFileSignaturesRef]); - - useEffect(() => { - const client = getWebSocketClient(); - const sessionId = activeSessionIdRef.current; - if (!client || !sessionId) return; - - const gitFiles = gitStatus?.files ?? {}; - const sigMap = gitFileSignaturesRef.current; - for (const [path, file] of openFiles.entries()) { - // For symlinks, also check the resolved target path in git status - const gitFileInfo = (gitFiles[path] ?? - (file.resolvedPath ? gitFiles[file.resolvedPath] : undefined)) as FileInfo | undefined; - const nextSignature = buildGitFileSignature(gitFileInfo); - const prevSignature = sigMap.get(path); - if (prevSignature === undefined) { - sigMap.set(path, nextSignature); - continue; - } - if (prevSignature === nextSignature) continue; - - sigMap.set(path, nextSignature); - void syncOpenFileFromWorkspace({ client, sessionId, path, updateFileState }); - } - }, [gitStatus, openFiles, updateFileState, activeSessionIdRef, gitFileSignaturesRef]); -} - type FileEditorActionsParams = { activeSessionIdRef: React.MutableRefObject<string | null>; setFileState: (path: string, state: FileEditorState) => void; diff --git a/apps/web/lib/state/dockview-env-switch.test.ts b/apps/web/lib/state/dockview-env-switch.test.ts index 168b04bbb0..5e6b2f25f0 100644 --- a/apps/web/lib/state/dockview-env-switch.test.ts +++ b/apps/web/lib/state/dockview-env-switch.test.ts @@ -23,9 +23,12 @@ vi.mock("./layout-manager", () => ({ import { getEnvLayout } from "@/lib/local-storage"; import { layoutStructuresMatch, savedLayoutMatchesLive } from "./layout-manager"; +const NEW_SESSION_PANEL_ID = "session:new-session"; + function makeMockApi() { return { panels: [], + groups: [], layout: vi.fn(), fromJSON: vi.fn(), getPanel: vi.fn(() => null), @@ -67,6 +70,25 @@ function makeParams(overrides?: Partial<EnvSwitchParams>): EnvSwitchParams { }; } +function makeTwoLeafSavedLayout( + leaves: Array<{ id: string; views: string[]; activeView: string }>, + activeGroup: string, +): ReturnType<typeof getEnvLayout> { + return { + grid: { + root: { + type: "branch" as const, + data: leaves.map((leaf) => ({ type: "leaf", data: leaf })), + }, + height: 600, + width: 800, + orientation: "HORIZONTAL" as const, + }, + panels: { chat: { contentComponent: "chat" } }, + activeGroup, + } as unknown as ReturnType<typeof getEnvLayout>; +} + describe("performEnvSwitch", () => { beforeEach(() => { vi.clearAllMocks(); @@ -100,7 +122,7 @@ describe("performEnvSwitch", () => { expect(params.api.addPanel).toHaveBeenCalledWith( expect.objectContaining({ - id: "session:new-session", + id: NEW_SESSION_PANEL_ID, component: "chat", params: { sessionId: "new-session" }, }), @@ -109,11 +131,11 @@ describe("performEnvSwitch", () => { it("skips addPanel on the fast path when the session panel already exists", () => { vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true); - const panel = { id: "session:new-session", api: { component: "chat" }, group: { id: "g1" } }; + const panel = { id: NEW_SESSION_PANEL_ID, api: { component: "chat" }, group: { id: "g1" } }; const params = makeParams({ api: { ...makeMockApi(), - getPanel: vi.fn((id: string) => (id === "session:new-session" ? panel : null)), + getPanel: vi.fn((id: string) => (id === NEW_SESSION_PANEL_ID ? panel : null)), } as unknown as EnvSwitchParams["api"], }); @@ -146,4 +168,86 @@ describe("performEnvSwitch", () => { expect(params.api.layout).toHaveBeenCalledWith(800, 600); expect(params.buildDefault).toHaveBeenCalledWith(params.api); }); + + it("preserves the outgoing session panel's tab index when adding the new session on the fast path", () => { + // Regression: the fast-path used to call addPanel with only + // { referenceGroup }, so dockview appended the new session tab to the end + // of the group instead of restoring it to its original slot. + vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true); + const groupPanels = [ + { id: "files", api: { component: "files" } }, + { id: "session:old-session", api: { component: "chat" } }, + { id: "changes", api: { component: "changes" } }, + { id: "terminal-default", api: { component: "terminal" } }, + ]; + const groupId = "center-group"; + const outgoing = { + ...groupPanels[1], + group: { id: groupId, panels: groupPanels }, + }; + const api = { + ...makeMockApi(), + panels: [outgoing], + groups: [{ id: groupId }], + getPanel: vi.fn(() => null), + } as unknown as EnvSwitchParams["api"]; + const params = makeParams({ api }); + + performEnvSwitch(params); + + expect(api.addPanel).toHaveBeenCalledWith( + expect.objectContaining({ + id: NEW_SESSION_PANEL_ID, + position: { referenceGroup: groupId, index: 1 }, + }), + ); + }); +}); + +describe("performEnvSwitch fast-path active view restoration", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("restores saved per-group active tabs on the fast path", () => { + // Regression: the fast path skips fromJSON, so per-group active tabs + // from the outgoing env would persist into the incoming env. The saved + // layout's activeView for each group must be reapplied. + const setActiveRight = vi.fn(); + const setActiveCenter = vi.fn(); + const rightGroup = { + id: "right", + panels: [ + { id: "plan", api: { setActive: setActiveRight } }, + { id: "files", api: { setActive: vi.fn() } }, + ], + }; + const centerGroup = { + id: "center", + panels: [{ id: NEW_SESSION_PANEL_ID, api: { setActive: setActiveCenter } }], + }; + const savedLayout = makeTwoLeafSavedLayout( + [ + { id: "center", views: ["chat"], activeView: "chat" }, + { id: "right", views: ["plan", "files"], activeView: "plan" }, + ], + "right", + ); + vi.mocked(getEnvLayout).mockReturnValueOnce(savedLayout).mockReturnValueOnce(savedLayout); + vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true); + const api = { + ...makeMockApi(), + groups: [centerGroup, rightGroup], + getPanel: vi.fn((id: string) => (id === NEW_SESSION_PANEL_ID ? centerGroup.panels[0] : null)), + } as unknown as EnvSwitchParams["api"]; + + performEnvSwitch(makeParams({ api })); + + expect(setActiveRight).toHaveBeenCalled(); + // The saved activeGroup ("right") is applied last, so its setActive must + // be the most recent — otherwise center would steal global focus. + const lastRightCall = setActiveRight.mock.invocationCallOrder.at(-1) ?? 0; + const lastCenterCall = setActiveCenter.mock.invocationCallOrder.at(-1) ?? 0; + expect(lastRightCall).toBeGreaterThan(lastCenterCall); + }); }); diff --git a/apps/web/lib/state/dockview-env-switch.ts b/apps/web/lib/state/dockview-env-switch.ts index d2057357af..1863e10aaa 100644 --- a/apps/web/lib/state/dockview-env-switch.ts +++ b/apps/web/lib/state/dockview-env-switch.ts @@ -40,6 +40,60 @@ function savedLayoutHasEphemeralPanels(serialized: SerializedDockview): boolean return Object.values(panels).some((p) => EPHEMERAL_COMPONENTS.has(p.contentComponent ?? "")); } +/** Walk the serialized grid tree collecting (groupId, activeView) for each leaf. */ +function collectSavedActiveViews( + saved: SerializedDockview, +): Array<{ groupId: string; activeView: string }> { + const out: Array<{ groupId: string; activeView: string }> = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const walk = (node: any): void => { + if (!node) return; + if (Array.isArray(node.data)) { + for (const child of node.data) walk(child); + return; + } + const data = node.data; + if (data?.id && data.activeView) out.push({ groupId: data.id, activeView: data.activeView }); + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + walk((saved as any).grid?.root); + return out; +} + +/** + * Restore each group's `activeView` from the saved layout. The fast path + * doesn't call `fromJSON`, so per-group active tabs would otherwise carry + * over from the outgoing env (e.g. Task B left "changes" focused in the + * right group, and switching back to Task A would still show "changes" + * even though Task A had "plan" active when it was last saved). + * + * The saved `activeGroup` is applied last so the resulting global focus + * matches what was persisted. + */ +function restoreSavedActiveViews(api: DockviewApi, saved: SerializedDockview): void { + const entries = collectSavedActiveViews(saved); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const savedActiveGroup = (saved as any).activeGroup as string | undefined; + const ordered = savedActiveGroup + ? [ + ...entries.filter((e) => e.groupId !== savedActiveGroup), + ...entries.filter((e) => e.groupId === savedActiveGroup), + ] + : entries; + for (const { groupId, activeView } of ordered) { + const group = api.groups.find((g) => g.id === groupId); + if (!group) continue; + const panel = group.panels.find((p) => p.id === activeView); + if (panel) { + try { + panel.api.setActive(); + } catch { + /* panel may be in a transient state */ + } + } + } +} + export type EnvSwitchParams = { api: DockviewApi; oldEnvId: string | null; @@ -53,30 +107,37 @@ export type EnvSwitchParams = { }; /** - * Remove ephemeral panels (file-editors, diffs, commit-details) from the - * live layout. These are env-scoped panels that shouldn't carry over. + * Predicate matching panels that `removeEphemeralPanels` will close. * - * When `keepSessionId` is provided, session chat panels whose ID does not - * match `session:{keepSessionId}` are also removed. This handles cross-env - * (cross-task) switches where the fast path is taken: without this, session - * tabs from the old env's task remain visible alongside the new task's tab. + * Ephemeral panels (file-editors, diffs, commit-details, etc.) are env-scoped + * and never carry across switches. When `keepSessionId` is provided, chat + * panels for any other session are also removed so the old env's session tab + * doesn't bleed into the new env. Pulled out so `computeSurvivingIndex` can + * reuse the same survival rules without duplicating them. + */ +function shouldRemoveDuringSwitch( + panel: { id: string; api: { component: string } }, + keepSessionId: string | null, +): boolean { + const comp = panel.api.component; + if (EPHEMERAL_COMPONENTS.has(comp)) return true; + if ( + keepSessionId !== null && + comp === "chat" && + panel.id.startsWith("session:") && + panel.id !== `session:${keepSessionId}` + ) { + return true; + } + return false; +} + +/** + * Close every panel that matches `shouldRemoveDuringSwitch`. Used to clear + * env-scoped ephemerals before the new env's panels are restored. */ function removeEphemeralPanels(api: DockviewApi, keepSessionId: string | null): void { - const toRemove = api.panels.filter((p) => { - const comp = p.api.component; - if (EPHEMERAL_COMPONENTS.has(comp)) { - return true; - } - if ( - keepSessionId !== null && - comp === "chat" && - p.id.startsWith("session:") && - p.id !== `session:${keepSessionId}` - ) { - return true; - } - return false; - }); + const toRemove = api.panels.filter((p) => shouldRemoveDuringSwitch(p, keepSessionId)); for (const p of toRemove) { try { p.api.close(); @@ -86,6 +147,26 @@ function removeEphemeralPanels(api: DockviewApi, keepSessionId: string | null): } } +/** + * Given the panels of a group and the id of the panel being replaced, return + * the target tab index for the replacement among the siblings that will + * survive `removeEphemeralPanels`. Returns -1 if the panel isn't in the group. + */ +function computeSurvivingIndex( + groupPanels: readonly { id: string; api: { component: string } }[], + outgoingPanelId: string | undefined, + keepSessionId: string | null, +): number { + if (!outgoingPanelId) return -1; + const idx = groupPanels.findIndex((p) => p.id === outgoingPanelId); + if (idx < 0) return -1; + let count = 0; + for (let i = 0; i < idx; i++) { + if (!shouldRemoveDuringSwitch(groupPanels[i], keepSessionId)) count++; + } + return count; +} + /** * Fast path: check if we can skip `api.fromJSON()` because the layout * structure hasn't changed. Returns group IDs if the fast path was taken, @@ -106,35 +187,66 @@ function tryFastEnvSwitch(params: EnvSwitchParams): LayoutGroupIds | null { if (!structuresMatch) return null; if (saved && savedLayoutHasEphemeralPanels(saved as SerializedDockview)) return null; - const outgoingSessionPanel = api.panels.find( - (p) => p.id.startsWith("session:") || p.api.component === "chat", - ); - const outgoingGroupId = outgoingSessionPanel?.group?.id; + // Prefer the active session panel so multi-session tasks anchor the + // incoming panel to the group the user was looking at, not whichever + // session tab happens to come first in `api.panels` iteration order. + const isSessionPanel = (p: (typeof api.panels)[number]) => + p.id.startsWith("session:") || p.api.component === "chat"; + const outgoingSessionPanel = + api.panels.find((p) => isSessionPanel(p) && p.api.isActive) ?? api.panels.find(isSessionPanel); + const outgoingGroup = outgoingSessionPanel?.group; + const outgoingGroupId = outgoingGroup?.id; + // Capture the session's index among siblings that will survive + // `removeEphemeralPanels`, so the new session panel lands in the same tab + // slot. Without this, dockview appends and the agent tab drifts to the end + // of the group on every cross-task fast-path switch. + const outgoingIndex = outgoingGroup + ? computeSurvivingIndex(outgoingGroup.panels, outgoingSessionPanel?.id, activeSessionId) + : -1; removeEphemeralPanels(api, activeSessionId); - if (activeSessionId && !api.getPanel(`session:${activeSessionId}`)) { - const sidebarPanel = api.getPanel("sidebar"); - let position: import("dockview-react").AddPanelOptions["position"]; - if (outgoingGroupId && api.groups.some((g) => g.id === outgoingGroupId)) { - position = { referenceGroup: outgoingGroupId }; - } else if (sidebarPanel) { - position = { direction: "right" as const, referencePanel: "sidebar" }; - } - api.addPanel({ - id: `session:${activeSessionId}`, - component: "chat", - tabComponent: "sessionTab", - title: "Agent", - params: { sessionId: activeSessionId }, - position, - }); + addIncomingSessionPanel(api, activeSessionId, outgoingGroupId, outgoingIndex); } + // The fast path skips `fromJSON`, so per-group active tabs from the + // outgoing env would otherwise persist into the incoming env. Reapply + // them from the saved layout to match what `fromJSON` would have done. + if (saved) restoreSavedActiveViews(api, saved as SerializedDockview); + api.layout(params.safeWidth, params.safeHeight); return applyLayoutFixups(api); } +/** + * Add the incoming task's session chat panel, restoring it to the same tab + * slot the outgoing session occupied within `outgoingGroupId` when possible. + */ +function addIncomingSessionPanel( + api: DockviewApi, + sessionId: string, + outgoingGroupId: string | undefined, + outgoingIndex: number, +): void { + let position: import("dockview-react").AddPanelOptions["position"]; + if (outgoingGroupId && api.groups.some((g) => g.id === outgoingGroupId)) { + position = + outgoingIndex >= 0 + ? { referenceGroup: outgoingGroupId, index: outgoingIndex } + : { referenceGroup: outgoingGroupId }; + } else if (api.getPanel("sidebar")) { + position = { direction: "right" as const, referencePanel: "sidebar" }; + } + api.addPanel({ + id: `session:${sessionId}`, + component: "chat", + tabComponent: "sessionTab", + title: "Agent", + params: { sessionId }, + position, + }); +} + /** * Switch the dockview layout between task environments. * diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 06624e437b..08a93ed776 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -651,17 +651,42 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({ (window as unknown as { __dockviewApi__: DockviewApi | null }).__dockviewApi__ = api; } if (api) { + const resolveFilePath = (panelId: string | undefined): string | null => { + if (!panelId) return null; + if (panelId.startsWith("file:")) return panelId.slice(5); + if (panelId === "preview:file-editor") { + const path = (api.getPanel(panelId)?.params as Record<string, unknown> | undefined) + ?.path as string | undefined; + return path ?? null; + } + return null; + }; api.onDidActivePanelChange((event) => { - const id = event?.id; - if (id?.startsWith("file:")) { - set({ activeFilePath: id.slice(5) }); - } else if (id === "preview:file-editor") { - const path = (api.getPanel(id)?.params as Record<string, unknown> | undefined)?.path as - | string - | undefined; - set({ activeFilePath: path ?? null }); - } else { - set({ activeFilePath: null }); + set({ activeFilePath: resolveFilePath(event?.id) }); + }); + // Track per-panel param-change subscriptions so they can be disposed when + // the panel is removed (e.g. across env switches that re-create the + // preview panel) instead of relying on dockview's internal cleanup. + const paramSubs = new Map<string, { dispose: () => void }>(); + api.onDidAddPanel((panel) => { + // The preview file-editor panel reuses a single dockview panel and swaps + // its `params.path` via `updateParameters` when the user previews a + // different file. Dockview does not refire `onDidActivePanelChange` for + // params-only updates on an already-active panel, so subscribe to the + // panel's own parameter-change event and refresh `activeFilePath`. + if (panel.id !== "preview:file-editor") return; + paramSubs.get(panel.id)?.dispose(); + const sub = panel.api.onDidParametersChange(() => { + if (!panel.api.isActive) return; + set({ activeFilePath: resolveFilePath(panel.id) }); + }); + paramSubs.set(panel.id, sub); + }); + api.onDidRemovePanel((panel) => { + const sub = paramSubs.get(panel.id); + if (sub) { + sub.dispose(); + paramSubs.delete(panel.id); } }); } diff --git a/apps/web/lib/state/layout-manager/merger.test.ts b/apps/web/lib/state/layout-manager/merger.test.ts index e213e0a22e..101682e77b 100644 --- a/apps/web/lib/state/layout-manager/merger.test.ts +++ b/apps/web/lib/state/layout-manager/merger.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from "vitest"; import type { LayoutState } from "./types"; import { mergePanelsIntoPreset } from "./merger"; +const SESSION_ABC = "session:abc"; +const SESSION_DEF = "session:def"; + function makeLayout(centerPanels: Array<{ id: string; component: string }>): LayoutState { return { columns: [ @@ -21,6 +24,34 @@ function makeLayout(centerPanels: Array<{ id: string; component: string }>): Lay }; } +function makeLayoutWithSide( + centerPanels: Array<{ id: string; component: string }>, + sideColumnId: string, + sidePanels: Array<{ id: string; component: string }>, +): LayoutState { + return { + columns: [ + { + id: "sidebar", + groups: [{ panels: [{ id: "sidebar", component: "sidebar", title: "Sidebar" }] }], + }, + { + id: "center", + groups: [{ panels: centerPanels.map((p) => ({ ...p, title: p.id })) }], + }, + { + id: sideColumnId, + groups: [{ panels: sidePanels.map((p) => ({ ...p, title: p.id })) }], + }, + ], + }; +} + +function panelIdsIn(state: LayoutState, columnId: string): string[] { + const col = state.columns.find((c) => c.id === columnId); + return col?.groups.flatMap((g) => g.panels.map((p) => p.id)) ?? []; +} + describe("mergePanelsIntoPreset", () => { it("replaces chat panel with session panels when session panels exist", () => { const currentState = makeLayout([{ id: "session:abc123", component: "chat" }]); @@ -49,8 +80,8 @@ describe("mergePanelsIntoPreset", () => { it("preserves multiple session panels and drops chat", () => { const currentState = makeLayout([ - { id: "session:abc", component: "chat" }, - { id: "session:def", component: "chat" }, + { id: SESSION_ABC, component: "chat" }, + { id: SESSION_DEF, component: "chat" }, ]); const targetPreset = makeLayout([{ id: "chat", component: "chat" }]); @@ -59,8 +90,56 @@ describe("mergePanelsIntoPreset", () => { const centerPanels = result.columns.find((c) => c.id === "center")!.groups[0].panels; const panelIds = centerPanels.map((p) => p.id); - expect(panelIds).toContain("session:abc"); - expect(panelIds).toContain("session:def"); + expect(panelIds).toContain(SESSION_ABC); + expect(panelIds).toContain(SESSION_DEF); expect(panelIds).not.toContain("chat"); }); + + it("places side extras (files/changes/terminal) in the side column when one exists", () => { + // Toggling plan mode ON: default layout has files/changes/terminal in + // "right"; the plan preset has a "plan" column. They should land there, + // not pile into center alongside the chat. + const currentState = makeLayoutWithSide([{ id: SESSION_ABC, component: "chat" }], "right", [ + { id: "files", component: "files" }, + { id: "changes", component: "changes" }, + { id: "terminal-default", component: "terminal" }, + ]); + const targetPreset = makeLayoutWithSide([{ id: "chat", component: "chat" }], "plan", [ + { id: "plan", component: "plan" }, + ]); + + const result = mergePanelsIntoPreset(currentState, targetPreset); + + expect(panelIdsIn(result, "center")).toEqual([SESSION_ABC]); + expect(panelIdsIn(result, "plan")).toEqual(["plan", "files", "changes", "terminal-default"]); + }); + + it("places the surviving plan panel in the center column, not the side column", () => { + // Toggling plan mode OFF: the plan preset has plan in its own column, + // which the default preset doesn't have. Plan should follow the chat + // into center rather than being dumped into "right" alongside files. + const currentState = makeLayoutWithSide([{ id: SESSION_ABC, component: "chat" }], "plan", [ + { id: "plan", component: "plan" }, + ]); + const targetPreset = makeLayoutWithSide([{ id: "chat", component: "chat" }], "right", [ + { id: "files", component: "files" }, + { id: "changes", component: "changes" }, + ]); + + const result = mergePanelsIntoPreset(currentState, targetPreset); + + expect(panelIdsIn(result, "center")).toEqual([SESSION_ABC, "plan"]); + expect(panelIdsIn(result, "right")).toEqual(["files", "changes"]); + }); + + it("falls back to center for side extras when the target preset has no side column", () => { + const currentState = makeLayoutWithSide([{ id: SESSION_ABC, component: "chat" }], "right", [ + { id: "files", component: "files" }, + ]); + const targetPreset = makeLayout([{ id: "chat", component: "chat" }]); + + const result = mergePanelsIntoPreset(currentState, targetPreset); + + expect(panelIdsIn(result, "center")).toEqual([SESSION_ABC, "files"]); + }); }); diff --git a/apps/web/lib/state/layout-manager/merger.ts b/apps/web/lib/state/layout-manager/merger.ts index ee44fe9a01..b29efe398a 100644 --- a/apps/web/lib/state/layout-manager/merger.ts +++ b/apps/web/lib/state/layout-manager/merger.ts @@ -5,6 +5,12 @@ import { fromDockviewApi } from "./serializer"; /** Panel IDs that always come from the preset and should never be merged. */ const PRESET_ONLY_PANELS = new Set(["sidebar"]); +/** Panel IDs that, when surviving as extras, belong next to the chat in the + * center column rather than in the side column. The plan panel is paired + * with the chat conceptually, so toggling off plan mode keeps it visible + * in the center group rather than dumping it into the right column. */ +const CENTER_EXTRA_PANELS = new Set(["plan"]); + /** Collect all panels from a LayoutState, flattened. */ function collectAllPanels(state: LayoutState): LayoutPanel[] { const panels: LayoutPanel[] = []; @@ -23,10 +29,36 @@ function collectPanelIds(state: LayoutState): Set<string> { return new Set(collectAllPanels(state).map((p) => p.id)); } +/** Identify the column that should receive non-session extras. Prefer the last + * non-sidebar, non-center column (e.g. "right", "plan", "preview", "vscode"), + * falling back to "center" when the preset has no such side column. */ +function pickExtrasColumnId(targetPreset: LayoutState): string { + const sideCols = targetPreset.columns.filter((c) => c.id !== "sidebar" && c.id !== "center"); + return sideCols[sideCols.length - 1]?.id ?? "center"; +} + +/** Append `toAdd` panels to a group's first leaf, preserving identity when no-op. */ +function appendToFirstGroup( + groups: LayoutState["columns"][number]["groups"], + toAdd: LayoutPanel[], + filterExisting?: (p: LayoutPanel) => boolean, +): LayoutState["columns"][number]["groups"] { + return groups.map((group, idx) => { + if (idx !== 0) return group; + const basePanels = filterExisting ? group.panels.filter(filterExisting) : group.panels; + const existingIds = new Set(basePanels.map((p) => p.id)); + const additions = toAdd.filter((p) => !existingIds.has(p.id)); + if (additions.length === 0 && basePanels.length === group.panels.length) return group; + return { ...group, panels: [...basePanels, ...additions] }; + }); +} + /** * Pure merge logic: merge extra panels from the current state into a target - * preset layout. Session panels (`session:*`) replace the generic `chat` - * panel rather than coexisting alongside it. + * preset layout. Session panels (`session:*`) replace the generic `chat` + * panel in the center column. Other extras (files, changes, terminal, etc.) + * are appended to the side column when one exists (e.g. "right"/"plan"/ + * "preview"/"vscode"), otherwise they fall back to the center column. */ export function mergePanelsIntoPreset( currentState: LayoutState, @@ -43,36 +75,29 @@ export function mergePanelsIntoPreset( return targetPreset; } - const hasSessionPanels = extraPanels.some((p) => p.id.startsWith("session:")); - - console.debug( - "[layout-merger] merging extra panels into preset:", - extraPanels.map((p) => p.id), + const sessionExtras = extraPanels.filter((p) => p.id.startsWith("session:")); + const centerExtras = extraPanels.filter((p) => CENTER_EXTRA_PANELS.has(p.id)); + const sideExtras = extraPanels.filter( + (p) => !p.id.startsWith("session:") && !CENTER_EXTRA_PANELS.has(p.id), ); + const hasSessionPanels = sessionExtras.length > 0; + const extrasColumnId = pickExtrasColumnId(targetPreset); const mergedColumns = targetPreset.columns.map((col) => { - if (col.id !== "center") return col; - - const groups = col.groups.map((group, idx) => { - if (idx !== 0) return group; - - // If extra panels include session tabs, drop the generic "chat" - // placeholder — session panels replace it. - const basePanels = hasSessionPanels - ? group.panels.filter((p) => p.id !== "chat") - : group.panels; - - const existingIds = new Set(basePanels.map((p) => p.id)); - const toAdd = extraPanels.filter((p) => !existingIds.has(p.id)); - if (toAdd.length === 0 && basePanels.length === group.panels.length) return group; - - return { - ...group, - panels: [...basePanels, ...toAdd], - }; - }); - - return { ...col, groups }; + if (col.id === "center") { + // Sessions always replace the generic "chat" placeholder in center. + // Center-affinity extras (e.g. "plan") sit alongside the chat. When + // the side column doesn't exist, side extras land here too. + const fallbackSide = extrasColumnId === "center" ? sideExtras : []; + const filterChat = hasSessionPanels ? (p: LayoutPanel) => p.id !== "chat" : undefined; + const additions = [...sessionExtras, ...centerExtras, ...fallbackSide]; + if (additions.length === 0 && !filterChat) return col; + return { ...col, groups: appendToFirstGroup(col.groups, additions, filterChat) }; + } + if (col.id === extrasColumnId && sideExtras.length > 0) { + return { ...col, groups: appendToFirstGroup(col.groups, sideExtras) }; + } + return col; }); return { columns: mergedColumns }; From deb12d31b26cce844a280345f6a8d8c02c733a1b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 11:30:38 +0000 Subject: [PATCH 33/33] release: 0.41.0 --- CHANGELOG.md | 14 ++++++++++++++ apps/cli/package.json | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a837da0f0..fe108bcc13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to Kandev. +## 0.41.0 - 2026-05-09 + +### Features + +- allow subtasks to target a sibling repository ([#852](https://github.com/kdlbs/kandev/pull/852)) + +### Bug Fixes + +- preserve dockview state across task and plan-mode switches ([#855](https://github.com/kdlbs/kandev/pull/855)) +- show sent message in chat without waiting for ws broadcast ([#851](https://github.com/kdlbs/kandev/pull/851)) +- rank longest/quickest tasks by active duration ([#849](https://github.com/kdlbs/kandev/pull/849)) +- keep "All Workflows" filter on task nav and workflow.created ([#854](https://github.com/kdlbs/kandev/pull/854)) +- keep command palette selection on first result ([#845](https://github.com/kdlbs/kandev/pull/845)) + ## 0.40.0 - 2026-05-08 ### Features diff --git a/apps/cli/package.json b/apps/cli/package.json index 81c5c396f7..1a576be34f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "kandev", - "version": "0.40.0", + "version": "0.41.0", "private": false, "description": "Launcher for Kandev — manage tasks, orchestrate agents, review changes, and ship value", "license": "AGPL-3.0-only",