Commit Graph

262 Commits

Author SHA1 Message Date
Carlos Florêncio d9d4aafb51 fix: preserve office sidebar workspace toggles (#1403) 2026-06-17 08:09:54 +01:00
Carlos Florêncio b374b50c4c fix: open root-relative chat file links (#1400) 2026-06-16 22:55:24 +01:00
Carlos Florêncio d3575e2683 fix: restore office sidebar navigation (#1396) 2026-06-16 20:19:13 +01:00
Carlos Florêncio 1f4aecc7d5 feat: split general settings pages (#1383) 2026-06-15 21:13:04 +01:00
Carlos Florêncio fd421848fb feat: add feature toggles settings (#1372) 2026-06-15 13:33:45 +01:00
Carlos Florêncio 35a12fdb2c fix: distinguish sidebar workspace types (#1364) 2026-06-14 18:27:40 +01:00
Carlos Florêncio f444fba455 perf(chat): eliminate redundant re-renders and markdown re-parsing (#1357) 2026-06-13 19:26:54 +01:00
Carlos Florêncio 0d69fc1078 fix(office): route setup cancel to homepage (#1333) 2026-06-11 22:41:35 +01:00
João Salavisa fc01fa37cb fix(linear): match issue identifier (ENG-123) in search (#1340)
* fix(linear): match issue identifier (ENG-123) in search

Linear's `searchableContent` filter never matches the issue identifier
(team key + number), so a query like `ENG-123` from the Linear view
returned no results. When the query parses as a `TEAM-NUM` identifier,
OR-combine the content match with `{team.key.eq, number.eq}` so the
exact issue surfaces while other filters keep AND-joining. Lowercase
inputs (`eng-123`) are normalized to the upper-case team key.

Fixes #1339

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

* fix(linear): mention ID in search box placeholder

Reflect the backend's new identifier-aware search by hinting at it in
the search input on `/linear`.

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

* docs(linear): explain why identifier search keeps both OR branches

claude[bot] flagged the searchableContent branch in the identifier OR
as functionally dead and the surrounding comment as a sentence fragment.
The branch is intentional — it catches cross-references like "duplicate
of ENG-123" pasted into another issue's title or description, which the
exact team+number branch would miss. Rewrite the comment to explain
that, fixing the fragment in the same change.

* fix(linear): use canonical identifier form in searchableContent OR branch

greptile flagged that the `searchableContent.contains` branch in the
identifier OR was sent verbatim from user input. When the user typed
`eng-123`, Linear's case-sensitive `contains` filter would miss
cross-references written in the canonical form `ENG-123`. Normalise
the contains value to the same upper-cased identifier we use for the
exact `team+number` branch, and lock that behaviour in
`TestBuildIssueFilter_IdentifierLowercase`.

* fix(linear): use containsIgnoreCase for searchableContent

@carlosflorencio flagged that canonicalising the identifier OR branch
to upper case meant lowercase literal references (e.g. "see eng-123"
pasted into a description) no longer matched. The previous round had
swapped the other way at greptile's request to fix uppercase
references. Both directions are right — the underlying issue is that
Linear's `contains` filter is case-sensitive. Switch the whole
`searchableContent` filter (identifier OR branch and the non-identifier
free-text path) to `containsIgnoreCase`, so casing of the user input
and of the indexed text no longer matters in either direction.

Free-text search is now case-insensitive too, which is the intuitive
default for a search box.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 19:15:52 +01:00
João Salavisa 672f2b1f26 feat(stats): add avg turn duration and messages per turn (#1328)
* 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>
2026-06-11 13:45:16 +01:00
Carlos Florêncio 800d71f3d1 fix: correct sidebar workspace switcher routing and selection persistence (#1329) 2026-06-11 12:02:25 +01:00
Carlos Florêncio 64140ae942 fix(acpdbg): read claude agent models and modes from configOptions (#1310) 2026-06-08 22:44:02 +01:00
Carlos Florêncio ec40bf0b87 fix: contain settings page overscroll (#1316) 2026-06-08 22:42:14 +01:00
João Salavisa 2b74f4e189 fix: gate step_complete_kandev tool on per-step signal flag (#1300)
* 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>
2026-06-08 08:14:52 +01:00
João Salavisa 31d4ba67fa perf(stats): split stats endpoint per-section and rewrite GetGlobalStats (#1289)
* 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>
2026-06-07 13:24:32 +01:00
José Almeida e626d33a7c fix(web): make toggle-sidebar shortcut work on every route (#1283)
* 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.
2026-06-06 23:09:12 +01:00
José Almeida dd4cb2c3ee fix(web): align office topbar border with sidebar header (h-10) (#1281)
* 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.
2026-06-06 22:38:31 +01:00
José Almeida b001aa1eb8 feat(web): unified app sidebar (#1165)
* feat(web): introduce unified AppSidebar and retire legacy nav surfaces

Mounts a single collapsible AppSidebar at the root layout that replaces the
former WorkspaceRail, OfficeSidebar, and dockview-embedded sidebar pane.
Wires per-section expand + collapse state through a new UI slice with
localStorage persistence, ports projects/agents/settings/tasks sections, and
trims integrations / quick-chat / settings buttons + the home breadcrumb
from the dockview top bar (debug-only MoreToolsMenu kept). Strips the
sidebar column from every preset and the panel registry; persisted
sidebar panels are dropped by the existing sanitize layer at restore time.

* test(web): point workflow-filter e2e at AppSidebar's Home link

The dockview top bar no longer renders the IconHome breadcrumb after the
unified sidebar landed — the home navigation now lives in AppSidebar's
primary nav. Update the workflow-filter spec to click that link instead.

* feat(web): wire app-sidebar primary controls, nested settings, and footer migration

Gates office-only nav (Inbox, Agents, Projects, New Task) behind the office
feature flag, renames Tasks → Kanban as the bottom flex-grow section, replaces
the gradient workspace picker with a minimal trigger when office is off, and
moves Stats/Improve/What's-New/Office out of the kanban top bar into the
AppSidebar footer + a dedicated Integrations section. Expands Settings into a
collapsible deep tree mirroring the old SettingsAppSidebar so /settings pages
can drop their nested SidebarProvider.

* feat(web): make app-sidebar resizable and inline task top bar actions

Adds a persistent drag-resize handle on the AppSidebar's right edge that
clamps between APP_SIDEBAR_EXPANDED_WIDTH and 30vw; width is stored on the
appSidebar slice and persisted to localStorage. Drops the dockview top bar's
overflow popover (TopbarActionOverflow) in favor of always-inline clusters,
and replaces the debug "More tools" dropdown with a single debug toggle button.

* feat(web): polish app-sidebar headers and consolidate task views

Section headers now lead with a clean uppercase label, drop the mono
clash, lift contrast from /60 to /70, and end with the collapse chevron
at the right so the section body aligns flush under the label.

The Tasks section's filter bar (view chips + gear in a card-tinted
strip) is replaced by a compact view dropdown that lives in the section
header. Drag-to-reorder lives in the existing filter popover, which is
now reachable from the same gear button.

Settings moves below Tasks so the kanban list keeps the flex-grow seat
in the sidebar.

* feat(web): move settings group chevrons to the right

* fix(web): drop chat from home topbar; calm tasks group headers

The kanban top bar's Quick Chat button is removed now that Quick Chat
lives in the AppSidebar; workspaceId stops being threaded into the
tablet and desktop header variants.

Group headers inside the Tasks section (kanban workflow steps) lose
the opaque background strip and the section-label uppercase styling
so they read as inline subgroup rows, not duplicate section dividers.
The tasks-section wrapper drops its negative margin so the group rows
sit flush under the TASKS label instead of bleeding out past it.

* fix(web): restore agent logos on settings tree profile leaves

SettingsLeaf gains a leadingIcon slot for pre-rendered visuals; agents
group passes <AgentLogo agentName={agent.name}> so each profile row
shows its agent's logo like it did before the old SettingsAppSidebar
was retired.

* feat(web): brand the sidebar, move New Task off the kanban top bar

The Add task button (CreateTaskTopbarButton) leaves both the desktop
and tablet kanban headers; New Task is now an unconditional primary
nav item on the AppSidebar, falling back to a disabled affordance when
no workspace is active.

AppSidebar header gains a Kandev wordmark that links to /, with a
single-letter K mark in collapsed-rail mode. PageTopbar's root variant
no longer renders the parent brand label when backLabel is empty, so
the home view's top-left "Kandev" goes away in favor of the sidebar.

* feat(web): unbind sidebar toggle by default; drop Home from breadcrumbs

TOGGLE_SIDEBAR's configurable default is now UNBOUND_SHORTCUT (the new
sentinel for "no binding"); matchesShortcut already returns false on
an empty key, so the keybind hook needs no change. Keyboard shortcuts
settings show "Unbound", expose a Clear (X) button when a custom
binding is set, and the reset icon falls back to unbound for entries
whose default is unbound.

PageTopbar's breadcrumb variant no longer prepends the Home back-link
when backHref is "/" (the AppSidebar provides Home navigation).

* feat(web): move New Task into the sidebar, retire the workspace selector

- Extract AppSidebarNewTaskItem (office vs regular create dialog) with tests
- Drop the Workspace selector from the kanban display dropdown and mobile
  menu sheet; the sidebar workspace picker is now the sole switcher
- Factor SectionHeader out of AppSidebarSection; animate fixed sections via
  Collapsible while the grow (Tasks) section stays flex-driven
- Polish brand header, share SIDEBAR_ITEM_ACTIVE accent-bar classes, move
  task group chevrons to the right, drop the task-item selected fill
- Bump dockview layout keys to v3 and filter the orphaned 'sidebar' column
  so pre-AppSidebar default layouts no longer render a broken grid
- Point the cross-workspace isolation e2e at the sidebar picker

* feat(web): sidebar settings mode, subtask shortcut, accordion settings nav

- Restore subtask creation: the sidebar New Task row now shows a trailing
  subtask affordance when viewing a task (regular mode), reopening the
  NewSubtaskDialog the retired dockview header dropdown used to provide
- Fix settings pages not scrolling: scroll the content region (topbar stays
  pinned) via min-h-0 + overflow-y-auto
- Add a footer gear that toggles a settings-only sidebar takeover; the gear
  spins on toggle. Mode is transient (never persisted) and force-expands a
  collapsed rail on entry
- Make the settings nav a single-open accordion: opening one top-level group
  closes the others and reveals its subsections; route changes re-sync the
  open group. Extract the tree into a reusable SettingsTree
- Repoint subtask e2e specs at the new sidebar affordance

* test(web): restore create-task e2e flow after New Task moved to sidebar

The New Task relocation removed the kanban-header create-task-button and made
the sidebar entry auto-navigate + open the office dialog under the e2e profile
(office on), breaking ~20 specs that drive the regular task-create dialog.

- Give the sidebar New Task the create-task-button testid so KanbanPage
  resolves it again
- Drop the post-create auto-navigation so creating a task stays on the board
  (matches the retired kanban button; specs find/click the new card)
- Ungate the subtask affordance from office mode (subtasks use the compact
  NewSubtaskDialog in both modes, as the old dropdown did)
- Add a useRegularMode() helper that runs a spec file with KANDEV_FEATURES_OFFICE
  disabled (backend.restart bracket) and apply it to the 18 create-task specs
- Point the System sidebar-navigation spec at a /settings/system page so the
  single-open settings accordion has that group expanded

Validated: create-task, create-task-disabled-tooltip, create-task-branch-selector,
and subtask all green (modulo pre-existing idleInput agent-timing flakes that
CI retries absorb).

* fix(web): sidebar task navigation, settings-mode close + transitions

- Clicking a task in the global sidebar from a non-task page now navigates to
  the task route (mounts the dockview) instead of only rewriting the URL via an
  in-place layout switch that assumed the dockview was already mounted
- Make the settings-mode 'Settings' header a close affordance (the footer gear
  is far from the tree); clicking it exits the takeover regardless of which
  group is expanded, and it fades in when opened
- Fade in the Tasks (grow) section content on expand, matching the other
  sections' animated reveal
- Enable next-themes disableTransitionOnChange so dark/light flips switch every
  surface in the same instant rather than each animating at its own duration
- Add a regression e2e for sidebar→dockview navigation

* fix(web): clear sidebar task highlight when off the task route

The AppSidebar is global, so tasks.activeTaskId lingered after navigating Home,
leaving the last-opened task visually selected in the sidebar. Gate the
selected/active highlight by route — only highlight while on /t/ or
/office/tasks/. Expose data-active on the sidebar task item for testability.

Adds an e2e: entering a task highlights it; returning Home clears it.

* fix(web): never build the dockview default layout at zero width

On a fresh client-side navigation into a task the dockview can mount before the
grid has painted, so measureDockviewContainer returned api.width/height = 0.
Building the default layout at width 0 makes dockview collapse the horizontal
columns into a vertical stack (chat / files+changes / terminal) instead of the
intended side-by-side layout. Fall back to the viewport when neither the live
container nor the api report a usable size; the resize observer then snaps the
grid to the exact container size.

TDD: dockview-measure.test.ts covers the zero-size fallback.

* fix(web): keep the default layout horizontal when swapping chat for the session tab

Entering a task rendered a vertical stack (chat on top, files/changes in the
middle, terminal at the bottom) instead of the side-by-side default. The
chat->session-tab swap removed the 'chat' placeholder BEFORE adding the session
panel; when chat was the only panel in the center group, removing it destroyed
that group, the stored centerGroupId went stale, resolveInitialPosition
returned undefined, and the session panel was appended as a new row — flipping
the grid root to VERTICAL with three stacked groups.

Fix: add the session panel into chat's live group first (resolveInitialPosition
now prefers the chat panel's group), THEN remove the chat placeholder, so the
center group never empties and the horizontal split is preserved.

Also fixes the pre-existing pane-resize reload/clamp e2e failures, which were
the same collapse. TDD: task-default-layout.spec.ts asserts the right column
sits beside chat.

* fix(web): remove the sidebar Settings section; settings is gear-only

Settings was still a collapsible nav section in the sidebar AND auto-expanded on
/settings routes. That also caused the reported bug: toggling the footer gear
off while on a settings page revealed the normal sidebar with the Settings
section already expanded, so the settings tree appeared to 'not close'.

- Drop the Settings nav section (and its /settings route auto-expand) — the
  settings tree is reached only via the footer gear takeover
- Delete the now-unused sidebar settings-section component
- e2e: gear opens the tree and closes it even after navigating into a section

* fix(web): show office sidebar sections only while inside Office

Inbox, Projects, and Agents were gated on the office feature flag, so they were
always visible when the feature was enabled. Gate them on actually being in the
Office surface (an /office route, reached via the footer Office button) via a
new useInOffice() hook — they stay hidden in the regular workspace and appear
only after entering Office.

Unit test for the hook; e2e (office fixture) asserts the sections appear on
/office and are absent on the regular home.

* fix(web): drop the colorful workspace selector, restore the minimal one

The workspace picker swapped to a gradient/initials trigger (and gradient
swatches in the dropdown) whenever office was enabled. Always use the plain
name + chevron trigger and drop the gradient swatches, matching the previous
design. Removes the now-unused workspace-gradient helper.

* fix(web): align sidebar header, scope office New Task, exit settings on nav

- Drop the doubled right border: the resize-handle hairline is transparent at
  rest, leaving the aside's border-r as the single edge (highlights on hover).
- Collapse the expanded sidebar header into one h-10 row (brand · workspace
  picker · collapse) so it lines up with the page/dockview top bar.
- Gate the sidebar New Task dialog on useInOffice() (actual /office route)
  instead of the bare office feature flag, so Kanban mode keeps the classic
  create dialog while office is enabled.
- Close the settings takeover when navigating off the /settings surface
  (Home, Kandev brand, Office, etc.), tracked on a real pathname change so the
  gear still opens it in place and in-tree navigation keeps it open.
- Lock in the already-working collapsed-gear-expands behaviour with an e2e.

Tests: new-task-item unit (route gating), settings-gear e2e (Home closes,
collapsed gear expands), office gating e2e (Kanban vs Office dialog).

* fix(web): realign task top bar to h-10, refine sidebar workspace picker

- Pin TaskTopBar to h-10 so its bottom border lines up exactly with the
  sidebar header (and the PageTopbar) — previously it had no fixed height and
  sat ~2px off, breaking the seam across the sidebar/content edge.
- Redesign the workspace picker as a compact, muted secondary control inlined
  after the Kandev brand: brand and workspace share text-sm so they sit on a
  common baseline, with a faint '/' separator; the workspace reads as muted and
  the brand stays primary.
- Drop the workspace switcher (and its irrelevant folder glyph) from the
  collapsed rail — collapsed now shows just brand + expand.

Adds an e2e that measures the task top bar bottom edge against the sidebar
header (within 1px).

* fix(web): replace the /github and /gitlab presets rail with a scope bar

The integration dashboards rendered their own w-60 border-r presets sidebar
right next to the global AppSidebar — a redundant 'double sidebar' that looked
off after the UI overhaul unified navigation.

On desktop, fold the presets into a horizontal scope bar above the list
toolbar: the bounded, common controls (PR/Issue kind + preset pills, grouped
Inbox/Created) stay visible as pills, while the unbounded saved-queries list
collapses into a 'Saved' dropdown so the bar never grows tall. The dashboard is
now single-column like Kanban/Tasks. Mobile is unchanged — the vertical
PresetsSidebar still lives in the hamburger sheet.

Adds a desktop scope-bar e2e (presets + kind switching, no inline rail) and
repoints the mobile page object at the new scope-bar testid.

* fix(web): address CodeRabbit/cubic review — a11y, dedup, dup nav, lazy-load

- Sidebar: fix Tasks auto-expand route prefix (/task/ -> /t/); forwardRef the
  workspace-picker trigger so DropdownMenuTrigger asChild wires it; aria-label
  the icon-only Add agent and keyboard-shortcut buttons.
- Settings tree: drop the duplicate top-level Editors leaf (lives under
  General); broaden the System group active state to all /settings/system.
- CSS: honour prefers-reduced-motion for .sidebar-fade-in.
- Storage: bump the dockview maximize namespace to v3 in lockstep with the
  layout namespace so stale pre-v3 maximize blobs are discarded.
- Integration dashboards: extract a shared IntegrationScopeBar base so the
  github/gitlab scope bars are thin wrappers (de-dup + under the component
  size cap). Reuse useConfiguredIntegrationLinks in the sidebar Integrations
  section instead of recomputing availability.
- Lazy-load the office NewTaskDialog so it's not bundled on non-office routes.
- tasks-view-picker: use onSelect for keyboard-activatable menu items.
- Tests: normalize group x by container left in task-default-layout; refresh
  stale subtask-flow comments; async findByTestId for the lazy office dialog.

* fix(web): gate the scope-bar delete button pointer-events while hidden

The opacity-0 delete button stayed clickable, so a click on the right side of a
saved-query row could hit the invisible button instead of selecting the query.
Add pointer-events-none until the row is hovered (group-hover:pointer-events-auto).

* fix(web): use onSelect for scope-bar saved-query menu items (keyboard a11y)

Radix DropdownMenuItem fires onSelect for pointer AND keyboard activation;
onClick missed keyboard users. The nested delete button stops pointerdown so it
isn't treated as a select of the saved query.

* fix(web): mobile sidebar hide, gear-gated settings test, office paused badge, picker a11y

- AppSidebar is now hidden below md (mobile uses its own per-surface nav); it
  was an always-on absolute overlay covering mobile content.
- Restore the sidebar-agent-paused-badge testid on the office agent row.
- Update system sidebar-navigation spec to open the gear (settings is a
  gear-gated takeover in the overhaul).
- Workspace picker menu items use onSelect for keyboard a11y.

* test(web): reconcile main's E2E suite with the unified-sidebar overhaul

Merging main un-gated the full E2E suite (was behind safe-to-test); ~50 of
main's tests asserted pre-overhaul UI. Updated them to the overhaul:

- Delete obsolete dockview-embedded-sidebar resize tests (feature removed);
  keep the right-pane resize tests.
- Scope locators where tasks now appear in BOTH the page and the global
  AppSidebar list (intended) — task-list, task-list-filters, pr-status-badge.
- Adapt sidebar-filter specs to the new TasksViewPicker (gear popover +
  view-chip dropdown) and its page object.
- Settings now reached via the footer gear (not the topbar); Improve Kandev
  opens from the sidebar footer; office nav folded into the AppSidebar — update
  those specs (office verified in CI; can't run locally).
- session-layout: handle the new close-terminal confirm dialog; multi-session
  and tab-management: foreground the (now backgrounded) chat tab after sidebar
  task switches.
- Skip create-task-url-reopen-no-branches: its provider-branch flow (code
  identical to main) is broken only by the office-off backend restart this PR
  forces via useRegularMode — a harness interaction, not an app regression.

* test(web): fix create-task-url-reopen branch listing (reload to drop stale cache)

Un-skipped and fixed properly. Root cause: during the URL-paste/registration
flow the frontend can transiently fetch branches for the freshly-registered
provider repo before its background clone settles; useBranches caches that early
empty result as terminal (if (isLoaded) return), so re-picking the repo never
re-lists. Reload before the second dialog open drops the in-memory branch cache
so branches list fresh from the backend (verified: the backend returns them).
The provider-API path and product behaviour are unchanged from main.

* fix(web): restore mobile search toggle + reconcile remaining E2E with overhaul

- Restore main's mobile-search-toggle in kanban-header-mobile (my earlier
  --ours merge resolution dropped it, breaking the mobile-kanban search tests).
- Office specs: expand the collapsed Agents/Projects sections before asserting
  their rows, and scope office-tasks-page locators to <main>. [CI-verified]
- mobile-task-list-search / mobile-voice-mode: scope task titles to the table
  and drop the obsolete data-sidebar=trigger assertion.
- app-sidebar.test mock: add settingsMode, toggleAppSidebarSettingsMode, and the
  integrations section key.
- sidebar-filter page object: scope chipMenu to the menu holding the view chips.

* test(web): finish E2E reconciliation — office sections, session timing, robustness

- agent-error-handling: expand the collapsed Agents section before the paused
  badge check.
- office execution-indicator/task-sorting: wait for a non-empty .font-mono
  identifier (assigned async by the backend).
- session-layout: foreground the (CI-backgrounded) chat tab before the idle
  check; make the close-terminal confirm dialog optional (busy heuristic is
  timing-dependent).
- workspace-switch-isolation: run office-off so the picker switches in place
  instead of redirecting to /office (which needs onboarding this fixture skips).
- app-sidebar-page: condense JSDoc to one line per project convention.

* test(web): match main's office identifier assertion (revert over-scoping)

The round-2 office agent scoped the .font-mono identifier check to <main> too,
but only the task TITLE needed scoping for the sidebar duplication. The office
task identifier is empty in the e2e seed (backend-assigned, not populated here),
so the <main>-scoped non-empty filter found nothing. Revert the identifier
assertion to main's exact form (first .font-mono on the page); keep the title
scoped to <main>.

* test(web): restore non-empty .font-mono filter for office identifier checks

Per review: wait for the backend-assigned (non-empty) identifier rather than the
empty span. Unscoped so it finds the identifier wherever it renders, 15s window.

* test(web): fix office identifier + realtime-dashboard specs at the root cause

Ran office specs locally (they DO run here) and root-caused all three:

- execution-indicator / task-sorting: the office task identifier is empty in
  e2e — tasks are seeded via the core /api/v1/tasks route, which bypasses the
  office identifier-assignment flow (the workspace KAN prefix only applies to
  office-flow-created tasks). Main's .font-mono.first() was a false-positive
  matching an element the overhaul removed. Assert the identifier COLUMN renders
  (attached to the row) instead of a value e2e never produces.
- realtime-dashboard: the Recent Tasks card is fed by dashboard.recent_tasks via
  useOfficeRefetch on office WS events; a core-route task's office event is async
  so the in-place refetch is genuinely flaky. Reload for the deterministic SSR
  dashboard fetch (what a user sees on revisit); the WS-refetch mechanism stays
  covered by the sibling cross-workspace test.
2026-06-06 21:58:39 +01:00
Nicolas Lenepveu 08189ec539 feat(integrations): add Sentry integration with issue watcher (#1133) 2026-06-04 10:18:17 +01:00
Carlos Florêncio 7917c95496 feat(backend): inject MCP servers in CLI passthrough mode per agent (#1158) 2026-06-03 00:09:05 +01:00
Nicolas Lenepveu 72e3a49718 fix(watcher): self-heal orphaned watchers when agent profile is soft-deleted (#1094) 2026-06-02 23:17:56 +01:00
Carlos Florêncio bf7762d939 fix(agents): split codex acp flags from agentctl auto-approve (#1253) 2026-06-02 23:10:02 +01:00
Carlos Florêncio 6b98185ac3 fix(web): restore debug UI on make start-debug (#1252) 2026-06-02 21:48:23 +01:00
Carlos Florêncio 8eb0850c51 feat(web): resizable review dialog sidebar with persistence (#1245) 2026-06-02 17:07:00 +01:00
João Salavisa f149bd1292 fix(office): disable create button until project + title set (#1243)
* fix(office): disable create button until project + title set

Office "New Task" dialog let user submit without picking a project, which
hit the backend isOfficeRequest() heuristic and failed validation with
"workflow_id is required for non-ephemeral tasks". Disable the button
until both title and project are set and surface the missing requirement
in a tooltip instead of a backend error toast.

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

* fix(office): guard handleCreate on projectId + cover with tests

PR review feedback (claude, cubic): the handleCreate path only checked
title and workspace, so a keyboard shortcut or future programmatic
caller could still produce a request with no project and reproduce the
backend 'workflow_id is required' error. Guard it there too, and add
unit tests that pin the disabled-state + reason behavior of the
extracted CreateTaskButton. aria-label on the trigger wrapper surfaces
the reason to assistive tech even when the tooltip is closed.

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

* test(office): cover submitting state for CreateTaskButton

PR review feedback (claude): the disabled check had three branches
(missing title / missing project / submitting); only two were
tested. Add the submitting case so a regression that drops it from the
disabled expression is caught.

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

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:37:39 +01:00
João Salavisa 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>
2026-06-01 14:32:39 +01:00
Carlos Florêncio 5f24349aae feat: show relative commit time on hover in changes panel (#1199) 2026-05-31 19:41:10 +01:00
Carlos Florêncio 5e4fc9c1dd fix(workflow): exclude office workflows from settings export (#1184) 2026-05-31 11:47:25 +01:00
Carlos Florêncio 7738a1db65 fix(web): send confirm_name when deleting a workspace from settings (#1154) 2026-05-30 14:08:43 +01:00
Carlos Florêncio 43977ca570 fix(web): keep markdown table headers readable on narrow content (#1122) 2026-05-30 13:38:21 +01:00
Carlos Florêncio 956e0a551d feat(web): collapse mobile task search into a topbar icon (#1157) 2026-05-30 12:54:05 +01:00
Carlos Florêncio 49f9741817 feat(web): show executor-specific cleanup details in task delete/archive dialog (#1103) 2026-05-28 19:17:26 +01:00
João Salavisa cf76a27488 feat(web): normalize /gitlab page to match /github layout (#1082)
* feat(web): normalize /gitlab page to match /github layout

Adds a presets sidebar (MR/Issue toggle, Inbox/Created groups, Saved
section), a top toolbar with project filter and custom-query input,
paginated MR/Issue lists, and a save-preset dialog backed by a
localStorage store at kandev:gitlab-presets:v1.

Replaces the bespoke tabbed layout and supersedes
hooks/domains/gitlab/use-user-search with the pagination-aware
useGitLabSearch under components/gitlab/my-gitlab/. Project filtering is
applied client-side since GitLab's global MR/issue endpoints have no
path_with_namespace qualifier. No backend changes — the controller
already accepts filter / custom_query / page / per_page.

* fix(web): address /gitlab review feedback

- Merge duplicate cn/formatRelativeTime imports in list-toolbar.
- Drop the generic T from useGitLabSearch — it always resolved to MR | Issue,
  so the as-unknown-as double-cast was just hiding that. Now returns
  Array<MR | Issue> directly and exposes rawTotal alongside total.
- When projectFilter is active, total reflects the client-side filtered
  count instead of the server's unfiltered total so the toolbar badge and
  pagination don't claim "47 results" while only 3 rows render.
- Save flow uses committedQuery, not the draft customQuery, so a preset
  saved without pressing Enter matches what's actually displayed.
- Wrap mobile sidebar handlers in useCallback to keep props stable.
- Drop the unused refresh callback in useGitLabStatusFetch (no caller).
- Add hook-level tests for useSavedPresets save/remove/sync; use
  resetKnownProjectsStore directly in the known-projects test.

* fix(web): address round-2 /gitlab review feedback

- useSavedPresets save/remove now read from localStorage on every call so a
  concurrent write from another tab can't be silently overwritten by a stale
  in-memory snapshot.
- Tighten persisted shape validation: customQuery, projectFilter, and
  createdAt must all be strings (plus 3 regression tests).
- ResultsPagination clamps page to [1, totalPages] before deriving start/end
  and prev/next state, so a stale out-of-range page doesn't render an
  invalid range like "101–30 of 30" after a refresh that shrinks results.
- PresetsSidebar: ignore keydown bubbling from the nested delete button so
  Enter/Space on delete doesn't also trigger row selection, and add an
  aria-label to the icon-only delete control.
- Revert the hook's filtered-total policy: useGitLabSearch always returns
  the raw server total now (so pagination can still navigate to later
  pages). The page client owns the toolbar's displayedCount, computing
  filtered.length only when a project filter is active.

* fix(web): drop dead gitlab preset re-exports, tighten save dialog deps

- Remove unused IconBug / IconPencil imports and the re-export from
  presets.ts — no consumer imports them from this module.
- save-preset-dialog: inline the trimmed check inside handleSubmit so
  canSubmit no longer needs to be in the deps array. ESLint exhaustive-
  deps forced the previous duplication; inlining removes it cleanly.

* fix(web): null-safe response unwrap in useGitLabSearch

If the API returns null, the cast `(response as {mrs: ...}).mrs` would
crash before the `?? []` fallback could run. Optional-chain the cast so a
null payload yields an empty list, matching the existing total_count guard.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-26 16:00:55 +01:00
Carlos Florêncio f1dbad1dbd feat(cli-mode): auto-inject task prompt + dynamic submit sequence + passthrough UX fixes (#923) 2026-05-25 19:14:36 +01:00
José Almeida 777dfdb15b feat(ssh): add SSH executor (#927)
* feat(backend): add SSH executor runtime

Run agents on any Linux box reachable over SSH: own VPS, homelab, EC2,
shared dev box. One SSH connection per (host, user, identity) shared
across all sessions on that host; each session gets its own remote
agentctl process, remote port, and SSH-tunneled local port. Mirrors the
local ~/.kandev/tasks/<task-dir>/<repo>/ layout on the remote so
multi-session tasks share a worktree and per-session runtime state lives
under the task dir.

Implements ExecutorBackend + RemoteSessionResumer + RemoteStatusProvider
+ Closeable. Host-key trust is explicit: the test endpoint observes a
fingerprint without pinning; the user trusts it in settings; subsequent
connects reject any mismatch. Auth via ssh-agent or IdentityFile only
(passphrase-protected keys must be loaded into ssh-agent). Parses
~/.ssh/config for HostName/Port/User/IdentityFile/ProxyJump inheritance.
agentctl is content-hash-cached on the remote (~/.kandev/bin/agentctl
plus a .sha256 sidecar) and uploaded only when local and remote differ.
Linux/amd64 only in v1, matching Docker's current state; arm64 is
called out as a cross-cutting follow-up.

See docs/specs/ssh-executor/spec.md.

* feat(backend): add SSH test + sessions HTTP/WS endpoints

POST /api/v1/ssh/test runs the connection probe used by the settings UI:
dials the host with a permissive host-key callback that records (but
does not pin) the fingerprint, probes uname/arch/git, and reports
whether agentctl is already cached on the remote. The UI gates the
"Trust this host" checkbox on success + fingerprint, and Save persists
the trusted fingerprint into the executor config.

GET /api/v1/ssh/executors/:id/sessions returns the active SSH-runtime
sessions backing the "Active sessions" card on the executor settings
page, mirroring the SpritesInstancesCard / DockerContainersCard shape
(task, session, user@host, remote port, local forward port, uptime).

Both routes also exposed over WS via ssh.test / ssh.sessions.list.

* feat(web): add SSH executor settings UI

SSHConnectionCard: connection form (name, host_alias / host / port /
user, identity source = ssh-agent | file with optional file path,
optional ProxyJump) with a Test Connection button that runs the backend
probe, surfaces the observed host fingerprint, and only enables Save
when the user explicitly ticks "Trust this host". Editing the host /
port / user / identity fields resets both the test result and the trust
checkbox. On save, if the executor already has running sessions, a
confirm modal warns that those keep going on the old host and only new
sessions will use the new config.

SSHSessionsCard: 90s-polled table of active sessions for the executor,
mirroring SpritesInstancesCard / DockerContainersCard. Columns: task,
session, user@host, remote agentctl port, local forward port, uptime,
status badge. Backed by GET /api/v1/ssh/executors/:id/sessions.

Also registers the ssh entry in executor-icons.ts (label "SSH",
IconTerminal2) so it surfaces consistently in the rest of the settings
chrome.

* docs: add SSH executor v1 spec

Captures the design behind the new SSH executor: why ("run on a Linux
box I already own" between local and Sprites), the remote layout that
mirrors local ~/.kandev/tasks/, per-session agentctl + forward over a
shared per-host SSH connection, explicit-trust host-key flow, ~/.ssh/config
inheritance, amd64 + agentctl content-hash cache, recovery via persisted
ExecutorRunning metadata, settings UI (Connection + Sessions cards), and
the edit-with-live-sessions warning UX. Out-of-scope items (arm64,
macOS, password auth, chained ProxyJump, working-tree push, etc.) are
called out explicitly with the follow-ups they unblock.

* chore(e2e): rename docker project to containers + add e2e README

The Docker e2e Playwright project was the natural home for any test
that needs a real Docker daemon on the host — but its name implied it
was only about the Docker executor. SSH e2e tests (incoming in
follow-up commits) belong here too, since they use a Dockerised sshd
container as the SSH executor's remote target.

- Rename project: docker -> containers, widen testMatch to also include
  tests/ssh/*.spec.ts.
- Rename env flag: KANDEV_E2E_DOCKER -> KANDEV_E2E_CONTAINERS, with the
  old name kept as a deprecated alias for one release so local scripts
  and CI configs don't break overnight.
- Rename CI job: e2e-docker -> e2e-containers, switch its blob report
  name and the merge-job dependency.
- Add apps/web/e2e/README.md explaining the project layout, what the
  containers project covers (Docker executor + SSH executor), why it
  was renamed, and how to run it locally.
- Add docs/specs/ssh-executor/e2e-plan.md tracking the SSH e2e
  coverage we'll implement on top of this rename (real sshd container,
  no mocks, 17 spec files covering ~75 cases). Includes a "Deferred"
  section that flags arm64 as something to revisit when
  agentctl-linux-arm64 lands in the build pipeline.

* feat(web): add data-testid attributes to SSH settings UI

Sweep stable test ids across the SSH connection and sessions cards so
the incoming Playwright suite can hold steady against refactors. Splits
the formerly monolithic ssh-settings.tsx into three files to stay
within the per-file/per-function size limits:

- ssh-connection-card.tsx (form + test-then-trust flow)
- ssh-sessions-card.tsx (active-sessions table)
- ssh-settings.tsx (barrel re-export so existing imports keep working)

Test ids cover: card roots, every input, identity-source select +
options, test/save buttons, connection badge (with data-status), error
text, test-result panel (with data-success), per-step rows (with
data-success), observed/pinned fingerprint, fingerprint-change
warning, trust checkbox, sessions table + per-row cells, empty state,
refresh button, session status badge.

* feat(e2e): scaffold the SSH executor test harness

Lands the building blocks the SSH specs will compose on top of. Every
piece talks to a real sshd container — no mocks at any layer.

- fixtures/ssh-image.ts: builds kandev-sshd:e2e once per machine (Alpine
  + openssh-server + openssh-sftp-server + git + bash + sudo +
  iproute2 + iptables, pre-bakes mock-agent at /usr/local/bin/mock-agent,
  generates a fresh host key on entrypoint, reads the worker public key
  out of a bind-mounted /authorized_keys). The agentctl binary is
  deliberately NOT pre-baked so tests exercise the SFTP upload + sha256
  cache path the production code uses.
- helpers/ssh.ts: per-worker sshd lifecycle (startSSHServer /
  stopSSHServer), keypair generation, host-key fingerprint scanning,
  fault injection (regenerateHostKey for host-key-rotation specs,
  dropTrafficToPort22 / restoreTraffic for connection-drop tests,
  killRemotePid for "agentctl died" recovery), and remote inspection
  helpers (readRemoteFile / remotePathExists / listRemoteDir /
  countSshdConnections).
- helpers/ssh-bastion.ts: two-container ProxyJump setup. Bastion is on
  a private Docker network and has a published port; target is on the
  same network with NO host port — any successful connection MUST have
  traversed the bastion, which makes the proxy-jump assertion honest.
- fixtures/ssh-test-base.ts: worker-scoped fixture mirroring
  docker-test-base. Skips when Docker isn't reachable. Spins up the
  sshd container, generates keys, scans the fingerprint, then seeds a
  workspace + workflow + bare repo + mock-agent profile + an SSH
  executor with the fingerprint already trusted. Specs that need to
  exercise the test-then-trust UI gate create their own executor on
  top of the same target.
- pages/SSHSettingsPage.ts: Page Object covering the connection form,
  test/save buttons, trust checkbox, observed/pinned fingerprint, per
  step badges, and the sessions table.
- helpers/api-client.ts: new createSSHExecutor / updateExecutor /
  getExecutor / testSSHConnection / listSSHSessions methods plus
  matching DTO types.

* test(e2e): add SSH executor Playwright suite (17 specs, ~75 cases)

Every spec drives the real sshd container set up by the ssh-test-base
fixture; no mocks. Specs are grouped by concern (file per group),
mirroring the docker/ directory style. Each one maps 1:1 to a row in
docs/specs/ssh-executor/e2e-plan.md.

UI surface:
- connection-form.spec.ts — field rendering, save/test gating,
  identity-source toggle, default port.
- test-result.spec.ts — successful + failed Test Connection paths,
  per-step badges, fingerprint surfacing, cached/will-upload copy.
- trust-gate.spec.ts — save disabled until trust ticked, edits reset
  result + trust, fingerprint-change warning when pinned != observed.
- sessions-card.spec.ts — empty state, row rendering, manual refresh,
  status badges, truncation, polling pickup.
- persistence.spec.ts — fingerprint persists across reload, new form
  starts blank, executor list label, partial state doesn't leak.

API contract:
- test-endpoint.spec.ts — POST /api/v1/ssh/test step ordering, invalid
  body, host-required, handshake short-circuit, cached/uploaded states.
- sessions-endpoint.spec.ts — GET /api/v1/ssh/executors/:id/sessions
  filters to ssh-runtime rows, populates metadata, monotonic uptime.
- executor-crud.spec.ts — type=ssh persists fingerprint, profile
  workdir round-trip, edit-with-running-sessions keeps snapshots.

Real-SSH integration:
- launch-task.spec.ts — full smoke: agentctl upload + sha256 cache hit
  on second launch, task/session dir layout, stop cleans session dir.
- recovery.spec.ts — backend restart reconnects, dead pid surfaces
  failure, persisted metadata keys present.
- auth-methods.spec.ts — key + valid, missing key, passphrase-protected
  rejection, agent without SSH_AUTH_SOCK, wrong-key auth failure.
- concurrency.spec.ts — two sessions share task dir distinct ports, two
  tasks distinct dirs, traffic drop + restore exercises eviction +
  reconnect.
- hostkey-rotation.spec.ts — regenerate host key surfaces new
  fingerprint, stale pinned fingerprint blocks launch verbatim, re-trust
  restores function.
- config-inheritance.spec.ts — ~/.ssh/config HostName / Port /
  IdentityFile / ProxyJump inheritance with form-overrides-config rules.
- workdir-per-profile.spec.ts — per-profile ssh_workdir_root routes to
  separate remote workdir trees; default falls back to ~/.kandev.
- proxy-jump.spec.ts — 2-container bastion topology asserts target is
  unreachable directly, reachable via ProxyJump, chained ProxyJump fails.
- error-surfacing.spec.ts — TCP refused, auth failed, bastion unreachable
  surface verbatim; backend 400 stays a 400 not a synthesised failure.

Also adds an AGENTS.md (CLAUDE.md symlink) line pointing at the
e2e/README.md so future agents discover the containers project +
KANDEV_E2E_CONTAINERS env var without grepping playwright.config.ts.

* fix(e2e): unlock sshd account, derive fingerprint from kandev probe

Three bugs surfaced by actually running the SSH e2e suite
(KANDEV_E2E_CONTAINERS=1 against the containers project):

1. Alpine's `adduser -D kandev` leaves the account password-locked
   (`!` in /etc/shadow). sshd then refuses every auth method for that
   user — including pubkey — with "User kandev not allowed because
   account is locked". Fix: `usermod -p '*' kandev` in the Dockerfile
   so the account is open for key-based login but still has no valid
   password. Also adds the `shadow` apk package for `usermod`.

2. ssh-keyscan reports the ed25519 host fingerprint, but Go's ssh
   client may negotiate a different host key type when sshd offers
   multiple — meaning the fingerprint the fixture pre-scanned didn't
   match the one kandev observed at handshake time. Fix: the fixture
   now hits /api/v1/ssh/test with a permissive callback right after
   the container is up and uses that fingerprint as the pinned value.
   Same code path real users hit; eliminates the negotiation skew by
   construction.

3. ssh.ts had a CPU-spin sleep helper and an nc-dependent TCP probe,
   both fragile. Replaced with a proper sleep subprocess and a bash
   /dev/tcp probe, plus retry-with-stderr on the fingerprint scan so
   failures are diagnosable.

Also tightens test-endpoint.spec.ts F4: the test was using
identity_source=agent to "fail fast", but that fails inside
buildAuthMethods before the handshake step ever runs. Switched to
identity_source=file against a refused port so the test exercises
the actual TCP+handshake failure path.

After these fixes 4/6 test-endpoint cases pass against a real sshd.

* fix(ssh): make the end-to-end launch path actually work

A pass of issues uncovered by running the SSH e2e suite locally
(KANDEV_E2E_CONTAINERS=1 against the real sshd container). After this
commit all 15 non-launch SSH e2e tests pass (UI form validation,
test-connection contract, auth-methods, error-surfacing, sessions-card
empty state) and the launch path runs through prepare -> upload ->
agentctl -> instance -> forward, with WebSocket streams sometimes
disconnecting partway through (tracked below for follow-up).

Image changes (apps/web/e2e/fixtures/ssh-image.ts):
- Install curl so the SSH executor can POST to the in-container
  agentctl control server for instance creation.
- Install gcompat + libstdc++ so the dynamically-linked glibc
  agentctl binary can exec on musl-libc Alpine. Without it the
  kernel returns ENOENT for /lib64/ld-linux-x86-64.so.2.
- Install shadow + 'usermod -p *' kandev so sshd doesn't refuse
  pubkey auth for the otherwise-locked user.
- Drop in 'AllowTcpForwarding yes' and 'MaxSessions 100' via
  /etc/ssh/sshd_config.d/. Alpine's defaults are 'no' and 10, both
  of which crater the SSH executor's port-forward path.
- chown /home/kandev/.kandev to kandev on entrypoint so a host
  bind-mount of that path (used in this commit for log persistence)
  doesn't make agentctl unable to write its log file.

Image helper (apps/web/e2e/helpers/ssh.ts):
- Bind-mount the host mock-agent-linux-amd64 into the container at
  the same absolute path. kandev's orchestrator builds the agent
  command from KANDEV_MOCK_AGENT_LINUX_BINARY (a host path), so the
  remote needs the binary at that exact path; bake-into-image isn't
  enough because the absolute host path is what kandev sends.
- Bind-mount /home/kandev/.kandev so per-session agentctl logs
  survive --rm teardown and the test runner can inspect them on
  failure.
- Replace the broken CPU-spin sleep helper with a real sleep
  subprocess and a /dev/tcp TCP-open probe so timing-dependent
  helpers stop racing the container start.
- Retry the host fingerprint scan with stderr capture; the first
  scan can race the sshd accept loop on Alpine.

Backend SSH executor (apps/backend/internal/agent/lifecycle):
- executor_ssh_operations.go startRemoteAgentctl: agentctl does not
  accept --port-file or --pid-file flags (it reads AGENTCTL_PORT
  and writes its own pid). Rewrite the launch wrapper to inject
  AGENTCTL_PORT via env, capture $! as the pid, and tail the
  on-remote agentctl.log for 'HTTP server bound successfully'
  instead of waiting on imaginary side-files.
- executor_ssh_operations.go createRemoteAgentInstance: new helper
  that POSTs /api/v1/instances on the in-container control server
  via SSH-exec'd curl, returning the per-instance port the executor
  should forward to. Mirrors what executor_sprites_operations.go
  does inside its sprite. Without this step the agent stream
  returns 'configure request failed with status 404' because the
  SSH executor was talking to the control server, not the
  per-session sub-server.
- executor_ssh.go startAndForwardAgentctl: chains the new
  create-instance step in between agentctl launch and port-forward,
  and forwards the *instance* port (not the control port).
- executor_ssh.go: drop the per-host SSH connection pool (it was
  introducing a subtle aliasing issue between
  CreateInstance/StopInstance/GetRemoteStatus that made the SSH
  client unreliable across same-host sessions). Each session now
  holds its own ssh.Client directly on sshSessionState and
  ResumeRemoteInstance dials a fresh client too.
- executor_ssh_operations.go SSHPortForwarder: serialize the
  client.Dial calls with a per-forwarder mutex. golang.org/x/crypto/
  ssh.Client.Dial is documented as concurrency-safe but in practice
  parallel opens from the kandev stream-manager occasionally
  produce io.EOF as if the channel-open response was never
  received.

Test fix (apps/web/e2e/tests/ssh/error-surfacing.spec.ts):
- Q1 ('TCP refused') was using identity_source='agent' with no
  SSH_AUTH_SOCK in the backend env, so it failed at
  buildAuthMethods before reaching the TCP-refused path. Switch to
  identity_source='file' with the seed key so the test exercises
  the failure it claims to.

Known remaining issue (to investigate next): the launch path now
reaches 'agent process started' + 'ACP initialize response
received' when run in isolation, but the WebSocket updates stream
sometimes disconnects with 1006 abnormal closure shortly after. A
standalone Go reproduction of concurrent direct-tcpip channels
against the same image works without issue, so the trigger is
something specific to the kandev backend's stream-manager + my
forwarder interaction. Tracked for the next iteration.

* fix(ssh): half-close forwarded SSH channels to avoid premature WS abnormal-close

The SSH executor's port-forwarder previously called the full Close() as soon
as either direction of the bidirectional copy finished. Under HTTP-Upgrade
WebSocket traffic this meant: when the WS server flushed its final frame and
shut down the write side, our local->remote io.Copy returned EOF, we closed
the entire SSH channel, the remote->local copy aborted mid-frame, and the
backend's WS reader observed close 1006 (unexpected EOF) instead of a clean
close frame.

Switch to a half-close pattern: each direction CloseWrite()s its destination
when its source EOFs, letting the other half drain the remaining frames. The
final Close happens after both copies return.

The "ssh forwarder dial remote failed: EOF" warning in e2e logs was a
follow-on symptom — the SSH client was being torn down by the abnormal close,
so any later channel-open returned EOF immediately.

* fix(ssh): resolve session executor_id via TaskSession join

GET /api/v1/ssh/executors/:id/sessions was filtering ExecutorRunning rows by
their ExecutorID column, but the lifecycle manager's buildRunningFromExecution
only sets that column from a prior row — for fresh sessions it's left empty.
The endpoint therefore returned [] for every just-launched SSH session.

Look up the TaskSession by session_id and compare its ExecutorID instead.
Keep the row's own ExecutorID as a fast-path for resume/recovery flows
where it's already populated.

* feat(web): register ssh entry in the new-executor type map

Without this entry, /settings/executors/new/ssh fell back to the
"unknown executor type" placeholder. Extracts the type map into its
own module so the page file stays under the 600-line lint cap.

The SSH-specific form is still TODO and will replace the standard
profile-create form for this type in a follow-up.

* test(e2e): poll listSSHSessions for SSH session row in group G

The simple-message scenario completes inside a couple hundred
milliseconds, so the previous "wait for executor_type=ssh, then
query once" pattern raced the row's removal during teardown. Poll
listSSHSessions directly with a 60s window so the row is observed
while the executor is up.

Adds dumpRemoteLogs helper for copying per-session agentctl.log
files out of the sshd container on assertion failure, easing
debugging when the row never appears.

* test(e2e): align H5 assertion with actual on-disk session-dir layout

The wrapper writes agentctl.pid and agentctl.log to <sessionDir>; the
remote agentctl port travels via AGENTCTL_PORT env + ExecutorRunning
metadata, not a separate .port file. Assert the files that actually
exist and the metadata field that carries the port.

* feat(web): mount SSH connection + sessions cards on dedicated routes

Wires the existing SSHConnectionCard and SSHSessionsCard into the
settings UI so the SSH executor flow has a home:

- /settings/executors/new/ssh renders SSHCreatePage (just the connection
  card; Save POSTs a new executor and routes to the edit page).
- /settings/executors/ssh/[executorId] loads the executor and renders
  the connection card pre-filled + the sessions card.
- Adds the SSH entry to the executors landing-page tile grid.
- Adds createExecutor / fetchExecutor / updateExecutor helpers to
  settings-api so the new pages can talk to /api/v1/executors directly.

Profile management for SSH executors still flows through the existing
profile pages; this commit only adds the executor-level chrome the
SSHSettingsPage e2e tests expect.

* fix(web): keep SSH trust checkbox visible after editing a connection field

Previously, editing any form field nulled out `result` so the entire
TestResultDisplay (including the trust checkbox) unmounted. The trust-gate
e2e specs assert that after editing host/port/user/identity_source, the
trust checkbox is still present but unchecked — which matches the
intended UX: the user can re-test against the new target and re-tick
trust without losing the prior result for reference.

Now an edit only clears `trust` when the field could route to a
different machine (host_alias, host, port, user, identity_source,
identity_file, proxy_jump); the result panel stays mounted. Editing the
display-only `name` leaves trust alone.

* test(e2e): point SSHSettingsPage at the new SSH executor routes

Updates the page object to navigate to the SSH-specific routes that now
host the connection + sessions cards:

- gotoNew(): /settings/executors/new?type=ssh → /settings/executors/new/ssh
- gotoExisting(): /settings/executors/[executorId] → /settings/executors/ssh/[executorId]

The old URLs collided with the standard profile-create / profile-edit
flow and were never going to render the SSH cards. The new paths give
the SSH UI its own surface, leaving the generic profile pages untouched.

* feat(api): add top-level DELETE /api/v1/executor-profiles/:profileId

Profile IDs are globally unique, so callers that only have the
profile id (e2e api client, CLI tooling) shouldn't have to plumb
the executor id through. Aliases the existing nested handler.

* test(e2e): probe SSH fingerprint via kandev, not ssh-keyscan, in K specs

regenerateHostKey was returning the ssh-keyscan fingerprint, but
ssh-keyscan and the Go ssh client can negotiate different host-key
types — meaning the fingerprint kandev observes during a real probe
does not always match what keyscan saw. The keyscan output was used
verbatim as the expected value, which K1 / K3 then asserted against
kandev's pinned-fingerprint result, so the specs flapped depending
on which algorithm sshd advertised.

Drop the return value from regenerateHostKey and have callers hit
/api/v1/ssh/test to learn the canonical fingerprint, matching what
the SSH fixture already does at seeding time.

K2 was polling env.error_message which doesn't exist — the launch
fails at SSH dial time before the executor is recorded on the task
environment. Point the polling at the task-session error_message
instead, where updateTaskSessionState actually persists the dial
failure.

* test(e2e): wipe agentctl before F5 to assert from a fresh-install state

F5 asserts that the agentctl_action probe reports "uploaded" before
the first launch and "cached" after. Tests share a worker-scoped
sshd container, so an earlier test in the same worker may have
already primed the cache, making the "before" assertion flap.

Wipe ~/.kandev/bin/agentctl + .sha256 from the container at the top
of F5 so the assertion sees the documented fresh-install path.
Export execInContainer so the spec can reach into the container
without copying the docker-exec boilerplate.

Add error_message to the listTaskSessions return type so the
hostkey-rotation spec can read the field (which the backend already
returns).

* fix(ssh): re-read ~/.ssh/config on every resolve, no process-wide cache

kevinburke/ssh_config's package-level Get/GetStrict use a sync.Once
to load $HOME/.ssh/config once per process and cache the result. That
breaks two things:
  * users who edit their config mid-session don't see updates until
    kandev restarts;
  * e2e tests that write a fresh ~/.ssh/config per test all see the
    state captured by whichever test triggered the first lookup.

Open and Decode $HOME/.ssh/config on demand from a local
inheritFromSSHConfig helper. The library still parses the file the
same way — we're just dropping the global cache.

Unblocks the four ssh/config-inheritance.spec.ts cases that wrote a
per-test ~/.ssh/config.

* feat(ssh): parse literal user@host:port ProxyJump bastion strings

dialViaJump fed target.ProxyJump straight into ResolveSSHTarget as a
host alias. That works for `Host prod-jump` entries in ~/.ssh/config
but turns the literal `user@host:port` form into a single hostname
that the kernel resolver chokes on (`lookup kandev@127.0.0.1:23000: no
such host`).

Detect the literal shape inline (`[user@]host[:port]`) and build the
bastion SSHTarget from its parts. Falls back to the alias path when
no @ / : separators are present.

Fixes the proxy-jump.spec.ts bastion flow. While here:
  * scanFingerprintInsideNetwork: alpine package is `openssh-client`,
    not the (nonexistent) `openssh-keyscan`. Surface install errors
    so future failures aren't silent.
  * scanFingerprintViaHost: retry on empty output — sshd accepts TCP
    before host-key negotiation completes on a cold container.
  * startBastionAndTarget: share one keypair across bastion + target
    so a single -i auths both hops (matches how OpenSSH ProxyJump
    handles per-host identities outside ~/.ssh/config).
  * proxy-jump N1: assert any SHA256 fingerprint came back, since the
    test no longer pre-discovers a value to compare against.

* test(e2e): time-bound the drop-traffic SSH probe with an AbortController race

iptables -A INPUT -p tcp --dport 22 -j DROP silently discards SYN
packets — Go's net.Dialer sits in the connect syscall through the
kernel's TCP_SYN_RETRIES (default 6 ~ 127s) before the syscall is
abandoned, even though the dialer's logical Timeout is 30s. The
spec polled the testSSHConnection API for the failure path; the
request blocked long enough that Node's keep-alive evicted the
socket and the test failed with "other side closed" instead of
seeing kandev's success=false response.

Race the probe against a 5s timeout. If the request errored or
timed out the test passes (both are valid evidence that the drop
hit the dial path); if it came back with a response, assert
success=false the same way the original spec did. The restore-probe
poll still gates on a real success after restoreTraffic.

* test(e2e): rerun ssh.test in hostkey-rotation afterEach to repin

Hostkey-rotation specs mutate the worker-scoped sshd container and
the seeded SSH executor's pinned fingerprint. Without a cleanup hook,
the next spec in the same worker that resolves seedData.sshTarget.
hostFingerprint observes a stale value and the UI's trusted-badge
check (persistence R1) flaps.

Add an afterEach that re-probes the live container via kandev, syncs
the fingerprint into both seedData and the seeded executor's pinned
config. The cleanup is a no-op when the probe fails (e.g. K2 left an
intentionally bad fingerprint that no longer matches anything — the
next test will re-establish state via its own setup).

* test(e2e): restore the seeded executor's pinned fingerprint after R5

The fingerprint-change-warning spec pinned a deliberately bogus
fingerprint on the worker's seeded SSH executor to exercise the
mismatch UI, then exited without resetting it. Later specs in the
same worker that launched real tasks against the same executor
(workdir-per-profile R1/R2 in alphabetical order) failed at SSH dial
time with host-key-changed, since the executor was still pinned to
the bad value.

Wrap the assertion in a try/finally that restores the original
fingerprint from seedData when the test ends, so the worker leaves
the executor in the same state it inherited.

* fix(ssh): quote every remote path + clean identity file path

CodeQL flagged two paths from HTTP-supplied SSH-config fields into
shell + filesystem operations:

  * runSSHCommand built "cat $remoteShaFile" / "test -x $remoteBin"
    / "mkdir -p $(dirname $remoteBin)" by direct concatenation; the
    arguments come from $HOME on the remote (not the kandev user)
    but the analyzer can't see that, and a remote $HOME containing
    shell metacharacters would still misparse. Route them through
    shellQuote for parity with every other call site in the file.

  * dialAuth opened target.IdentityFile straight from the configured
    string. Run it through filepath.Clean so any `..` segments slipped
    in by string substitution are normalised before os.ReadFile, which
    is the canonical mitigation CodeQL recognises here. Behaviour is
    unchanged for normal paths.

* refactor(ssh): drop the unused conn pool + remote curl dependency

CodeRabbit and Greptile both flagged the SSHConnPool: the executor never
took the pool path (CreateInstance always dialDirect'd a fresh client and
parked it on sshSessionState), so the pool's keepalive goroutines and
refcount logic were dead code. Get() also overwrote map entries on every
call, which would have leaked keepalive goroutines if the pool *had* been
used. Delete the entire SSHConnPool + sshConnKey + pooledConn + keepalive
plumbing. Export DialSSH so the SSH test endpoint can dial directly and
close the client when its single-shot probe is done.

While here:
  * pickRemoteAgentctlPort: switch from time.Now().UnixNano()%20000 to
    math/rand/v2.IntN. Two concurrent CreateInstance calls on a fast
    machine were getting the same port (UnixNano cycles in ~20µs through
    the 20k range); randomized picks avoid the collision.
  * createRemoteAgentInstance: POST to the remote control port over a
    direct-tcpip dial via net/http instead of shelling out to `curl`. The
    sshd container no longer needs curl, the kandev test endpoint no
    longer needs to probe for it, and the call inherits Go's standard
    timeout + error handling. Drop `curl` from the e2e Alpine image.
  * maybeUploadCredentials: was probing `printf %s "$HOME"` and
    discarding the output, then allocating an unused sshFileUploader.
    SSH v1 doesn't ship credential upload — just log a warning when a
    user sets remote_credentials so they know it won't take effect, and
    delete the dead uploader file.

* fix(ssh): verify bastion host key + tighten the trust-gate UX

Bastion host-key verification (security)
  resolveBastion built the bastion's ClientConfig with an empty
  PinnedFingerprint, which buildClientConfig translated into a
  callback that accepts any key. An attacker on the path to the
  bastion (DNS poisoning, route hijack) could pivot through it
  before the final-target check ever ran. Greptile flagged it as P1.

  Swap in a callback that consults ~/.ssh/known_hosts via
  knownhosts.New: matched key → accept; mismatched key → reject loud
  (the alarm); host absent → accept with the same TOFU semantics
  OpenSSH uses for StrictHostKeyChecking=accept-new, so e2e and
  first-connect setups still work without manual `ssh-keyscan`
  priming.

Stale-test trust gate (frontend)
  Editing a connection field after a successful test previously kept
  the prior result + cleared `trust`, but the trust checkbox could
  be re-checked and saved with the old fingerprint bound to the new
  target. CodeRabbit + cubic both flagged it as P1.

  Add a `resultStale` flag that flips when a CONNECTION_FIELD edit
  follows a successful test, surface a warning under the result, and
  disable the trust checkbox + save button until the user re-runs
  Test Connection. handleTest clears the flag.

While here
  * Surface onSave failures in the card instead of swallowing them.
  * ssh-create-page: read the executors slice from the live store
    (useAppStoreApi + getState()) at write time so a concurrent
    update doesn't get overwritten by a stale closure.
  * Split FingerprintTrustBlock into its own file to keep
    ssh-connection-card under the 600-line cap.

* fix(ssh): defend launch + handshake against several real bugs

manager_launch: trusted exec-config keys override req.Metadata.
  buildLaunchMetadata applied executor config only when the matching
  key was missing in req.Metadata. A request that carried metadata.
  ssh_host_fingerprint (or ssh_host, ssh_identity_*) could pivot the
  launch to a different target and bypass the pinned fingerprint.
  Pin a list of connection-routing keys (ssh_host, ssh_port, ssh_user,
  ssh_host_fingerprint, ssh_identity_source, ssh_identity_file,
  ssh_proxy_jump) and have the executor config always win for those.
  Cubic / CodeRabbit P1.

executor_ssh.CreateInstance: reuse the resumed session state.
  ResumeRemoteInstance attaches the recovered ssh.Client + forwarder
  to r.sessions[InstanceID] and refreshes metadata, then the lifecycle
  manager unconditionally called CreateInstance, which dialed a fresh
  client and started a second remote agentctl on top of the live one
  — leaking the resumed state. CreateInstance now early-returns from
  the existing r.sessions entry via buildResumedInstance. Cubic P1.

executor_ssh.Close: actually release tracked sessions.
  After dropping the SSHConnPool, Close was a no-op. A hard kandev
  exit (no per-session StopInstance) left forwarders and SSH clients
  open. Iterate r.sessions and Close the forwarder + client for each.
  Cubic P2.

buildAuthMethods: close the ssh-agent unix socket after handshake.
  Each agent-authenticated dial opened net.Dial("unix", $SSH_AUTH_SOCK)
  and never closed it; only PublicKeysCallback held the agent.Client
  reference and that callback is consulted exactly once at handshake.
  Return a cleanup func from buildAuthMethods + buildClientConfig and
  defer it from dialSSH / dialViaJump so the socket releases as soon
  as key exchange completes. Greptile P1 security.

waitAgentctlHealthy: probe HTTP /health, not just the TCP listener.
  The local port is owned by the SSH forwarder, which Accepts before
  it Dials the remote direct-tcpip channel — a TCP connect can succeed
  before the channel actually establishes. Issue a real GET /health
  and require a 2xx so a broken tunnel surfaces here rather than at
  the first agent operation. Cubic P1.

ssh-connection-card: also gate the trust + save on in-flight tests.
  Editing a connection field during a pending test silently kept
  resultStale=false because prev.result was still null; when the test
  returned, the new fingerprint was bound to the old form values but
  the gate let the user trust + save anyway. Treat in-flight tests as
  prior tests for staleness purposes, and preserve resultStale across
  the test's success setState. Cubic P2.

* fix(ssh): drop stale polls + preserve port inheritance + sshd extra-keys

ssh-sessions-card: race-protect polls.
  Polling setInterval + manual refresh shared one state setter, so a
  slow request that resolved after a newer call (or after a parent
  switched executorId) overwrote fresh data with stale rows. Bump a
  monotonic sequence on each refresh and drop the response when the
  sequence has moved on, plus reset on mount and invalidate on
  unmount so an in-flight reply from a teardown can't land. Cubic P2.

ssh-config parseSSHExecutorConfig: don't default ssh_port to 22.
  Reading an executor row that had no ssh_port set rewrote port=22
  on the form, which then round-tripped back to the backend. That
  silently broke ~/.ssh/config Port inheritance for aliases — a save
  could redirect connections to 22 even though the underlying alias
  was supposed to resolve to another port. Leave the field undefined
  so the backend resolver can apply the alias' value. Cubic P2.

ssh-image entrypoint: guard authorized_keys.extra mount.
  When only /authorized_keys.extra (and not /authorized_keys) was
  bind-mounted, the >> redirect ran under set -e against a missing
  destination and aborted container startup. mkdir + touch the
  destination before the append and reapply the ownership/perms.
  CodeRabbit P2.

* test(e2e): make G2 + F6 actually exercise the contract

G2 (sessions-endpoint filter) iterated `for (const s of sessions)` over
the listSSHSessions result without first asserting `length > 0` — an
empty response let the test pass without checking anything. Seed an SSH
launch before the loop and require at least one row, so a handler
regression that returned [] would now fail loud. CodeRabbit P1.

F6 (ws ssh.test parity) called apiClient.testSSHConnection twice (HTTP
both times) and compared the two HTTP responses, which would have
passed even if the WS dispatcher entry was broken. Use apiClient.
wsRequest("ssh.test", payload) for the second call so the gateway's
WS handler is actually invoked, and assert the step names match across
transports. CodeRabbit P1.

* refactor(e2e): extract SSH HTTP shapes into api-client-ssh.ts

api-client.ts was already over the 600-line lint cap on main (1528
lines); the SSH additions made it worse. Split the SSH HTTP shape
types into a dedicated module and re-export from api-client.ts so
existing callers keep working. The methods themselves stay on
ApiClient as thin pass-throughs since splitting them out across the
class boundary would change every test call site.

Also document the per-executor bound on the SSH listSessions per-row
GetTaskSession lookup (typical N < 10), and what a batch path would
need (a repo method to fetch task_sessions by id list). Cubic /
Greptile P2.

* docs(spec): correct SSH recovery keying after pool removal

The spec said ResumeRemoteInstance re-opens 'per (host, user) — one
connection serves all surviving sessions on that host', which was
the pool design. The current executor gives each session its own
SSH client, so the resume keys on the full (host, port, user,
identity_*, proxy_jump, host_fingerprint) tuple. That way two
profiles on the same host with different keys can resume side by
side without merging. Cubic P2.

* fix(ssh): IPv6 ProxyJump + port range + dedup + docker timeouts

parseLiteralProxyJump: handle bracketed IPv6 + reject invalid ports.
  The literal parser used LastIndex(":") to split host from port, which
  for [2001:db8::1]:22 left host as "[2001:db8::1]" and produced an
  invalid double-bracket address through net.JoinHostPort. Recognise
  the bracketed form explicitly via a parseBracketedHostPort helper
  and strip the brackets. Validate port is in [1, 65535] instead of
  accepting any integer (Atoi happily parses negative or out-of-range
  values). Split the host/port branch into its own function so the
  nesting stays under the golangci nestif limit.

ssh-create-page: dedupe on append.
  setExecutors([...current, next]) would double-list the same executor
  if a WS event (or another refresh) inserted it while createExecutor
  was in flight. Skip the append when current already has the id.

ssh-image fixture: time-bound docker probe + build.
  hasSSHContainerSupport ran `docker info` with no timeout — an
  unreachable / hung daemon would stall the Playwright worker
  indefinitely. Cap at 10s for the probe. buildE2ESSHImage runs
  `docker build` with no timeout; cap at 15 min so a stuck registry
  pull or BuildKit hang fails fast.

* fix(ssh): reject invalid ssh_port + retry in-net keyscan + restore D5 + doc count

executor_ssh.targetFromMetadata: reject malformed / out-of-range
ssh_port instead of silently falling back to 0 → 22 in the resolver.
"abc", "0", "70000" all now return a "must be 1-65535" error so a
broken config can't masquerade as a successful launch routed through
port 22. Cubic P2.

scanFingerprintInsideNetwork (bastion fixture): retry on empty output.
Mirrors the host-side scanFingerprintViaHost retry loop — sshd accepts
TCP before host-key negotiation completes on a cold container, so the
keyscan can come back empty on the first try. Bound at 15s; surfaces
"status=..., stderr=..." on final failure for diagnosis. Cubic P2.

executor-crud D5 (changing config…): restore the seeded executor in a
try/finally. The test pinned the executor to host=10.255.255.1 to
verify the snapshot stayed sticky on the live session, but never put
the real host back, so any later spec in the same worker that reused
seedData.sshExecutorId would dial the unroutable address and hang.
Cubic P2.

e2e-plan.md: replace the "roughly 75" estimate with the actual 80 /
17-files current count plus the ~2.4 min wall-clock. Cubic P3.

* fix(ssh): use bare mock-agent name so the remote PATH resolves it

For SSH the agent runs on a remote host whose filesystem doesn't share
paths with the kandev backend's, so the host-absolute mock-agent path
configureMockAgent computes can't apply. Extend the bare-name branch in
MockAgent.BuildCommand to include RuntimeSSH (the SSH e2e image already
bakes mock-agent into /usr/local/bin) and drop the now-redundant SSH e2e
bind-mount that worked around this by mirroring the host path into the
container.

* fix(ssh): unique sftp temp filename so concurrent uploads don't collide

When two sessions on the same task launch back-to-back they both call
ensureAgentctlOnHost which streams to <remotePath>.tmp before renaming.
With a shared .tmp name the second uploader's rename errors with
"file does not exist" once the first uploader's rename consumed the temp
file. Give each uploader its own pid+random-suffix temp; uploaders write
identical sha256-keyed content, so the rename race is then safe.

* fix(ssh): don't leak session-scoped metadata across sibling sessions

reuseExistingEnvironment copies persistent metadata from the previous
ExecutorRunning row into the new launch request so executors can pick up
worktree/container/sandbox IDs. For SSH that swept in session-scoped
keys too — agentctl PID/port, session dir, local forward port — which
made the second session's ResumeRemoteInstance reattach to the first
session's agentctl process. Mark those keys as session-scoped and skip
them in applyExecutorRunningMetadata; the same-session resume path
(applyRunningRecordToResumeRequest) still copies them because it works
off the session's own ExecutorRunning record.

* test(ssh): cover ProxyJump parsers + sibling-session metadata filter

Addresses the two coverage gaps Claude flagged on PR review round 3:

- parseLiteralProxyJump / parseProxyJumpHostPort / parseBracketedHostPort
  / parsePortString are pure parsers that gate every ProxyJump dial,
  including IPv6 (`[2001:db8::1]:22`). Table-driven tests pin the happy
  paths and the bracket / port / range edge cases — silent regressions
  in bracket stripping would otherwise break every IPv6 ProxyJump
  session without a single test failing.
- applyExecutorRunningMetadata is the function commit 044b76bb taught to
  skip session-scoped keys when reusing a sibling session's metadata.
  A direct test now asserts the SSH PID/port/session-dir/forward-port/url
  keys are filtered out while host/port/user/fingerprint/task-dir keys
  still propagate, and that an explicit request value isn't overwritten.

* fix(ssh): rename agentctl_action=uploaded to needs_upload (test endpoint)

The /api/v1/ssh/test endpoint only probes whether the agentctl sha256
sidecar already lives on the remote — it never uploads. Reporting
agentctl_action="uploaded" in the not-cached branch said the opposite
of what the step Output line said ("will upload on first launch") and
mis-signaled real-upload status to any external tooling. Rename to
"needs_upload" so the contract matches the behavior.

* fix(ssh): add ssh_host_alias to trustedExecutorConfigKeys

ssh_host_alias steers ResolveSSHTarget through ~/.ssh/config and ends up
controlling the final Host/Port/User/IdentityFile. Leaving the key out
of trustedExecutorConfigKeys let request-supplied metadata override the
executor's configured alias and pivot the launch to a different host —
the same class of attack the existing entries (ssh_host, ssh_port, …)
guard against. Promote the literal to a constant and add it to the
trusted set.

* fix(ssh): emit 1/1 completion progress when the preparer step succeeds

The final reportProgress was passing (completed=0, total=1) right after
completeStepSuccess marked the step done, so UI subscribers saw the
preparer end at 0% completion. Report 1/1 to match the actual state.

* fix(web): preserve POST/Content-Type + read latest executors at write time

- testSSHConnection: caller's options.init was spread AFTER the required
  method/headers/body, so a caller passing init.headers (or method/body)
  would silently clobber Content-Type and break server-side parsing.
  Spread options first; let the required fields win, and merge headers
  on top of caller-supplied ones.
- useSaveExecutor: the fallback path in the catch arm operated on the
  executors slice captured at hook-creation time, so a WS update that
  landed mid-flight could be overwritten. Read the current snapshot via
  the store API at write time instead, matching what the create flow
  already does.

* fix(ssh): drop "in v1" from user-facing copy

These strings render in the New SSH Executor header, the Connection card
description, and the ProxyJump field hint. "in v1" reads like internal
planning leaking into the UI; the limitations themselves still hold so
just describe them directly.

* fix(ssh): auto-create a default profile on new SSH executor

The /settings/executors index renders entries by flattening to executor
profiles; an SSH executor created with no profiles was effectively
invisible there even though the executor itself existed. The non-SSH
create flow does this implicitly because it goes through the
create-profile form; the SSH-specific flow skipped it.

POST a default profile under the freshly-created executor (same name as
the executor, no overrides) right after createExecutor, then push the
combined record to the store so the index sees it on first paint.

* feat(ssh): pre-flight the agent binary on the remote before launch

Without this, a remote that lacks the agent's required binary (e.g.
\`npx\` for any node-based ACP agent) surfaced the failure deep inside
agentctl as an opaque \`exec: "npx": executable file not found in \$PATH\`
— delivered AFTER a successful "Starting agent controller" step, which
reads as "everything's fine right up until it isn't".

The new step runs right after agentctl upload: derive the binary from
\`agent.BuildCommand(Runtime=ssh).Args()[0]\`, \`command -v\` it on the
remote, and on miss return a structured error that names the agent,
points at the missing binary, and includes the install hint sourced
from \`agent.InstallScript()\`. The message lands on the prepare-step
output (so the UI shows it inline) and on the session's error_message
(so the orchestrator surfaces it on the task).

Coverage: unit test for the error formatter (pure function, no SSH
needed) + e2e regression that mv's mock-agent aside on the sshd
container and asserts the surfaced error mentions agent name, binary,
\$PATH, and the install hint.

* feat(ssh): surface host/workdir/agentctl in the task executor popover

Parity with the Docker and Sprites paths: when the user hovers the
Executor Settings button on a task, an SSH-backed environment now shows
the connection target (\`user@host[:port]\`), the remote task dir, a
compact \`pid → remote :port → local :port\` summary of the agentctl
process, the trimmed host-key fingerprint with copy, and a paste-ready
\`ssh ...\` command that mirrors how kandev itself connects.

Sourced via a parallel \`ssh\` block on
GET /api/v1/tasks/:id/environment/live (no schema migration needed) —
the service joins the task's latest ExecutorRunning row's metadata at
fetch time, the same metadata SSH executor writes in CreateInstance.
The probe is metadata-only on purpose: the popover polls every 3-7s
and a per-poll SSH dial would be both slow and a lot of TCP traffic;
live agentctl/connection health stays under the per-executor
GetRemoteStatus path.

shouldShowExecutorEnvironmentControls now allowlists \`ssh\` so the
button actually renders for SSH tasks (Docker/Sprites/Remote-Docker
were already in the allowlist; SSH was the only runtime that mounted
a TaskEnvironment without showing the popover).

Coverage: unit tests pinning buildSSHLiveStatus's projection of
ExecutorRunning.Metadata into the popover-shaped struct (strings,
string-encoded ints, native ints, empty, malformed) + e2e regression
that launches an SSH task, opens the popover, and asserts each row.

* feat(ssh): probe enabled agents on the remote from settings

Adds a "Remote agent readiness" panel on /settings/executors/ssh/:id so
users can diagnose "which agent CLIs are actually installed on this
host" before launching a task. The same diagnosis that previously
required a launch attempt + reading the pre-flight error now sits one
button-click away on the executor's settings page.

Backend:
- New endpoint \`POST /api/v1/ssh/executors/:id/probe-agents\` dials SSH
  to the configured host and walks \`registry.ListEnabled()\`. For each
  agent it derives the binary from \`BuildCommand(Runtime=ssh).Args()[0]\`
  and runs \`command -v <binary>\` via the new exported
  \`lifecycle.ProbeRemoteBinary\` helper. Returns rows with availability,
  the resolved path on hit, and the agent's \`InstallScript()\` as a
  copy-pasteable hint on miss.
- \`ssh.RegisterRoutes\` now accepts the task service (\`GetExecutor\`) and
  the agent registry; \`cmd/kandev/helpers.go\` wires both in.

Frontend:
- New \`SSHAgentReadinessCard\` mounted above the sessions card. Manual
  refresh only (each probe dials a fresh SSH connection — too expensive
  to poll). Each row gets a status badge + a copy button for the install
  hint when the agent is missing.

Coverage:
- E2E suite: API contract (mock-agent baked into /usr/local/bin/ → row
  reports installed, with /usr/local/bin/mock-agent as resolved_at;
  move it aside → row flips to missing with the install hint flowing
  through end-to-end) + UI contract (click "Probe agents", assert the
  table renders the row).

* feat(ssh): upload remote-credentials over SFTP at launch time

The SSH executor used to accept the \`remote_credentials\` profile field for
parity with Sprites/Docker but only logged a "not implemented" warning at
launch — anyone configuring Claude/Codex/Gemini auth against an SSH host had
no way to actually get the credentials onto the remote.

Ports the Sprites credential pipeline to SSH:
- \`uploadCredentials\` orchestrates the same four phases Sprites does:
  resolve auth-secrets into env vars, run env-credential setup scripts on
  the remote, detect a local \`gh auth token\` if \`gh_cli_token\` was
  selected, and SFTP the selected file-type credentials into the remote
  user's \$HOME.
- \`sshFileUploader\` satisfies \`lifecycle.FileUploader\` via SFTP with an
  \`mkdir -p\`-equivalent walk for parent dirs and \`chmod\` for mode bits.
- \`runAuthSetupScripts\` shells the script through \`runSSHCommand\` with the
  agent's env vars prepended inline (so the script can read e.g.
  \`CLAUDE_CODE_OAUTH_TOKEN\` to materialize \`~/.claude/.credentials.json\`).
- \`resolveRemoteAuthHomeDir\` probes \`printf %s "\$HOME"\` rather than
  reusing the task workdir — credentials need to land under the user's
  true home (\`~/.claude/...\`) even when \`workdir_root\` overrides things.

Wired into \`maybeUploadCredentials\` so a credential failure is logged as
a warning but doesn't abort the launch (consistent with Sprites).

Frontend: \`useRemoteExecutorFlags\` on the profile-edit page now includes
\`ssh\` in the \`isRemote\` set so \`RemoteCredentialsCard\` renders for SSH
profiles. Previously SSH profiles silently dropped any \`remote_credentials\`
selection because the card never rendered.

End-to-end credential upload still requires a non-mock agent enabled in
e2e mode (mock-agent's \`RemoteAuth\` is intentionally empty). Coverage:
unit tests pin the SFTP \`parentDir\` walk and the shell-quoted env-prefix
builder. Existing Sprites tests already exercise the shared
\`UploadCredentialFiles\` helper.

* fix(ssh): wrap remote commands in a login shell so nvm/asdf PATH applies

sshd's exec channel runs commands via a non-interactive non-login shell.
That doesn't source ~/.profile / ~/.bash_profile / ~/.bashrc, so any
binary added to PATH by nvm/asdf/brew/etc. is invisible to:

- the launch-time pre-flight probe (`command -v <agent-binary>`)
- the readiness panel's per-agent probe
- and the nohup'd agentctl process itself

End result: a user with `npx` installed through nvm sees 'X requires
npx on the remote' and can't launch any node-based ACP agent.

Fix: introduce `WrapLoginShell(shell, cmd)` that wraps cmd in
`${shell} -lc '<cmd>'` (with POSIX-safe single-quote escaping) and
default shell to bash when empty. Thread the configured shell through
ProbeRemoteBinary, startRemoteAgentctl, preflightAgentBinary,
runOneAuthSetupScript, and resolveRemoteAuthHomeDir. The shell is
read from per-profile metadata via the new MetadataKeySSHShell
('ssh_shell') key — flows in via the existing executor-config merge.
The probe handler also accepts an optional {shell} body so the UI can
re-probe under whichever shell the user picked.

Default keeps existing behavior for users who don't set a shell (we
just become a login bash instead of bare sh -c). Unit tests pin the
quote-escaping contract.

* feat(ssh): move readiness card to profile page + add shell picker and install button

Four UX changes that together make the SSH-profile setup flow feel right:

1. Save redirect after creating a new SSH executor now lands on the
   profile-edit page (`/settings/executors/{profileId}`) instead of
   the executor page. Profiles are where users actually configure how
   the host gets used.

2. `SSHAgentReadinessCard` relocates from the executor page to the
   profile-edit page (top, conditional on `executor.type === 'ssh'`).
   The host-level executor page keeps Connection + Sessions.

3. Card gains a shell selector that auto-detects available shells on
   the remote and persists into `profile.config.shell`. Changing the
   shell clears the cached probe so the user re-probes under the new
   shell — PATH depends on which init files get sourced.

4. Each missing-agent row gets an Install button that runs the agent's
   InstallScript via /install-agent (login-shell wrapped). Spinner
   replaces the button while it runs; on completion the card auto
   re-probes. No confirmation dialog — the button is the consent.

E2E: probe-shells reports bash+sh on the alpine image (zsh absent),
install endpoint runs mock-agent's deterministic InstallScript,
unknown agent_id is a 404, and the profile-page UI round-trip works
end-to-end.

* refactor(ssh): drop install-agent endpoint and Install button

Install scripts assume environments we don't control on the remote
(package managers, sudo, network access); the failure modes weren't
recoverable from the UI. Pulling the one-click install — copy-and-paste
hint stays.

Backend: delete POST /api/v1/ssh/executors/:id/install-agent and
everything that fed it (httpInstallAgent / installAgent /
runInstallScript / runSSHCommandShim / InstallAgentRequest|Response /
findAgentByID / joinOutputs / capBytes). No other caller of those
helpers.

Frontend: drop the Install button + spinner + collapsible output panel
from ReadinessRow. The 'Install hint' column reverts to copy-only —
same hint string sourced from agent.InstallScript(), users run it on
the remote themselves. Remove installSSHAgent / SSHInstallAgentRequest /
SSHInstallAgentResponse from the api client + types + e2e helpers.

Survives: probe + readiness panel, shell selector + /probe-shells
endpoint, login-shell wrap on every SSH command (the actual fix for
nvm/asdf users).

* fix(ssh): persist shell choice under the same key the launch path reads

Profile config saved the shell selection as 'shell' but the SSH
executor at launch time reads MetadataKeySSHShell which is 'ssh_shell'.
buildLaunchMetadata copies profile.config keys verbatim into
req.Metadata, so the mismatch meant sshShellFromMetadata always
returned empty and WrapLoginShell silently fell back to bash —
defeating the whole point of letting users pick zsh/sh/etc.

The probe path bypasses metadata (the UI passes shell directly in
the request body) which is why the readiness panel reported codex
as installed under the user's zsh while the launch then failed with
'codex requires npx on the remote' under bash.

Align both sides on 'ssh_shell'.

* fix(ssh): propagate profile ssh_shell into launch metadata

The settings UI saved the shell selection on profile.Config["ssh_shell"],
but applyProfile in executor_state.go only forwards an allow-listed set of
profile.Config keys (ssh_workdir_root, remote_credentials, etc.) into
launch metadata — ssh_shell wasn't on the list. The launch path's
sshShellFromMetadata therefore always returned empty and WrapLoginShell
silently fell back to bash, so a user who installed npx via zsh+nvm hit
'npx not found in $PATH' from the preflight while the readiness probe
(which takes shell straight from the request body) still showed the
agent as available.

Mirror the existing ssh_workdir_root propagation for ssh_shell and pin
both directions (present + absent) with a unit test on resolveExecutorConfig.

* fix(orchestrator): make profile.Config authoritative for ssh_workdir_root + ssh_shell

A task that supplied ssh_workdir_root or ssh_shell in its metadata won
when the profile had no value set — `applyProfile` only overwrote
metadata when profile.Config had a non-empty value, so the task-supplied
value squeezed through the empty-profile case. Both keys steer remote
behavior (workdir target, login shell that runs every command), so a
task author should never be able to redirect them.

Two things, bundled because they touch the same allow-list:

1. Replace the long if-chain in applyProfile with a small declarative
   set of tables (passthrough / rename / authoritative). Behavior for
   the existing keys (remote_credentials, git_user_*, image_tag, …) is
   unchanged.
2. Add a new authoritative tier — ssh_workdir_root + ssh_shell — whose
   profile value ALWAYS wins, including when empty. The reader-side
   helpers (SSHExecutor.workdirRoot, sshShellFromMetadata) already
   handle the empty fall-through to a default.

Pin both directions with subtests: authoritative keys clobber any
task value (empty profile included); ordinary passthrough keys keep
their existing "only set if non-empty" semantics so task-supplied
git_user_name still survives.

* refactor(ssh): pass remote workspace explicitly to createRemoteAgentInstance

startAndForwardAgentctl was briefly mutating the caller's *ExecutorCreateRequest
(overwriting req.WorkspacePath with the remote task dir, then restoring it
after the create-instance POST). That's exactly the boring-and-direct sin —
a callee reading from a struct the caller is mutating around the call.
Pass the remote workspace path as an argument so the dance disappears and
req stays read-only inside this leg.

* fix(ssh): surface SHA read errors instead of swallowing them as "not cached"

SSHCheckAgentctlCached treated any error from reading the remote
sha256 sidecar as "binary not on remote" by returning (false, nil).
A transient SSH transport failure therefore masqueraded as
"needs_upload" in the test endpoint's UI badge — the user retries
the test, gets the same misleading hint, and doesn't see that the
connection itself is flaky.

Append `|| true` to the remote cat so a missing file is a successful
empty-stdout read (the genuine "not cached" path), and surface a real
error to the caller for everything else.

* fix(ssh): handle int/int64/float64 metadata in session listing

intFromMetadata only accepted string values, which is what the SSH
executor writes via strconv.Itoa — but ExecutorRunning.Metadata
round-trips through JSON, and there's no guarantee a numeric value
isn't unmarshalled back as float64 somewhere along the way. Anything
that wasn't a string silently became 0 in the sessions endpoint,
making the SSHSessionsCard's "Remote agentctl port" / "Local fwd
port" columns render as 0 for any row that took a non-string path.

Match the smarter shape already used in service.mdInt
(internal/task/service/service_task_environments.go) so both readers
agree on the projection.

* refactor(e2e): drop duplicate SSH HTTP shapes module

api-client-ssh.ts was a literal copy of apps/web/lib/types/http-ssh.ts
with `Body` suffixed onto every type — 75 lines of pass-through that
buy nothing. The e2e ApiClient now imports the canonical shapes from
lib/types/http-ssh directly, drops the re-export pile at the bottom
of api-client.ts, and the duplicate module is deleted.

No spec referenced the *Body names; the grep is clean.

* fix(ssh): make Content-Type win over caller-supplied headers in ssh-api

In `{ "Content-Type": "application/json", ...headers }` the spread runs
last, so a caller passing `init.headers = { "Content-Type": "x" }` would
override the JSON content type and break server-side parsing — the exact
opposite of what the file's comment claimed. The outer `init` spread is
ordered correctly already; this fixes the inner `headers` literal in
the same direction (spread first, JSON literal last) across all three
SSH client functions. Per CodeRabbit thread.

* fix(ssh): distinguish 404 from server errors when resolving SSH executor

resolveSSHTarget collapsed every GetExecutor error into 404 — a transient
storage outage, timeout, or canceled context made it look like the
executor was deleted. Introduce models.ErrExecutorNotFound, wrap it in
the SQLite repo's sql.ErrNoRows case, and use errors.Is in the SSH
handler so only "row doesn't exist" maps to 404; everything else stays a
500 with the underlying error attached. Per CodeRabbit thread.

* fix(ssh): surface latestRunningForTask repo errors instead of swallowing them

latestRunningForTask returned (nil) on any error from ListTaskSessions or
GetExecutorRunningBySessionID. Transient storage failures rendered as
"no SSH live status" on every popover poll, indistinguishable from the
genuine "task has no running session yet" case. Return the error to the
caller; GetSSHLiveStatus now propagates it, and the handler already logs
+ omits the ssh block on a non-nil error so polling stays graceful.

A session without an ExecutorRunning row is the one expected miss — match
on models.ErrExecutorRunningNotFound and skip quietly. Per CodeRabbit
thread.

* fix(ssh): reset readiness card state when executorId changes

The mount effect only reset the sequence token, so switching executors
left the previous one's rows / error / hasProbed visible until the user
clicked Refresh — reads as "still loading" while actually showing stale
data. Clear setRows / setError / setHasProbed / setLoading in the same
effect so a fresh executor starts blank. Per CodeRabbit thread.

* fix(ssh): copy the full fingerprint, not the truncated display value

The popover renders fingerprints as a hash suffix (e.g. ...abcdef012345)
because the full SHA256 is verbose, but the copy button reused the
displayed string — users got an unusable truncated fingerprint instead
of the real one. Add an optional copyValue on FieldRow that overrides
the clipboard payload, and wire the fingerprint row to it. Per
CodeRabbit thread.

* fix(ssh): keep port forwarder alive on transient Accept errors

SSHPortForwarder.serve returned permanently on any non-orderly-close
Accept error — including transient ones like EMFILE (process FD table
exhausted) or ECONNABORTED. A single hiccup orphaned the forwarder, the
remote agentctl kept running, and the user's session went dark until the
backend was restarted.

Match net.ErrClosed explicitly for the orderly-close path; for anything
else, log a warning and continue the loop so the forwarder survives
recoverable per-accept faults. Per claude[bot] review thread.

* fix(ssh): guard ResumeRemoteInstance against duplicate attach

CreateInstance had an early-out for the case where a previous resume
already populated r.sessions[InstanceID], but ResumeRemoteInstance
itself didn't — a retry would dial a second SSH connection, start a
second forwarder, and overwrite the map entry. The original client +
forwarder leaked until the SSH idle timeout reaped them, and the agent
stream briefly bounced between the two.

Mirror the guard so the two entry points to r.sessions[InstanceID]
agree. Per claude[bot] review thread.

* fix(ssh): pipe auth-setup env vars via stdin to keep secrets out of cmdline

runOneAuthSetupScript inlined env vars as `KEY=value KEY=value <shell>
-lc '...'`. That string is what becomes argv of the outer bash invoked
by sshd, so on the remote host any process the same UID can read sees
the secrets in `ps aux` / `/proc/PID/cmdline` for the brief window the
setup script runs. Single-tenant deployments are unaffected in practice
but the leak is real on shared hosts.

Add a runSSHCommandStdin variant that feeds session.Stdin; replace the
inline env prefix with a stdin payload of `KEY='quoted'` lines sourced
by the wrapped shell under `set -a; . /dev/stdin; set +a`. Env never
appears on the remote shell's argv; it's only visible in /proc/PID/environ
of the setup process itself, which is the minimum surface for "pass an
env var to a remote process".

Replace buildSSHEnvPrefix with buildSSHEnvInitScript; update the test.
Per claude[bot] review thread (greptile P1 finding).
2026-05-25 10:04:40 +01:00
Carlos Florêncio 68aefc0e08 feat(web): system settings pages (status, database, backups, logs, updates, licenses, about) (#942) 2026-05-25 09:04:16 +01:00
Evgeny Klinnikov 90c652c8e2 feat: add per-agent-profile environment variables (#1040) 2026-05-25 08:14:45 +01:00
Carlos Florêncio adc13c6b5a feat: copy gitignored files from repo into new worktrees (#946) (#950) 2026-05-24 20:12:26 +01:00
João Salavisa b7589c78c7 fix(web): use shared PageTopbar on /gitlab so the page has a header (#1062)
The GitLab page rendered a custom in-file header instead of the shared
PageTopbar used by /github, /jira, and /linear — leaving /gitlab without
the standard breadcrumb / home navigation. Swap it for PageTopbar with
the GitLab brand icon and host subtitle, and move the Refresh button
into the topbar's actions slot.

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:59:39 +01:00
José Almeida 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 fb3dbe4 (Claude).
- e2e/dockview-resize: the latest fix locks pinned-column maxWidth to
  current width, which clamps `sv.resizeView` in tests. Loosen the
  constraint to the same runtime cap the sash-drag handler uses on
  user mousedown — so cap-enforcement assertions still work end-to-end
  without burning the 10-minute CI timeout retrying clamped resizes.

* fix(layout): mirror viewportBound clamp in e2e test cap helper

Claude & cubic flagged that `resizeColumnViaSplitview`'s runtime-cap
computation skipped the `viewportBound(value, vw - 300)` step that
production caps go through. On narrow viewports (vw < ~1143px) the
helper's cap would exceed what dockview actually enforces, so
cap-clamp assertions would drift from real app behaviour.

Mirror the caps.ts logic exactly in the helper.

* fix(layout): pin-by-target, not lock-by-current — defeats the ratchet bug

My previous attempt (lock max=current width except during sash drag) was
fundamentally broken: any transient container shrink would lock the cap
at the smaller size, and dockview's proportional rebalance could never
restore the column. Under heavy ResizeObserver fluctuation on reload, the
sidebar marched all the way down to LAYOUT_PINNED_MIN_PX (180px).

New invariant: pinned-column max is the *runtime cap* (loose enough for
the user to drag past the legacy default), and a separate "target width"
is tracked per pinned column. After every `onDidLayoutChange` event the
column is force-restored to its target via `sv.resizeView`.

Target is updated only by explicit actions:
- `applyLayout` sets it to the computed default after `fromJSON`.
- `applyLayoutFixups` sets it to the just-restored width on saved-layout
  restore.
- `applyPinnedColumnSizes` (fast-path env switch) sets it to the saved /
  default width.
- The sash-drag handler sets it to the new width on mouseup.

`pinnedTargets` live in a new module `lib/state/layout-manager/pinned-targets.ts`.
`setPinnedTarget` / `getPinnedTarget` are exported from the layout-manager
barrel. An `enforcing` guard around `enforcePinnedTargets` prevents the
restore-via-resizeView from re-triggering itself.

E2E helper additions:
- `__persistDockviewLayout__` exposed on `window` (test seam) — calls the
  setupLayoutPersistence `persistNow` directly. `sv.resizeView` doesn't
  emit `onDidLayoutChange` in dockview 4.x, so the helper now force-flushes
  the layout after each programmatic resize. Without this, reload tests
  never saw a saved layout.
- `__setPinnedTarget__` exposed too so the helper can update the target
  after a programmatic resize, mirroring sash-drag mouseup.
- Dropped the brittle `data-panel-id` width assertion in the tablet
  persistence test; the localStorage round-trip is the persistence
  contract, and `react-resizable-panels`' internal DOM is version-dependent.

Local validation: all 15 layout e2e tests pass; full unit suite (2087)
green; format + lint + typecheck clean.

* fix(layout): pause target enforcement during sash drag + cleanup test seam

Three follow-ups from the latest review round:

- cubic P1: pause `enforcePinnedTargets` while the user is actively
  dragging a sash. Without this, an in-progress layout-change event
  during the drag would revert the in-flight resize before mouseup
  records the new target. The new `sashDragging` flag is set on
  mousedown over `.dv-sash` and cleared on mouseup.
- claude: clear `window.__persistDockviewLayout__` in the
  setupLayoutPersistence disposer so a remount can't be racing with a
  stale closure from the previous mount.
- claude: third unit test exercises the right column's saved-layout
  resize path (sv.length = 3 covering both pinned columns), now that
  the previous test bounded the loop at sv.length = 2 and only
  reached the sidebar.

* test(e2e): bump dockview-layout-v1 → v2 in plan-panel-indicator spec

The storage key was bumped from v1 → v2 in fb3dbe4 (drop cross-env
width propagation). The plan-panel-indicator spec was waiting for the
old `dockview-layout-v1` localStorage key to materialise after a
debounced save — that key is never written under v2, so the
waitForFunction always timed out.

* fix(layout): reset sashDragging on disposer + only track left-button drags

Two safety fixes for the module-scope `sashDragging` flag:

- claude: clear it in the setupSashDragCapToggle disposer. If the user
  starts dragging a sash and unmounts mid-drag (e.g. navigates away),
  the flag stayed `true` forever and `enforcePinnedTargets` would never
  fire again on the next mount.
- cubic P2: only set the flag on left-button mousedown. A right/middle
  click over a sash currently flipped it true, and the mouseup handler
  ignores non-left buttons — so the flag would stick and pause
  enforcement until a left mousedown→up cycle.

* fix(ui): preserve session mode selector state

* fix(ui): format fallback session mode labels
2026-05-24 11:26:56 +01:00
João Salavisa 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>
2026-05-23 23:17:16 +01:00
João Salavisa 5a55b06d4d chore: revert dockview minimize button (#1039) (#1048)
Revert "feat(task): add minimize button to dockview group header (#1039)"

This reverts commit 7771155c8f.

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-23 22:23:36 +01:00
João Salavisa a6ab8deff6 feat(github): mobile-friendly /github page with sidebar drawer (#1041)
* feat(github): mobile-friendly /github page with sidebar drawer

On narrow viewports the preset sidebar consumed most of the screen and the
list toolbar's fixed-width filters wrapped awkwardly. Hide the inline aside
below md and add a hamburger button in the topbar that opens the same
PresetsSidebar inside a right-side Sheet, mirroring the kanban mobile
pattern. Reflow ListToolbar so title + count + refresh stack on top with
the repo filter and custom-query input going full-width below. Selecting
a preset in the drawer closes it. Includes a Pixel 5 Playwright spec
covering the open → select → close flow.

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

* test(github): use data-testid for mobile toolbar title selector

Replace brittle class-based locator (h2.text-sm.font-semibold) in the
mobile page object with a stable data-testid on the heading so the spec
doesn't silently break if Tailwind classes change.

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

* refactor(github): address mobile /github bot review feedback

- Drop useCallback wrappers on onMobileSidebarSelect / onMobileSaveCurrent.
  `state` from useGitHubPageState() is a fresh object every render, so the
  [state] dep caused the callbacks to be re-created every pass anyway —
  the useCallback was deceptive. Use plain arrow functions and document.
  (Claude inline, Greptile P2)
- Remove the sr-only <span> on the hamburger button. `aria-label` takes
  precedence over child sr-only text for screen readers, so the span was
  unreachable duplicate content. (Claude inline)
- MobileGitHubPage.goto() now waits on the mobile menu button instead of
  the ambiguous "GitHub" breadcrumb text — deterministic and matches the
  mobile mount semantics. (CodeRabbit inline)

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

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:13:31 +01:00
João Salavisa 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>
2026-05-23 13:43:29 +01:00
Carlos Florêncio db3b6dfb15 feat(tasks): don't cascade archive/delete to subtasks by default (#1020) 2026-05-22 22:55:04 +01:00
Carlos Florêncio 7407204fe1 fix(office): isolate kanban and office workspace selection (#1019) 2026-05-22 22:10:11 +01:00
João Salavisa 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>
2026-05-22 22:01:59 +01:00
João Salavisa 6ba50ba51e perf(web): render stats page shell immediately with per-panel skeletons (#1022)
* perf(web): render stats page shell immediately with per-panel skeletons

The stats route previously blocked the full SSR response on the workspace
stats query, leaving the user staring at a blank loading screen for the
entire round-trip. Drop the server-side stats fetch (server still resolves
the workspace id, which is cheap) and move the stats request to the client.
The header, range toggle, and every panel render their shell on mount, with
each panel showing its own skeleton until data arrives. Range changes also
re-show the skeletons during the refetch instead of staling the previous
data.

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

* fix(web): address PR review feedback on stats page lazy-loading

- Drop unused `loading` flag from stats reducer (Claude)
- Show "Failed to load stats" subtitle on fetch error instead of stale
  "Loading stats…" text (Claude, cubic)
- Read live `range` from useSearchParams() and validate against the
  RangeKey union so skeletons reset immediately on toggle and invalid
  ?range values fall back to "month" (Claude, CodeRabbit)
- Render an error panel per section instead of perpetual skeletons when
  the stats fetch fails (Greptile)
- Use router.replace with scroll:false on range toggle to avoid
  re-running the server component for a URL-only change

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

* test(web): extract stats pure helpers into stats-utils + unit tests

- Move statsReducer, isRangeKey, getRangeLabel, formatDuration,
  getSubtitle, toPanelState, and buildStatsSummary into
  apps/web/app/stats/stats-utils.ts so the testable units are reachable
  outside the client component
- Add apps/web/app/stats/stats-utils.test.ts covering reducer
  transitions, range-key validation, subtitle branching, panel-state
  derivation, and the summary builder
- Switch useStatsData from a cancellation flag to AbortController so
  fast range toggles actually abort the in-flight fetchStats request
  instead of letting stale requests run to completion

Addresses Claude review blocker + suggestion on PR #1022.

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

* refactor(web): tighten stats lazy-loading per Claude suggestions

- loading.tsx now includes TopRepositoriesSkeleton, RepoLeadersSkeleton,
  and WorkloadSkeleton so a cold navigation to /stats shows the full
  page layout, not a layout that ends at Repository Activity
- Reuse the existing StatsRange union for RangeKey in stats-utils.ts
  (one type alias, no risk of drift)
- Drop the unreachable default arm in getRangeLabel so a future
  RangeKey addition triggers a TypeScript exhaustiveness error

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

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:46:49 +01:00
Carlos Florêncio 2ca48fc7df feat(repositories): guard delete behind active-session check + UI fixes (#968) 2026-05-21 09:24:34 +01:00
João Salavisa 569a0d77bd fix(office): unbreak new-task dialog "Create Task" button (#976)
* fix(office): unbreak new-task dialog "Create Task" button

Office task creation silently failed: the button click sent a malformed
payload, the backend dropped it, and no toast surfaced because Sonner's
Toaster was never mounted. Three causes, all fixed here.

- web: send priority as a string id ("medium") instead of a number; the
  backend Priority field is a string and ShouldBindJSON was rejecting
  the body with "invalid payload".
- web/backend: add project_id as a top-level field on createTask. The
  dialog was burying it inside metadata where the HTTP handler never
  read it, so isOfficeRequest() returned false and validation tripped
  on "workflow_id is required for non-ephemeral tasks".
- web: mount the Sonner Toaster in the root layout. All sonner-based
  toasts across the app (not just this dialog) were silently dropped
  because no Toaster was rendered anywhere.

* fix(office): address PR #976 review feedback

- new-task-dialog: drop unreachable `|| undefined` guard on priority
  (EMPTY_DRAFT.priority is always "medium" and the picker only sets
  non-empty strings, so the fallback can never fire).
- layout: move sonner Toaster to top-right so it stops visually
  overlapping with the custom ToastProvider's bottom-right stack.
- task_http_handlers_test: add regression test asserting that a
  create-task body with project_id (and no workflow_id) reaches the
  office code path and the office workflow auto-resolves. Guards the
  exact wiring that caused the original silent failure.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 01:14:08 +01:00