d2ab3cd5095d4ee61ec0065d7ecdeec0a00c308e
222 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bf7762d939 | fix(agents): split codex acp flags from agentctl auto-approve (#1253) | ||
|
|
dfaab11959 |
feat: full GitLab integration — parity with GitHub (#1120)
* feat(backend): full GitLab parity — store, service, poller, WS, presets Add the watcher/poll/write-action surface on top of the existing GitLab client + service skeleton: * 5 new SQLite tables (mr_watches, review_watches, review_mr_tasks, issue_watches, issue_watch_tasks, action_presets) + CRUD * Client + Service: MergeMR, GetProjectMergeMethods, GetProtectedBranch, ListUserProjects, SearchProjects, SetMRLabels, SetMRAssignees * Service: review/issue/MR watch CRUD + Check/Trigger, GetStats, CleanupAllReview/IssueTasks (cleanup-policy aware), TriggerMRSync, action-presets CRUD with defaults * Background poller (MR / review / issue loops) wired from main.go * ~35 GitLab WS action constants + handlers.go registrations * Mock controller for E2E seeding (MRs, issues, pipelines, discussions, approvals, branches) * eventBus + taskDeleter + taskSessionChecker plumbed through Service * Event types: GitLabMRFeedback / NewReviewMR / NewIssue / TaskMRUpdated * feat(web): GitLab frontend parity — types, API, store slice, domain hooks Extend the frontend GitLab integration on top of the new backend surface: * HTTP API client expansion (gitlab-api.ts): full CRUD for review/issue/MR watches, MR write actions (merge/approve/labels/assignees), MR files/commits/feedback, action presets, projects autocomplete, stats * Store slice expansion: review/issue/MR watches lists, action presets by workspace, stats + status with loading flags. Properties prefixed with gitlab to avoid collision with GitHub slice * Domain hooks: use-gitlab-status, use-gitlab-stats, use-gitlab-review-watches, use-gitlab-issue-watches, use-gitlab-action-presets — mirror github hook shapes * Type model: ReviewWatch, IssueWatch, MRWatch, ActionPresets, Stats, ProjectMergeMethods, MRApproval, Pipeline, MRFeedback, etc. * Backend controller HTTP routes for watches/presets/write-actions/ projects/stats so the frontend HTTP API client has endpoints to hit All passes: backend (build + vet + lint + tests), web (typecheck + lint + 2400 tests). * fix(gitlab): wire orchestrator event handlers, fix label-filter drop, add tests Address Claude review blockers on the parity PR: 1. Wire orchestrator subscriptions for GitLab review/issue watch events. Add event_handlers_gitlab.go with handleGitLabNewReviewMR / handleGitLabNewIssue, dedup reservation handshake, and task-creator interfaces (mirrors event_handlers_github.go). main.go now calls orchestratorSvc.SetGitLabService so the dedup APIs are usable 2. Fix silent label-filter drop: when a custom_query is set on an issue watch, labels were being appended to the unused filter arg. Fold labels into customQuery (or default filter) so they actually reach GitLab; extracted appendLabelsToQuery helper with explicit handling of pre-existing labels= clauses 3. Switch event payloads to pointer types (mirrors GitHub) so handlers can type-assert pointer events 4. Backend tests: store_watches_test.go (CRUD + reserve/assign for all 4 new tables + presets), service_watches_test.go (label-filter helper, cleanup policy, project normalisation), action_presets_test.go 5. Frontend slice tests: review/issue watch CRUD round-trips, action presets + stats reducer coverage All green: backend (build+vet+lint+tests), web (typecheck+lint+2403 tests). * fix(gitlab): address remaining blockers — cleanup wiring, safe defaults, nil guards Address the second round of Claude review blockers: 1. main.go: wire SetTaskDeleter + SetTaskSessionChecker on services.GitLab (mirrors GitHub). Without these, manual cleanup sweeps would always error with "task deleter not configured" 2. service_cleanup.go: default cleanup policy on transient DB error is now CleanupPolicyNever (preserve tasks) instead of CleanupPolicyAuto (silently delete). Genuine "watch was deleted" path also falls under the safe-side default; user can manually delete the orphan tasks 3. service_watches.go: add nil guards on requireStore() so the 6 MR-watch list/get/delete methods return an errStoreUnavailable error instead of panicking when the SQLite store failed to construct at boot 4. use-gitlab-status.ts + use-gitlab-stats.ts: add per-mount attemptedRef so an unreachable GitLab doesn't trigger an infinite re-fetch loop — the previous useEffect re-ran every render because the failure path left status null, satisfying the !status guard * fix(gitlab): propagate session-check errors, clamp poll interval on update Address remaining Claude blockers: * service_cleanup.go: HasUserAuthoredMessage transient errors are now preserved (return false → skip delete) instead of silently ignored; the alternative was occasionally deleting tasks a user had touched * service_watches.go: extract clampPollInterval helper applying the same bounds (0 → default, <60 → 60) as the create path; both applyReviewWatchPatch and applyIssueWatchPatch use it so user-supplied zero or tiny values via UpdateXxxWatchRequest no longer hammer GitLab * fix(gitlab): clear dupl lint, fix preset retry loop + per-workspace watch cache * service_watches.go: applyReviewWatchPatch / applyIssueWatchPatch get nolint:dupl markers — they share shape but per-domain field validation lives in the create paths, so deduplicating via generics would obscure the contract. Restores backend lint to 0 issues * use-gitlab-action-presets.ts: per-workspace attemptedRef set so a failing preset fetch doesn't retry on every render * use-gitlab-review-watches.ts + use-gitlab-issue-watches.ts: track lastFetchedRef per consumer so a workspace switch triggers a refetch. The slice-level loaded flag is shared across instances and can't double as a per-workspace cache key * fix(gitlab): add nil-store guards across review/issue watch + cleanup paths Round-3 Claude finding: ReviewWatch / IssueWatch / preset / reservation methods still called s.requireStore().X() directly. If NewStore fails at boot (table migration error), the service struct's store is nil and every list/get/update/create/reserve method panics on first request. Apply the same store-nil → errStoreUnavailable pattern from f95a929 to every remaining method: * CreateReviewWatch / CreateIssueWatch * GetReviewWatch / ListReviewWatches / ListAllReviewWatches / UpdateReviewWatch / TriggerReviewWatchAll * GetIssueWatch / ListIssueWatches / ListAllIssueWatches / UpdateIssueWatch / TriggerIssueWatchAll * ReserveReviewMRTask / AssignReviewMRTaskID / ReleaseReviewMRTask * ReserveIssueWatchTask / AssignIssueWatchTaskID / ReleaseIssueWatchTask * lookupReviewPolicy / lookupIssuePolicy (return CleanupPolicyNever) * fix(gitlab): nil-store guards on CheckXxxWatch + DeleteXxxWatch + cleanup paths Round-4 Claude finding: 4 remaining panic sites the previous nil-guard pass missed — these run from the background poller (every 5 min) and the manual delete/cleanup flows, so a boot-time NewStore failure would crash the orchestrator process on first tick. * service_watches.go: add store-nil guards to DeleteReviewWatch / DeleteIssueWatch / CheckReviewWatch / CheckIssueWatch * service_cleanup.go: guard CleanupAllReviewTasks / CleanupAllIssueTasks entry points; wrap the trailing DeleteReviewMRTask / DeleteIssueWatchTask calls in `if store := s.requireStore(); store != nil` blocks so the cleanup succeeds even when the dedup-row delete is unreachable * refactor(gitlab): split service_watches.go to stay under 800-line revive limit CI's --new-from-rev lint caught service_watches.go at 808 lines (limit 800). Split into three focused files: * service_events.go (94 LOC) — publish helpers for MR feedback / new review MR / new issue / watch lifecycle events * service_issue_watches.go (302 LOC) — Issue watch CRUD + Check + Trigger + fetch + helpers * service_reservations.go (59 LOC) — Reserve/Assign/Release dedup handles used by the orchestrator event handlers service_watches.go is now 505 lines and contains MR watch + Review watch only. No behavioral change. * fix(gitlab): address inline code-review feedback Address actionable inline comments from Claude/CodeRabbit/cubic/Greptile: * appendLabelsToQuery: use url.ParseQuery for exact key match instead of strings.Contains; previously false-matched keys like mylabels= and silently dropped the watch's labels (test added) * fetchReviewMRs: drop dead `filter = watch.CustomQuery` assignment — SearchMRs's buildMRSearchQuery returns customQuery verbatim and ignores filter when customQuery is non-empty * store.go: add workspace_id indexes on gitlab_review_watches and gitlab_issue_watches (the 5-min poller and HTTP list endpoints did full table scans) * mock_client.SearchProjects: switch from == to case-insensitive Contains so partial-query autocomplete returns the seeded project, matching the doc comment * Poller: add sync.Mutex to guard `started` field; Start/Stop are now safe to call concurrently (go test -race would have flagged the previous read/write race) * Add ErrWatchNotFound sentinel; controller_watches.go maps it to HTTP 404 via httpRespondError helper. Update/Trigger handlers previously returned 500 for missing watches, hiding the distinction from real server faults * service_events.go: log Publish errors on watch lifecycle events instead of swallowing them silently * handlers.go: reject malformed JSON payloads in list-style WS handlers rather than silently falling through to broader list/search behavior * event_handlers_gitlab.go: remove dead gitlabRepoSlug helper + blank-identifier suppression * ci: re-trigger to clear flaky E2E shard 3 * fix(gitlab): address final review round — nil guards, store merge, logs, body validation - CheckMRWatch, UpdateReviewWatch, UpdateIssueWatch: nil guards for store/req - handlers: wsNewDiscussionNote rejects empty body - action_presets: Update reads raw stored row (no default freezing); Reset validates workspace_id - service_search.GetStats: log Warn per sub-call failure before zero - event_handlers_gitlab: log Release* dedup-row errors with context - web/store.ts: post-slice merge restores every GitLab sub-state field; extract buildStateOverrides helper - web/default-state.ts: add GitLab fields to defaultState + mergeInitialState via mergeGitLabFields helper - web/gitlab-api.ts: UpdateXxxWatchRequest now Omit workspace_id 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> |
||
|
|
6b98185ac3 | fix(web): restore debug UI on make start-debug (#1252) | ||
|
|
cb7b5429d5 |
fix(tasks): harden multi-branch add_branch and surface it in UI (#1239)
Three areas were broken in the MCP add_branch_to_task flow: 1. Predictable branch names. autoBranchName counted up from existing branch-N rows. Concurrent add_branch calls raced onto the same name and the worktree directory leaked ordering. Now uses a random branch-<3 random chars> suffix via worktree.SmallSuffix. 2. Sibling materialize did not flow to the UI. The materializer published AgentctlReady with the NEW sibling worktree's path, which the frontend handler wrote onto the session row — clobbering the primary's worktree_id/branch and pointing the file browser at the sibling instead of the task root. Added TaskWorkspacePath to the payload, detected the sibling case on the frontend (existing worktree_id != payload's), kept the primary's id/branch, repointed worktree_path to the task root, bumped commits refetch, and dropped the pre-multi-repo legacy git-status entry so the Changes panel stops surfacing the frozen single-repo snapshot. File browser's resetKey now includes the worktree count so the tree reloads when the sibling lands. 3. workspace_path promotion + agentctl rescan refused to act on historical envs. A few envs had workspace_path pointing at the source repo's local_path (orchestrator computeWorkspacePath fallback to req.RepositoryPath when resp.WorktreePath is empty) instead of the primary worktree. The materializer's promotion gate compared against env.WorkspacePath's parent, so those envs were never promoted, and agentctl's resolveRescanPath only accepted parent-of-current so the rescan to the real task root was rejected. Trackers stayed on the wrong directory and the sibling's commits never appeared in the Commits panel even after a full reload. Anchored promotion on env.TaskDirName (basename cross-check) and added a recovery branch to resolveRescanPath that accepts any absolute directory which actually contains git-repo subdirs — the bearer-token middleware already gates the endpoint and scanRepositorySubdirs validates the children, so the relaxed check stays safe. Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
fd79d6170f |
feat(tasks): multi-branch tasks — N (repo, branch) pairs per task (#1226)
* feat(tasks): multi-branch tasks — N (repo, branch) pairs per task Extends multi-repo into multi-branch so a single task can hold multiple worktrees on the same repo (one per branch). Sibling worktrees layout at <task>/<repo>/ + <task>/<repo>-<slug>/, dedicated PRWatch per branch, live agentctl tracker rescan + frontend store updates so the UI splits PRs / files / commits per branch without a session restart. MCP add_branch_to_task_kandev tool is shielded to the worktree executor. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): unify multi-branch repo label as <repo> · <branch> across panels PR Changes and Commits panels rendered repo labels differently for multi-branch tasks — Commits showed "kandev · feature/x" while PR Changes leaked the raw "kandev-feature-x" agentctl tracker tag. Reformat tracker-tagged repo names via formatRepoLabel using the longest known-repo prefix so kandev-cli-* doesn't collapse to kandev · cli-*. * fix(tasks): address multi-branch PR review — data races, slug collisions, MCP error codes Backend - agentctl process.Manager: add repoTrackersMu RWMutex guarding repoTrackers + workspaceTracker. Reads snapshot under RLock; RescanRepositories builds trackers first, takes Lock for swap/append. Validates newWorkDir via os.Stat before publishing cfg.WorkDir. - worktree.Manager: reject invalid explicit BranchSlug up-front; cache key uses sanitized wt.BranchSlug so read/write paths are symmetric. - worktree.store: narrow isDuplicateColumnError to "duplicate column name". - task service AddBranchToTask: slug-collision guard rejects feat/a vs feat-a. - orchestrator resolvePushRepo: two-pass exact-name match before subdir prefix fallback so "backend" doesn't swallow pushes for "backend-admin". - executor buildRepoSpecs: duplicate repos always get a non-empty BranchSlug (falls back to branch-N when both branch fields sanitize to empty). - MCP handler: classifyAddBranchError maps service errors to NOT_FOUND / CONFLICT / VALIDATION_ERROR; add ErrorCodeConflict constant. - MCP server: extract registerAddBranchToTaskTool, register in task mode only (external mode contract: config tools + create_task_kandev). - workspace_rescan handler: reject malformed JSON with 400 instead of silently rescanning the current root. - branch_materializer: split MaterializeBranch into prepare + finalize helpers to satisfy the 80-line cap. Frontend - existingPrUrl returns undefined when a task has >1 PR so per-repo View PR buttons take over (no silent linking to a sibling). - formatRepoLabel checks exact known-repo match before prefix splitting so "kandev-cli" isn't mislabeled as "kandev · cli". - Repo chip key now includes base_branch so siblings differing only on base render as distinct chips. Docs - multi-branch spec uniqueness wording aligned with the canonical 4-column key (task_id, repository_id, base_branch, checkout_branch). - worktree.CreateRequest.BranchSlug doc describes the sibling layout (<repo>-<slug>) instead of the obsolete nested form. * fix(tasks): scope slug-collision check to repo only + use errors.Is for ErrTaskNotFound - service_branches: drop base_branch from the slug-collision check. Worktree path is (repo, slug); two rows on the same repo cut from different bases still collide on disk. - service_branches: prepareBranchAdd wraps taskrepo.ErrTaskNotFound via fmt.Errorf with %w so classifiers don't have to substring-match the formatted task UUID. - mcp handlers: classifyAddBranchError uses errors.Is(err, ErrTaskNotFound). * fix(agentctl): sanitize rescan work_dir before os.Stat to clear CodeQL path-injection CodeQL flagged the rescan endpoint as a path-injection sink (the HTTP-supplied work_dir flowed straight into os.Stat and downstream scanRepositorySubdirs). Add validateRescanPath which: - requires the input to be a clean absolute path with no ".." segments - rejects anything that isn't equal to or an ancestor of the current cfg.WorkDir — the only legitimate use case is the branch materializer promoting <task>/<repo>/ to <task>/ Defense in depth: agentctl binds to localhost, but any colocated process could otherwise direct it at /etc or the user's home by POSTing /api/v1/workspace/rescan. * fix(agentctl): tighten validateRescanPath, close TOCTOU on existingTrackers, add tests - Drop the strings.Contains(clean, "..") guard. filepath.Clean already resolves traversal; the substring check rejects legitimate names like /home/u/..hidden without adding defense (the ancestor check via filepath.Rel is the real boundary). - Drop the rel == "." branch in the ancestor check — early return for clean == currentClean already covers identity. - Read existingTrackers inside the write-lock section (not under RLock then re-Lock) so two concurrent rescans can't both see 0 trackers and double-bootstrap the multi-repo set. - Add table-driven TestValidateRescanPath covering rejection paths (relative, descendant, sibling, traversal-cleaned, empty input), legitimate ancestor promotion, exact match, no-anchor first-launch, and a /home/u/..hidden dotted-segment case. * fix(agentctl): break taint flow into os.Stat — derive rescan path from cfg, not HTTP body CodeQL's go/path-injection trace still flagged manager_rescan.go after the prior sanitizer because the user-supplied newWorkDir flowed (after validation) into os.Stat and then into cfg.WorkDir → tracker paths → workspace_monitor os.Stat sinks. Replace validateRescanPath with resolveRescanPath: it returns a path DERIVED from currentWorkDir (no-op = currentClean, promotion = filepath.Dir(currentClean)) and uses newPath only for comparison, never as a source for the returned string. os.Stat now receives a path whose provenance is trusted manager config. Functionally the legitimate caller (branch materializer promoting <task>/<repo>/ to <task>/) is unchanged. * test(agentctl): make TestResolveRescanPath windows-portable The Linux Backend Tests job passed but the Backend (windows) job rejected every "absolute" case because filepath.IsAbs("/task/repo") is false on Windows (no volume letter). Build inputs via filepath.Join against absRoot() (filepath.Abs of "/" / "\\") so the same table covers POSIX (`/...`) and Windows (`C:\...`) without per-OS branches. Keep the traversal-after-Clean case POSIX-only since filepath.Clean is the shared primitive and that input form needs a volume to be considered absolute on Windows anyway. * fix(agentctl): serialize RescanRepositories + re-check trackers under write lock Two concurrent RescanRepositories calls could both observe an empty tracker set (between the snapshot read and the bootstrap branch) and both enter transitionToMultiRepoMode, leaking a duplicate bare-root tracker and duplicate per-repo trackers. Same race in appendNewRepoTrackers would double-append the same new child across both callers. Fix: - Add rescanMu sync.Mutex on Manager. Acquired for the full body of RescanRepositories so the snapshot → branch → mutate sequence is serialized. repoTrackersMu still guards individual field reads from unrelated goroutines (subscribe/unsubscribe/poll-mode). - appendNewRepoTrackers re-checks membership inside the write-lock before appending and drops already-present trackers (Stop + detach outside the lock). Defense in depth. - Test: add `dotted segment is legal as promotion` case so the ..hidden-segment promotion path stays exercised. --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
1fdeda904b |
feat: add voice mode for chat input (#1159)
* feat: add voice mode for chat input Adds a voice input button to the chat composer with three transcription engines: in-browser Web Speech API, in-browser Whisper via @huggingface/transformers, and a server-side OpenAI Whisper fallback gated on KANDEV_VOICE_OPENAI_API_KEY. User-configurable from a new /settings/voice-mode page: - Master on/off toggle (defaults to on; opt-out, not opt-in) - Engine preference (auto / Web Speech / Whisper Web / Whisper Server) - Language hint (auto-detect or BCP-47 pinned) - Activation mode (click-to-toggle or hold-to-talk) - Auto-send after transcription - Whisper Web model size (tiny / base / small) - Editable Cmd+Shift+M keyboard shortcut Voice settings live under user_settings.voice_mode as a nested JSON object — no schema migration. Setting changes broadcast via the existing user.settings.updated WS event so any open chat tab updates live. The mic button is rendered on the right of the composer alongside the send button (round primary fill; destructive fill with pulse ring while recording). On browsers without a usable engine (e.g. iOS Chrome over HTTP) the button stays visible as a muted greyed icon — tapping it surfaces a toast with the reason (typically a missing secure context). Whisper Web runs in a Web Worker with fp16 decoder weights (avoids the MatMulNBits scale-missing crash that q4/q8 hit on WASM). Models are pulled from the onnx-community/* mirror and cached by transformers.js in the Cache Storage on first use. * fix: address voice mode PR review feedback and E2E selector clash Bot-review fixes from #1159: - Map http.MaxBytesError from c.FormFile to 413 instead of bare 400. - Strip BCP-47 region suffix before passing language hint to Whisper Web. - Recover from worker crash: terminate + null worker + reset ready flag so next init/transcribe creates a fresh worker instead of hanging. - Read live store state via storeApi.getState() in voice-mode settings savers to avoid stale-closure clobbering concurrent settings updates. - Detach Web Speech callbacks before abort() so trailing onend/onerror events don't mutate hook state after cancel(). - Spread caller init before method/body in voice-api fetch so a caller- supplied init can't override the multipart POST. - Replace t.Fatalf with t.Errorf+return inside test HTTP handler goroutine (FailNow from non-test goroutine is unsafe). - Add Enabled/AutoSend zeroing assertions in partial-update test so the bool-overwrite-on-PATCH behavior is locked in explicitly. E2E fix: - toolbar-overflow spec used button.rounded-full which now matches both the send and voice buttons (strict-mode violation). Switched to the submit-message-button testid for an unambiguous locator. * fix: scope voice settings rollback + whisper worker error to changed keys Round 2 of PR review feedback on #1159: - voice-mode settings rollback now restores only the keys patched by the failing request (not the whole snapshot), preserving concurrent edits to unrelated voiceMode fields. - Shortcut rollback diffs previous vs next and reverts only the changed keys (restore prior value or delete if absent), preserving other concurrent shortcut edits. - Whisper worker error handler captures the worker reference at attach time and only clears refs when this.worker still matches it — a stale error from a previously replaced worker no longer nukes the active one. - Update stale whisper-web-models JSDoc that still pointed at Xenova mirrors. * fix: voice mode progress scale, hold-mode race, race-aware rollback Round 3 of PR review feedback on #1159: - Normalise Whisper progress to 0–1 in ensureWhisperClient (transformers.js emits 0–100, the button's display + the `ready: 1` convention expect 0–1 — fixes the 5000% mid-download display). - Claim driverRef synchronously at the top of finishCapture so concurrent pointerup + pointerleave invocations early-return instead of racing and clobbering a freshly-started recording's ref. - Race-aware rollback: voice-mode and shortcut catch branches now skip reverting a key when latest[key] !== next[key]. A failed earlier request no longer overwrites a later successful save to the same key. - Update the Worker wire-protocol comment to show onnx-community/whisper-base instead of the stale Xenova/* example. --------- Co-authored-by: Kandev Agent <agent@kandev.dev> |
||
|
|
83c800879a | fix(web): stop right pane width from drifting across tasks (#1201) | ||
|
|
d76836d740 | fix(web): stop changes panel flicker and restore planning chat streaming (#1197) | ||
|
|
d35f36ae73 | fix(web): make left sidebar width global + steady monitor-switch settling (#1140) | ||
|
|
956e0a551d | feat(web): collapse mobile task search into a topbar icon (#1157) | ||
|
|
5cab91238b | feat(web): showcase merge conflicts in the PR panel (#1151) | ||
|
|
c959209c46 | fix(web): populate prepare progress when switching tasks client-side (#1150) | ||
|
|
0c031c6098 | fix(web): honor default dockview widths on task open (#1136) | ||
|
|
b38dc21fb0 | fix: stop changes panel from showing stale or flickering content (#1128) | ||
|
|
f7eff241b9 | ci: faster e2e via image split, shard rebalance, and flake fixes (#1126) | ||
|
|
f4be0d279c | fix: dockview, sprite, auth, and CLI db backup improvements (#1075) | ||
|
|
8a7b7a0b60 | feat(agentctl): azure Repos PR follow-ups after #1066 (#1071) | ||
|
|
68aefc0e08 | feat(web): system settings pages (status, database, backups, logs, updates, licenses, about) (#942) | ||
|
|
3bc092bf20 |
feat(terminal): first-class user terminals — stable seq, rename, park/resume (#1009)
* feat(terminal): persist user terminals with stable seq + park state
Ordinary user shells now have a DB-backed `user_terminals` row keyed by
the agentctl PTY id, carrying a per-task monotonic seq (gaps preserved on
delete), custom_name override, and open/parked state. The terminal
service owns the create/list/rename/park/resume/destroy surface and
guards reject `bottom-panel` (cmd+J) + `script-*` ids so passthrough
flows are untouched. Closing a tab now parks (PTY keeps running);
destroy kills the PTY and removes the row.
* feat(web): expose terminal seq badge, rename, and parked-terminals menu
Wires the right-panel strip and SSR page state to the new task-keyed
terminals endpoint and discriminated UserShell union, adds the `#N`
badge for ordinary terminals, plumbs context-menu / double-click rename,
and renders a "Parked" submenu so closed-but-running terminals can be
resumed. Closing a managed tab now parks instead of destroying so the
PTY (and its buffer) survives accidental closes.
* test(terminal): e2e coverage for first-class persistent entities
Covers the persistence guarantees the user explicitly called out:
monotonic seqs, refresh-survives-rename-and-park, gap-preserving destroy,
park+resume cycle, and bottom-panel/script guards. Exercises the new
user_shell.{create,list,rename,park,resume,destroy} WS actions through
the shared ApiClient.wsRequest helper.
* fix(terminal): atomic seq allocation + propagate destroy errors
- Repository.Create folds MAX(seq) read + INSERT into a single
`INSERT … SELECT` statement so concurrent creates can no longer pick
the same seq. Locked in with TestCreate_ConcurrentSeqAllocation (25
parallel creates, all unique).
- wsUserShellStop now propagates the service-Destroy error so the
frontend stops optimistically removing a row the backend failed to
delete.
- Service.Destroy / CleanupTask log PTY-stop failures at Warn (instead
of silently swallowing) while keeping the proceed-on-failure semantics
intentional — the DB row is the source of truth for "user expects
this terminal gone".
- IsUserShellAlive copies ProcessID under the read lock before calling
r.Get(), removing the post-unlock pointer-deref race.
- Get-not-found test asserts errors.Is(err, ErrNotFound) so it locks to
the repository contract.
* fix(web): close-tab waits for backend ack + className undefined guard
- handleCloseTab defers `removeTerminal` into the `.then()` of the
park/destroy promise. A failed backend call leaves the tab visible
rather than vanishing it optimistically; the next `user_shell.list`
poll reconciles state.
- session-tabs guards `tab.className` with `?? ""` so an undefined value
no longer produces "undefined group relative..." in the rendered
class string.
- user-shells.test simplification: `createSessionRuntimeSlice` only
consumes `set`, so the test passes the slice creator straight to
`immer()` instead of wrapping it in an adapter with unused args.
* fix(terminal): task-scoped mutations + legacy-shell list + UI hardening
Round-2 review feedback (cubic P1/P2, claude, coderabbit):
Backend:
- Rename/Park/Resume/Destroy now require a task_id and verify the
terminal belongs to that task via a shared requireOwnership helper.
Cross-task mutations return ErrTaskMismatch instead of silently
succeeding by raw terminal id.
- wsUserShellList no longer drops managed-shaped agentctl shells when
the terminal service is disabled or task_id is missing — legacy
passthrough callers see their existing shells again. `managedCovered`
flag scopes the orphan filter to the DB-backed path.
- service_test.go uses the shared-cache in-memory SQLite + MaxOpenConns(1)
setup, matching repository_test.go and removing the flake-one-race-away
warning.
- New cross-task guard tests: TestRename_RejectsCrossTask,
TestPark_RejectsCrossTask, TestDestroy_RejectsCrossTask, plus a
TestRename_EmptyTaskIDSkipsOwnership for internal callers.
Frontend:
- updateUserShell patch type narrowed to Partial<Omit<UserShellInfo,
"terminalId">> so the row key can't be silently mutated.
- destroyUserShell now throws on a missing WS client instead of silently
resolving — handleCloseTab's .then() no longer fires prematurely.
- rename/park/resume/destroy API client + useTerminals hook plumb the
active task id through every mutating call.
E2E:
- New "cross-task mutating ops are rejected" spec creates a terminal
under task A and verifies task B can't rename/park/destroy it by raw
id.
- Existing specs updated to send task_id with their mutating calls.
* fix(terminal): require task_id on all mutators + wire taskID into hooks
Round-3 review feedback (cubic P1, claude blocker, coderabbit):
Backend:
- requireOwnership no longer skips the ownership check when taskID is
empty. The previous carve-out was reachable from the WS layer (where
task_id is user-controlled), so an attacker who guessed a terminal id
could omit task_id and mutate any row. CleanupTask walks the repo
directly and never touched the bypass.
- TestRename_EmptyTaskIDRejected pins the new contract.
Frontend (the real bug claude caught):
- useUserShells now accepts a taskId parameter and forwards it on the
user_shell.list WS call. Without it the backend's DB-backed ordinary-
shell path was skipped and the Zustand store wiped the SSR-hydrated
panel strip on the first WS fetch.
- Response mapping switched to a discriminated union: id (new field)
falls back to terminal_id (legacy), and kind/seq/state/pty_status/
display_name/custom_name flow into the store so the parked-terminals
submenu actually populates.
- useTerminals + useMobileTerminals read the active task id from the
store and pass it through to useUserShells.
- destroyUserShell now retries with user_shell.stop when the server
returns an unknown-action error — keeps the rollout-window
compatibility intact for older backends that only know the legacy
action name.
* fix(terminal): list parked, rollout-safe kind default, helper extraction
Round-4 review feedback (claude + greptile P1):
- useUserShells now sends include_parked: true so the "Parked terminals"
submenu populates after a page reload. Previously the WS list returned
only state=open rows; parked terminals were invisible until the user
parked something in the same session. The main strip still hides
parked entries via buildTerminalsFromShells.
- useAddTerminal stops blindly defaulting kind to "ordinary" — that
misclassified legacy passthrough payloads (terminal_id without seq)
during a rollout where the frontend is newer than the backend. The
managed-tab path then called parkUserShell on a server that didn't
know the action. Now only tags as ordinary when kind is explicit or
the row carries a seq.
- handleCloseTab extracted into a standalone useCloseTab + helpers
hoisted into use-terminals-build.ts / use-terminals-types.ts. Brings
use-terminals.ts back under the 600-line file limit and the
useTerminalActions wrapper inside the 100-line function limit.
* fix(terminal): scope-keyed shell cache + null-task guard + tighter rollout default
Round-5 review feedback (cubic P1, coderabbit x4):
- useUserShells now keys its fetch cache on env+task (`${env}:${task}`),
not just env. An earlier load without a task id (e.g. before the
active task hydrated) no longer pins the store on a legacy-only shell
list and blocks the task-scoped refetch.
- useMobileTerminals delays auto-create until a real taskID is
available. Previously a null taskID could bootstrap a legacy
non-persistent shell, mark the env as auto-created, and starve the
real task of its first-class terminal. Guard key is now env:task, and
releaseAutoCreatedEnvironment clears every scope on that env.
- useAddTerminal tightens the rollout check: a row is "ordinary" only
when `seq !== undefined`. A bare `kind: "ordinary"` without seq from
an older backend stays legacy so close → destroy on the first click.
- useRemoveTerminal reads the current activeTab from a ref instead of
closing over the value captured when handleCloseTab first ran. Async
park/destroy resolves after a user tab-switch no longer clobber the
newer selection with the stale fallback shift.
Tests:
- New "waits for taskID before auto-creating — null taskID is a no-op"
case in use-mobile-terminals.test.ts confirms the null-task guard.
- All existing mobile-terminal + slice + E2E tests pass.
* fix(terminal): live activeTab lookup + selectable-tab guard
Round-6 review feedback (cubic P2 x2):
- getActiveTab now reads the active tab from the Zustand store via
storeApi.getState() instead of a useRef/useEffect pair. Closes the
post-render race where an async close handler could resolve between
the new render's commit and the effect-driven ref sync.
- computeTerminalTabValue requires activeTab to be selectable
(present in `terminals` or === "commands") before honoring it. A
stale id left over from a parked/destroyed terminal no longer pins
the panel on a nonexistent tab and blocks the fallback shift to a
real shell.
* chore(terminal): inline savedTabFromStorage to drop file under line cap
Round-7 review feedback (claude suggestion): the useMemo around the
synchronous getSessionStorage call was zero-value memoisation that
pushed use-terminals.ts three lines over the 600-line max-lines limit.
Inlined; file now at 600 lines.
* chore(terminal): drop savedTabExists local + comment to land under 600 lines
Round-8 follow-up (claude): the previous commit's two-line comment block
held the file at 603 lines (still over the 600-line cap). Inline
savedTabExists at the only call site and remove the redundant comment —
the round-7 PR thread already explains why the useMemo was removed. File
now at 599 lines.
* fix(terminal): plumb task_id through dockview + tolerate legacy destroys
Manual-test feedback: clicking X on a dockview terminal panel surfaced
"destroy terminal: terminal does not belong to the supplied task" because
the dockview path was creating + destroying terminals without sending
task_id at all. The new requireOwnership guard then correctly rejected
the empty task_id against a non-empty term.TaskID.
Frontend:
- addTerminalPanel grows task_id + title parameters; stamps both into
panel params at creation time so the destroy cleanup can pass them
back to user_shell.destroy / .stop.
- Every dockview-side createUserShell call site now passes the active
task_id, so the backend creates a DB-backed ordinary terminal (with
seq + displayName) instead of a legacy passthrough shell.
- destroyUserShell + stopUserShell already accepted an optional task_id;
dockview-layout-setup.onDidRemovePanel now reads the stamped task id
from panel params and forwards it.
Backend:
- wsUserShellStop now requires both terminalSvc and a non-empty
req.TaskID before routing through the managed service. An empty
task_id falls through to the legacy passthrough StopUserShell so older
clients (and dockview panels persisted before task_id was stamped)
keep working.
Known follow-ups (separate task): the #N badge, rename context menu,
and parked-terminals dropdown still only show on the mobile/tablet
right-panel strip. Desktop dockview shows the seq inside the panel
title text but doesn't expose the badge/rename/parked-list UI yet.
* feat(terminal): dockview tab badges + rename context menu + existing-terminals list
Manual-test feedback round 2:
- Custom dockview tab component (TerminalTab) renders the #N pill
only when the active env has more than one ordinary terminal —
mirrors the session-tab badge semantics. Right-click on any ordinary
terminal tab now exposes Rename / Park / Destroy.
- Dockview + menu picks up a new "Terminals" section listing every
ordinary terminal for the active task (open and parked). Clicking a
row focuses the existing dockview panel if one is mounted, otherwise
re-opens it; parked rows are auto-resumed via user_shell.resume
before the panel is added. The base "Terminal" action was renamed
to "New Terminal" so the existing-terminals list is distinguishable
in the menu.
- Reopen menu uses a stable EMPTY_SHELLS sentinel for its store
selector to avoid Zustand infinite re-renders when the env has no
shells.
E2E:
- terminal-new-tab.spec uses getByRole("menuitem", { name: "New
Terminal", exact: true }) to disambiguate from the new "Terminals"
submenu label.
- Full terminal suite still passes (26/26).
* fix(terminal): tab title shows "Terminal" with seq as badge, populate store on session page
Re-reading the user feedback: the title text was rendering as
"Terminal 1" (backend's displayName), and the dockview "+" menu's
Terminals section was empty because nothing on desktop was calling
useUserShells to populate the store.
- TerminalTab.pickDisplayName now returns the literal "Terminal" for
ordinary terminals (custom names still override). The seq lives only
in the sibling badge — same split as session-tab where the agent
name is the title and the seq is a pill before it. Reading "Terminal
1" out of "[1] Terminal" instead of "[1] Terminal 1".
- LeftHeaderActions (dockview "+") now calls useUserShells on mount.
Without this the desktop layout never triggered the WS list fetch,
so the Terminals submenu and the badge "count > 1" check both saw
an empty store and silently disappeared.
- TerminalReopenMenuItems also calls useUserShells defensively (cheap
thanks to scope-keyed cache) so it works when mounted in isolation.
* feat(terminal): "New X" rows live inside their section labels
User feedback: move "New Agent" under the Agents section header and
"New Terminal" under the Terminals section header so the dockview "+"
menu reads top-to-bottom as Agents → New Agent → existing sessions →
Terminals → New Terminal → existing terminals → other panels.
- SessionReopenMenuItems accepts an optional onNewSession prop and
renders the "New Agent" row as the first item under the "Agents"
label. Render gate now keeps the section visible whenever the create
handler is supplied, even with zero existing sessions.
- TerminalReopenMenuItems gets a matching onNewTerminal prop with
the same behaviour ("New Terminal" row under the "Terminals" label).
- dockview-add-panel-items drops its standalone "New Agent" and "New
Terminal" rows since both now live inside their sections.
* feat(terminal): auto-migrate default panel to DB-backed ordinary terminal
The legacy `terminal-default` dockview panel was hard-wired to a
non-managed `shell-default` PTY so it never picked up an ordinary
seq/customName/parked state — the new "Terminal N" badge UX could
only show a badge once the user had explicitly created a second
terminal. With useEnsureDefaultTerminalOrdinary the default panel is
upgraded into a real `shell-<uuid>` row on session-page mount, so
every visible tab is now a first-class ordinary terminal.
Plus a UI-level e2e spec asserting the multi-terminal title + badge
contract, and an expectation bump on the existing rename+park spec
since visiting the session page now mints 1 extra ordinary terminal.
* fix(terminal): park ordinary terminals on tab close so they survive refresh
Dockview's tab X close button routed straight to stopUserShell (an
alias for destroyUserShell), wiping the DB row on close — after a
refresh the user had no way to resume a still-running PTY they had
just tucked away.
The X is now a "hide for now" affordance: ordinary terminals get
parked (PTY survives, row stays in DB, reappears in the "+" →
Terminals menu with a `parked` pill), while scripts / bottom-panel /
legacy passthrough shells stay on the destroy path because they have
no resume story.
Permanent delete still lives on the right-click Destroy menu item
and (in the next commit) on each reopen-menu row.
* fix(terminal): preserve DB-backed terminal panel ids across layout restore
The serializer's normalizePanel rewrote every user-created terminal
panel id to `terminal-saved-N` and dropped environmentId/taskID from
params, so after a reload that panel couldn't be matched back to its
live PTY or its store record — the tab rendered with no badge and a
stale fallback title.
Treat `shell-<uuid>` panel ids the same way we already treat
`terminal-default`: preserve id and params on restore. Legacy
ephemeral `terminal-saved-*` ids still mint a fresh PTY on restore.
Also force tabComponent=terminalTab and title="Terminal" for any
restored terminal panel so layouts persisted before the badge UX
land on the new presentation.
* feat(terminal): destroy button on reopen-menu rows for permanent delete
Destroy used to live only on the tab's right-click menu — useless once
the tab was already closed. Each ordinary terminal row in the dockview
"+" → Terminals submenu now ends with an × button that confirms-then
calls destroyUserShell, killing the PTY and dropping the DB row so the
terminal does not reappear after reload.
Pairs with the previous close-to-park change: closing the tab parks
(soft, resumable), the × button on the menu row destroys (hard,
permanent).
* fix(terminal): reopen-menu focuses existing tab, drops parked pill, says Terminate
Three small fixes to the dockview "+" → Terminals submenu:
1. Clicking a row whose terminal is already mounted now FOCUSES the
existing panel instead of duplicating it. The default-migrated
panel keeps its registry id `terminal-default` even after the
migration to a `shell-<uuid>` PTY, so a direct getPanel(uuid) miss
would fall through to addTerminalPanel and create a second tab for
the same PTY. Fall back to scanning `params.terminalId` so the
lookup catches that case.
2. Drop the "parked" pill from the row — parking is now an internal
detail (close = park) that the user doesn't need to read off the
menu. Clicking the row brings the terminal back into view either
way, so distinguishing it visually is noise.
3. Rename the row × tooltip + confirm copy from "Destroy" / "Permanently
delete" to "Terminate" to match the new tab-context-menu label.
Spec: add a test that asserts no duplicate tab when reopening an
already-open terminal, and drop the parked-pill assertion from the
existing close+reload test.
* feat(terminal): empty-group watermark uses the rich + menu
When a user splits a group (split-right / split-down) the new half is
empty and dockview renders the watermark. Before this, the watermark
showed a hard-coded Agent/Plan/Terminal/Browser row and clicking
"Terminal" called createUserShell without a taskId — so it spawned a
legacy passthrough shell that never appeared in the user-shell list,
and clicking "Agent" only ever focused the singleton chat panel
(invisible if the task had multiple sessions).
The watermark now renders the same AddPanelMenuItems content the
header "+" uses, so the user gets:
- The Agents list (existing sessions, greyed when already open)
- The Terminals list (existing terminals, greyed when already open)
- The Browser/Plan/VS Code/Changes/Files/PR options
…served behind a single "Add panel" dropdown trigger centered on the
empty group.
* feat(terminal): inline rename on the tab; drop Park, rename Destroy → Terminate
The tab title is now editable in place — picking Rename from the
right-click context menu swaps the title for an input. Enter commits,
Escape / blur-with-no-change cancels, and the new name is pushed
through renameUserShell + the user-shell store so the API and badge
list stay in sync. Replaces the previous window.prompt which felt
out of place in the dockview chrome.
While here:
- Drop the "Park (hide, keep PTY)" context-menu item. Tab close
already parks ordinary terminals, so the explicit Park action was
redundant and broke the "one obvious affordance per outcome" rule.
- Rename "Destroy" → "Terminate" in the context menu. Matches the
reopen-menu × tooltip and reads more like a system action than a
destructive UI primitive.
Spec adds an inline-rename test asserting the input appears, accepts
typed text, and commits the new title to the tab.
* fix(terminal): right-click Terminate closes the tab; drop displayName DOM warning
Two unrelated fixes both landing in terminal-tab.tsx:
- Terminate used to fire the WS destroy and update the store, but left
the dockview panel mounted with a dead PTY underneath. Now it
optimistically drops the shell row from the store, closes the panel
via api.close(), then sends the destroy — same effect as the X close
but routed to the hard-destroy path (the onDidRemovePanel cleanup
sees no shell entry and skips its park branch).
- TerminalTabBody typed `displayName` as a prop but never destructured
it, so {...props} spread it into DockviewDefaultTab and on into a DOM
div, triggering "React does not recognize the `displayName` prop on
a DOM element". Drop it from the spread.
* feat(terminal): polish + menu — badge-style indices, flush-left New rows, terminate closes panel
Polish pass on the dockview "+" → Agents / Terminals submenus:
- Replace the plain `#N` text with the same pill-style badge the tabs
use (small bg, rounded, 11px). Agents and terminals both pick this
up — so the menu row reads "[N] label" matching the tab style.
- Drop the empty index-column placeholder span on the "New X" rows so
the icon + label sit flush-left under the section header instead of
awkwardly indented.
- Strip the backend "Terminal {seq}" suffix from row labels — the
badge already carries the number, so showing "Terminal 1" next to
the "1" pill duplicated it. Custom-renamed terminals still show
their name.
- The row × button now also closes the dockview panel if the terminal
is currently open, mirroring the right-click → Terminate flow.
Spec: switch the row-by-seq lookups from `hasText: "#N"` (now stale
since the `#` prefix is gone) to a `has:` filter against the new
`reopen-terminal-seq-N` badge testid, and add a regression test for
right-click → Terminate closing the tab and removing the row in one
gesture.
* fix: address terminal review feedback
* fix: resolve terminal review suggestions
* fix: catch terminal close stop failures
* fix: harden terminal terminate failures
* fix: use pinned pnpm docker arg
* fix: satisfy backend lint after main merge
* fix: stabilize e2e after layout merge
|
||
|
|
f1bdc56892 |
feat(layout): roomier pane caps + remember user-set widths (#1005)
* refactor(layout): swap pane width caps for viewport-proportional runtime cap Replace the hard 350px sidebar / 450px right caps with max(800, viewport*0.7) so wide displays get a roomy file/terminal panel and the sidebar can grow with the screen. Pinned minimum is raised from 50px to 180px so panels don't collapse into unusable slivers. Caps now live in lib/state/layout-manager/caps.ts; constants module no longer exports per-column max-px constants. * feat(layout): persist preferred pane widths, flush on unload, roomier tablet split - Mirror sidebar / right widths into a sessionStorage pinned-defaults slot on every resize; seed brand-new task envs with those widths so the user's last-set sizes follow them across tasks. - Re-apply the runtime cap to every pinned group on each ResizeObserver tick so a panel pinned to the old cap grows when the user widens the window (and re-clamps when they shrink it). - Flush the debounced layout save on `beforeunload`, so a reload less than 300ms after the last drag doesn't lose the new width. - Drop the tablet right-panel inner split minSize from 30/20 to 15/15 so the files vs. terminal proportions match the loosened desktop feel. * test(e2e): cover pane resize, persistence, and edge cases Four spec files under apps/web/e2e/tests/layout/ exercise the runtime cap and pinned-defaults persistence end-to-end via dockview's `.dv-sash` drag handles and `window.__dockviewApi__`: - pane-resize-right: drag past the old 450px cap, viewport-shrink re-clamp, reload + cross-env propagation of preferred width. - pane-resize-sidebar: same coverage for the sidebar including toggle-off/on width retention. - pane-persistence-tablet: localStorage round-trip for the tablet layout keys (`task-layout-tablet-v1`, `task-layout-right-v2`). - pane-resize-edge-cases: double-click smoke, rapid drags + beforeunload flush, maximize round-trip, sash off-viewport, resize with sidebar hidden. Adds e2e/helpers/dockview-resize.ts for the shared drag + width read-out helpers. * fix(layout): cap sidebar at 30% screen width, preserve legacy initial widths - Split the runtime cap into a sidebar-specific function (max(350, vw*0.3)) and the right-pane function (max(800, vw*0.7)). File-tree / task-list content rarely benefits from more than ~30% of the screen. - Preserve legacy initial widths: getPinnedWidth now clamps the ratio-based default to the old hard limits (350 sidebar, 450 right) so a fresh task env opens at exactly the same width as before. Users can still drag past these via setConstraints — only the *initial* default is capped tightly. * fix(layout): address PR review — narrow viewport, hidden sidebar, e2e flake Bot-review fixups for #1005: - caps: bound runtime caps by `viewport - VIEWPORT_RESERVE_PX` so the 800px right-cap floor can't collapse the center column on narrow screens (Cubic P1). - applier: respect `column.maxWidth` on the sidebar too, so the `compact` preset's 260px cap is honored at runtime (Cubic P2). - dockview-store: skip the pinned-defaults mirror when the column is hidden — auto-sync infers sidebar from grid index 0, which is the center when sidebar is hidden, so an unguarded mirror would persist the center's width as the sidebar's (Greptile/Cubic P1). - trackPinnedWidths: gate the auto-sync on `sidebarVisible` / `rightPanelsVisible` for the same reason. - sizing: use `LAYOUT_RIGHT_RATIO` for non-sidebar columns instead of silently reusing the sidebar ratio (Greptile P2). - e2e: tablet viewport bumped from 1024 → 700 (≥1024 is desktop, not tablet — the old viewport never rendered the tablet layout and every assertion timed out). Tests now also verify rendered panel proportions, not just the localStorage echo (CodeRabbit #2/#3). - e2e helpers: throw on missing `.dv-sash` instead of returning `-1` (CodeRabbit #1); shared `openWideTask` extracted from three spec files (CodeRabbit #5); silent `return` in evaluate blocks replaced with `throw` for fail-fast (CodeRabbit #4). * fix(layout): swap drag-mouse e2e for programmatic resize + claude/cr followups Round 2 of PR review fixups: - e2e: convert pane-resize-right/sidebar/edge-cases specs from `page.mouse` drag-by-delta to a programmatic `splitview.resizeView()` call. The mouse-drag approach was flaky in headless CI (5 chromium failures across shards 2-4); the programmatic path tests the same cap-enforcement and persistence contract without depending on pointer-events targeting through a 1px .dv-sash overlay. - dockview-layout-setup: remove an unreachable guard in trackPinnedWidths — `rightIdx === 0 && sidebarVisible` cannot occur given the `sv.length < 2` early return (Claude). - dockview-layout-setup: cancel any in-flight debounce timer in the setupLayoutPersistence disposer so a fire can't race with teardown and persist stale layout (CodeRabbit). - constants: document why LAYOUT_SIDEBAR_RATIO and LAYOUT_RIGHT_RATIO are both 0.25 today — separate constants stay decoupled so a future tweak to one doesn't silently change the other (Claude). * fix(layout): drop cross-env width propagation — defaults stay unchanged User feedback: the initial sidebar width on a brand-new task should be the same as it was before this PR (350px on typical viewports). The plan's "propagate preferred widths across envs" feature changed that in practice: after the first resize, every new task opened at the resized width instead of the legacy default. - Remove `getPinnedDefaults` / `setPinnedDefaults` helpers and the sessionStorage `kandev.dockview.pinned-defaults` slot. - Drop the write-through in `setPinnedWidth` and the seed in `performBuildDefault`. `freshPinned` is back to `new Map()`, so a brand-new env always renders at the ratio-based default (same as before this PR, since `getPinnedWidth` already clamps the initial ratio to the legacy 350/450 hard caps). - Per-env layout persistence still works via `setEnvLayout` — when a user resizes inside a task and switches back later, the width is restored. Only the cross-env *default* is no longer carried over. - Remove e2e tests + helpers that exercised the propagation contract. - Drop the flaky `rapid resizes persist across reload` edge test — too brittle around the debounce/beforeunload race. - `LAYOUT_INITIAL_RATIO` introduced as the canonical name; `LAYOUT_SIDEBAR_RATIO` / `LAYOUT_RIGHT_RATIO` kept as deprecated aliases for backwards compatibility while sizing.ts dispatches. * fix(layout): bump saved-layout storage keys to v2 Layouts saved by intermediate builds of this PR captured sidebar/right widths beyond the legacy initial caps (when the larger runtime cap was the only thing constraining serialized widths). Restoring those layouts on the current build replays the over-wide widths, defeating the "defaults stay the same" guarantee. Bumping the storage key prefixes (`env-layout` → `env-layout-v2`, `env-maximize` → `env-maximize-v2`, `dockview-layout-v1` → `dockview-layout-v2`) invalidates legacy saves once on first open; subsequent opens persist + restore under v2 normally. Existing users lose stored column widths once and re-customize from defaults — a one-time cost in exchange for the upgrade being clean. * fix(layout): apply target env's widths after fast-path switch Fast-path env switch (`tryFastEnvSwitch`) skips `fromJSON` for speed, which means column widths carry over from the *outgoing* env. If a user resized the sidebar to 480px in task A and then opens task B, B inherits A's 480px sidebar — even with empty sessionStorage on different dev-server ports, because the widths live in dockview's in-memory grid state, not in storage. Explicitly resize sidebar/right after the panel swap: - saved layout exists for target env → use its serialized sizes - no saved layout (brand-new env) → compute defaults via `getPinnedWidth` (legacy initial caps: 350 sidebar / 450 right) * fix(layout): pass measured dims to fromJSON so widths don't grow on rescale Even on a fresh dev instance with no prior storage, brand-new tasks were opening with oversized sidebar/right widths. Root cause: applyLayout fed `api.width` into toSerializedDockview, but `api.width` lags the live container during the dockview mount sequence. fromJSON wrote a layout sized for the stale width; the subsequent `api.layout(safeWidth, ...)` in performBuildDefault's rAF rescaled the grid proportionally — and without the old hard cap (350/450) clamping it, the sidebar grew past its legacy default. Pass the measured container dimensions through applyLayout into toSerializedDockview so fromJSON's grid.width already matches the live container. api.layout in the rAF is then a no-op, no rescale, and sidebar/right open at exactly their legacy defaults. Also applies to applyBuiltInPreset and applyCustomLayout for the same flow. * fix(layout): lock pinned column max width to current size, unlock on drag The real reason brand-new tasks opened with oversized sidebar/right: dockview's splitview rebalances pinned columns proportionally on every `api.layout` call. After we relaxed setConstraints from the legacy 350/450 hard caps to viewport-proportional runtime caps (576/1344), nothing clamped the rebalance — sidebar grew from 350 → ~450, right from 450 → ~530 on the first ResizeObserver tick. New invariant: pinned-column `maximumWidth` is locked to the column's *current* width whenever the user is not actively dragging a sash. The rebalance has zero room to grow. - `applyLayout` / `applyLayoutFixups` set the lock as soon as fromJSON finishes — sidebar/right open at exactly their just-computed widths. - `setupContainerResizeSync` re-applies the lock on every viewport ResizeObserver tick before and after `api.layout`. - `setupSashDragCapToggle` listens for mousedown/mouseup on `.dv-sash`: on mousedown, widen the cap to the runtime viewport-proportional cap so the user can drag past the legacy default; on mouseup, snap the cap back to the new current width. Also addresses Claude's latest review: - caps.test.ts: rename "hits the 350px floor on roomy viewports" → "… when vw*0.3 falls below the minimum" (test name now matches assertion). - dockview-resize.ts: drop the stale "pinned-defaults mirror" comment; guard the sidebar splitview index against the hidden-sidebar footgun. - pane-resize-right.spec.ts: rename the reload test to mention the correct localStorage key (`dockview-layout-v2`). - new `dockview-env-switch-pinned.test.ts` unit-tests applyPinned- ColumnSizes with a spy splitview — verifies sidebar/right are explicitly resized after a fast-path env switch. * fix(layout): constrain all groups, loosen e2e resize cap, drop stale docs Round of PR-review fixes: - applier: `applyConstraintsToAllPanelGroups` replaces the previous first-only helper. The default right column has separate top + bottom groups; constraining only the first left the bottom group unbounded and let the column grow on rebalance via that group (cubic P2 + Claude regression). - sizing: collapse `LAYOUT_SIDEBAR_RATIO` / `LAYOUT_RIGHT_RATIO` dispatch — they alias the same `LAYOUT_INITIAL_RATIO`, so the ternary was a silent no-op (Claude). - sizing: doc — drop "widths seeded from pinned-defaults sessionStorage" reference; that feature was removed in |
||
|
|
68aeefa296 |
feat(gitlab): add GitLab integration with MR / discussions support (#861)
* feat(gitlab): add GitLab integration with MR / discussions support Adds a GitLab integration parallel to the GitHub one: PAT and glab CLI auth, self-managed host configuration, MR feedback (approvals + discussions + pipelines), and discussion reply / resolve so agents can address review comments. The pr skill detects the host from git remote and creates a real GitLab merge request via glab or REST. Closes #820 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(gitlab): address review feedback and tighten lint surface - Add service_test.go covering ConfigureToken / ClearToken / ConfigureHost. - Drop speculative MRWatch / TaskMR / ReviewWatch / IssueWatch / preset scaffolding that had no migrations or handlers behind it. - Consolidate /approvals into one round-trip via fetchApprovals. - Use http.DefaultClient in CheckHost (context already carries the deadline). - Rename misleading id → configured in ConfigureToken. - Introduce mrStateOpen constant so the lint goconst rule passes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): address PR review feedback (CodeQL SSRF, races, atomicity) - connectivity: parse host with url.Parse, validate http/https scheme, build probe URL via &url.URL{} so user-controlled host can't smuggle into the request — clears the CodeQL go/request-forgery alert. - pat_client: guard cached username with sync.RWMutex; IsAuthenticated now propagates non-401/403 errors instead of masking transport failures; GetMRStatus skips pipelines query when HeadSHA / HeadBranch are both empty; parseNextLink drops cross-host links instead of returning bare absolute URLs that get() would double-prefix. - service: findTokenSecret returns (bool, string, error) and all callers propagate the secrets.List error so a backend outage isn't mistaken for "token absent"; ConfigureHost persists to hostStore before mutating in-memory host + client + authMethod under one lock. - repoclone: SSH clone URL falls back to ssh:// form when the host has a port (scp-style git@host:path can't carry one). - gitlab/client_helpers: namespace fallback uses LastIndex("/") so subgroup paths (acme/team/squad/repo) are preserved. - pr skill / plan doc: drop hardcoded --target-branch main, derive host from git remote instead of $CI_SERVER_URL, build curl payload with jq -n; fix /pipelines/:id/jobs → :pipeline_id typo. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): preserve host base path in CheckHost; broaden validation; test - connectivity: extract validateHost so the parse / scheme / hostname / userinfo / opaque-URL checks each have a single home. Preserve the configured base path (TrimRight slash) so a host like https://gitlab.acme.corp/gitlab probes /gitlab/api/v4/version instead of dropping the prefix. - connectivity_test: cover validateHost (default, https, http, trailing slash, subpath, missing scheme, ftp, empty host, userinfo) and CheckHost end-to-end (version endpoint, base-path preservation, scheme rejection, unreachable host). - pr skill: drop the broken `glab repo view --json defaultBranch` branch — glab doesn't accept --json, so the lookup silently fell back to "main" every time. Fetch default_branch via the GitLab REST API directly using the PAT we already have. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): override filter defaults, refresh auth uncached, mock pipelines - appendFilter: Del before re-Add so user-supplied filter keys actually override caller defaults (e.g. state=closed swaps the seeded state=opened instead of appending a second value GitLab silently ignored). - IsAuthenticated: probe /user directly instead of routing through the username cache; a token revoked upstream now surfaces as not-connected immediately. Cache is opportunistically refreshed on success. - MockClient.GetMRFeedback: include pipeline state in HasIssues, matching the real PATClient behavior so test flows can't miss a broken MR. - pr skill: document `glab mr note create --reply <id>` for replies (bare `glab mr note` opens a new thread); fix host-extraction block to handle ssh://host:2222/team/repo.git without leaking the port into PROJECT. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): guard mock pipelines on empty MR head; preserve HTTPS port in pr skill - MockClient.GetMRFeedback now mirrors PATClient: skip the pipelines fetch when HeadSHA and HeadBranch are both empty. The mock'\''s ListPipelines ignores its branch argument and returns every pipeline seeded under the project, so without this guard a brand-new MR would inherit a sibling MR'\''s failing pipeline and flip HasIssues to true. Tests cover both the empty-head and head-present cases. - pr skill: derive HOST scheme-aware so https://host:8443 keeps the port (HTTPS API endpoint), ssh://host:2222 strips it (different transport), and scp-style git@host stays portless. Avoids silently re-routing self-managed API calls to the default 443. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): export SeedPipelines; mock GetMRStatus parity; drain bodies; test cache hit - MockClient: add exported SeedPipelines(project, iid, []) alongside the other Seed* methods; mirror PATClient.GetMRStatus by populating PipelineState / jobs counts via summarizePipelines, guarded on a non-empty head ref. Brings mock parity with the real client so any future test seeding pipelines sees the same MRStatus shape. - PATClient HTTP plumbing: drain resp.Body via io.Copy(io.Discard, ...) when result == nil in both getWithTotal and doWrite, so HTTP/1.1 can reuse the connection instead of opening a new TCP socket per call. - TestPATClient_GetAuthenticatedUser: assert calls == 1 after the second GetAuthenticatedUser hit so the cache contract is actually verified (a non-caching implementation would have passed the prior version). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): surface connection errors, align MR pipeline guards, fix mock seed key - service.GetStatus now captures the IsAuthenticated error into a new Status.ConnectionError field (empty on healthy / 401-403, populated on network / 5xx / parse failures). Frontend GitLabStatus + settings page render a distinct "Unreachable" badge + alert so users don't delete a still-valid token during a transient GitLab outage. - PATClient.GetMRFeedback now guards on HeadSHA || HeadBranch, matching GetMRStatus and MockClient.GetMRFeedback (which had been updated in an earlier round). The three call sites were inconsistent — a mock test with HeadBranch-but-no-SHA would pass while the real client silently dropped pipelines. - MockClient.pipelines is now keyed by project alone (was map[mockMRKey][]Pipeline). SeedPipelines takes (project, []) so successive seeds for the same project deterministically overwrite, instead of leaving multiple entries that ListPipelines would resolve in Go's unspecified map-iteration order. - Tests: TestService_GetStatus_{PopulatesConnectionErrorOn5xx, ClearsConnectionErrorOn401} cover the new error surface; TestMockClient_SeedPipelines_OverwritesByProject locks the key change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): tighten validateHost; init Status scopes; close token-host race - validateHost: drop the parsed.Opaque != "" branch — Go only sets Opaque for opaque URIs, which have an empty Host the prior check already rejects. Narrow the userinfo error to mention the actual user:password@host shape so the message matches what users see. - service.GetStatus: initialize RequiredScopes: []string{} on the nil-client defensive branch so the JSON payload keeps the always-an-array contract declared on the TypeScript side. The branch is effectively unreachable (every Provide path returns at least a NoopClient) but the empty-slice fallback costs nothing. - service.ConfigureToken: build the installed client inside the write lock using the up-to-date s.host instead of the pre-probe snapshot. A concurrent ConfigureHost between RUnlock and Lock used to leave s.client pointing at the prior host while s.host advertised the new one — every subsequent API call would silently target the wrong instance until the next reconfigure. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): drop MR-commit stats fields; rename AuthorName; log post-lock host - MRCommitInfo: drop Additions / Deletions / FilesChanged. GitLab's /merge_requests/:iid/commits endpoint doesn't return a stats object, so those fields were always 0 — better to omit them than feed callers silently-wrong numbers. Rename AuthorUsername to AuthorName since the API returns a display name, not a login handle. Doc-comment records the per-commit endpoint to use if a future revision wants real stats. - ListMRCommits: drop the Stats anonymous struct from the JSON decode target now that the destination fields are gone. - service.ConfigureToken: capture installedHost inside the write lock and log that value. The previous log used the pre-validation host snapshot, so a concurrent ConfigureHost between snapshot and lock would leave the log entry referring to a stale value while the client itself was correctly built against the new host. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): encode subgroup paths; sentinel-route errors; mock approval parity - pat_client.SearchGroupProjects now encodes the group path through a new groupRef helper that %2F-substitutes slashes (mirrors projectRef). Subgroup paths like "acme/team" used to land at /groups/acme/team/projects, which GitLab routed to the parent group only and returned 404 for. Subgroups now work end-to-end. - service.ErrInvalidToken sentinel + controller errors.Is routing: token validation failures now wrap a package-level sentinel and the HTTP layer switches on it via errors.Is, replacing the fragile strings.Contains prefix check that would silently fall through to 500 if the message ever changed. Coverage: TestService_ConfigureToken_WrapsErrInvalidToken. - MockClient gains seedable approvals: SeedApprovals(project, iid, list, required) backs a dedicated approvals + requiredApprovals map. ListMRApprovals, GetMRFeedback, and GetMRStatus all consume them so ApprovalState / ApprovalCount / RequiredApprovals match what the real PATClient.GetMRStatus computes. Coverage: TestMockClient_GetMRStatus_UsesSeededApprovals. - ClearToken: same host-snapshot race ConfigureToken was fixed for last round — NewClient now runs under the write lock against s.host rather than a pre-acquired snapshot. - CheckHost: drain resp.Body before close so http.DefaultClient's keep-alive pool can reuse the connection for the next probe. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): full-path projectRef round-trip; unlock during glab probe - splitFullReference now returns (namespace, fullPath) where fullPath is the namespace-qualified project path ("group/sub/project") instead of the last segment alone. Callers can pass MR.ProjectPath straight to projectRef for round-trip API calls (GetMR, GetMRFeedback, etc.) — matches GitLab's own path_with_namespace convention. Existing test cases updated. - ClearToken: build the fallback client OUTSIDE the write lock so NewClient's glab subprocess (bounded by glabAuthTimeout = 10s) doesn't freeze every concurrent GetStatus / GetMRFeedback / Client() RLock reader for the duration. Brief WLock now only wraps the field swap. Tradeoff with the concurrent-ConfigureHost race is documented in-code: narrow stale-host window vs. 10s of blocked readers, and the next reconfigure resolves it. - New regression test TestPATClient_SearchGroupProjects_EncodesSubgroupSlash asserts the request URL is /groups/acme%2Fteam/projects so a future refactor that swapped groupRef back to a bare url.PathEscape would fail visibly instead of silently 404ing subgroup lookups. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): equality-guard ClearToken's client swap against ConfigureHost race ClearToken builds the fallback client outside the write lock to avoid blocking RLock readers for up to 10s while glab auth-probes. The swap-only critical section now compares s.host against the pre-probe snapshot before assigning — if a concurrent ConfigureHost moved the host underneath us, we leave its (newer, correctly-targeted) client alone instead of overwriting with our stale-host build. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): add GitLab to the settings integrations sidebar The /settings/integrations/gitlab page existed and the index card listed GitLab alongside the other integrations, but the settings sidebar nav (IntegrationsNavGroup) only listed GitHub / Jira / Linear / Slack. The page was unreachable through the standard sidebar. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(gitlab): task-MR topbar surface + DB-backed task↔MR store Phase 1 of the GitLab UI build-out: brings the MR side of the task topbar to parity with the existing GitHub PR button. Backend - gitlab_task_mrs table + Store wiring (UpsertTaskMR, ListTaskMRsByTask, ListTaskMRsByWorkspaceID, DeleteTaskMR), uniqued on (task_id, repository_id, project_path, mr_iid) for multi-repo. - service.SyncTaskMR folds a live GetMRStatus probe into an upsert; ListTaskMRsByTask / ListTaskMRsByWorkspace expose the rows. - Three HTTP endpoints: GET /workspaces/:id/task-mrs, GET /tasks/:taskID/mrs, POST /tasks/:taskID/mrs/sync. - initGitLabService now takes a *db.Pool and constructs gitlab.Store. Failures non-fatal: integration keeps working without the table. Frontend - TaskMR type + listWorkspaceTaskMRs / listTaskMRs / syncTaskMR API. - lib/state/slices/gitlab slice (taskMRs.byTaskId) wired through default-state, store.ts AppState, and slices/index exports. - hooks/domains/gitlab/use-task-mr.ts: useWorkspaceMRs (fetch-on- workspace-switch) and useTaskMRs (per-task selector). - components/gitlab/mr-topbar-button.tsx — single/multi MR variants, status icons mirroring pr-task-icon priority order. - task-top-bar.tsx renders PRTopbarButton and MRTopbarButton side-by- side; each returns null when empty, so the union handles GitHub-only, GitLab-only, and multi-repo tasks without an explicit switch. - kanban-board.tsx hydrates useWorkspaceMRs alongside useWorkspacePRs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(gitlab): add /gitlab page for browsing user MRs and issues Adds the GitLab counterpart to /github so users can browse their own merge requests and issues from inside kandev, parallel to the existing GitHub "My GitHub" page. Backend - /api/v1/gitlab/user/mrs and /user/issues — paginated wrappers around the existing SearchMRsPaged / ListIssuesPaged client methods. paginationFromQuery clamps page/per_page the same way as the client. Frontend - /gitlab SSR page + client with MR/Issue tabs, scope filters (assigned / created / review-requested), and a free-text search box that falls back to raw GitLab query-string for power users. - searchUserMRs / searchUserIssues API helpers + MR / Issue / *SearchPage types in lib/types/gitlab. - useGitLabAvailable hook (probes /status + refreshes on window focus) feeds the integrations menu so the GitLab link only shows when a token is configured or auth is green. Tests - task-top-bar.test mocks the new MRTopbarButton so renders no longer pick up useAppStore without a StateProvider. - integrations-menu.test wires the new useGitLabAvailable mock and gitlabReady prop into mockAvailability / getAvailableIntegrationLinks fixtures. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): stable empty array in useTaskMRs to avoid infinite re-render useTaskMRs returned `state.taskMRs.byTaskId[taskId] ?? []` — that fresh literal made the zustand selector produce a new reference on every render when the task had no MRs, triggering an infinite re-render loop in MRTopbarButton that crashed the task page with a Next.js client-side exception. The page never mounted session-chat, so Docker E2E tests that drive `/t/:id` (slow-bootstrap progress, external-stop resume, environment reset) all timed out waiting for the chat panel. Mirror the GitHub side's `EMPTY_PRS` pattern: hoist a module-level `EMPTY_MRS = []` and return it for the empty case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): address PR review — cache header, in-flight cancel, tests - Top-level `cache: "no-store"` in 5 sites (use-task-mr.ts, gitlab-api.ts, gitlab-page-client.tsx). The earlier `init: { cache: "no-store" }` placement was silently overwritten by fetchJson with undefined, so probes hit the browser cache instead. - useWorkspaceMRs now invalidates in-flight responses and calls resetTaskMRs when the workspace becomes null, so the previous workspace's MRs don't linger after sign-out / workspace switch. - Drop unpopulated UnresolvedThreads/DiscussionCount from TaskMR (model, schema, TS type). SyncTaskMR's source MRStatus never populated them and no surface reads them; the dropped columns are skipped via an explicit projection in store.go so existing dev DBs keep binding. - Extract MRList/IssueList inline fetches in /gitlab into useGitLabUserMRs / useGitLabUserIssues — matches CLAUDE.md "never fetch in components". - Tests for store (UpsertTaskMR, composite key, workspace-JOIN isolation, ordering, Delete), service (SyncTaskMR mock round-trip + field mapping + store-unset error, ListTaskMRsByWorkspace JOIN isolation, ListTaskMRsByTask), pat_client (ListIssuesPaged X-Total), gitlab-api (5 new fns), gitlab-slice (setTaskMR composite key), and the use-task-mr / use-user-search hooks. - pr SKILL.md: skip note for steps 5–6 on GitLab remotes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gitlab): only wrap 401/403 probe errors as ErrInvalidToken Network failures, 5xx, and DNS errors during `ConfigureToken` previously surfaced to the user as HTTP 400 "invalid token: …" — during a GitLab outage a user with a valid token might delete it assuming it was rejected. Now only `*APIError` with 401/403 is mapped to `ErrInvalidToken`; other errors propagate unwrapped so the controller routes them to "GitLab unreachable" instead. 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> |
||
|
|
5a55b06d4d |
chore: revert dockview minimize button (#1039) (#1048)
Revert "feat(task): add minimize button to dockview group header (#1039)"
This reverts commit
|
||
|
|
2c15cf83a9 | feat(web): add tree view for changes panel (#1026) | ||
|
|
586efd1c6b | fix(web): preserve session-tab grouping when restoring contaminated layout (#1028) | ||
|
|
122ea73f55 | feat(workflow): queue workflow messages during active moves (#1036) | ||
|
|
7771155c8f |
feat(task): add minimize button to dockview group header (#1039)
* feat(task): add per-group minimize state to dockview store Adds `minimizedGroupIds: Set<string>` plus `toggleGroupMinimized` and `clearMinimizedGroups` actions. The set is cleared on maximize, exit-maximize, preset apply, custom layout apply, env switch, default-layout rebuild, and release-to-default so minimize state never leaks across task environments and never overlaps with maximize. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(task): add minimize button to dockview group header Adds an IconMinus toggle button alongside Maximize/Split/Close in each non-chat dockview group header. Clicking it adds the group ID to the store's `minimizedGroupIds`; a new `useMinimizedGroupSync` hook walks `api.groups`, stamps a `data-kandev-minimized` attribute on each group element, and calls `group.api.setSize` to shrink the group to the splitview minimum (the freed space redistributes to siblings). A CSS rule hides the content container so only the tab strip remains visible. The hook also auto-restores a minimized group when a new panel is added to it (e.g. via the `+` menu) and evicts removed groups from the set. Clicking Minimize while maximized first exits maximize so the user doesn't end up with a header-only view. In wide mode the button is inline; in narrow mode it folds into the existing overflow dropdown as its first item. Chat group never sees the button (same exclusion as Close). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(task): tighten minimize-icon test, drop unused TID The previous "renders Restore tooltip and icon swap" case had two weak assertions: `getAttribute(...) !== null || btn !== null` is tautologically true, and `btn.innerHTML.toContain("svg")` matches both icon variants. Replaced with two focused tests that assert the Tabler class differs between the minimized and non-minimized state. Also dropped the unused `minimizeMenuItem` entry from the TID map. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task): minimize-from-maximize used stale group ID `handleMinimize` previously called `toggleGroupMinimized(group.id)` after `exitMaximizedLayout()`. During maximize, `group.id` is the ephemeral overlay group's ID — once the original layout is restored that ID is gone from `api.groups`, and the minimize was silently lost. Read `maximizedGroupId` (the pre-maximize group ID stored at maximize time) from the store and target that instead. Reported on PR #1039 by claude-review, CodeRabbit, and Greptile. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * perf(task): skip redundant data-attribute writes in minimize sync The sync hook re-runs on every `onDidAddGroup` / `onDidAddPanel`, which previously rewrote `data-kandev-minimized` on every group regardless of whether the value actually changed. Guard with a getAttribute-before-set check. Reported on PR #1039 by claude-review. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(task): cover useMinimizedGroupSync hook Adds 11 unit tests for the hook: mount-time attribute stamping, null-api no-op, minimize/restore round-trip with prior-size replay, auto-restore on panel add into a minimized group, no auto-restore for unrelated groups, group-removal eviction from the store and prior-size map, fresh size on re-added group with the same ID, sync of newly added groups, the perf guard on redundant attribute writes, and clean unmount. Reported on PR #1039 by claude-review as a blocker (CLAUDE.md mandates tests for new hooks). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task): defer minimize toggle to next RAF when exiting maximize `exitMaximizedLayout` schedules `api.layout(w, h)` in a requestAnimation Frame after the synchronous fromJSON rebuild. Calling `toggleGroup Minimized` immediately afterwards races with that layout pass, and the resulting `setSize({ width: 0, height: 0 })` could be overridden — the content was still hidden by CSS but the group kept its full splitview footprint. Defer the toggle one frame so the maximize-exit settles first. Reported on PR #1039 by claude-review, Greptile, and cubic. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(task): guard deferred minimize against env-switch + redundant setSize Two small polish fixes from PR #1039 review: - The RAF callback in `handleMinimize` now verifies the target group still exists in `api.groups` before toggling. Within the frame, an env-switch (or another maximize) could clear `minimizedGroupIds`; blindly re-adding a stale ID would leave it in the set, and if the next env happens to reuse the same well-known constant ID (e.g. "right-top"), the wrong group would render as minimized. (cubic P2 finding.) - `applyGroupMinimized` now early-returns when re-syncing an already- minimized group (`priorSizes.has(groupId)`), instead of re-emitting setSize(0,0) and its size-change events. New test covers this. (claude-review suggestion.) 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> |
||
|
|
1b58a64fd8 |
feat(automations): /settings/automations with run-mode + per-automation config (#1016)
* feat: add automation system with triggers, backend-driven metadata, and PR association Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(automations): execution_mode + flat /settings/automations Layered on top of #406's automation subsystem: - Add execution_mode to Automation (task | run, default task). Run-mode firings create an ephemeral kanban-hidden task tagged with origin=automation_run. The session pipeline still runs against it, but users surface them through the AutomationRun history instead of the kanban. Pragmatic shim while AutomationRun-as-true-session-owner is deferred to a follow-up. - Thread IsEphemeral + Origin through ReviewTaskRequest + reviewTaskCreatorAdapter so the orchestrator can create the hidden task in one call. - Run-mode always auto-starts (the user never sees the task to drag). - Flat /settings/automations route replaces the per-workspace sidebar entry. Picks the only workspace, shows a picker for many, or shows a CTA for zero workspaces. - Editor exposes execution mode + table shows it as a badge column. Spec at docs/specs/office/automations-settings.md documents the design, deferred items, and scenarios. * feat(automations): per-automation repository picker Adds a repository_id field on Automation so users can pin scheduled and webhook automations to a specific workspace repository instead of always falling through to "first repo". github_pr triggers ignore the field and keep using the PR's own repo — the editor disables the picker with a hint when a PR trigger is selected. Backend: - repository_id column folded into the canonical CREATE TABLE (no migration needed — PR #406 introduces the table). - RepositoryID threaded through model, DTOs, store INSERT/UPDATE, and service.CreateAutomation. - resolveAutomationRepository prefers the explicit ID, falls back to workspace-first on empty. Frontend: - repository_id on the Automation TS type + Create/Update payloads. - Repository SelectField in ConfigSection backed by useRepositories; "Auto" sentinel represents the empty/fallback choice. - Disabled with help-text for github_pr triggers. * feat(automations): repo picker shows discovered repos too Matches the task-creation dialog's UX: the editor's repository picker now lists registered workspace repos AND filesystem-discovered repos under the workspace's roots. Picking a discovered repo registers it with the workspace at save time (via createRepositoryAction), captures the new id, and writes it onto the automation. After the first save the picker selection is promoted from "discovered" to "registered" so re-saving doesn't re-register. Form state holds a RepositorySelection union (auto | registered | discovered) rather than a plain id string so the picker can carry the metadata needed to register. github_pr triggers still disable the picker — PR events always override any configured repo with the PR's own. * fix(automations): add idempotent ALTERs for in-branch columns The canonical CREATE TABLE covers fresh installs, but `CREATE TABLE IF NOT EXISTS` is a no-op for DBs already initialised from an earlier commit on this branch (PR #406's original schema). Without these ALTERs, dev DBs created before execution_mode + repository_id existed on the row hit "no such column" errors. Three idempotent ALTERs: task_title_template, execution_mode, repository_id. SQLite returns a duplicate-column error when the column already exists; we swallow it. * fix(automations): use generateUUID in non-secure contexts crypto.randomUUID is only defined in secure contexts (HTTPS or localhost). PR #406's editor used it directly, which throws "crypto. randomUUID is not a function" when the app is reached via a plain hostname like nova:58204. Switch to lib/utils.generateUUID, which falls back to a Math.random-based implementation when crypto isn't available. * fix(automations): polish run-mode lifecycle, validation, and editor UX Run-mode automations were partly broken end-to-end: - workflow_id was sent for ephemeral tasks (rejected by task service) and was required by the editor + automation service, so "Create Automation" was always disabled and silently failed - a permission_request hanging the agent left the run pinned at task_created forever; nothing reaped the ephemeral worktree - trigger config inputs (cron, branches, authors) did not re-sync with the loaded automation on page reload - runs row never linked back to the underlying task for task-mode automations, and the table header highlighted on hover Backend - skip workflow fields in CreateReviewTask / StartTask for run-mode - relax automation.Service.CreateAutomation: workflow only required for task-mode - auto-reject permission prompts on ephemeral automation tasks and mark the AutomationRun failed with the requested action - on agent completion / failure for ephemeral automation tasks, flip the run to succeeded/failed and remove the worktree + branch immediately (new RunStatusSucceeded + MarkRunSucceededByTaskID) - wire worktree.Manager into the orchestrator service Frontend - show Execution Mode first; hide Workflow / Workflow Step in run-mode - canSave no longer requires workflow in run-mode - surface save errors via toast instead of silent failure - sync trigger-config local input state with loaded prop value - runs table: clickable row → /tasks/<id> for task-mode runs, non-clickable in run-mode; suppress header hover * fix(automations): address PR review feedback Backend - automation/scheduler.go: parseCronFields returns an error for unsupported expressions instead of silently defaulting to hourly - automation/webhook.go: constant-time secret compare, header-only (no more ?secret= query param), reject with 409 when no enabled webhook trigger exists - automation/service.go: enforce max_concurrent_runs in FireTrigger via maybeSkipForConcurrencyCap (records a skipped run when capped) - cmd/kandev/e2e_reset.go: surface automation delete errors as 500 instead of swallowing them - orchestrator/event_handlers_git.go: auto-reject permission with cancelled=true so the message status is "rejected" and the session isn't flipped back to running - orchestrator/event_handlers_agent.go: finalizeAutomationRunIfEphemeral now runs at the top of handleAgentFailed, before the resume/recoverable early returns, so session-backed run-mode failures get cleaned up Frontend - state/slices/automations/automations-slice.ts: declare _get/_api so the call site's arity matches and CodeQL stops flagging ignored trailing arguments - automations/schedule-selector.tsx: clearing the input now propagates ""; cron is trimmed before validation+save - automations/triggers-section.tsx: switching to webhook mode deletes the existing scheduled/condition triggers so they don't keep firing hidden in the background - automations/trigger-configs/github-ci-config.tsx: useEffect re-sync for checkNames, matching the other trigger configs - hooks/domains/settings/use-automations.ts: per-workspace fetch via loadedWorkspaceRef + render-safe loadedWorkspaceId state; inFlightWorkspaceRef discards stale responses after workspace switch --------- Co-authored-by: Carlos Florêncio <carlosmflorencio@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Kandev Agent <agent@kandev.dev> |
||
|
|
35242b2982 |
feat(web): drag-reorder subtasks within a parent task (#962)
* feat(web): drag-reorder subtasks within a parent task Adds per-parent subtask ordering in the sidebar. Drag a subtask under its parent to reorder; the chosen order is independent of the global sort and persists per browser via localStorage. - New `sidebarTaskPrefs.subtaskOrderByParentId` slice field and matching `setSubtaskOrder(parentId, ids)` action, persisted to localStorage under `kandev.sidebar.subtaskOrderByParentId`. - `applyView` applies the per-parent override after `separateSubtasks`, so it never touches root order or the active sort spec. - TaskSwitcher exposes `onReorderSubtasks(parentId, ids)`; each parent's subtask list lives in its own nested DndContext / SortableContext using the existing dnd-kit setup. - Task-deletion cleanup strips the deleted id as both parent key and subtask entry from `subtaskOrderByParentId` in store + localStorage. * refactor(web): address PR review feedback on subtask reordering - Extract `pruneSubtaskOrder(map, taskId)` helper in `local-storage.ts`; use it in both `cleanupTaskStorage` and `removeTaskFromSidebarPrefs` to keep the two cleanup paths in lockstep. - Move `SortableSubtaskRow`, `SortableSubtaskList`, and `useSubtaskDnd` into a new sibling file `task-switcher-subtask-dnd.tsx` so the switcher stays under the 600-line soft limit. - `SortableSubtaskList` now short-circuits to a plain row map when `onReorderSubtasks` is undefined — read-only callers no longer get a misleading grab cursor or unused DnD scaffolding. * fix(web): add stable keys to non-DnD subtask render path When `onReorderSubtasks` is undefined, the early-return branch in `SortableSubtaskList` mapped subtasks without keys, falling back to React's index-based reconciliation. Wrap each child in a keyed `Fragment` so reorders or size changes reconcile correctly. * refactor(web): single-source DRAG_ACTIVATION_DISTANCE Export the constant from `task-switcher-subtask-dnd.tsx` and import it in `task-switcher.tsx` instead of declaring the same value twice. --------- Co-authored-by: Kandev Agent <agent@kandev.dev> |
||
|
|
862822dab1 | feat(web): add dev debug logging for git-status, dockview, and chat messages (#961) | ||
|
|
2d219f1d1b | fix(web): restore last-selected session tab on task re-entry (#951) | ||
|
|
54ddb0aa1e |
feat(web): highlight active-tab file row in Changes panel (#954)
* feat(web): track active file path for diff panels
Extend resolveFilePath so activeFilePath updates when a file-diff panel
(preview:file-diff or diff:file:<path>) becomes the active dockview
panel. Also re-subscribe onDidParametersChange for the preview file-diff
slot so swapping the preview to a different file refreshes the path.
* feat(web): highlight active-tab file row in Changes panel
When a file's diff tab is the active dockview panel, mark the matching
row in unstaged, staged, and PR Changes lists with data-active="true"
and reuse the existing bg-accent/60 highlight (same as multi-select).
PRFileRow also gains data-changes-file so it shares the existing row
selector with FileRow.
* test(web): cover active-tab highlight unit + E2E
Unit test in FileRow guards the data-active attribute and bg-accent/60
class when isActive (with and without isSelected). New E2E spec drives
the full flow: open file-a diff, switch to file-b, close the preview,
and verify highlight follows when a file moves from unstaged to staged.
* fix(web): use distinct e2e task titles for active-tab highlight spec
clickTab("Changes") fails strict-mode when the task title also contains
the word "Changes" — Playwright matches both the session tab and the
dockview Changes tab. Renames to "Active Highlight (Staged) Test".
* test(web): close pinned diff:file panels in closeFileDiffPreview
The helper previously closed only the preview slot, leaving pinned
diff:file:<path> panels (created when a preview is promoted) active.
Sweeps every diff:file: panel so the row settles to data-active=false
regardless of how the diff was opened.
* test(web): unit-cover resolveFilePath panel-id branches
Exercises every branch of the dockview-store resolveFilePath closure:
file:, diff:file:, preview:file-editor, and preview:file-diff (the
last two via params.path), plus the null-out paths. Catches regressions
in the panel-id mapping without paying for an E2E round-trip.
* fix(web): strip "changes" from active-tab spec profile names
The agent profile name becomes the dockview session tab title, so a
profile named "changes-active-highlight" makes clickTab("Changes")
match two .dv-default-tab elements (session tab + Changes tab) and
trip strict-mode. Rename to "active-highlight" / "active-highlight-
staged" to keep the locator unambiguous.
* docs(web): note multi-repo path collision in PRFilesRepoGroup
Mirrors the FileListBody comment so future authors see the same caveat
in both file-list components: activeFilePath carries the path but not
the repo, so identical paths across repos light up both rows.
* fix(web): snapshot panels before closing in closeFileDiffPreview
dockview's api.panels is a live reference; calling panel.api.close()
removes the panel in place, so iterating the live array skipped every
other diff:file:* panel when two or more were open. Filter into a
local array before closing.
|
||
|
|
20a660fe3e | fix(web): strip phantom session panels on env-layout restore (#944) | ||
|
|
b4ce4d6eef |
refactor(web): improve compact desktop layouts (#937)
* refactor(web): improve compact desktop layouts * fix(web): address compact desktop review feedback * fix(web): address responsive review followups * fix(web): preserve compact sidebar restore sizing * fix(web): harden compact preset sidebar test * refactor(web): extract dockview layout hooks |
||
|
|
f6bd83da33 | refactor: tighten type system across backend + frontend (#920) | ||
|
|
0b2085688b | feat(office): autonomous agent management layer (#914) | ||
|
|
7437194e2e | fix: show PR diff when clicking file row that has local changes (#908) | ||
|
|
39406fa778 | fix(web): anchor manual PR panel open to the session's live group (#901) | ||
|
|
7011bbcbc4 | fix: session tab leak, PR icon crash, and PR review prompt scope (#897) | ||
|
|
0743477d48 |
feat: persist N-entry FIFO message queue (#864)
* feat(orchestrator): persist N-entry FIFO message queue
Replaces the single-slot, latest-wins, in-memory queue with a SQLite-backed
FIFO queue (default cap 10 per session, configurable via
KANDEV_QUEUE_MAX_PER_SESSION). Drain stays one-per-turn so each entry gets its
own user message and `on_turn_*` workflow cycle. Inter-task `message_task_kandev`
returns a structured `queue_full` error with sender attribution when the
target session is saturated, so the calling LLM can decide whether to retry.
Surfaces a new WS action `message.queue.remove` for single-entry deletion;
`message.queue.cancel` now means clear-all. Edit/remove are keyed on the
entry's UUID, returning `entry_not_found` when atomic-take won the drain race.
* feat(web): N-entry queue state, API, and optimistic hook
Replaces the single-slot QueueStatus with an ordered `entries[]` per session
plus capacity meta (`count`, `max`). API exposes typed `QueueFullError` and
`QueueEntryNotFoundError`; the hook surfaces optimistic edit/remove with
drain-race rollback (refetch on `entry_not_found`).
* feat(web): inline ghost-message queue UI with sender chips
Replaces the floating QueuedMessageIndicator overlay with a stack of ghost
messages rendered above the chat input. Each ghost shows a sender chip
(user / agent) and per-entry edit/remove. A small "n/max queued · clear all"
pill summarizes capacity. Inter-task entries are visually distinct and edit
is gated to the original sender.
* fix(queue): address review — auth guard, TOCTOU, error UX
- Add `messagequeue.QueuedByUser` / `QueuedByAgent` constants and centralize
the `queue_full` error code so the WS and MCP handlers stay in sync.
- WS `message.queue.{add,append,update}` default empty user_id to QueuedByUser
so the UpdateContent ownership guard always runs against a non-empty owner;
inter-task entries (`queued_by="agent"`) can no longer be overwritten by
user-initiated edits even when `user_id` is omitted from the payload.
- memoryRepository: hold the mutex through the whole append-or-insert path
(prior split released the lock between tail-check and Insert, allowing two
same-sender callers to race past coalescing). TransferSession now shifts
source positions past the destination's max so transferred entries always
drain after pre-existing destination entries, matching SQLite semantics.
- Frontend: surface edit/clear/remove failures via `sonner` toasts (drain race
→ "message already sent"); exit edit mode on failure instead of leaving the
textarea silently stuck. Hide the remove button on read-only (agent) ghosts.
- `useQueue.clearAll` no longer derives `max` from pre-clear entry count;
resets to `metaMax ?? 0` so the next status_changed event lands authoritative
capacity.
- `senderKindOf` drops the unreachable metadata fallback; route on `queued_by`
alone since `dispatchTaskMessage` always sets it.
- Test loggers now write to stderr per CLAUDE.md "stderr for logs" guideline.
* test(web): align queue e2e selectors with new ghost-message UI
The ghost-message rewrite renamed button titles ("Cancel queued message"
→ "Remove queued message", "Edit message" → "Edit queued message") and
introduced a `data-testid="queued-ghost-list"` container. The
`message-queue.spec.ts` Playwright tests still pointed at the old titles.
* fix(queue): address second review pass — auth, drain race, tests
- WS handlers (`add` / `append` / `update`) now reject `user_id="agent"`
outright, closing the gap a hostile WS client could exploit by
impersonating the inter-task sender identity to overwrite agent
entries (greptile P1).
- WS `update` / `remove` now require `session_id` so the post-mutation
`MessageQueueStatusChanged` broadcast can never be silently skipped.
- `sqliteRepository.TakeHead` checks `RowsAffected()` after DELETE;
when two concurrent take callers race past the busy-timeout the
loser sees 0 affected rows and now returns `nil, nil` instead of
the already-drained message (would otherwise dispatch the same
prompt twice).
- `Service.GetStatus` returns `Entries: []` instead of `nil` on the
error path so the wire shape stays consistent (frontend already
guards but strict consumers wouldn't tolerate the type mismatch).
- New tests: `queue-api.test.ts` covers `rethrowQueueError` mappings
(queue_full / entry_not_found / generic / Error pass-through);
`session-slice.upsert.test.ts` covers `setQueueEntries`,
`removeQueueEntry`, and `clearQueueStatus`. Backend gains an
agent-impersonation handler test.
- `rethrowQueueError` now surfaces `WSError.message` for non-queue
errors instead of stringifying the whole object.
- Cleanup: replace unused `a, c := ...` discards in service_test
with `_, _ = ...`.
* fix(queue): scope update/remove by session_id (cross-session deletion guard)
The MCP `queue_full` error payload deliberately discloses sibling-task
entry IDs (so the calling agent's LLM can reason about the wedge state).
Without a session_id filter on `DeleteByID` / `UpdateContent`, a hostile
agent that received that payload could call `wsRemoveEntry` /
`wsUpdateMessage` against another task's entries and delete or rewrite
them — only the per-entry UUID was being checked.
- `Repository.UpdateContent` and `Repository.DeleteByID` now require
`sessionID` and add `AND session_id = ?` to the SQL guard. The memory
repo mirrors the scope (lookup keyed on session before the entry scan).
- `Service.UpdateMessage` / `Service.RemoveEntry` take `sessionID`; the
WS handlers thread `req.SessionID` through (already validated as
non-empty in the previous fixup).
- TypeScript: `session_id` is now required on `updateQueuedMessage` /
`removeQueuedEntry` params so callers fail at compile time, matching
the backend contract.
- Tests cover the cross-session attack: `RemoveEntry`/`UpdateMessage`
with an attacker-supplied session_id but a real entry id from another
session must return `ErrEntryNotFound` and leave the victim entry
untouched. SQLite repo gets parallel coverage.
* fix(queue): propagate transfer error + tighten WSError narrowing
- `Service.TransferSession` now returns an error so callers can fail
closed instead of silently leaving entries orphaned on the previous
session. The two existing call sites pick the right policy:
`switchSessionForStep` (reusing an existing session) propagates the
error so the workflow switch aborts; the new-session path logs and
continues, since the new session is already committed and rolling
back here would leave a half-switched workflow.
- `asWSError` now also narrows on `message` / `details`, so WS payloads
without a `code` field are still mapped to a meaningful `Error.message`
via `rethrowQueueError` (instead of "[object Object]"). Native
`Error` instances explicitly skip the WS-error branch so they pass
through untouched, preserving the existing pass-through guarantee.
- `queue-api.test.ts`: `expect.assertions(N)` on the catch-only tests
so a missing throw fails the test instead of silently passing.
* fix(queue): check TransferSession error in service tests
The TransferSession signature change in
|
||
|
|
f97ac8bbc1 | feat: streaming agent install, PTY login terminal, docker container UX (#869) | ||
|
|
14c19ea538 |
feat(web): add CI hover popover on PR top-bar button (#846)
* feat(github): plumb CI popover fields + branch protection cache
Adds RequiredReviews/UnresolvedReviewThreads/ChecksTotal/ChecksPassing
to TaskPR + PRStatus. Extends the GraphQL PR query with reviewThreads
to populate UnresolvedReviewThreads on every poll. New branch_protection.go
holds an in-memory TTL cache + BranchProtectionFetcher interface;
PATClient/GHClient implement REST/CLI lookups so SyncTaskPR can resolve
required_approving_review_count for the PR's base branch. SyncTaskPR
preserves existing counts when a lightweight (GraphQL) sync returns 0
so the popover doesn't flap during the polling cycle.
* feat(github): mock controller hooks for CI popover e2e
Extends associateTaskPR to accept review/check counts, required_reviews,
and unresolved_review_threads, and switches to ReplaceTaskPR so re-POSTing
the same task drives the popover live-refresh path. Adds POST /pr-feedback
to seed PRFeedback (checks/reviews/comments) keyed by a deterministic
synthetic head_sha, and PUT /auth-health to flip the mock client's
authenticated flag for the "Reconnect GitHub" branch. Mock client gains
ReplaceCheckRuns/ReplaceReviews/ReplaceComments helpers and an
authenticated field that defaults to true.
* feat(web): CI hover popover on PR top-bar button
Hovering the PR top-bar button now reveals a popover with
Passed/In-Progress/Failed counts, a workflow-rollup list under the
non-empty buckets (with "open on GitHub" + "+ ctx" affordances on
failed rows), a review-state row ("Approved 1 / 2 required"), and
unresolved-comments count. Click on the button keeps the existing
"open PR detail panel" behavior so shipped tests stay green.
PRFeedback is lazy-fetched on hover-open via a new SWR-style hook
backed by a 20-entry LRU cache slice; the popover refetches whenever
the underlying TaskPR's updated_at changes (the WS handler already
applies poll deltas). Bucket logic + workflow grouping live in a
small util with vitest coverage. The aggregate-popover variant for
multi-PR tasks ships but the multi-PR trigger preserves its existing
DropdownMenu so per-row click → addPRPanel still works.
* test(e2e): pr-topbar-popover scenarios A–F covering CI popover
11 specs covering: count rows + zero-bucket hiding, workflow rollup
with (N/M passed) badge under Failed, review row + unresolved comments,
hover open + leave close, failed-row open/+ctx affordances, header
external link, "No checks have started" empty state, "Reconnect GitHub"
auth-lost branch, and live count updates on a re-associate.
Adds api-client helpers (mockGitHubSeedPRFeedback, mockGitHubSetAuthHealth,
extended mockGitHubAssociateTaskPR) and SessionPage popover accessors.
* fix(github): address bot review comments on CI popover
- branch_protection.go: evict stale entries on read so the cache map
cannot grow unbounded over a long-running deployment; rewrite the
comment to drop the inaccurate singleflight claim (no dedup logic
exists, concurrent misses race harmlessly to set()).
- gh_client.go: replace fragile strings.Contains "HTTP 404" with an
isNotFoundErr helper that tolerates the formats current and future
gh CLI versions use ("HTTP 404", "404 Not Found", "status: 404").
- graphql.go: extend reviewThreads with totalCount and attribute the
un-fetched tail to the unresolved bucket so PRs with >100 threads
don't silently undercount in the popover.
- pr-ci-popover.tsx: drop the dead "icon" initializer in PRReviewRow
(every branch overwrites it). Memoize useAddCheckToContext so
PRWorkflowRow's reference equality stays stable across renders.
- pr-topbar-button.tsx: collapse the duplicate JSDoc on
usePopoverInteractions.
* fix(github): address remaining bot review comments + tests
Real correctness fixes:
- Replace the "0/0 means counts omitted" heuristic with an explicit
PRStatus.ChecksPopulated flag so the rich REST sync can clear stale
totals when checks are removed from a PR upstream.
- Refresh tp.BaseBranch from status.PR on each SyncTaskPR so a
retargeted PR's branch-protection lookup keys off the new branch.
Persist base_branch in UpdateTaskPR.
- Cache 403 from /branches/.../protection (token lacks Administration:
Read scope) the same way as 404 — otherwise every poller tick burns
rate-limit quota retrying. Both PATClient and GHClient.
- usePRCIPopover: refetch on every popover-open transition (not just
on updated_at change), via a wasEnabledRef gate. Restores the
intended SWR semantics that the lastSyncedRef-only guard suppressed.
- groupChecksByWorkflow: prefer the URL of a job matching the group's
bucket so clicking a "failed" workflow row navigates to a failing
job, not an earlier passing one.
Tests:
- branch_protection_test.go: cache hit/miss/TTL/eviction, error
not cached, no-rule cached, client-without-fetcher path.
- pat_client_test.go: TestPATClient_FetchBranchProtection table-test
for 200/200-with-null/404/403/500.
- service_test.go: ChecksPopulated preserve-on-zero, accept real
zero, base-branch retarget, change-publish for the new fields.
* refactor(github): trim associateTaskPR + drop unreachable PRAggregatePopover
- mock_controller.go: extract associateTaskPRRequest struct,
buildTaskPRFromRequest, and ensureMockPRForRequest helpers so the
handler is back under the 80-line Go limit.
- pr-ci-popover.tsx: remove unreachable PRAggregatePopover export.
Multi-PR tasks currently use the existing DropdownMenu trigger
(no hover popover), so the aggregate component was dead code.
* fix(github): drop unused anchorRef + tests for is{NotFound,Forbidden}Err
- pr-topbar-button.tsx: anchorRef was leftover from the dropped
click-to-pin onInteractOutside guard. Removed.
- gh_client_test.go: tests for the new isNotFoundErr / isForbiddenErr
helpers covering each accepted format and a few non-matches so a
future gh CLI wording change is caught.
* fix(github): UnresolvedReviewThreadsPopulated guard + drop unreachable nil checks
- service.go: mirror the ChecksPopulated/preserve dance for
UnresolvedReviewThreads. The REST single-PR poller (getPRStatus)
doesn't fetch review threads, so without this guard every REST
sync would clobber the GraphQL-populated value to 0 and emit a
spurious github.task_pr.updated event. The GraphQL path
(convertBatchedPRResult) sets the new flag.
- branch_protection.go: drop unreachable s == nil / fetcher == nil
checks. fetchRequiredReviews is only invoked from SyncTaskPR on a
service constructed via NewService, which always initialises the
cache. After a successful type assertion, the interface value
cannot be nil; the !ok branch already covers nil-interface inputs.
- service_test.go: new TestSyncTaskPR_UnresolvedThreads_PreservesOnLightweightSync
covers the regression. Existing NewFieldsPublishEvent threads-change
case updated to set UnresolvedReviewThreadsPopulated.
* refactor(web): drop CI popover progress bar — last-updated is enough
The animated progress bar was visual noise; the "updated Ns ago"
timestamp tells the user the same thing without the indicator. Footer
also hides entirely until the first fetch lands so an empty popover
doesn't show a useless "" line.
* fix(web): background-sync PRFeedback so popover is fresh on first hover
The on-hover SWR fetch was the only refresh path, so opening the popover
showed the previously-cached value while a new fetch raced behind it —
visibly stale until the refetch landed. The button icon, by contrast,
updates the moment a github.task_pr.updated WS push lands.
Add a usePRFeedbackBackgroundSync hook mounted at the PRTopbarButton
that subscribes to the same pr.updated_at change signal and refetches
PRFeedback into the cache in the background. The popover hook still
fires an on-open refetch as a safety net for the very first hover
(before any sync). No extra polling — fetches are gated on real
TaskPR changes, matching the icon's cadence.
* feat(web): CI status chip in chat status bar + tweak elapsed label
- New <PRStatusChip> renders a compact colored circle in the chat
status bar (left of the TodoIndicator, just above the input). Hover
opens the same PRCIPopover anchored to the chip's top edge so the
card expands upward — matching the user's "open bottom-up" ask. The
chip mounts the background-sync hook so the popover is fresh on
first hover even when the top-bar PR button isn't visible.
- elapsedLabel: "just now" → "updated just now" so the footer reads
the same regardless of how recently the cache was refreshed.
* fix(web): chip color was missing — use static class map for Tailwind JIT
\`getPRStatusColor().replace("text-", "bg-")\` produced class strings at
runtime, which Tailwind's content scanner doesn't pick up — the chip
rendered with no background. Replace with a static lookup so each
literal `bg-*` class appears in the source and gets compiled into the
bundle.
* fix(web): chip uses currentColor SVG so the dot actually renders
The previous bg-* approach relied on Tailwind compiling the literal
class strings. Even with a static map some palette entries (notably
bg-muted-foreground) didn't surface as a usable utility, so the chip
remained transparent. Apply getPRStatusColor's text-* class to the
button wrapper and fill an SVG circle with currentColor — the text-*
utilities are already compiled (PRTaskIcon uses them) so the dot
is guaranteed to render in the right color.
* fix(web): redesign PR status chip — clipboard icon + status glyph
- Move chip to right of TodoIndicator (was left).
- Render as "[clipboard icon] [status glyph]" instead of a bare circle:
passed → green CircleCheck, failed → red CircleX, in_progress →
yellow Loader2 (spinning), merged/neutral → muted dot. Status icons
reuse already-compiled text-* utilities so colors render reliably.
* fix(github): ReviewCount = approved-reviewer count + ReviewCountsPopulated guard
Two bugs feeding the popover's "Approved (0)" misreport:
- Semantic: REST path set ReviewCount to len(reviews), which double-counts
authors who left multiple reviews and includes COMMENTED rows. Replace
with countApprovedReviewers — distinct authors whose latest review
state is APPROVED. GraphQL converter gets the parallel
countApprovedReviewerNodes helper and also populates PendingReviewCount
from ReviewRequests.TotalCount (was unset, so the chip's "(K requested)"
suffix was always missing).
- Clobber: GraphQL polls used to write ReviewCount/PendingReviewCount=0
on every cycle because the converter never set them. Add
ReviewCountsPopulated to PRStatus and gate SyncTaskPR's writes on it
the same way ChecksPopulated already does, so a partial sync path
preserves the persisted values instead of resetting them to zero.
* fix(web): popover nitpicks (icon, review row, indent, badge)
- Chip icon: ClipboardCheck → ListCheck.
- PRReviewRow always renders (drop the early-return). Fresh PRs with
no approvals + no protection rule now show "Awaiting review" instead
of vanishing.
- Indent workflow rows under the In Progress / Failed group headers
(pl-5) so the hierarchy reads more clearly.
- In-progress workflow badge: "0/1 ran" → "1 running". The old phrasing
hid whether the job had even started.
* feat(web): stacked pass-rate progress bar in CI popover
Mirrors TodoIndicator's progress bar at the top of the CI section, but
split into three coloured segments:
green (passed) | yellow (in_progress) | red (failed)
sized proportionally to each bucket's count. The label reads
"Pass rate · K/N (P%)" so the user gets the same at-a-glance summary
TODOs shows. Skipped/cancelled-but-not-failed jobs aren't counted —
the bar always reflects the actionable picture.
* fix(web): right-align review counts + drop chip hover bg
- Move approval/required counts to the right edge of the review row
("Approved" on the left, "1 / 2" on the right), mirroring the
bucket-row layout. The "(K requested)" suffix becomes a "· K requested"
trailing fragment on the right side.
- PRStatusChip: drop hover:bg-accent/50 so the trigger doesn't grow a
background plate when the popover is open.
* fix(web): switch CI chip icon to IconChecklist
* fix(github): clear bot review-comment backlog
Real correctness fixes:
- pr-status-chip: pending checks / pending review now beat
checks_state === "success" in chipStatus precedence — a PR with
CI green but reviewers outstanding is no longer rendered as
"passed".
- branch_protection: drop the unreachable Reset() method.
- pr-topbar-button: stop double-mounting usePRFeedbackBackgroundSync;
the chip already covers it.
- use-pr-ci-popover: dedupe key combines prFeedbackKey + updated_at
so switching tasks with same-timestamp PRs still refetches.
- use-pr-ci-popover: drop stale comment about the removed progress bar.
Refactor:
- client_helpers + graphql: extract reviewSample +
latestReviewStateByAuthor helper so the three near-identical
per-reviewer dedup loops share one implementation.
Tests:
- countApprovedReviewers + reduceReviewSummary unit tests.
- TestSyncTaskPR_ReviewCounts_PreservesOnLightweightSync +
AcceptsRealZero mirror the existing ChecksPopulated guards.
- groupChecksByWorkflow distinct-URL test verifies the failed-job
URL-selection fix.
- branch_protection TTL tests now use testing/synctest — instant
virtual time, no real-sleep flake on CI.
* fix(web): clicking the CI chip no longer closes the popover
HoverCard treats the trigger button as outside the content's bounding
box, so a click on the chip fired Radix's onPointerDownOutside and
auto-closed the popover. Add a triggerRef + onPointerDownOutside guard
on HoverCardContent that filters out clicks landing on the trigger —
clicking the chip is now a no-op while the popover stays open via hover.
|
||
|
|
1066ac5b16 |
feat: extend implement plan button with fresh-agent path and server-side plan mode (#832)
* feat: extend implement plan button with fresh-agent path and server-side plan mode - Split-button: primary click = same-session, chevron = fresh agent with clean context window - Forward draft attachments and context files on implement (adds getAttachments() to handle) - Authoritatively clear plan_mode via new session.set_plan_mode WS endpoint, not a message flag - Add on_exit:disable_plan_mode to plan-and-build.yml plan step (mirrors feature-dev.yml) - Drop localStorage plan-mode rehydration; server session metadata is the single source of truth - Delete dead DocumentPanel component (no callers) - Add SetSessionPlanModeByID on orchestrator Service; wire wsSetPlanMode handler * test: fix store isolation in useImplementFresh test + extract duplicate strings - Fix test isolation: reset store directly and clear mocks before rendering - Extract sessionId, appFilePath, appFileName to constants to fix ESLint duplicate-string warnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(e2e): cover implement-plan fresh-agent split-button path Adds Playwright coverage for the new chevron menu on the violet Implement button: asserts a brand-new TaskSession is launched on the same task with the planning session's agent profile inherited, and that the planning session is left running in parallel. Second spec verifies the split-button is gated behind plan mode. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: defer plan-mode exit and surface error toast on implement failures Move handlePlanModeChange(false) into the message.add success callback in useImplementPlan so a failed send leaves the plan layout intact for retry. Add error toast to useImplementFresh catch so launchSession failures are visible to the user instead of silently logged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: guard fresh-agent launch on planning session executor_id TaskSession.executor_id is optional, so the fresh-agent path could call launchSession with an undefined executor and silently inherit the workspace default instead of the planning session's executor. Add the field to the early-return guard so the hook no-ops when the planning session is missing either an agent profile or an executor. * feat: mark fresh-agent session as primary and focus it When useImplementFresh launches a new implementation session, the fresh session is now automatically: 1. Set as primary via session.set_primary WS endpoint 2. Focused/selected as the active session in the UI This improves UX by switching the focus to the fresh implementation context immediately rather than keeping the planning session active. Addresses comment in PR #832 from carlosflorencio. - Extract setupFreshSession helper to keep complexity under 15 - Add tests for primary session marking and UI focus - Add test for graceful handling of set_primary failures - Update JSDoc to document the new behavior - Extract test constants to avoid duplicate string warnings * fix(test): replace self-referential test constants `const TASK_ID = TASK_ID;` (and SESS_PLAN, SESS_FRESH) caused a TDZ ReferenceError at module load, so vitest reported `(0 test)` and none of the new fresh-agent coverage actually ran. Replace with literal values so the 13 tests in this file execute again. * fix: drop strict executor_id guard and skip composer clear on missing session_id Two related issues: 1. e2e regression — the planning session in the FE store does not always carry executor_id (the field is omitempty on the backend DTO and isn't populated for fresh task sessions in the WS push path). The earlier guard required it before calling launchSession, so the fresh-agent path silently no-op'd in real flows. Drop executor_id from the guard and only forward it when set; backend resolves it from the agent profile / workspace default when omitted. 2. PR review (Greptile P1) — when launchSession resolves without a session_id, the previous flow still cleared the composer and draft even though no fresh session was activated. Bail out before the clear so the user keeps their typed text and can retry. Tests updated: - replace the no-op-on-missing-executor test with one that asserts the hook still launches and omits the key from the payload - add coverage for the no-session_id branch keeping the composer intact --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
40c89cd380 | fix(web): preserve dockview state across task and plan-mode switches (#855) | ||
|
|
9664e13f07 | fix(web): keep "All Workflows" filter on task nav and workflow.created (#854) | ||
|
|
7fe2affd6d | fix: respect "All Workflows" selection with multiple workflows (#844) | ||
|
|
0d3fce25b4 | fix(web): stop dockview layout corruption when switching between maximized / sessionless tasks (#838) | ||
|
|
7ec639bf2f |
feat(web): mobile parity (session/terminal/repo pickers + multi-terminal) (#840)
* refactor(web): extract shared use-session-actions hook from SessionTab
Move WS-level session lifecycle handlers (set primary, stop, resume,
delete) into hooks/domains/session/use-session-actions so the same logic
can power both the dockview tab context menu and upcoming mobile
controls. Dockview-specific panel removal stays local in SessionTab via
the onDeleted callback.
* feat(web): humanize task and session state labels
Mobile task sheet was rendering raw enum values like "IN_PROGRESS".
Add formatTaskStateLabel + formatTaskSessionStateLabel helpers and use
them in the mobile task sheet badge. Session state badge in the new
mobile sessions picker also reuses the formatter.
* feat(web): mobile session/terminal/repo pickers + multi-terminal support
Address parity gaps on mobile by introducing per-pane header pills that
open bottom-sheet pickers, scoped to the relevant view rather than
overloading the left task sheet.
Pickers:
- Sessions pill in chat header → bottom sheet with launch / set-primary
/ stop / resume / delete (with confirm). Pill shows agent label +
idx/total badge.
- Terminals pill in terminal header → bottom sheet with add / close
(with confirm) / switch. Replaces an earlier chip-strip prototype.
- Repos pill on the top bar (multi-repo only) → bottom sheet listing
task repositories; switching jumps to a session on that repo.
Each pill renders with a chevron-down icon + count badge so the
trigger reads as a button, not a status badge.
Multi-terminal:
- New useMobileTerminals hook wraps useTerminals and auto-creates a
first shell when the env reports zero.
- MobileTerminalPane multi-mounts PassthroughTerminal instances keyed
by terminalId so switching preserves scrollback. Active xterm
registers itself in a small mobile-active-terminal sender registry;
the existing key-bar reroutes input through xterm.paste() so Ctrl /
^C / arrows reach whichever terminal is currently visible, not just
the per-session default shell.
- Skip the WebGL renderer addon on mobile (new disableWebgl prop on
PassthroughTerminal). The WebGL atlas miscomputed glyph scaling
after the desktop→mobile responsive switch and rendered text at
several times the intended font size; the canvas renderer is correct
and fast enough for shell use.
Refactors:
- Left sheet reverts to a single-purpose task list (the sectioned-hub
variant was rolled back in favour of per-pane pickers).
- session-task-switcher-sheet-hooks extracted from the sheet to keep
it under the file-size lint cap.
* fix(web): address review feedback on mobile pickers + multi-terminal
Bots flagged a real first-mount bug and a handful of smaller items;
addressed each:
- mobile-terminal-pane: track xterm in state instead of a ref so the
active-sender registration effect re-runs once xterm is ready. With
the ref, the effect ran once on mount, found xtermRef.current === null
while PassthroughTerminal was still initializing (notably under the
ResizeObserver fallback path when the container starts at 0×0), and
silently never registered — leaving the key-bar wired to the default
shell instead of the visible terminal.
- send-shell-input: wrap the activeSender call in try/catch so a stale
registry entry (xterm GC'd mid-frame) falls back to the WS path
instead of dropping the keystroke and silently consuming modifiers.
Added tests for the activeSender routing branch (success, modifier
consumption, fallback on throw).
- mobile-sessions-section: remove the dead `showCloseConfirm ? "Delete" : "Delete"`
ternary and the unused prop on SessionActionsMenu.
- use-mobile-terminals: clear the auto-create guard when addTerminal()
rejects so the user gets a retry instead of an empty terminal list.
- mobile-repo-pill: sort taskRepos by position before falling back to
the first repo, so the pill label matches the picker's ordering when
no session is active yet.
- session-task-switcher-sheet-hooks: catch loadTaskSessionsForTask
rejection in handleSelectTask (was an unhandled rejection that left
the sheet open on backend errors), and stabilize useCallback deps in
handleWorkspaceChange by spreading the SheetNavOptions fields so a
fresh literal each render doesn't defeat memoization. Extracted
selectTaskWithoutPrimarySession to keep useSheetActions under the
function-size lint cap.
* fix(web): preserve task.repositories in sheet-hooks kanban mappers
mapSnapshotToKanban (workspace switch) and handleTaskCreated (newly
created task) only carried the first repository_id, dropping the full
TaskRepository array. The mobile repo picker (useTaskRepoCount +
MobileReposSection) reads task.repositories.length, so it silently
disappeared after a workspace switch and for freshly created
multi-repo tasks. Map the array with the same shape the SSR mapper
uses (id, repository_id, base_branch, checkout_branch, position).
Also address a small nit: wrap addTerminal in an arrow at the New
terminal button so the click MouseEvent isn't passed as the (unused)
first argument.
* fix(web): de-dup mobile terminal auto-create + add state-label tests + repo-tap toast
- use-mobile-terminals: move the auto-create-first-shell guard from
per-instance refs to a module-level Set keyed by environmentId.
MobileTerminalPane mounts useMobileTerminals three times for the
same session (the pane itself, the picker pill via
useActiveTerminalPillLabel, and the picker sheet content via
MobileTerminalsList — Vaul keeps it mounted while closed). With per-
instance guards, all three effects ran on first commit and each
fired createUserShell, racing into up to three shells. The shared
Set ensures only the first effect to observe an empty list creates,
with retry-on-failure preserved (delete + next render re-checks).
- mobile-repos-section: tapping a repo with no sessions now toasts a
hint to open the session picker, instead of silently doing nothing.
- state-labels: add tests covering known mappings, null/undefined
fallbacks, and unknown-state passthrough for both formatters.
* fix(web): single useMobileTerminals via context + tests + drop dead export
Address two follow-up review items:
- Lift useMobileTerminals to a single MobileTerminalsProvider owned by
MobileTerminalPane. The picker pill and the picker sheet's list now
read state from useMobileTerminalsContext instead of calling
useMobileTerminals themselves. The previous setup had three
independent useState-backed lists that drifted out of sync after
addTerminal/removeTerminal — the pane could show a stale active
terminal and the pill count could lag until a server WS push
reconciled them. The module-level guard (added in
|
||
|
|
b1cd2d2076 | feat(github): rate-limit awareness, poller throttling, and GraphQL batching (#821) | ||
|
|
9fe2cbd874 |
fix: source commit pushed status from git remote, not PR commits (#833)
* fix: source commit pushed status from git remote, not PR commits Commits showed as "not pushed" whenever the branch had no open PR, since the UI derived pushed status by matching local SHAs against the PR's commit list. Sourcing it from the upstream tracking ref in agentctl's GetLog keeps the answer correct without a PR, after rebases, and across multi-repo workspaces. * fix: treat PR-matched commits as pushed alongside the git remote signal mergeCommits now flags a commit as pushed when EITHER the backend's git-sourced flag is true OR a PR commit matches by SHA. The git signal fixes the original "no PR = unpushed" bug; the PR signal preserves the "PR exists ⇒ commit is on remote" assumption that the E2E mocks rely on (and that holds in practice, since GitHub PRs require pushed branches). Also corrects the validation-path comment in markPushedCommits to describe the actual non-branch fall-through. * perf: bound rev-list walk in markPushedCommits to result length Without -n the walk was unbounded — a branch with many local-only commits would traverse full unpushed history per GetLog call. Capping at len(commits) keeps the two walks proportional. rev-list walks newest-first like GetLog, so the cap covers every commit we mark. --------- Co-authored-by: Kandev Agent <agent@kandev.dev> |
||
|
|
5e3248a66b |
feat(web): pin tasks and drag-to-reorder in sidebar (#829)
Co-authored-by: Kandev Agent <agent@kandev.dev> |