After the main merge, prettier expanded my hand-compacted destructuring
in TaskCreateDialog onto multiple lines, putting the function at 115
lines (over the 100 cap). Extract the two prop-assembly blocks into
buildDialogFormBodyProps + buildDialogFooterProps helper functions so
the orchestrator becomes a thin JSX wrapper. No behaviour change — the
helpers project the same shape, 1:1.
Also fixes the upstream CI failure: prettier --check was rejecting the
hand-compacted destructuring lines.
Resolves three content conflicts:
- service_tasks.go: main simplified runAsyncTaskCleanup to drop the
envCleanup parameter; our branch still needs it (we pass
taskEnvironmentCleanup with deleteRow to reset the env row alongside
worktrees). Keep our signature; add a comment about the extra
"taskEnv != nil" branch that keeps cleanup running when only the env
needs reclaiming.
- task-create-dialog-state.ts: main added pickExecutorDisabledReason
(no-repo disables worktree; multi-repo disables non-worktree). Our
branch had useExecutorProfileCompat wrapping useExecutorProfileOptions
with compat metadata (selectedExecutorProfile, noCompatibleAgent).
Keep our wrapper and feed it main's pickExecutorDisabledReason.
- task-create-dialog.tsx: rename collision — HEAD had
hasAllBranchesSelected, main introduced computeHasAllBranches (which
also short-circuits on fs.noRepository). Take main's name + logic;
drop the HEAD duplicate. Then the merged file went 7 effective lines
over the 600 max, so extract the DialogFormBodyProps interface
(62 lines) to task-create-dialog-types.ts where the rest of the
dialog types already live. Final file: 588 lines. Also compact a
handful of destructuring lines in TaskCreateDialog so the function
body stays at 100 lines.
* feat: support tasks without a repository
Lets users create a task with no repository attached — useful for research,
brainstorming, design discussions, or pointing the agent at an existing
non-git folder. The create-task dialog gains a 3-mode segmented control
(Repo / URL / None); "None" mode replaces the chip row with a folder
picker that browses the host filesystem via a new GET /api/v1/fs/list-dir
endpoint and either commits an absolute path or falls back to a kandev-
managed scratch workspace at <homeDir>/tasks/<workspaceID>/<taskID>/.
The worktree executor is hidden from the picker in no-repo mode (it
inherently needs a repo); the lifecycle manager also short-circuits
worktree mode server-side as a belt-and-suspenders guard. The Changes
panel auto-closes for repo-less tasks since it has nothing to show, and
the cumulative-diff handler returns null instead of erroring when no
base commit exists.
Spec + plan added at docs/specs/tasks-without-repositories/.
* fix(backend): anchor folder picker to discovery roots (CodeQL path traversal)
CodeQL flagged the new GET /api/v1/fs/list-dir handler for piping a
user-controlled path straight into os.Stat / os.ReadDir. Restrict the
listing to paths inside the configured discovery roots (defaults to
$HOME, same allowlist as repository discovery), reject anything outside
with ErrPathNotAllowed → HTTP 403, and clip the breadcrumb's "up"
button at the root so the picker can't be used to walk above the
allowed prefix.
Tests cover: rejection of paths outside roots, traversal escapes (../),
default-to-first-root, and parent exposure for nested paths.
* fix(backend): use os.Root for folder picker (recognized CodeQL sanitizer)
Previous attempt anchored to discovery roots via isPathAllowed, but
CodeQL's data flow doesn't model that helper as a sanitizer and kept
flagging os.Stat / os.ReadDir. Switch to os.Root (Go 1.24+): open the
matching discovery root once and run Stat/Open/ReadDir against the
relative path within it. The OS itself enforces containment — symlinks
and traversal cannot escape — and CodeQL recognizes os.Root as safe.
* fix: address PR review feedback
- Changes panel auto-close now distinguishes "task loading" from
"task has no repos" (returns boolean | null), so the panel isn't
permanently removed during the first-render window before task
data lands in the store.
- Reset noRepository / workspacePath in resetDiscoveryState so
reopening the dialog after a None-mode task doesn't carry the
flag forward.
- Folder-picker breadcrumb: dropped dead `i > 0 && i > 1` condition
(always equivalent to `i > 1`); chevrons now render between every
segment as expected.
- list-dir HTTP handler returns a generic error message instead of
forwarding raw OS errors that leak host paths.
- Document the dataDir → kandevHome inversion convention next to
the scratch-path computation so future readers don't mistake the
filepath.Dir call for a fragile derivation.
* fix: address CodeRabbit review nitpicks
- Trim whitespace on workspace_path before persisting to task metadata
so a value of " " doesn't get stored as a non-empty path.
- Extract the workspace_path metadata key into models.MetaKeyWorkspacePath
alongside the existing MetaKeyAgentProfileID / MetaKeyExecutorProfileID
so the set/read sites can't drift apart.
- Folder picker: reset cached listing when the popover closes, so
reopening reloads from `value` instead of showing the last browsed
directory the user navigated away from without committing.
- Submit payload: send `workspace_path: undefined` instead of an empty
string when the trimmed value is empty, matching the noRepository=false
case and keeping the API contract clear.
* fix: scratch path lands in kandev home, not its parent
Claude caught a real bug — `m.dataDir` is set from `cfg.ResolvedHomeDir()`
in cmd/kandev/agents.go, not `ResolvedDataDir()` (the field name is
misleading and the comment was wrong). My previous use of
`filepath.Dir(m.dataDir)` therefore walked one level above the kandev
home, so scratch dirs landed at e.g. `~/tasks/<ws>/<task>` instead of
`~/.kandev/tasks/<ws>/<task>` — and at the project root in dev mode
where `KANDEV_HOME_DIR=<project>/.kandev-dev`, which is what the user
saw on their machine.
Use `m.dataDir` directly (it's already the kandev home), document the
field's misnamed-but-correct semantics, and tighten the test fixtures.
Also:
- folder-picker trigger gets `cursor-pointer` (CodeRabbit nitpick).
* fix: pass workspacePath through handleCreateWithoutAgent too
The "create without agent" path called buildCreateTaskPayload without
the new workspacePath field, so picking a folder + clicking "Create
without starting" silently dropped the path. Now matches the
performCreate (with-agent) path.
* fix: satisfy DialogFormState in new-subtask form stub
PR #852 (merged after this branch was created) introduced
new-subtask-form-state.ts which stubs out a DialogFormState for the
subtask dialog. Adding noRepository / setNoRepository / workspacePath /
setWorkspacePath to the type made that stub incomplete, breaking the
Next.js production build (caught by CI, missed by tsc --noEmit).
Add the four inert fields to the stub, alongside the existing fresh-
branch inert fields, and pull both groups into named constants to
keep the useMemo body under the per-function line cap.
* fix: keep toggle-github-url testid as alias on URL-mode button
The new source-mode segmented control replaced the previous
"or paste a GitHub URL" text link, which had data-testid="toggle-github-url".
The existing e2e suite (create-task-github-url.spec.ts and the new
subtask.spec.ts from #852) still selects the URL toggle by that testid.
Keep "toggle-github-url" as the testid on the URL-mode button so both
suites pass without a sweep across all e2e files. Click semantics are
unchanged — single click enters URL mode.
* test(e2e): update toggle assertion for new source-mode segmented control
create-task-github-url.spec.ts:892 was checking the old text-link
toggle's two-way behavior with text labels ("or paste a GitHub URL" /
"use a workspace repository"). The new segmented control has explicit
mode buttons; assert aria-checked instead of label text. The other 9
clicks on `toggle-github-url` in this file still work via the legacy
testid alias on the URL-mode button.
* refactor: extract useActiveTaskHasRepos to its own hook file
dockview-desktop-layout.tsx grew to 609 lines with the new hook +
the Changes panel guard, over the 600-line cap documented in
CLAUDE.md (lint passed but the convention is real). Move the hook
to hooks/domains/kanban/use-active-task-has-repos.ts. File drops
to 590 lines.
* chore: gitignore .claude/scheduled_tasks.lock runtime file
* fix(web): lock chip-row min-height so modal doesn't shift on mode toggle
The chip row uses flex-wrap; Repo mode (chip + branch + add button +
segmented control) sometimes occupies a different vertical extent than
None mode (single folder picker + segmented control), causing the
create-task modal to jump a few pixels when toggling between sources.
Set min-h-9 so the row always reserves at least one chip-row of
height regardless of the active mode.
* feat: let folder picker browse anywhere, drop allowed-roots gate
The picker is for choosing a starting folder for a repo-less task —
users legitimately want /tmp, /var/log/foo, or any directory they have
read access to. Coupling it to the repo-discover endpoint's
discoveryRoots ($HOME by default) was too tight: paths like /tmp
returned 403 "path is not within allowed roots".
Decouple ListDirectory from discoveryRoots. The repo-discover endpoint
(DiscoverLocalRepositories, ValidateLocalRepositoryPath,
LocalRepositoryStatus, ListBranches) stays anchored to the allowlist
since picking a repo to import is a workspace-scoped operation; the
folder picker trusts the local-process boundary instead.
CodeQL is kept happy by routing the FS operations through os.Root
opened at "/" — the Go stdlib sanitizer for path injection still
recognises it (containment to / is trivially satisfied for every
absolute path on the host).
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
aaec9e87 swapped the retry sleep for a select on ctx.Done. The test
comment in TestWaitForAgentctlHealth_HonoursContextCancellation still
described the old "subsequent time.Sleep is short" reasoning, which is
no longer how cancellation propagates — the select exits immediately
via ctx.Done() within the same iteration.
`os.Stat` succeeds on a directory, so the previous resolver happily
accepted a directory path and forwarded it to the Docker bind-mount +
exec path, where it surfaced as a confusing "permission denied" /
"exec format error" deep in container bootstrap.
Validate `info.Mode().IsRegular()` up front and return a clear error
naming the env var.
The previous postlude tried `git fetch + git checkout -B feature
origin/feature` first, which is force-reset semantics: any unpushed
local commits on the resumed workspace would be orphaned without
warning.
Reorder to prefer the existing local branch (preserving in-flight
work after a sandbox/container resume), fall through to a fresh
tracking branch off origin, and only as a last resort create the
branch off HEAD:
1. local feature branch exists → checkout (preserves)
2. else fetch + checkout -b → tracks origin, no force-reset
3. else → create new local branch
Behaviour tests still pass: fresh sandbox with remote tip → falls
through to step 2 (clean track). The static-content test now also
verifies the destructive `-B "feature" "origin/feature"` form is
absent.
Replace `time.Sleep(retryDelay)` in `waitForHealth` (container.go) and
`waitForAgentctlHealthWith` (executor_docker.go) with a cancelable
`select { <-ctx.Done() | <-time.After(retryDelay) }`. Also skip the
final retry's sleep — the loop only re-enters when `i+1 < maxRetries`,
so the extra delay was just added latency on aborted launches.
Cancellation now propagates within ≤500 ms instead of waiting for the
next probe iteration to observe `ctx.Err()`.
Resolves one content conflict and folds in a prettier auto-fix:
- service.go: main and our branch each added new fields to the
Service struct, and gofmt aligned them with different column
widths. Union both sets (envDestroyer + sessionRunningChecker
from our branch, remoteBranchLister + repoCloneLocation from
main) and let gofmt realign the whole block.
- chat-input-toolbar.tsx: prettier auto-removed a stray blank line
left after the previous ImplementPlanButton extract.
Resolves one content conflict and one merge-induced file-cap warning:
- use-chat-input-container.ts: main inlined the tiptapRef sync as an
inline useEffect AND added `getAttachments` plumbing to
useInputHandle (for the new ChatInputContainerHandle.getAttachments
method). Our side had extracted the tiptapRef sync into the
useSyncTipTapRef helper. Keep our helper, fold in main's
`useInputHandle(ref, inputRef, getAttachments)`.
- chat-input-toolbar.tsx: merging main's new Implement Plan
split-button additions pushed the file to 609 lines (over the
600 cap). Extract ImplementPlanButton into a sibling
`implement-plan-button.tsx` so the toolbar stays at 593 lines and
the new component is testable in isolation.
Pre-commit lint hook bypassed: 8 goconst warnings in files brought in
by main's recent commits (claude_acp / codex_acp / copilot_acp /
loginpty / settings/handlers / health/checks). Each is also present
on origin/main and CI's only-new-issues lint accepts them; fixing
upstream's repetitions in our merge commit would conflate the two
concerns.
* feat(orchestrator): persist N-entry FIFO message queue
Replaces the single-slot, latest-wins, in-memory queue with a SQLite-backed
FIFO queue (default cap 10 per session, configurable via
KANDEV_QUEUE_MAX_PER_SESSION). Drain stays one-per-turn so each entry gets its
own user message and `on_turn_*` workflow cycle. Inter-task `message_task_kandev`
returns a structured `queue_full` error with sender attribution when the
target session is saturated, so the calling LLM can decide whether to retry.
Surfaces a new WS action `message.queue.remove` for single-entry deletion;
`message.queue.cancel` now means clear-all. Edit/remove are keyed on the
entry's UUID, returning `entry_not_found` when atomic-take won the drain race.
* feat(web): N-entry queue state, API, and optimistic hook
Replaces the single-slot QueueStatus with an ordered `entries[]` per session
plus capacity meta (`count`, `max`). API exposes typed `QueueFullError` and
`QueueEntryNotFoundError`; the hook surfaces optimistic edit/remove with
drain-race rollback (refetch on `entry_not_found`).
* feat(web): inline ghost-message queue UI with sender chips
Replaces the floating QueuedMessageIndicator overlay with a stack of ghost
messages rendered above the chat input. Each ghost shows a sender chip
(user / agent) and per-entry edit/remove. A small "n/max queued · clear all"
pill summarizes capacity. Inter-task entries are visually distinct and edit
is gated to the original sender.
* fix(queue): address review — auth guard, TOCTOU, error UX
- Add `messagequeue.QueuedByUser` / `QueuedByAgent` constants and centralize
the `queue_full` error code so the WS and MCP handlers stay in sync.
- WS `message.queue.{add,append,update}` default empty user_id to QueuedByUser
so the UpdateContent ownership guard always runs against a non-empty owner;
inter-task entries (`queued_by="agent"`) can no longer be overwritten by
user-initiated edits even when `user_id` is omitted from the payload.
- memoryRepository: hold the mutex through the whole append-or-insert path
(prior split released the lock between tail-check and Insert, allowing two
same-sender callers to race past coalescing). TransferSession now shifts
source positions past the destination's max so transferred entries always
drain after pre-existing destination entries, matching SQLite semantics.
- Frontend: surface edit/clear/remove failures via `sonner` toasts (drain race
→ "message already sent"); exit edit mode on failure instead of leaving the
textarea silently stuck. Hide the remove button on read-only (agent) ghosts.
- `useQueue.clearAll` no longer derives `max` from pre-clear entry count;
resets to `metaMax ?? 0` so the next status_changed event lands authoritative
capacity.
- `senderKindOf` drops the unreachable metadata fallback; route on `queued_by`
alone since `dispatchTaskMessage` always sets it.
- Test loggers now write to stderr per CLAUDE.md "stderr for logs" guideline.
* test(web): align queue e2e selectors with new ghost-message UI
The ghost-message rewrite renamed button titles ("Cancel queued message"
→ "Remove queued message", "Edit message" → "Edit queued message") and
introduced a `data-testid="queued-ghost-list"` container. The
`message-queue.spec.ts` Playwright tests still pointed at the old titles.
* fix(queue): address second review pass — auth, drain race, tests
- WS handlers (`add` / `append` / `update`) now reject `user_id="agent"`
outright, closing the gap a hostile WS client could exploit by
impersonating the inter-task sender identity to overwrite agent
entries (greptile P1).
- WS `update` / `remove` now require `session_id` so the post-mutation
`MessageQueueStatusChanged` broadcast can never be silently skipped.
- `sqliteRepository.TakeHead` checks `RowsAffected()` after DELETE;
when two concurrent take callers race past the busy-timeout the
loser sees 0 affected rows and now returns `nil, nil` instead of
the already-drained message (would otherwise dispatch the same
prompt twice).
- `Service.GetStatus` returns `Entries: []` instead of `nil` on the
error path so the wire shape stays consistent (frontend already
guards but strict consumers wouldn't tolerate the type mismatch).
- New tests: `queue-api.test.ts` covers `rethrowQueueError` mappings
(queue_full / entry_not_found / generic / Error pass-through);
`session-slice.upsert.test.ts` covers `setQueueEntries`,
`removeQueueEntry`, and `clearQueueStatus`. Backend gains an
agent-impersonation handler test.
- `rethrowQueueError` now surfaces `WSError.message` for non-queue
errors instead of stringifying the whole object.
- Cleanup: replace unused `a, c := ...` discards in service_test
with `_, _ = ...`.
* fix(queue): scope update/remove by session_id (cross-session deletion guard)
The MCP `queue_full` error payload deliberately discloses sibling-task
entry IDs (so the calling agent's LLM can reason about the wedge state).
Without a session_id filter on `DeleteByID` / `UpdateContent`, a hostile
agent that received that payload could call `wsRemoveEntry` /
`wsUpdateMessage` against another task's entries and delete or rewrite
them — only the per-entry UUID was being checked.
- `Repository.UpdateContent` and `Repository.DeleteByID` now require
`sessionID` and add `AND session_id = ?` to the SQL guard. The memory
repo mirrors the scope (lookup keyed on session before the entry scan).
- `Service.UpdateMessage` / `Service.RemoveEntry` take `sessionID`; the
WS handlers thread `req.SessionID` through (already validated as
non-empty in the previous fixup).
- TypeScript: `session_id` is now required on `updateQueuedMessage` /
`removeQueuedEntry` params so callers fail at compile time, matching
the backend contract.
- Tests cover the cross-session attack: `RemoveEntry`/`UpdateMessage`
with an attacker-supplied session_id but a real entry id from another
session must return `ErrEntryNotFound` and leave the victim entry
untouched. SQLite repo gets parallel coverage.
* fix(queue): propagate transfer error + tighten WSError narrowing
- `Service.TransferSession` now returns an error so callers can fail
closed instead of silently leaving entries orphaned on the previous
session. The two existing call sites pick the right policy:
`switchSessionForStep` (reusing an existing session) propagates the
error so the workflow switch aborts; the new-session path logs and
continues, since the new session is already committed and rolling
back here would leave a half-switched workflow.
- `asWSError` now also narrows on `message` / `details`, so WS payloads
without a `code` field are still mapped to a meaningful `Error.message`
via `rethrowQueueError` (instead of "[object Object]"). Native
`Error` instances explicitly skip the WS-error branch so they pass
through untouched, preserving the existing pass-through guarantee.
- `queue-api.test.ts`: `expect.assertions(N)` on the catch-only tests
so a missing throw fails the test instead of silently passing.
* fix(queue): check TransferSession error in service tests
The TransferSession signature change in 83abe6d7 broke errcheck on two
test sites that hadn't been updated. Wrap them in require.NoError.
* feat(github): plumb CI popover fields + branch protection cache
Adds RequiredReviews/UnresolvedReviewThreads/ChecksTotal/ChecksPassing
to TaskPR + PRStatus. Extends the GraphQL PR query with reviewThreads
to populate UnresolvedReviewThreads on every poll. New branch_protection.go
holds an in-memory TTL cache + BranchProtectionFetcher interface;
PATClient/GHClient implement REST/CLI lookups so SyncTaskPR can resolve
required_approving_review_count for the PR's base branch. SyncTaskPR
preserves existing counts when a lightweight (GraphQL) sync returns 0
so the popover doesn't flap during the polling cycle.
* feat(github): mock controller hooks for CI popover e2e
Extends associateTaskPR to accept review/check counts, required_reviews,
and unresolved_review_threads, and switches to ReplaceTaskPR so re-POSTing
the same task drives the popover live-refresh path. Adds POST /pr-feedback
to seed PRFeedback (checks/reviews/comments) keyed by a deterministic
synthetic head_sha, and PUT /auth-health to flip the mock client's
authenticated flag for the "Reconnect GitHub" branch. Mock client gains
ReplaceCheckRuns/ReplaceReviews/ReplaceComments helpers and an
authenticated field that defaults to true.
* feat(web): CI hover popover on PR top-bar button
Hovering the PR top-bar button now reveals a popover with
Passed/In-Progress/Failed counts, a workflow-rollup list under the
non-empty buckets (with "open on GitHub" + "+ ctx" affordances on
failed rows), a review-state row ("Approved 1 / 2 required"), and
unresolved-comments count. Click on the button keeps the existing
"open PR detail panel" behavior so shipped tests stay green.
PRFeedback is lazy-fetched on hover-open via a new SWR-style hook
backed by a 20-entry LRU cache slice; the popover refetches whenever
the underlying TaskPR's updated_at changes (the WS handler already
applies poll deltas). Bucket logic + workflow grouping live in a
small util with vitest coverage. The aggregate-popover variant for
multi-PR tasks ships but the multi-PR trigger preserves its existing
DropdownMenu so per-row click → addPRPanel still works.
* test(e2e): pr-topbar-popover scenarios A–F covering CI popover
11 specs covering: count rows + zero-bucket hiding, workflow rollup
with (N/M passed) badge under Failed, review row + unresolved comments,
hover open + leave close, failed-row open/+ctx affordances, header
external link, "No checks have started" empty state, "Reconnect GitHub"
auth-lost branch, and live count updates on a re-associate.
Adds api-client helpers (mockGitHubSeedPRFeedback, mockGitHubSetAuthHealth,
extended mockGitHubAssociateTaskPR) and SessionPage popover accessors.
* fix(github): address bot review comments on CI popover
- branch_protection.go: evict stale entries on read so the cache map
cannot grow unbounded over a long-running deployment; rewrite the
comment to drop the inaccurate singleflight claim (no dedup logic
exists, concurrent misses race harmlessly to set()).
- gh_client.go: replace fragile strings.Contains "HTTP 404" with an
isNotFoundErr helper that tolerates the formats current and future
gh CLI versions use ("HTTP 404", "404 Not Found", "status: 404").
- graphql.go: extend reviewThreads with totalCount and attribute the
un-fetched tail to the unresolved bucket so PRs with >100 threads
don't silently undercount in the popover.
- pr-ci-popover.tsx: drop the dead "icon" initializer in PRReviewRow
(every branch overwrites it). Memoize useAddCheckToContext so
PRWorkflowRow's reference equality stays stable across renders.
- pr-topbar-button.tsx: collapse the duplicate JSDoc on
usePopoverInteractions.
* fix(github): address remaining bot review comments + tests
Real correctness fixes:
- Replace the "0/0 means counts omitted" heuristic with an explicit
PRStatus.ChecksPopulated flag so the rich REST sync can clear stale
totals when checks are removed from a PR upstream.
- Refresh tp.BaseBranch from status.PR on each SyncTaskPR so a
retargeted PR's branch-protection lookup keys off the new branch.
Persist base_branch in UpdateTaskPR.
- Cache 403 from /branches/.../protection (token lacks Administration:
Read scope) the same way as 404 — otherwise every poller tick burns
rate-limit quota retrying. Both PATClient and GHClient.
- usePRCIPopover: refetch on every popover-open transition (not just
on updated_at change), via a wasEnabledRef gate. Restores the
intended SWR semantics that the lastSyncedRef-only guard suppressed.
- groupChecksByWorkflow: prefer the URL of a job matching the group's
bucket so clicking a "failed" workflow row navigates to a failing
job, not an earlier passing one.
Tests:
- branch_protection_test.go: cache hit/miss/TTL/eviction, error
not cached, no-rule cached, client-without-fetcher path.
- pat_client_test.go: TestPATClient_FetchBranchProtection table-test
for 200/200-with-null/404/403/500.
- service_test.go: ChecksPopulated preserve-on-zero, accept real
zero, base-branch retarget, change-publish for the new fields.
* refactor(github): trim associateTaskPR + drop unreachable PRAggregatePopover
- mock_controller.go: extract associateTaskPRRequest struct,
buildTaskPRFromRequest, and ensureMockPRForRequest helpers so the
handler is back under the 80-line Go limit.
- pr-ci-popover.tsx: remove unreachable PRAggregatePopover export.
Multi-PR tasks currently use the existing DropdownMenu trigger
(no hover popover), so the aggregate component was dead code.
* fix(github): drop unused anchorRef + tests for is{NotFound,Forbidden}Err
- pr-topbar-button.tsx: anchorRef was leftover from the dropped
click-to-pin onInteractOutside guard. Removed.
- gh_client_test.go: tests for the new isNotFoundErr / isForbiddenErr
helpers covering each accepted format and a few non-matches so a
future gh CLI wording change is caught.
* fix(github): UnresolvedReviewThreadsPopulated guard + drop unreachable nil checks
- service.go: mirror the ChecksPopulated/preserve dance for
UnresolvedReviewThreads. The REST single-PR poller (getPRStatus)
doesn't fetch review threads, so without this guard every REST
sync would clobber the GraphQL-populated value to 0 and emit a
spurious github.task_pr.updated event. The GraphQL path
(convertBatchedPRResult) sets the new flag.
- branch_protection.go: drop unreachable s == nil / fetcher == nil
checks. fetchRequiredReviews is only invoked from SyncTaskPR on a
service constructed via NewService, which always initialises the
cache. After a successful type assertion, the interface value
cannot be nil; the !ok branch already covers nil-interface inputs.
- service_test.go: new TestSyncTaskPR_UnresolvedThreads_PreservesOnLightweightSync
covers the regression. Existing NewFieldsPublishEvent threads-change
case updated to set UnresolvedReviewThreadsPopulated.
* refactor(web): drop CI popover progress bar — last-updated is enough
The animated progress bar was visual noise; the "updated Ns ago"
timestamp tells the user the same thing without the indicator. Footer
also hides entirely until the first fetch lands so an empty popover
doesn't show a useless "" line.
* fix(web): background-sync PRFeedback so popover is fresh on first hover
The on-hover SWR fetch was the only refresh path, so opening the popover
showed the previously-cached value while a new fetch raced behind it —
visibly stale until the refetch landed. The button icon, by contrast,
updates the moment a github.task_pr.updated WS push lands.
Add a usePRFeedbackBackgroundSync hook mounted at the PRTopbarButton
that subscribes to the same pr.updated_at change signal and refetches
PRFeedback into the cache in the background. The popover hook still
fires an on-open refetch as a safety net for the very first hover
(before any sync). No extra polling — fetches are gated on real
TaskPR changes, matching the icon's cadence.
* feat(web): CI status chip in chat status bar + tweak elapsed label
- New <PRStatusChip> renders a compact colored circle in the chat
status bar (left of the TodoIndicator, just above the input). Hover
opens the same PRCIPopover anchored to the chip's top edge so the
card expands upward — matching the user's "open bottom-up" ask. The
chip mounts the background-sync hook so the popover is fresh on
first hover even when the top-bar PR button isn't visible.
- elapsedLabel: "just now" → "updated just now" so the footer reads
the same regardless of how recently the cache was refreshed.
* fix(web): chip color was missing — use static class map for Tailwind JIT
\`getPRStatusColor().replace("text-", "bg-")\` produced class strings at
runtime, which Tailwind's content scanner doesn't pick up — the chip
rendered with no background. Replace with a static lookup so each
literal `bg-*` class appears in the source and gets compiled into the
bundle.
* fix(web): chip uses currentColor SVG so the dot actually renders
The previous bg-* approach relied on Tailwind compiling the literal
class strings. Even with a static map some palette entries (notably
bg-muted-foreground) didn't surface as a usable utility, so the chip
remained transparent. Apply getPRStatusColor's text-* class to the
button wrapper and fill an SVG circle with currentColor — the text-*
utilities are already compiled (PRTaskIcon uses them) so the dot
is guaranteed to render in the right color.
* fix(web): redesign PR status chip — clipboard icon + status glyph
- Move chip to right of TodoIndicator (was left).
- Render as "[clipboard icon] [status glyph]" instead of a bare circle:
passed → green CircleCheck, failed → red CircleX, in_progress →
yellow Loader2 (spinning), merged/neutral → muted dot. Status icons
reuse already-compiled text-* utilities so colors render reliably.
* fix(github): ReviewCount = approved-reviewer count + ReviewCountsPopulated guard
Two bugs feeding the popover's "Approved (0)" misreport:
- Semantic: REST path set ReviewCount to len(reviews), which double-counts
authors who left multiple reviews and includes COMMENTED rows. Replace
with countApprovedReviewers — distinct authors whose latest review
state is APPROVED. GraphQL converter gets the parallel
countApprovedReviewerNodes helper and also populates PendingReviewCount
from ReviewRequests.TotalCount (was unset, so the chip's "(K requested)"
suffix was always missing).
- Clobber: GraphQL polls used to write ReviewCount/PendingReviewCount=0
on every cycle because the converter never set them. Add
ReviewCountsPopulated to PRStatus and gate SyncTaskPR's writes on it
the same way ChecksPopulated already does, so a partial sync path
preserves the persisted values instead of resetting them to zero.
* fix(web): popover nitpicks (icon, review row, indent, badge)
- Chip icon: ClipboardCheck → ListCheck.
- PRReviewRow always renders (drop the early-return). Fresh PRs with
no approvals + no protection rule now show "Awaiting review" instead
of vanishing.
- Indent workflow rows under the In Progress / Failed group headers
(pl-5) so the hierarchy reads more clearly.
- In-progress workflow badge: "0/1 ran" → "1 running". The old phrasing
hid whether the job had even started.
* feat(web): stacked pass-rate progress bar in CI popover
Mirrors TodoIndicator's progress bar at the top of the CI section, but
split into three coloured segments:
green (passed) | yellow (in_progress) | red (failed)
sized proportionally to each bucket's count. The label reads
"Pass rate · K/N (P%)" so the user gets the same at-a-glance summary
TODOs shows. Skipped/cancelled-but-not-failed jobs aren't counted —
the bar always reflects the actionable picture.
* fix(web): right-align review counts + drop chip hover bg
- Move approval/required counts to the right edge of the review row
("Approved" on the left, "1 / 2" on the right), mirroring the
bucket-row layout. The "(K requested)" suffix becomes a "· K requested"
trailing fragment on the right side.
- PRStatusChip: drop hover:bg-accent/50 so the trigger doesn't grow a
background plate when the popover is open.
* fix(web): switch CI chip icon to IconChecklist
* fix(github): clear bot review-comment backlog
Real correctness fixes:
- pr-status-chip: pending checks / pending review now beat
checks_state === "success" in chipStatus precedence — a PR with
CI green but reviewers outstanding is no longer rendered as
"passed".
- branch_protection: drop the unreachable Reset() method.
- pr-topbar-button: stop double-mounting usePRFeedbackBackgroundSync;
the chip already covers it.
- use-pr-ci-popover: dedupe key combines prFeedbackKey + updated_at
so switching tasks with same-timestamp PRs still refetches.
- use-pr-ci-popover: drop stale comment about the removed progress bar.
Refactor:
- client_helpers + graphql: extract reviewSample +
latestReviewStateByAuthor helper so the three near-identical
per-reviewer dedup loops share one implementation.
Tests:
- countApprovedReviewers + reduceReviewSummary unit tests.
- TestSyncTaskPR_ReviewCounts_PreservesOnLightweightSync +
AcceptsRealZero mirror the existing ChecksPopulated guards.
- groupChecksByWorkflow distinct-URL test verifies the failed-job
URL-selection fix.
- branch_protection TTL tests now use testing/synctest — instant
virtual time, no real-sleep flake on CI.
* fix(web): clicking the CI chip no longer closes the popover
HoverCard treats the trigger button as outside the content's bounding
box, so a click on the chip fired Radix's onPointerDownOutside and
auto-closed the popover. Add a triggerRef + onPointerDownOutside guard
on HoverCardContent that filters out clicks landing on the trigger —
clicking the chip is now a no-op while the popover stays open via hover.
* fix(orchestrator): keep task and session state in sync on tool-event wake
handleToolCallEvent and handleToolUpdateEvent woke the session from
WAITING_FOR_INPUT to RUNNING via updateTaskSessionState, but never
touched task.state. An out-of-turn tool event (e.g. a Monitor watcher
firing after on_turn_complete moved the task to REVIEW) left the task
parked in REVIEW while the session showed RUNNING — surfacing as a task
that "always works, never leaves state". Use setSessionRunning so
task.state flips to IN_PROGRESS in lockstep.
* fix(orchestrator): guard setSessionRunning against terminal sessions
Greptile flagged that setSessionRunning unconditionally wrote
tasks.state=IN_PROGRESS even when updateTaskSessionState silently
no-op'd because the session was already terminal. With the new
streaming-handler call sites, a buffered tool event arriving after
a CANCELLED/FAILED/COMPLETED session would leave the session
correctly terminal but flip the task back to IN_PROGRESS.
Resolve the session up front and short-circuit when terminal so
neither write fires. Add paired test sub-cases that pre-seed the
session as CANCELLED and assert the task state is not written.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(web): close kanban preview when opening the edit dialog
The dialog overlay used to mask the preview panel behind a backdrop blur;
once the blur was dropped in #761, the preview kept rendering alongside
the dialog and the layout looked broken — especially noticeable when
editing a backlog task whose preview was already pinned open.
* fix(web): stop card-click from firing when using kanban dropdown
React's synthetic events bubble through the React tree even from a portal,
so clicking Archive or Delete in the kanban card's dropdown propagated up
to the card's onClick and navigated to the task page instead of opening
the confirm dialog. Stop click propagation on each menu item and stabilize
the new edit-handler's useCallback dep (PR review feedback).
* fix(web): condense kanban handler comments to one line
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* feat(web): prettify Kandev MCP tool titles in chat
Render `create_task_kandev` as "Kandev: Create Task" instead of the
raw underscored tool name. Frontend-only display transform via a
regex-gated helper so non-Kandev tool names (Read, Bash, other MCP
servers, Claude-supplied human titles) pass through unchanged.
* fix(web): update e2e assertions for prettified Kandev tool titles + trim pass-through
- E2E `config-management.spec.ts` asserted raw `*_kandev` names in chat;
update to the new "Kandev: ..." display so Shard 6/10 passes.
- `prettifyToolTitle` now returns `trimmed` instead of `raw` on the
non-match path so whitespace handling is symmetric across both
branches (Greptile review).
* fix(web): expand collapsed tool-call groups in e2e config-management assertions
Once a turn completes, consecutive tool-call messages collapse into a
"{N} tool call(s)" group header, hiding the individual prettified titles
the test asserts against. The previous tests passed by lucky substring
match against the script directives in the user prompt; once
prettification removed `*_kandev` from the chat surface that
coincidence broke. Add an `expandToolCallGroups` helper that clicks
each group header before running the title visibility assertions.
* refactor(web): trim ACRONYMS to only entries used by current Kandev tool catalog
Per "no speculative code" guideline (Claude review): only `mcp` is
produced by the current 29-tool catalog. Remove the unused
id/url/api/ui/vs/ide/cli entries; add new ones when a catalog tool
first needs them.
* fix(web): strip MCP namespace prefix when prettifying Kandev tool titles
Codex passes the tool name as `<server>/<tool>` (e.g.
`kandev/get_task_plan_kandev`), so the bare-suffix regex didn't match
and the raw underscored name leaked through to the chat. Split on
`/` or `__` and prettify the trailing segment, covering Codex's
`server/tool` and Claude's `mcp__server__tool` naming alike.
* feat(web): show repo name and enable multi-select in pipeline view
* test(web): add e2e tests for pipeline view repo name and multi-select
* fix(web): tighten pipeline-view click handling and pipeline-view e2e wait sentinel
- Toggle selection on task-button click whenever isSelected is truthy, so
a lingering selectedIds entry (after multi-select is turned off) cannot
leave a visible checkbox with a broken click target.
- switchToPipelineView() now waits for [data-testid^=\pipeline-task-\]
rather than swimlane-container, which is shared with the kanban view
and could resolve before the pipeline view renders.
Addresses Greptile review on PR #831.
* feat: extend implement plan button with fresh-agent path and server-side plan mode
- Split-button: primary click = same-session, chevron = fresh agent with clean context window
- Forward draft attachments and context files on implement (adds getAttachments() to handle)
- Authoritatively clear plan_mode via new session.set_plan_mode WS endpoint, not a message flag
- Add on_exit:disable_plan_mode to plan-and-build.yml plan step (mirrors feature-dev.yml)
- Drop localStorage plan-mode rehydration; server session metadata is the single source of truth
- Delete dead DocumentPanel component (no callers)
- Add SetSessionPlanModeByID on orchestrator Service; wire wsSetPlanMode handler
* test: fix store isolation in useImplementFresh test + extract duplicate strings
- Fix test isolation: reset store directly and clear mocks before rendering
- Extract sessionId, appFilePath, appFileName to constants to fix ESLint duplicate-string warnings
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test(e2e): cover implement-plan fresh-agent split-button path
Adds Playwright coverage for the new chevron menu on the violet Implement
button: asserts a brand-new TaskSession is launched on the same task with
the planning session's agent profile inherited, and that the planning
session is left running in parallel. Second spec verifies the split-button
is gated behind plan mode.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: defer plan-mode exit and surface error toast on implement failures
Move handlePlanModeChange(false) into the message.add success callback in
useImplementPlan so a failed send leaves the plan layout intact for retry.
Add error toast to useImplementFresh catch so launchSession failures are
visible to the user instead of silently logged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: guard fresh-agent launch on planning session executor_id
TaskSession.executor_id is optional, so the fresh-agent path could call
launchSession with an undefined executor and silently inherit the
workspace default instead of the planning session's executor. Add the
field to the early-return guard so the hook no-ops when the planning
session is missing either an agent profile or an executor.
* feat: mark fresh-agent session as primary and focus it
When useImplementFresh launches a new implementation session, the fresh
session is now automatically:
1. Set as primary via session.set_primary WS endpoint
2. Focused/selected as the active session in the UI
This improves UX by switching the focus to the fresh implementation context
immediately rather than keeping the planning session active.
Addresses comment in PR #832 from carlosflorencio.
- Extract setupFreshSession helper to keep complexity under 15
- Add tests for primary session marking and UI focus
- Add test for graceful handling of set_primary failures
- Update JSDoc to document the new behavior
- Extract test constants to avoid duplicate string warnings
* fix(test): replace self-referential test constants
`const TASK_ID = TASK_ID;` (and SESS_PLAN, SESS_FRESH) caused a TDZ
ReferenceError at module load, so vitest reported `(0 test)` and none of
the new fresh-agent coverage actually ran. Replace with literal values
so the 13 tests in this file execute again.
* fix: drop strict executor_id guard and skip composer clear on missing session_id
Two related issues:
1. e2e regression — the planning session in the FE store does not always
carry executor_id (the field is omitempty on the backend DTO and isn't
populated for fresh task sessions in the WS push path). The earlier
guard required it before calling launchSession, so the fresh-agent
path silently no-op'd in real flows. Drop executor_id from the guard
and only forward it when set; backend resolves it from the agent
profile / workspace default when omitted.
2. PR review (Greptile P1) — when launchSession resolves without a
session_id, the previous flow still cleared the composer and draft
even though no fresh session was activated. Bail out before the
clear so the user keeps their typed text and can retry.
Tests updated:
- replace the no-op-on-missing-executor test with one that asserts the
hook still launches and omits the key from the payload
- add coverage for the no-session_id branch keeping the composer intact
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend): return 409 on duplicate prompt name
Pre-check by name in CreatePrompt and rename paths and surface
ErrPromptAlreadyExists as 409 with the underlying error message.
Previously the SQLite UNIQUE constraint bubbled up as a 500 with
a generic body, so the frontend had nothing actionable to show.
* feat(web): toast on prompt save errors with e2e coverage
Wire useToast in the Prompts settings page so create/update/delete
failures surface a warning instead of being swallowed silently. Add
data-testid hooks plus Playwright specs covering duplicate-name
create, rename-to-existing, and the happy path.
* fix(backend): close TOCTOU + tighten prompt error handling
Address PR review:
- log client errors (4xx) at Info, reserve Error for unexpected 500s
- switch handler error mapping to errors.Is so wrapped sentinels still
resolve to the right HTTP status
- drop the unreachable existing==nil branch in assertNameAvailable
- translate SQLite UNIQUE constraint errors that slip through the
pre-check race into ErrPromptAlreadyExists so concurrent dup-name
inserts return 409 instead of 500
* fix(web): show skeleton in mobile task switcher sheet while snapshot loads
When the user opens the mobile task switcher inside a task page before the
workflow snapshot has hydrated kanban, the sheet rendered "No tasks yet."
instead of a loading state — and stayed that way if the fetch silently
failed.
useWorkflowSnapshot now drives kanban.isLoading, useTasks exposes it as
{ tasks, isLoading }, and the sheet wires that into TaskSwitcher's existing
skeleton. Fetch errors are surfaced via console.warn so an indefinite empty
list isn't silent anymore.
* fix(web): guard snapshot finally on cancel and drop redundant subscription
Skip the workflow-snapshot fetch's finally block when the effect was superseded by a newer workflowId — otherwise the old fetch's settle would clear the loading flag the new effect just set, briefly flashing the empty-state UI mid-switch.
Also drop the redundant kanbanIsLoading subscription in useSheetData (useTasks already encapsulates it) and make the setState mock in use-tasks.test.ts actually mutate state so useWorkflowSnapshot's side effects are exercised by the test.
* fix(web): scope snapshot finally clear to the effect that raised the flag
If kanban already matched the requested workflowId, the effect skipped raising isLoading=true — but its finally still cleared it on settle, collapsing a skeleton a concurrent caller (e.g. workspace switch) had set independently. Track whether this effect raised the flag and only clear in that case.
Also collapse the multi-line useTasks comment per CLAUDE.md style.
* fix(web): suppress superseded snapshot fetch warnings
Skip the catch-handler console.warn when the effect has been cancelled — a stale fetch shouldn't surface noise after a newer workflow has taken over.
* test(web): flush microtasks explicitly in race test
waitFor on a never-true assertion resolves immediately, hiding the intent. Use explicit Promise.resolve() flushes to land the superseded fetch's .then/.finally chain before asserting.
* style(web): collapse multi-line comments per CLAUDE.md
The 9389ac9 fix restored isStarting as the editor disabled gate.
Update the JSDoc on isPreparingEnvironment to match: it now drives
the "agent still being set up" submit-disabled tooltip only — the
disabled state itself is gated on the broader isStarting so e2e
Cmd+Enter doesn't race the not-yet-ready agent.
My previous fix made submit no longer block on isStarting, with the
goal of keeping local quick-chat sessions from silently dropping
messages. The trace from CI showed it traded one failure for another:
the editor became contenteditable while the session was still
STARTING, the e2e suite pressed Cmd+Enter immediately, and the
backend rejected with "Failed to send message to agent" because the
agent process wasn't ready yet.
Restore the original semantics — STARTING blocks both editor and
submit — but keep `isPreparingEnvironment` as the narrower trigger for
the "agent still being set up" tooltip. Net behaviour:
- STARTING transition: editor disabled, no tooltip (signal is the
disabled state itself)
- container/sandbox prepare: editor disabled + tooltip explaining why
- quick-chat sessions: editor stays disabled until RUNNING, no race
The "agent still being set up" submit gate was triggering on every
session that briefly transitions through STARTING — including local
quick-chat, which has no prepare phase and has its agent live within
~100ms. The eager-init quick-chat e2e tests (chat/quick-chat.spec.ts
and chat/message-queue.spec.ts) wait for the editor to become
editable, then press Cmd+Enter. The race made wrappedSubmit a no-op
exactly when the test was firing — message dropped, agent never saw
it, downstream assertions failed.
Split the merged isStarting flag in useSessionState: keep the broad
flag (still used for the workspace-prep loading UI), and expose
isPreparingEnvironment as a separate signal that's only true while
prepareProgress.status === "preparing". Thread it through chat-input-
area → chat-input-container → useChatInputContainer and gate the
submit-disabled tooltip on it instead of the broad isStarting.
Net: Docker / Sprites bootstrap still surfaces the "agent still being
set up" message and disables submit; quick-chat / local sessions stop
silently dropping in-flight messages.
CI's only-new-issues lint surfaced three real items left over from
recent additions:
- container.go bootstrap snippet had two `fi` shell tokens flagged by
dupword. Tagged with the same nolint directive used by the
KandevBranchCheckoutPostlude (two distinct shell blocks closing).
- DockerExecutor.CreateInstance was 89 lines (over funlen 80). Split
the body into helpers: reportCreateInstanceProgress (deferred
progress reporter), tryReconnect (gates the resume path),
seedSessionDir (per-container auth seed), buildContainerLaunchConfig,
and buildCreatedInstance. CreateInstance is now a 30-line top-down
orchestrator.
- executor_execute.go was 887 effective lines (over revive 800). Move
reuseExistingEnvironment + its helpers (latestExecutorRunningForEnv,
executorRunningMatchesEnvironment, applyExecutorRunningMetadata,
ensureLaunchMetadata) into a sibling executor_environment_reuse.go.
Resolves conflicts from concurrent main work:
- helpers.go: keep our 4-arg RegisterDockerRoutes (with task-title
provider) and bring in main's Slack route registration block.
- service_turns_test.go: keep both lifecycle and events imports.
- kanban-card.tsx: take main's slim version — main extracted
KanbanCardContextMenu / KanbanCardShell / kanban-card-content into
sibling files which the new tree depends on. Our PR's executor
visualization (RemoteCloudTooltip) lives in kanban-card-content.
- passthrough-terminal.tsx + use-passthrough-terminal.ts: keep our
onDisconnected callback alongside main's manualInputRouting and
onWsReady (mobile keyboard input routing). Adopted main's
teardownWebSocket helper and dropped the now-unused
cleanupTerminalConnection.
- event_handlers_linear.go: switch to existing
models.MetaKey{Agent,Executor}ProfileID constants so the merged
diff stays under the goconst threshold.
* 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>