* feat(stats): add avg turn duration and messages per turn to averages card
Adds two new metrics to the workspace stats Averages card: mean turn
duration and mean message count per turn. Both exclude outliers — turns
shorter than 1s (no-op/aborted), longer than 1h (zombie turns where
completed_at was backfilled across an agent restart), and turns with no
messages — so the displayed means reflect typical work rather than tail
skew. On a ~4k-turn sample the filter drops avg duration from ~1062s to
~289s.
* fix(stats): exclude exact 1h turns and use Tooltip for averages help text
Address PR feedback: the BETWEEN bound was inclusive so turns exactly at
the 1h ceiling were retained, contradicting the documented half-open
range. Also swap the title attribute on the new Averages rows for the
shared Tooltip component so the help text is reachable via keyboard and
screen readers.
* fix(stats): align turn-row zero state with em-dash convention
Address PR feedback: avg_messages_per_turn rendered "0.0" while
avg_turn_duration_ms rendered "—" under the same empty-data condition.
Also corrects the GetGlobalStats docstring to acknowledge that
clean_turn_agg adds one correlated per-turn lookup on top of the four
single-scan CTEs.
* test(stats): cover exact 1h turn boundary in clean-turn exclusion test
Add a turn of exactly 3,600,000ms (cleanTurnMaxDurationMs) to
TestGetGlobalStats_CleanTurnExcludesOutliers so the exclusive upper
bound is exercised directly, not just via the 2h zombie turn that
sits well above the boundary.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(web): collapse dockview workflow stepper to current step when cramped
On narrow windows the full workflow step indicator outgrew the top-bar
center track and rendered beneath the right-side action buttons. Switch
the grid so the breadcrumb takes only its content width and the center
track absorbs the real leftover space, then collapse the stepper to a
minimal current-step view (via the shared useToolbarCollapsed hook) only
when the full version genuinely cannot fit.
* fix(web): stabilize stepper test id and drop margin skewing collapse
Move data-testid="workflow-stepper" to the persistent outer container so
the e2e locator resolves in both expanded and collapsed modes, and remove
mx-2 from the w-full measuring container (it overflowed the wrapper and
shifted the collapse threshold by ~8px); the grid gap-2 already spaces the
columns.
* refactor(web): trim verbose stepper collapse comment
* fix(web): tag archived minimal badge with stepper test id
Give the archived branch of MinimalWorkflowStepper the same
workflow-stepper-minimal test id as the current-step branch so collapsed
mode is detectable for archived tasks, and tighten two comments.
* test(web): collapse multi-line comments in stepper test to single lines
* fix(web): drop aria-current on minimal stepper no-current fallback
When no step is current the minimal view falls back to the first step;
it should not claim aria-current="step", matching the expanded path where
no step is marked current in that case.
* fix(web): keep dockview group alive on task switch to stop layout collapse
On a fast-path env switch, the incoming session chat was added only after
removeEphemeralPanels closed the outgoing chat. When that chat was the last
surviving panel in its group (e.g. the default layout's chat column), removing
it first emptied — and so destroyed — the group. The captured group id then no
longer existed, and post-#1165 the old `referenceGroup: "sidebar"` fallback is
dead, so addPanel ran with an undefined position: dockview dropped the incoming
chat into the active (terminal) group and collapsed the grid root to a vertical
stack. The corrupted layout was then persisted, so it survived reloads.
Add the incoming session panel before removing the outgoing one, anchored at the
outgoing panel's current index, so the group stays alive throughout the swap.
Removing the panels ahead of the insert point shifts the new panel into the same
final slot, so computeSurvivingIndex is no longer needed.
* docs(web): note the raw-index equivalence assumption in env switch
* fix: gate step_complete_kandev tool on per-step signal flag
Two ADR-0015 follow-up bugs:
- "Wait for agent completion signal" checkbox snapped back because
updateWorkflowStepAction dropped auto_advance_requires_signal from the
PUT body. Add it to the Pick<>, body builder, BackendWorkflowStep, and
transformWorkflowStep.
- step_complete_kandev MCP tool was injected into every first-turn
prompt, polluting context and tempting agents on non-signal-gated
steps to call it. Replace the static description in
kandev-context.md with a {step_complete_section} placeholder filled
only when the current step has auto_advance_requires_signal=true.
Add orchestrator.Service.StepRequiresCompletionSignal helper and
thread it through all four InjectKandevContext call sites.
* fix: address PR 1300 review feedback
- Hoist ProcessOnTurnStart above the first-turn MCP wrap so an
on_turn_start step transition is reflected in the
auto_advance_requires_signal flag baked into the stored/forwarded
prompt (coderabbit, cubic).
- Add Service.WorkflowStepRequiresCompletionSignal step-ID variant and
use it from task_operations.go / autoStartStepPrompt to skip the
redundant GetTask round-trip at hot first-turn launch sites (claude).
- Add table-driven tests covering every silent-false branch and the
positive path of both helpers (greptile, claude, cubic).
* docs(sysprompt): document stepCompleteSection trailing newline contract
Address Claude review suggestion on PR 1300: make explicit that the
constant must end with "\n" because the kandev-context.md template inlines
{step_complete_section} immediately before the next bullet, so the
trailing newline is what separates the two list items.
* fix: restore task-id lookup at startTask wrap site
Address PR 1300 review regression: the previous swap to
WorkflowStepRequiresCompletionSignal(ctx, workflowStepID) silently hid
step_complete_kandev on manual starts where the workflowStepID parameter
is empty but the task is already bound to a signal-gated step in the DB.
The `task` variable in scope at this call site is `*v1.Task` (from the
scheduler) and does not carry WorkflowStepID, so the cheaper step-ID
variant is not applicable here — go back through StepRequiresCompletionSignal
so the repo lookup pulls the canonical step.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(agentctl): read models/modes from configOptions fallback
claude-agent-acp v0.42 dropped the unstable `models` field from
session/new and publishes models only via `configOptions[]` with
category=model. The probe still relied on the legacy field, so
the inference-agents endpoint reported zero models and the UI
showed "Claude advertised no models."
Read the legacy fields first, then fall back to configOptions
when they are absent. Same fallback applied to modes for parity
with future agents.
* fix(agentctl): gate configOptions fallback on nil legacy field
Switch the fallback guard in `applySessionProbeFields` from
`len(out.Models) == 0` to `sessionResp.Models == nil` (same for
modes). A non-nil `Models` struct with an empty `AvailableModels`
slice is schema-valid: the legacy branch already stamps
`CurrentModelID`, and the previous guard would then fire the
configOptions fallback and overwrite it with a different source.
Adds `TestApplySessionProbeFields_LegacyEmptyModelsBlocksFallback`
covering the edge case.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* perf(stats): split stats endpoint per-section and rewrite GetGlobalStats
Stats page used to wait on a single endpoint that ran 7 SQL queries in parallel
and rendered nothing until the slowest one returned. Cold first-paint was the
bottleneck users actually hit.
- Backend: split /api/v1/workspaces/:id/stats into 7 per-section endpoints
(global, tasks, daily-activity, completed-activity, agent-usage,
repositories, git). Each handler runs one repo call and returns its DTO.
- Backend: rewrote GetGlobalStats from 9 independent correlated subqueries
into 4 CTEs (task / session / turn / message). Each underlying table is now
scanned at most once per request instead of 1-3 times.
- Frontend: replaced the monolithic fetchStats + single PanelState with
useStatsSections hook (7 parallel fetches, per-section SectionStatus).
Each panel renders skeleton -> data -> error independently; light queries
(overview, agent usage, git) paint immediately, heavy ones stream in.
- Tests: added TestGetGlobalStats_AggregatesAcrossTables to lock the
CTE-based query against regressions for turns, user/tool messages, and
total duration.
* fix(stats): address PR review feedback
- Restore cache: "no-store" on section fetches; Gin emits no Cache-Control
headers so the browser could otherwise serve stale stats on revisit
(Greptile P1, Cubic).
- Stop blanking the Overview panel when /git fails: render OverviewCards as
soon as `global` is ready and treat `git` as optional, falling back to the
Averages card. Updates GitOrAveragesCard / OverviewCards types (Claude).
- Fix TaskStatsHasMore off-by-one at exactly taskStatsLimit rows: probe
taskStatsLimit+1, slice back to the page size, only flag hasMore on a
confirmed extra row (Claude, Cubic).
- Rename anyError → firstError with a comment documenting the deterministic
insertion-order iteration; clearer signal that callers should use it as
"any section failed?" and surface per-section errors in their own panels
(Claude).
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(utility): surface probe status in inference-agents endpoint
GET /api/v1/utility/inference-agents previously filtered out agents whose
host-utility probe didn't reach StatusOK, so a Claude entry with auth or
install issues vanished from the response. The frontend then defaulted
agent_id to "claude-acp", failed to match anything in the list, and
silently rendered an empty Model picker with no explanation.
Include every registered ACP-capable agent in the response with a new
`status` field (and sanitized `status_message`) so the UI can render an
inline note + Refresh button. Add a companion
POST /api/v1/utility/inference-agents/:id/refresh endpoint backed by the
existing hostutility.Manager.Refresh so users can re-probe after fixing
auth or installing the CLI without restarting kandev.
Credential-looking substrings ("api_key=...", "token=...") in probe
errors are stripped before reaching the wire.
Refs #1269, #1263.
* fix(web): show probe status note + Refresh on utility-agent model picker
Previously the Model picker rendered empty and disabled whenever the
selected inference agent was missing from /inference-agents (probe
hadn't reached StatusOK) or advertised no models, with no way for the
user to understand or recover.
Wire the new `status` field through the API client and render an inline
note ("Sign in to Claude to load models.", "Claude CLI is not installed
on this machine.", "Setting up Claude…") with a Refresh button that
calls the new POST /inference-agents/:id/refresh endpoint and merges the
updated agent back into local state — no full refetch, so a slow probe
on a different agent doesn't block the UI.
Both call sites — the create/edit dialog and the Default Model row in
the settings page — render the note. Component-level unit tests cover
the status-to-copy mapping, fallback ("no longer available") copy, and
the in-flight Refresh button disabled state. The e2e test asserts the
auth_required → click Refresh → status:ok state machine end to end.
Refs #1269.
* test(utility): add HTTP integration tests for inference-agents endpoints
End-to-end coverage of the new /api/v1/utility/inference-agents and
/api/v1/utility/inference-agents/:id/refresh routes against a real gin
router with a stateful in-memory host stub.
- ProbeStateMachine: GET (auth_required) → POST refresh → GET (ok+models)
- RefreshUnknownAgent: 404 without invoking the host probe
- RefreshPathFuzz: traversal, null byte, 512-char id — no 5xx, no probe
- ConcurrentRefresh: 50 parallel POSTs under -race, no panic
- StatusMessageSanitizationOnTheWire: 110KB payload + unicode probe
errors — no secret material reaches the wire, unicode prose preserved
Caught the gap unit tests had: the multi-request GET→POST→GET sequence
on a shared cache, and the wire format (Content-Type, JSON shape)
through the real router.
* fix(utility): tighten credential regex + drop Go 1.22 loop-var shadow
Address two inline review comments from claude bot on PR #1287:
- Drop `ia := ia` shadow in httpRefreshInferenceAgent — pre-Go 1.22
pointer-capture workaround, dead weight under Go 1.24+ (per
apps/backend/AGENTS.md).
- Split the credential sanitizer into two patterns so prose without a
real `=`/`:` separator is no longer mangled. The single regex
previously turned "access token was revoked" into
"access token=<redacted> revoked" by consuming the next word as the
value. New shape:
- credentialKVPattern — requires `[:=]` (api_key, token, secret,
password)
- credentialBearerPattern — allows bare whitespace, anchored to
"bearer" only
Existing redaction cases still pass; two new prose cases guard the
regression.
* fix(utility): address cubic + greptile review findings on PR #1287
Round-2 review feedback:
- P0 (cubic): postEmpty called `t.Fatalf` from `go func()` in
TestIntegration_ConcurrentRefresh. Go panics on t.Fatalf from non-test
goroutines. Split into `postEmptyE` (error-returning, goroutine-safe)
and `postEmpty` (main-goroutine wrapper). Concurrent test now
forwards the error through the existing `errs` channel.
- P1 (cubic + greptile): tightened the credential sanitizer regex.
* `api[ _-]?key` now also matches a literal space ("invalid api key=…")
* separators are restricted to `[^\S\n]` so multi-line stderr like
"invalid token\ncaused by network" no longer eats words across lines
Test coverage added for both regressions (and for tab as a valid
horizontal-whitespace separator).
- P2 (cubic): refresh-failure log now feeds `err.Error()` through
sanitizeStatusMessage before zapping it — same wire-side guard, but
applied to server logs too. Closes the "credentials in journalctl"
hole.
- P2 (cubic): InferenceAgent.status is now optional in the TS type and
the status note treats an undefined status with `models.length > 0`
as healthy, so older API responses don't flash a spurious warning.
Deferred (acknowledged in PR replies, not changed here):
- cubic P2 useInferenceAgents drift — the hook is currently called in
both the dialog and the parent section. Drift is bounded by the
dialog's own `onSuccess` refetch and only visible inside an open
dialog. Lifting state to the parent and prop-drilling is the right
fix but touches multiple files; out of scope for this review round.
* docs(utility): fix stale comments flagged by claude review on PR #1287
Two doc-only items, no code change:
- handlers_integration_test.go: TestIntegration_ConcurrentRefresh
comment said "we don't assert on call count" but the test DOES
assert `host.refreshCalls != N` two lines later. Updated to match
reality (singleflight lives in the real Manager, the stub has none
→ every call must reach the stub).
- utility-agents.spec.ts: the ACP-probe E2E said "backend filters out
non-StatusOK agents", which is the exact behavior this PR removes.
Updated to explain the real reason only Mock appears (KANDEV_MOCK_AGENT=only
registers only `mock-agent`).
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(web): guard dockview measure against mid-transition sidebar width
The unified AppSidebar (#1165) animates its width over 300ms; the dockview
content container (a flex sibling) can be measured mid-transition at a tiny
positive width in onReady, so the default layout builds too narrow and dockview
collapses the horizontal columns into a vertical stack. measureDockviewContainer
trusted any positive parent clientWidth. Reject an implausibly-small live width
(< 50% of viewport) as a not-yet-laid-out transient and fall back to the
viewport; the resize observer then snaps to the exact size.
* fix(web): preserve live height when width guard fires; assert it
* fix(web): preserve manual task selection during archive/delete
When the user archived (or deleted) a task and then immediately picked
another task, the in-flight archive flow would auto-switch to a third
task, clobbering the manual selection.
The shared `removeTaskFromBoard` was honoring the pre-API captured
`wasActiveTaskId` unconditionally. That capture exists to survive the
WS `task.deleted` / `task.updated(archived_at)` handler clearing
`activeTaskId` to null. But it also overrode the legitimate case where
the current store value reflected a user-initiated navigation, so the
equality check still passed and `switchToNextTask` ran.
New guard: switch only when (a) the store still points at the removed
task, or (b) the store has been WS-cleared to null AND the captured
pre-API id matches the removed task. Any other state means the user
moved on — leave them where they chose to go.
* chore(web): tighten null check + no-opts test in use-task-removal
Addresses non-blocking suggestions from claude bot review on #1286.
- Use strict `=== null` in `shouldSwitchAfterRemoval` for consistency
with the surrounding strict-equality comparison.
- Add unit test documenting that a no-opts caller skips the auto-switch
when the WS handler has already cleared `activeTaskId` to null.
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>
* fix(github): handle UNIQUE collision in UpdatePRWatchBranchIfSearching
When a sibling PRWatch already owns the destination
(session_id, repository_id, branch) triple, the raw UPDATE tripped
the UNIQUE constraint and the change was logged as an error
("failed to update PR watch branch"). Wrap the update in a tx that
first checks for a sibling; if one exists, delete the source watch
(pr_number=0, no PR state to preserve) and cede branch ownership.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(github): close TOCTOU and cover sibling-has-PR collision case
Address PR review feedback on #1285:
- Catch SQLite "UNIQUE constraint failed" on the no-sibling UPDATE path
and treat it as the same collision case (Greptile P2 inline). A
concurrent CreatePRWatch that commits between our existence probe
and the UPDATE would otherwise re-trigger the bug this PR fixes.
- Switch the existence probe from `SELECT id` + `siblingID` dummy to
`SELECT 1` scanned into a typed `probe int` with an inline comment
(Claude inline readability nit).
- Extract `dropSourceAndCommit` helper so the probe-hit branch and the
TOCTOU fallback share one code path.
- Add `TestUpdatePRWatchBranchIfSearching_CollidesWithSiblingHasPR_DropsSource`
exercising the realistic race: sibling has already found its PR
(pr_number != 0) when source tries to migrate (Claude / Greptile P2 /
cubic P3 all flagged the gap). Asserts sibling row + PR number
survive untouched after source is dropped.
* test(github): cover missing-row no-op and reframe TOCTOU comment
Address Claude bot's follow-up suggestions on #1285:
- store.go: rewrite the UNIQUE-catch comment. The original wording
implied an in-process race, but SQLite's writer pool is
SetMaxOpenConns(1) so a concurrent in-process CreatePRWatch cannot
commit between probe and UPDATE. Reframe as defensive belt-and-
suspenders for an external SQLite writer / future pool changes.
- service_pr_watch_multi_branch_test.go: add
TestUpdatePRWatchBranchIfSearching_MissingRow_NoOp covering the
ErrNoRows -> tx.Commit() idempotency contract that the new
transaction code introduces. Locks the prior UPDATE-by-id semantics
of "0 rows affected, no error" for a nonexistent id.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(workflow): ship explicit completion signal toggle + YAML round-trip
ADR 0015's "Wait for agent completion signal" UI shipped as a deferral
comment in PR #1276 instead of a working toggle, and the
auto_advance_requires_signal flag never reached the workflow export
format. Add the pipeline-editor sub-toggle and propagate the field
through StepPortable so the setting survives workflow export/import.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(workflow): guard completion-signal toggle + cover import in tests
Address PR review:
- hide ExplicitCompletionToggle when on_turn_complete has no transition
(flag has no effect there; visible checkbox was misleading)
- add service-level import test for auto_advance_requires_signal so the
field has explicit ImportWorkflows coverage, mirroring the existing
show_in_command_panel pattern
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(workflow): drop omitempty from AutoAdvanceRequiresSignal tags
Match the sibling bool fields (IsStartStep, ShowInCommandPanel,
AllowManualMove) so a `false` value still appears in exported YAML/JSON.
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>
defer RUNNING/STARTING moves instead of immediate MoveTask; add normalizeTaskState aliases; regression test for promptless Work to Done.
Co-authored-by: cybertronic <cybertronic@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): make toggle-sidebar shortcut work on every route
TOGGLE_SIDEBAR (Cmd/Ctrl+B) was handled only inside useEditorKeybinds,
mounted just by the dockview desktop layout, so it was dead on Kanban,
the task list, Office, Settings, etc. Lift it to a global app-root
listener (useAppShortcuts, mounted in GlobalCommands) that honors user
overrides via getShortcut and keeps the isEditableTarget guard, and drop
the now-duplicate branch from useEditorKeybinds. Adds a hook unit test
and a Playwright e2e covering Kanban, Settings, and the dockview session.
* fix(web): hydrate userSettings into the root store on settings routes
Each StateProvider creates its own store; the settings/general page mounts
a nested one that the root-mounted GlobalCommands/AppSidebar can't see. The
settings layout hydrated the root store but omitted userSettings, so
override-driven global shortcuts (TOGGLE_SIDEBAR, Quick Chat) were dead on
every /settings route. Hydrate userSettings there too, matching the
kanban/tasks/office/github routes.
* test(web): cover the macOS Cmd path; note the settings-layout catch trade-off
Address review feedback: add a metaKey (Cmd) variant to the useAppShortcuts
test so both ctrlOrCmd branches are exercised, and document that the settings
layout's outer catch intentionally drops userSettings overrides in the
already-degraded error path.
* fix(web): point sidebar Home to office dashboard in office mode
The AppSidebar Home item always navigated to the Kanban view (/), even on
office routes. Make its destination office-aware so Home targets the office
dashboard (/office) while in office mode and the board (/) otherwise.
* test(web): start Kanban Home e2e from a non-home route
Per PR review: the Kanban-mode Home test started at / and clicked Home (also
/), so no navigation occurred. Start from /settings/system/status so the click
is a real navigation back to the board.
* fix(web): align office topbar border with sidebar header (h-10)
The OfficeTopbar was h-12 (48px) while the AppSidebar header is h-10 (40px),
so the office top bar's bottom border sat ~8px below the sidebar's
under-workspace-picker border, breaking the continuous seam where the sidebar
meets the page content. Drop the topbar to h-10 to match the sidebar header and
the PageTopbar/dockview top bars used elsewhere.
Add data-testid hooks to both bordered sections and a Playwright regression
asserting they resolve to the same height and bottom y-position.
* test(web): force expanded sidebar in topbar alignment check
Addresses review feedback (Greptile/Claude): the alignment assertion only
holds for the expanded sidebar header (its border sits under the workspace
picker, and that layout carries data-testid="app-sidebar-header"). Set the
appSidebar.collapsed flag off via addInitScript before load so leftover
collapsed state can't turn the check into a confusing timeout instead of a
real assertion.