Commit Graph

52 Commits

Author SHA1 Message Date
Carlos Florêncio 79633ea376 fix(web): preserve repo metadata in kanban.update and check legacy repositoryId (#1258) 2026-06-03 00:02:44 +01:00
Carlos Florêncio f4be0d279c fix: dockview, sprite, auth, and CLI db backup improvements (#1075) 2026-05-28 16:23:17 +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 3bc092bf20 feat(terminal): first-class user terminals — stable seq, rename, park/resume (#1009)
* feat(terminal): persist user terminals with stable seq + park state

Ordinary user shells now have a DB-backed `user_terminals` row keyed by
the agentctl PTY id, carrying a per-task monotonic seq (gaps preserved on
delete), custom_name override, and open/parked state. The terminal
service owns the create/list/rename/park/resume/destroy surface and
guards reject `bottom-panel` (cmd+J) + `script-*` ids so passthrough
flows are untouched. Closing a tab now parks (PTY keeps running);
destroy kills the PTY and removes the row.

* feat(web): expose terminal seq badge, rename, and parked-terminals menu

Wires the right-panel strip and SSR page state to the new task-keyed
terminals endpoint and discriminated UserShell union, adds the `#N`
badge for ordinary terminals, plumbs context-menu / double-click rename,
and renders a "Parked" submenu so closed-but-running terminals can be
resumed. Closing a managed tab now parks instead of destroying so the
PTY (and its buffer) survives accidental closes.

* test(terminal): e2e coverage for first-class persistent entities

Covers the persistence guarantees the user explicitly called out:
monotonic seqs, refresh-survives-rename-and-park, gap-preserving destroy,
park+resume cycle, and bottom-panel/script guards. Exercises the new
user_shell.{create,list,rename,park,resume,destroy} WS actions through
the shared ApiClient.wsRequest helper.

* fix(terminal): atomic seq allocation + propagate destroy errors

- Repository.Create folds MAX(seq) read + INSERT into a single
  `INSERT … SELECT` statement so concurrent creates can no longer pick
  the same seq. Locked in with TestCreate_ConcurrentSeqAllocation (25
  parallel creates, all unique).
- wsUserShellStop now propagates the service-Destroy error so the
  frontend stops optimistically removing a row the backend failed to
  delete.
- Service.Destroy / CleanupTask log PTY-stop failures at Warn (instead
  of silently swallowing) while keeping the proceed-on-failure semantics
  intentional — the DB row is the source of truth for "user expects
  this terminal gone".
- IsUserShellAlive copies ProcessID under the read lock before calling
  r.Get(), removing the post-unlock pointer-deref race.
- Get-not-found test asserts errors.Is(err, ErrNotFound) so it locks to
  the repository contract.

* fix(web): close-tab waits for backend ack + className undefined guard

- handleCloseTab defers `removeTerminal` into the `.then()` of the
  park/destroy promise. A failed backend call leaves the tab visible
  rather than vanishing it optimistically; the next `user_shell.list`
  poll reconciles state.
- session-tabs guards `tab.className` with `?? ""` so an undefined value
  no longer produces "undefined group relative..." in the rendered
  class string.
- user-shells.test simplification: `createSessionRuntimeSlice` only
  consumes `set`, so the test passes the slice creator straight to
  `immer()` instead of wrapping it in an adapter with unused args.

* fix(terminal): task-scoped mutations + legacy-shell list + UI hardening

Round-2 review feedback (cubic P1/P2, claude, coderabbit):

Backend:
- Rename/Park/Resume/Destroy now require a task_id and verify the
  terminal belongs to that task via a shared requireOwnership helper.
  Cross-task mutations return ErrTaskMismatch instead of silently
  succeeding by raw terminal id.
- wsUserShellList no longer drops managed-shaped agentctl shells when
  the terminal service is disabled or task_id is missing — legacy
  passthrough callers see their existing shells again. `managedCovered`
  flag scopes the orphan filter to the DB-backed path.
- service_test.go uses the shared-cache in-memory SQLite + MaxOpenConns(1)
  setup, matching repository_test.go and removing the flake-one-race-away
  warning.
- New cross-task guard tests: TestRename_RejectsCrossTask,
  TestPark_RejectsCrossTask, TestDestroy_RejectsCrossTask, plus a
  TestRename_EmptyTaskIDSkipsOwnership for internal callers.

Frontend:
- updateUserShell patch type narrowed to Partial<Omit<UserShellInfo,
  "terminalId">> so the row key can't be silently mutated.
- destroyUserShell now throws on a missing WS client instead of silently
  resolving — handleCloseTab's .then() no longer fires prematurely.
- rename/park/resume/destroy API client + useTerminals hook plumb the
  active task id through every mutating call.

E2E:
- New "cross-task mutating ops are rejected" spec creates a terminal
  under task A and verifies task B can't rename/park/destroy it by raw
  id.
- Existing specs updated to send task_id with their mutating calls.

* fix(terminal): require task_id on all mutators + wire taskID into hooks

Round-3 review feedback (cubic P1, claude blocker, coderabbit):

Backend:
- requireOwnership no longer skips the ownership check when taskID is
  empty. The previous carve-out was reachable from the WS layer (where
  task_id is user-controlled), so an attacker who guessed a terminal id
  could omit task_id and mutate any row. CleanupTask walks the repo
  directly and never touched the bypass.
- TestRename_EmptyTaskIDRejected pins the new contract.

Frontend (the real bug claude caught):
- useUserShells now accepts a taskId parameter and forwards it on the
  user_shell.list WS call. Without it the backend's DB-backed ordinary-
  shell path was skipped and the Zustand store wiped the SSR-hydrated
  panel strip on the first WS fetch.
- Response mapping switched to a discriminated union: id (new field)
  falls back to terminal_id (legacy), and kind/seq/state/pty_status/
  display_name/custom_name flow into the store so the parked-terminals
  submenu actually populates.
- useTerminals + useMobileTerminals read the active task id from the
  store and pass it through to useUserShells.
- destroyUserShell now retries with user_shell.stop when the server
  returns an unknown-action error — keeps the rollout-window
  compatibility intact for older backends that only know the legacy
  action name.

* fix(terminal): list parked, rollout-safe kind default, helper extraction

Round-4 review feedback (claude + greptile P1):

- useUserShells now sends include_parked: true so the "Parked terminals"
  submenu populates after a page reload. Previously the WS list returned
  only state=open rows; parked terminals were invisible until the user
  parked something in the same session. The main strip still hides
  parked entries via buildTerminalsFromShells.
- useAddTerminal stops blindly defaulting kind to "ordinary" — that
  misclassified legacy passthrough payloads (terminal_id without seq)
  during a rollout where the frontend is newer than the backend. The
  managed-tab path then called parkUserShell on a server that didn't
  know the action. Now only tags as ordinary when kind is explicit or
  the row carries a seq.
- handleCloseTab extracted into a standalone useCloseTab + helpers
  hoisted into use-terminals-build.ts / use-terminals-types.ts. Brings
  use-terminals.ts back under the 600-line file limit and the
  useTerminalActions wrapper inside the 100-line function limit.

* fix(terminal): scope-keyed shell cache + null-task guard + tighter rollout default

Round-5 review feedback (cubic P1, coderabbit x4):

- useUserShells now keys its fetch cache on env+task (`${env}:${task}`),
  not just env. An earlier load without a task id (e.g. before the
  active task hydrated) no longer pins the store on a legacy-only shell
  list and blocks the task-scoped refetch.
- useMobileTerminals delays auto-create until a real taskID is
  available. Previously a null taskID could bootstrap a legacy
  non-persistent shell, mark the env as auto-created, and starve the
  real task of its first-class terminal. Guard key is now env:task, and
  releaseAutoCreatedEnvironment clears every scope on that env.
- useAddTerminal tightens the rollout check: a row is "ordinary" only
  when `seq !== undefined`. A bare `kind: "ordinary"` without seq from
  an older backend stays legacy so close → destroy on the first click.
- useRemoveTerminal reads the current activeTab from a ref instead of
  closing over the value captured when handleCloseTab first ran. Async
  park/destroy resolves after a user tab-switch no longer clobber the
  newer selection with the stale fallback shift.

Tests:
- New "waits for taskID before auto-creating — null taskID is a no-op"
  case in use-mobile-terminals.test.ts confirms the null-task guard.
- All existing mobile-terminal + slice + E2E tests pass.

* fix(terminal): live activeTab lookup + selectable-tab guard

Round-6 review feedback (cubic P2 x2):

- getActiveTab now reads the active tab from the Zustand store via
  storeApi.getState() instead of a useRef/useEffect pair. Closes the
  post-render race where an async close handler could resolve between
  the new render's commit and the effect-driven ref sync.
- computeTerminalTabValue requires activeTab to be selectable
  (present in `terminals` or === "commands") before honoring it. A
  stale id left over from a parked/destroyed terminal no longer pins
  the panel on a nonexistent tab and blocks the fallback shift to a
  real shell.

* chore(terminal): inline savedTabFromStorage to drop file under line cap

Round-7 review feedback (claude suggestion): the useMemo around the
synchronous getSessionStorage call was zero-value memoisation that
pushed use-terminals.ts three lines over the 600-line max-lines limit.
Inlined; file now at 600 lines.

* chore(terminal): drop savedTabExists local + comment to land under 600 lines

Round-8 follow-up (claude): the previous commit's two-line comment block
held the file at 603 lines (still over the 600-line cap). Inline
savedTabExists at the only call site and remove the redundant comment —
the round-7 PR thread already explains why the useMemo was removed. File
now at 599 lines.

* fix(terminal): plumb task_id through dockview + tolerate legacy destroys

Manual-test feedback: clicking X on a dockview terminal panel surfaced
"destroy terminal: terminal does not belong to the supplied task" because
the dockview path was creating + destroying terminals without sending
task_id at all. The new requireOwnership guard then correctly rejected
the empty task_id against a non-empty term.TaskID.

Frontend:
- addTerminalPanel grows task_id + title parameters; stamps both into
  panel params at creation time so the destroy cleanup can pass them
  back to user_shell.destroy / .stop.
- Every dockview-side createUserShell call site now passes the active
  task_id, so the backend creates a DB-backed ordinary terminal (with
  seq + displayName) instead of a legacy passthrough shell.
- destroyUserShell + stopUserShell already accepted an optional task_id;
  dockview-layout-setup.onDidRemovePanel now reads the stamped task id
  from panel params and forwards it.

Backend:
- wsUserShellStop now requires both terminalSvc and a non-empty
  req.TaskID before routing through the managed service. An empty
  task_id falls through to the legacy passthrough StopUserShell so older
  clients (and dockview panels persisted before task_id was stamped)
  keep working.

Known follow-ups (separate task): the #N badge, rename context menu,
and parked-terminals dropdown still only show on the mobile/tablet
right-panel strip. Desktop dockview shows the seq inside the panel
title text but doesn't expose the badge/rename/parked-list UI yet.

* feat(terminal): dockview tab badges + rename context menu + existing-terminals list

Manual-test feedback round 2:

- Custom dockview tab component (TerminalTab) renders the #N pill
  only when the active env has more than one ordinary terminal —
  mirrors the session-tab badge semantics. Right-click on any ordinary
  terminal tab now exposes Rename / Park / Destroy.
- Dockview + menu picks up a new "Terminals" section listing every
  ordinary terminal for the active task (open and parked). Clicking a
  row focuses the existing dockview panel if one is mounted, otherwise
  re-opens it; parked rows are auto-resumed via user_shell.resume
  before the panel is added. The base "Terminal" action was renamed
  to "New Terminal" so the existing-terminals list is distinguishable
  in the menu.
- Reopen menu uses a stable EMPTY_SHELLS sentinel for its store
  selector to avoid Zustand infinite re-renders when the env has no
  shells.

E2E:
- terminal-new-tab.spec uses getByRole("menuitem", { name: "New
  Terminal", exact: true }) to disambiguate from the new "Terminals"
  submenu label.
- Full terminal suite still passes (26/26).

* fix(terminal): tab title shows "Terminal" with seq as badge, populate store on session page

Re-reading the user feedback: the title text was rendering as
"Terminal 1" (backend's displayName), and the dockview "+" menu's
Terminals section was empty because nothing on desktop was calling
useUserShells to populate the store.

- TerminalTab.pickDisplayName now returns the literal "Terminal" for
  ordinary terminals (custom names still override). The seq lives only
  in the sibling badge — same split as session-tab where the agent
  name is the title and the seq is a pill before it. Reading "Terminal
  1" out of "[1] Terminal" instead of "[1] Terminal 1".
- LeftHeaderActions (dockview "+") now calls useUserShells on mount.
  Without this the desktop layout never triggered the WS list fetch,
  so the Terminals submenu and the badge "count > 1" check both saw
  an empty store and silently disappeared.
- TerminalReopenMenuItems also calls useUserShells defensively (cheap
  thanks to scope-keyed cache) so it works when mounted in isolation.

* feat(terminal): "New X" rows live inside their section labels

User feedback: move "New Agent" under the Agents section header and
"New Terminal" under the Terminals section header so the dockview "+"
menu reads top-to-bottom as Agents → New Agent → existing sessions →
Terminals → New Terminal → existing terminals → other panels.

- SessionReopenMenuItems accepts an optional onNewSession prop and
  renders the "New Agent" row as the first item under the "Agents"
  label. Render gate now keeps the section visible whenever the create
  handler is supplied, even with zero existing sessions.
- TerminalReopenMenuItems gets a matching onNewTerminal prop with
  the same behaviour ("New Terminal" row under the "Terminals" label).
- dockview-add-panel-items drops its standalone "New Agent" and "New
  Terminal" rows since both now live inside their sections.

* feat(terminal): auto-migrate default panel to DB-backed ordinary terminal

The legacy `terminal-default` dockview panel was hard-wired to a
non-managed `shell-default` PTY so it never picked up an ordinary
seq/customName/parked state — the new "Terminal N" badge UX could
only show a badge once the user had explicitly created a second
terminal. With useEnsureDefaultTerminalOrdinary the default panel is
upgraded into a real `shell-<uuid>` row on session-page mount, so
every visible tab is now a first-class ordinary terminal.

Plus a UI-level e2e spec asserting the multi-terminal title + badge
contract, and an expectation bump on the existing rename+park spec
since visiting the session page now mints 1 extra ordinary terminal.

* fix(terminal): park ordinary terminals on tab close so they survive refresh

Dockview's tab X close button routed straight to stopUserShell (an
alias for destroyUserShell), wiping the DB row on close — after a
refresh the user had no way to resume a still-running PTY they had
just tucked away.

The X is now a "hide for now" affordance: ordinary terminals get
parked (PTY survives, row stays in DB, reappears in the "+" →
Terminals menu with a `parked` pill), while scripts / bottom-panel /
legacy passthrough shells stay on the destroy path because they have
no resume story.

Permanent delete still lives on the right-click Destroy menu item
and (in the next commit) on each reopen-menu row.

* fix(terminal): preserve DB-backed terminal panel ids across layout restore

The serializer's normalizePanel rewrote every user-created terminal
panel id to `terminal-saved-N` and dropped environmentId/taskID from
params, so after a reload that panel couldn't be matched back to its
live PTY or its store record — the tab rendered with no badge and a
stale fallback title.

Treat `shell-<uuid>` panel ids the same way we already treat
`terminal-default`: preserve id and params on restore. Legacy
ephemeral `terminal-saved-*` ids still mint a fresh PTY on restore.
Also force tabComponent=terminalTab and title="Terminal" for any
restored terminal panel so layouts persisted before the badge UX
land on the new presentation.

* feat(terminal): destroy button on reopen-menu rows for permanent delete

Destroy used to live only on the tab's right-click menu — useless once
the tab was already closed. Each ordinary terminal row in the dockview
"+" → Terminals submenu now ends with an × button that confirms-then
calls destroyUserShell, killing the PTY and dropping the DB row so the
terminal does not reappear after reload.

Pairs with the previous close-to-park change: closing the tab parks
(soft, resumable), the × button on the menu row destroys (hard,
permanent).

* fix(terminal): reopen-menu focuses existing tab, drops parked pill, says Terminate

Three small fixes to the dockview "+" → Terminals submenu:

1. Clicking a row whose terminal is already mounted now FOCUSES the
   existing panel instead of duplicating it. The default-migrated
   panel keeps its registry id `terminal-default` even after the
   migration to a `shell-<uuid>` PTY, so a direct getPanel(uuid) miss
   would fall through to addTerminalPanel and create a second tab for
   the same PTY. Fall back to scanning `params.terminalId` so the
   lookup catches that case.

2. Drop the "parked" pill from the row — parking is now an internal
   detail (close = park) that the user doesn't need to read off the
   menu. Clicking the row brings the terminal back into view either
   way, so distinguishing it visually is noise.

3. Rename the row × tooltip + confirm copy from "Destroy" / "Permanently
   delete" to "Terminate" to match the new tab-context-menu label.

Spec: add a test that asserts no duplicate tab when reopening an
already-open terminal, and drop the parked-pill assertion from the
existing close+reload test.

* feat(terminal): empty-group watermark uses the rich + menu

When a user splits a group (split-right / split-down) the new half is
empty and dockview renders the watermark. Before this, the watermark
showed a hard-coded Agent/Plan/Terminal/Browser row and clicking
"Terminal" called createUserShell without a taskId — so it spawned a
legacy passthrough shell that never appeared in the user-shell list,
and clicking "Agent" only ever focused the singleton chat panel
(invisible if the task had multiple sessions).

The watermark now renders the same AddPanelMenuItems content the
header "+" uses, so the user gets:
- The Agents list (existing sessions, greyed when already open)
- The Terminals list (existing terminals, greyed when already open)
- The Browser/Plan/VS Code/Changes/Files/PR options

…served behind a single "Add panel" dropdown trigger centered on the
empty group.

* feat(terminal): inline rename on the tab; drop Park, rename Destroy → Terminate

The tab title is now editable in place — picking Rename from the
right-click context menu swaps the title for an input. Enter commits,
Escape / blur-with-no-change cancels, and the new name is pushed
through renameUserShell + the user-shell store so the API and badge
list stay in sync. Replaces the previous window.prompt which felt
out of place in the dockview chrome.

While here:
- Drop the "Park (hide, keep PTY)" context-menu item. Tab close
  already parks ordinary terminals, so the explicit Park action was
  redundant and broke the "one obvious affordance per outcome" rule.
- Rename "Destroy" → "Terminate" in the context menu. Matches the
  reopen-menu × tooltip and reads more like a system action than a
  destructive UI primitive.

Spec adds an inline-rename test asserting the input appears, accepts
typed text, and commits the new title to the tab.

* fix(terminal): right-click Terminate closes the tab; drop displayName DOM warning

Two unrelated fixes both landing in terminal-tab.tsx:

- Terminate used to fire the WS destroy and update the store, but left
  the dockview panel mounted with a dead PTY underneath. Now it
  optimistically drops the shell row from the store, closes the panel
  via api.close(), then sends the destroy — same effect as the X close
  but routed to the hard-destroy path (the onDidRemovePanel cleanup
  sees no shell entry and skips its park branch).

- TerminalTabBody typed `displayName` as a prop but never destructured
  it, so {...props} spread it into DockviewDefaultTab and on into a DOM
  div, triggering "React does not recognize the `displayName` prop on
  a DOM element". Drop it from the spread.

* feat(terminal): polish + menu — badge-style indices, flush-left New rows, terminate closes panel

Polish pass on the dockview "+" → Agents / Terminals submenus:

- Replace the plain `#N` text with the same pill-style badge the tabs
  use (small bg, rounded, 11px). Agents and terminals both pick this
  up — so the menu row reads "[N] label" matching the tab style.
- Drop the empty index-column placeholder span on the "New X" rows so
  the icon + label sit flush-left under the section header instead of
  awkwardly indented.
- Strip the backend "Terminal {seq}" suffix from row labels — the
  badge already carries the number, so showing "Terminal 1" next to
  the "1" pill duplicated it. Custom-renamed terminals still show
  their name.
- The row × button now also closes the dockview panel if the terminal
  is currently open, mirroring the right-click → Terminate flow.

Spec: switch the row-by-seq lookups from `hasText: "#N"` (now stale
since the `#` prefix is gone) to a `has:` filter against the new
`reopen-terminal-seq-N` badge testid, and add a regression test for
right-click → Terminate closing the tab and removing the row in one
gesture.

* fix: address terminal review feedback

* fix: resolve terminal review suggestions

* fix: catch terminal close stop failures

* fix: harden terminal terminate failures

* fix: use pinned pnpm docker arg

* fix: satisfy backend lint after main merge

* fix: stabilize e2e after layout merge
2026-05-24 12:59:30 +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
Carlos Florêncio 372779ad32 feat(preview): inspect mode with pin and area annotations (#917) 2026-05-24 11:21:50 +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 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
João Salavisa fb56af4e4e fix(web): aggregate changes count across all repos to stop flicker (#986)
Addresses #980 — multi-repo workspaces showed a flickering / stale Changes count because the source hook returned only the last-arriving repo's status. `useSessionChangesCount` now sums file counts across the per-repo `byEnvironmentRepo` map plus commits and is wired into the three call sites; single-repo behaviour is unchanged.
2026-05-22 23:08:46 +01:00
Carlos Florêncio 20a660fe3e fix(web): strip phantom session panels on env-layout restore (#944) 2026-05-19 09:19:05 +01:00
José Almeida b4ce4d6eef refactor(web): improve compact desktop layouts (#937)
* refactor(web): improve compact desktop layouts

* fix(web): address compact desktop review feedback

* fix(web): address responsive review followups

* fix(web): preserve compact sidebar restore sizing

* fix(web): harden compact preset sidebar test

* refactor(web): extract dockview layout hooks
2026-05-18 23:08:14 +01:00
Luan Martins 7437194e2e fix: show PR diff when clicking file row that has local changes (#908) 2026-05-16 14:04:09 +01:00
João Salavisa 45a46f9899 feat: support tasks without a repository (#850)
* feat: support tasks without a repository

Lets users create a task with no repository attached — useful for research,
brainstorming, design discussions, or pointing the agent at an existing
non-git folder. The create-task dialog gains a 3-mode segmented control
(Repo / URL / None); "None" mode replaces the chip row with a folder
picker that browses the host filesystem via a new GET /api/v1/fs/list-dir
endpoint and either commits an absolute path or falls back to a kandev-
managed scratch workspace at <homeDir>/tasks/<workspaceID>/<taskID>/.

The worktree executor is hidden from the picker in no-repo mode (it
inherently needs a repo); the lifecycle manager also short-circuits
worktree mode server-side as a belt-and-suspenders guard. The Changes
panel auto-closes for repo-less tasks since it has nothing to show, and
the cumulative-diff handler returns null instead of erroring when no
base commit exists.

Spec + plan added at docs/specs/tasks-without-repositories/.

* fix(backend): anchor folder picker to discovery roots (CodeQL path traversal)

CodeQL flagged the new GET /api/v1/fs/list-dir handler for piping a
user-controlled path straight into os.Stat / os.ReadDir. Restrict the
listing to paths inside the configured discovery roots (defaults to
$HOME, same allowlist as repository discovery), reject anything outside
with ErrPathNotAllowed → HTTP 403, and clip the breadcrumb's "up"
button at the root so the picker can't be used to walk above the
allowed prefix.

Tests cover: rejection of paths outside roots, traversal escapes (../),
default-to-first-root, and parent exposure for nested paths.

* fix(backend): use os.Root for folder picker (recognized CodeQL sanitizer)

Previous attempt anchored to discovery roots via isPathAllowed, but
CodeQL's data flow doesn't model that helper as a sanitizer and kept
flagging os.Stat / os.ReadDir. Switch to os.Root (Go 1.24+): open the
matching discovery root once and run Stat/Open/ReadDir against the
relative path within it. The OS itself enforces containment — symlinks
and traversal cannot escape — and CodeQL recognizes os.Root as safe.

* fix: address PR review feedback

- Changes panel auto-close now distinguishes "task loading" from
  "task has no repos" (returns boolean | null), so the panel isn't
  permanently removed during the first-render window before task
  data lands in the store.
- Reset noRepository / workspacePath in resetDiscoveryState so
  reopening the dialog after a None-mode task doesn't carry the
  flag forward.
- Folder-picker breadcrumb: dropped dead `i > 0 && i > 1` condition
  (always equivalent to `i > 1`); chevrons now render between every
  segment as expected.
- list-dir HTTP handler returns a generic error message instead of
  forwarding raw OS errors that leak host paths.
- Document the dataDir → kandevHome inversion convention next to
  the scratch-path computation so future readers don't mistake the
  filepath.Dir call for a fragile derivation.

* fix: address CodeRabbit review nitpicks

- Trim whitespace on workspace_path before persisting to task metadata
  so a value of "   " doesn't get stored as a non-empty path.
- Extract the workspace_path metadata key into models.MetaKeyWorkspacePath
  alongside the existing MetaKeyAgentProfileID / MetaKeyExecutorProfileID
  so the set/read sites can't drift apart.
- Folder picker: reset cached listing when the popover closes, so
  reopening reloads from `value` instead of showing the last browsed
  directory the user navigated away from without committing.
- Submit payload: send `workspace_path: undefined` instead of an empty
  string when the trimmed value is empty, matching the noRepository=false
  case and keeping the API contract clear.

* fix: scratch path lands in kandev home, not its parent

Claude caught a real bug — `m.dataDir` is set from `cfg.ResolvedHomeDir()`
in cmd/kandev/agents.go, not `ResolvedDataDir()` (the field name is
misleading and the comment was wrong). My previous use of
`filepath.Dir(m.dataDir)` therefore walked one level above the kandev
home, so scratch dirs landed at e.g. `~/tasks/<ws>/<task>` instead of
`~/.kandev/tasks/<ws>/<task>` — and at the project root in dev mode
where `KANDEV_HOME_DIR=<project>/.kandev-dev`, which is what the user
saw on their machine.

Use `m.dataDir` directly (it's already the kandev home), document the
field's misnamed-but-correct semantics, and tighten the test fixtures.

Also:
- folder-picker trigger gets `cursor-pointer` (CodeRabbit nitpick).

* fix: pass workspacePath through handleCreateWithoutAgent too

The "create without agent" path called buildCreateTaskPayload without
the new workspacePath field, so picking a folder + clicking "Create
without starting" silently dropped the path. Now matches the
performCreate (with-agent) path.

* fix: satisfy DialogFormState in new-subtask form stub

PR #852 (merged after this branch was created) introduced
new-subtask-form-state.ts which stubs out a DialogFormState for the
subtask dialog. Adding noRepository / setNoRepository / workspacePath /
setWorkspacePath to the type made that stub incomplete, breaking the
Next.js production build (caught by CI, missed by tsc --noEmit).

Add the four inert fields to the stub, alongside the existing fresh-
branch inert fields, and pull both groups into named constants to
keep the useMemo body under the per-function line cap.

* fix: keep toggle-github-url testid as alias on URL-mode button

The new source-mode segmented control replaced the previous
"or paste a GitHub URL" text link, which had data-testid="toggle-github-url".
The existing e2e suite (create-task-github-url.spec.ts and the new
subtask.spec.ts from #852) still selects the URL toggle by that testid.
Keep "toggle-github-url" as the testid on the URL-mode button so both
suites pass without a sweep across all e2e files. Click semantics are
unchanged — single click enters URL mode.

* test(e2e): update toggle assertion for new source-mode segmented control

create-task-github-url.spec.ts:892 was checking the old text-link
toggle's two-way behavior with text labels ("or paste a GitHub URL" /
"use a workspace repository"). The new segmented control has explicit
mode buttons; assert aria-checked instead of label text. The other 9
clicks on `toggle-github-url` in this file still work via the legacy
testid alias on the URL-mode button.

* refactor: extract useActiveTaskHasRepos to its own hook file

dockview-desktop-layout.tsx grew to 609 lines with the new hook +
the Changes panel guard, over the 600-line cap documented in
CLAUDE.md (lint passed but the convention is real). Move the hook
to hooks/domains/kanban/use-active-task-has-repos.ts. File drops
to 590 lines.

* chore: gitignore .claude/scheduled_tasks.lock runtime file

* fix(web): lock chip-row min-height so modal doesn't shift on mode toggle

The chip row uses flex-wrap; Repo mode (chip + branch + add button +
segmented control) sometimes occupies a different vertical extent than
None mode (single folder picker + segmented control), causing the
create-task modal to jump a few pixels when toggling between sources.
Set min-h-9 so the row always reserves at least one chip-row of
height regardless of the active mode.

* feat: let folder picker browse anywhere, drop allowed-roots gate

The picker is for choosing a starting folder for a repo-less task —
users legitimately want /tmp, /var/log/foo, or any directory they have
read access to. Coupling it to the repo-discover endpoint's
discoveryRoots ($HOME by default) was too tight: paths like /tmp
returned 403 "path is not within allowed roots".

Decouple ListDirectory from discoveryRoots. The repo-discover endpoint
(DiscoverLocalRepositories, ValidateLocalRepositoryPath,
LocalRepositoryStatus, ListBranches) stays anchored to the allowlist
since picking a repo to import is a workspace-scoped operation; the
folder picker trusts the local-process boundary instead.

CodeQL is kept happy by routing the FS operations through os.Root
opened at "/" — the Go stdlib sanitizer for path injection still
recognises it (containment to / is trivially satisfied for every
absolute path on the host).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-12 01:10:22 +01:00
Carlos Florêncio 9664e13f07 fix(web): keep "All Workflows" filter on task nav and workflow.created (#854) 2026-05-08 21:23:48 +01:00
Carlos Florêncio 0d3fce25b4 fix(web): stop dockview layout corruption when switching between maximized / sessionless tasks (#838) 2026-05-07 18:43:07 +01:00
José Almeida 3e676e3bb2 feat(web): double-click tab to toggle maximize (#823)
* feat(web): double-click tab to toggle maximize

Adds a dblclick gesture on dockview tabs that toggles maximize/restore on
the containing group, mirroring the existing maximize button. Preview tabs
keep their promote-to-pin shortcut: first dblclick pins, then dblclick
maximizes. Sidebar group is excluded.

* fix(web): add cursor-pointer to tab wrappers for consistency

* fix(web): add cursor-pointer to plan-tab wrapper

* fix(web): make tab dblclick maximize resilient to layout rebuild

Refactor the dblclick-to-maximize handler so it reads the panel's group ID
and store state at call time instead of from a render-time closure, and
guards against firing while the dockview store is mid-layout-restore.

This addresses oscillation when double-clicking a session tab on a task
that has multiple sessions: the maximize call now stops event propagation,
respects isRestoringLayout, and always uses the live api.group.id, so it
remains correct even after dockview rebuilds groups via fromJSON.
2026-05-05 13:55:46 +01:00
Carlos Florêncio c042050469 fix: stabilize task switch flow and session recovery (#818) 2026-05-04 18:53:33 +01:00
João Salavisa a510b46e4e feat(multi-repo): support tasks spanning multiple repositories (#767)
* feat(task): add task_environment_repos schema and model for multi-repo support

Phase 0 of multi-repository task support: introduces the per-repository
child table and model alongside legacy single-repo fields (dual-write,
no behavior change). Lays the foundation for the orchestrator/lifecycle
refactor in subsequent phases.

- New task_environment_repos table with FK CASCADE on task_environments.
- New task_dir_name column on task_environments (idempotent ALTER).
- TaskEnvironmentRepo model + Repos slice + RepoFor helper on TaskEnvironment.
- CRUD methods + interface extension; env getters preload Repos.
- Idempotent backfill from legacy single-repo task_environments rows.
- Tests cover round-trip, CRUD, preload ordering, embedded create, cascade
  delete, and backfill from a legacy-shaped row.

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

* feat(lifecycle): add Repositories list to LaunchRequest and EnvPrepareRequest

Phase 1.1 of multi-repository task support: extends the lifecycle types
with explicit per-repo lists, while keeping the legacy single-repo
top-level fields as the synthesized fallback. No caller behavior changes
yet — preparers still see length-1 specs for single-repo launches.

- LaunchRequest.Repositories []RepoLaunchSpec + RepoSpecs() helper.
- EnvPrepareRequest.Repositories []RepoPrepareSpec + RepoSpecs() helper.
- EnvPrepareResult.Worktrees []RepoWorktreeResult for per-repo outcomes.
- runEnvironmentPreparer forwards req.Repositories into the prepare
  request, inheriting the request-level RepoSetupScript when each
  per-repo entry has none.
- Tests cover explicit-list precedence, synthesized fallback, and
  repo-less requests.

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

* feat(worktree,lifecycle): support multiple worktrees per session

Phases 1.2 and 1.3 of multi-repository task support. The pieces are
intertwined so they ship together — the preparer's per-repo loop
depends on the manager being able to hold one worktree per
(session, repository) pair instead of one per session.

Worktree manager:
- In-memory cache key changes from sessionID to (sessionID|repositoryID).
- New MultiRepoStore optional capability + SQLite implementation:
  GetWorktreesBySessionID and GetWorktreeBySessionAndRepository.
- New Manager methods: GetAllBySessionID, GetBySessionAndRepo. Existing
  GetBySessionID returns the first match (single-repo callers unchanged).
- Manager.Create's "already exists" check is now repo-scoped so the
  second repo's create no longer returns the first repo's worktree.
- All cache write/delete sites updated to the composite key.

WorktreePreparer:
- New prepareMultiRepo path activated when EnvPrepareRequest.Repositories
  has >=2 entries; single-repo and repo-less behavior is byte-identical.
- prepareOneRepo runs validate -> sync -> create -> fetch-PR -> per-repo
  setup-script for each spec; rolls back already-created worktrees on
  any failure.
- Workspace path becomes the task root (parent of any repo subdir);
  legacy WorktreeID/WorktreeBranch/MainRepoGitDir mirror Worktrees[0]
  for downstream code that hasn't been migrated yet.
- Per-repo setup script runs verbatim (no script-engine resolution
  yet — the per-repo placeholder namespace lands in a follow-up).

Tests:
- Manager: GetAllBySessionID, GetBySessionAndRepo (hit + miss),
  GetBySessionID single-repo compat, cache key separation.
- Preparer: multi-repo creates one worktree per repo; rollback removes
  active worktrees on partial failure; per-repo setup script runs in
  each worktree directory and emits its own step.

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

* feat(orchestrator): resolve all task repos and persist per-repo rows on launch

Phase 1.4 of multi-repository task support: the orchestrator now reads
every repository linked to a task, forwards the full list through the
adapter into the lifecycle layer, and persists one TaskEnvironmentRepo
plus one TaskSessionWorktree row per repo when the launch is multi-repo.
Single-repo and repo-less tasks keep their previous code path verbatim
(dual-write — legacy single-repo fields stay populated from the primary).

- New resolveAllRepoInfo / resolveTaskRepoInfo on the executor; primary
  is the first by Position.
- buildLaunchAgentRequest takes both the primary repoInfo and the full
  list, populating req.Repositories []RepoSpec only when length > 1.
- New RepoSpec / RepoWorktreeResult on the orchestrator's executor
  package mirror the lifecycle types so the adapter can translate
  between them without leaking lifecycle types into the orchestrator
  public API.
- Adapter (cmd/kandev/adapters.go) forwards req.Repositories into
  lifecycle.LaunchRequest and surfaces per-repo prepare results back
  on LaunchAgentResponse.Worktrees.
- persistTaskEnvironment writes env.Repos in the same create
  transaction; persistTaskEnvironmentRepos handles the existing-env
  reuse path. TaskEnvironment.TaskDirName is now persisted.
- persistWorktreeAssociation loops over resp.Worktrees in multi-repo,
  inserting one row per repo with its own RepositoryID.
- ResumeSession now also populates req.Repositories from task repos
  and stamps the existing TaskDirName so the preparer reuses the same
  task root.
- repoStore / executorStore interfaces extended with the new repo and
  env-repo CRUD methods; mocks updated accordingly.

Tests:
- LaunchPreparedSession with two task repositories populates
  req.Repositories in order and keeps legacy single-repo top-level
  fields filled from the primary.
- The same launch persists one TaskEnvironment + 2 TaskEnvironmentRepo
  + 2 TaskSessionWorktree rows.
- Single-repo launch still produces zero entries in req.Repositories
  and exercises only the legacy fields.

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

* feat(agentctl): git endpoints accept optional repo subpath for multi-repo

Phase 2 of multi-repository task support: every per-call git endpoint
gains an optional `repo` field (form param for GET, JSON for POST) that
selects a sub-directory of the workspace. Empty `repo` = workspace root,
preserving single-repo behavior verbatim.

- Manager.GitOperatorFor(subpath) returns a per-subpath GitOperator
  (cached). Validates the subpath against path traversal, absolute
  paths, and missing directories.
- New api server helper gitOpForRepo() emits a 400 with a per-operation
  GitOperationResult on invalid subpaths.
- Repo field added to: pull, push, rebase, merge, abort, commit,
  rename-branch, stage, unstage, discard, create-pr, revert-commit,
  show-commit, reset, log, cumulative-diff. (git/status still reads
  from the workspace tracker — that endpoint will gain multi-repo
  support when the tracker itself is split per repo.)
- Tests cover empty subpath, valid subpath caching, traversal
  rejection, and missing-directory rejection.

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

* feat(web/comments): scope diff comments by repository for multi-repo tasks

Phase 3 of multi-repository task support: comments now carry an
optional repositoryId so two repos with the same file path render
their own comment threads instead of bleeding together.

- CommentBase.repositoryId (optional, backwards compatible).
- getCommentsForFile gains an optional repositoryId arg; legacy
  comments without a repositoryId match any repo filter so persisted
  data keeps showing.
- Diff store interface in lib/diff/types.ts mirrors the new arg.
- Tests cover per-repo filter, omitted-arg fallback, and legacy data.

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

* feat(github): per-repo PR association on task PRs and watches

Phase 4 of multi-repository task support: github_task_prs and
github_pr_watches gain a repository_id column so multi-repo tasks can
have one PR per repo without colliding on (task_id) uniqueness.

- Idempotent ALTER TABLE adds repository_id to both tables.
- TaskPR.RepositoryID and PRWatch.RepositoryID model fields.
- New GetTaskPRByRepository and ListTaskPRsByTask lookups; existing
  GetTaskPR keeps single-PR semantics for callers not yet migrated.
- ReplaceTaskPR's delete is scoped by repository_id when set so a
  multi-repo task replacing one repo's PR no longer wipes the other.
- Tests cover per-repo storage and scoped replace.

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

* feat(web): foundations for multi-repo task UI

Phase 5 of multi-repository task support: primitives the upcoming UI
work needs without changing any visible behavior yet.

- TaskRepository.checkout_branch surfaces the optional PR head branch
  field that the backend already exposes.
- primaryTaskRepository(repos) picks by lowest Position so consumers
  match the backend's "primary" semantics instead of `[0]`.
- New useTaskRepositories(taskId) / useTaskPrimaryRepository(taskId)
  hooks read the live kanban task and return its repos sorted by
  position. Future kanban/diff/UI consumers should use these instead
  of poking task.repositories directly.
- Tests cover lowest-position selection and empty/single-repo edge cases.

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

* feat(web): kanban card shows multi-repo "+N" chip and hydrates full repo list

Phase 6 (subset) of multi-repository task support. The kanban now
exposes the per-task repository list to the UI and renders a compact
"+N" badge on cards whose tasks span more than one repository.

- KanbanState.tasks[].repositories slim shape (id, repository_id,
  base_branch, checkout_branch, position).
- SSR mapper hydrates the full list and uses primaryTaskRepository()
  to pick the position-aware primary instead of [0].
- useKanbanData passes repositories through to the card props.
- KanbanCard renders the primary repo name with a small "+N" chip
  when len(repositories) > 1; tooltip names the count.
- useTaskRepositories / useTaskPrimaryRepository hooks return the
  slim KanbanTaskRepository shape so multi-repo consumers share one
  read path.

Larger UI surfaces (task-create dialog, diff view, file browser,
mobile sheets) still target the primary repo only — they will be
ported as the multi-repo task creation flow lands. No visible change
for single-repo tasks.

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

* feat(task): reject duplicate repositories on multi-repo task create

Phase 8 of multi-repository task support: surface duplicate-repository
input as a clean validation error instead of letting the unique
(task_id, repository_id) constraint surface mid-loop with some rows
already inserted.

- createTaskRepositories tracks seen repository IDs and returns
  "repository <id> listed more than once for the task" before any
  CreateTaskRepository call.
- Tests cover the rejection path and confirm two distinct repos still
  succeed with both rows persisted in Position order.

Repository discovery and per-repo cloning were already covered by
Phase 1.4 (resolveAllRepoInfo loops ensureRepoCloned) and the
existing repository_discovery service is repo-list aware by design.

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

* feat(web/task-create): repeatable repository rows for multi-repo task creation

Users can now actually create a multi-repo task: a "+ Add repository"
button below the primary repo + branch selectors expands the form with
additional (repository, base-branch) rows. Each extra row picks from a
filtered repository list that excludes the primary and any other already-
chosen extras, so duplicates are impossible to enter.

- ExtraRepositoryRow type + form-state hook (extraRepositories, add /
  remove / update / reset). Lives in its own file so the main state hook
  stays under the file-length lint cap.
- ExtraRepositoryRows component renders the rows and the "+ Add" button;
  per-row branches are loaded on demand via the existing
  useRepositoryBranches hook.
- buildRepositoriesPayload accepts the extras and appends them after the
  primary, dropping rows with empty repository_id silently so an
  unfinished row does not block submit. The backend's duplicate-repo
  validation (Phase 8) catches anything that slips through.
- DialogFormBody is split: CreateModeSelectors (selectors + extra rows)
  and useDialogHandlers move to dedicated files, keeping touched files
  under the lint thresholds.
- Tests cover the multi-repo payload (ordering, drop-empty, no-primary
  recovery, single-repo unchanged) and the existing
  store-multi-repo.test.ts gets named constants to satisfy the
  no-duplicate-string rule.

Verified: pnpm exec tsc --noEmit clean, vitest 608/608 green,
eslint --max-warnings 0 clean.

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

* feat(agentctl): per-repo workspace tracker for multi-repo git/status

The git/status endpoint now accepts an optional ?repo=<subpath> query
param that selects a sub-directory of the workspace, completing the
per-repo plumbing started in the multi-repo phase. Single-repo callers
keep the workspace-root behavior unchanged.

- Manager.GetWorkspaceTrackerFor(subpath) returns a per-subpath
  WorkspaceTracker (lazy + cached). Validation reuses a new
  resolveSubpath helper shared with GitOperatorFor, so both code paths
  reject path traversal / absolute paths / missing dirs the same way.
- Per-subpath trackers are NOT started (no polling goroutines); the
  multi-repo path uses them only for synchronous GetCurrentGitStatus
  queries which fall through to getGitStatus when no cache is present.
  This keeps per-repo cost cheap.
- handleGitStatus reads c.Query("repo") and routes through the new
  resolver, returning a 400 with an error payload on invalid input.
- Tests cover empty subpath (root), valid subpath caching, traversal
  rejection.

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

* feat(web/review): group file tree by repository for multi-repo task changes

The review file tree now renders per-repo top-level nodes when a task's
changes span multiple repositories, so the same file path in two repos
no longer collapses into a single ambiguous entry.

- ReviewFile.repository_id and ReviewFile.repository_name (optional)
  identify which repo each file belongs to. Single-repo callers leave
  them unset and the tree falls back to the existing flat grouping.
- buildFileTree splits into buildMultiRepoTree (when 2+ distinct
  repository_names are present) and buildFlatTree (the original logic).
  Repo roots have isRepoRoot=true and a repositoryId for downstream
  filters; their children are full path subtrees per repo.
- collapse() skips repo roots so a repo with a single changed file
  still shows its repo header.
- ReviewFileTree renders repo roots with a stronger label, a top
  border, and an "N files" count badge; data-testid="repo-root-node"
  for E2E coverage. File-level rendering is unchanged.
- Tests cover the flat-tree fallback (no repo info, single repo),
  multi-repo grouping, repo-root pinning, and same-filename-different-
  repos isolation.

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

* feat(web/task-top-bar): "+N" repo chip on multi-repo task header

The task page top-bar breadcrumb now shows a small "+N" chip next to the
primary repository name when the task spans more than one repository,
matching the kanban-card treatment introduced earlier. Hover surfaces the
exact count via tooltip ("frontend (+2 more repos)").

- resolveTaskProps populates repositoryCount from task.repositories.length.
- TaskTopBar threads repositoryCount through to the new
  RepositoryBreadcrumb subcomponent.
- The chip carries data-testid="topbar-extra-repo-count" for E2E
  selectors.
- TopBarLeft was nearing the function-length / complexity caps; the
  breadcrumb extraction restores headroom for future additions.

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

* fix(web/task-create): disable "Add repository" when no other repos available

When the user only has the primary repository in their workspace,
clicking "+ Add repository" used to insert a row whose dropdown was
empty — looking like a bug ("no other repos show up"). The button now
disables itself when there are no remaining workspace repositories to
choose from, with a tooltip explaining why ("All workspace repositories
are already added"). The previous "primary not selected" guard is also
surfaced via tooltip.

- ExtraRepositoryRows computes remainingCount = repos − used (primary
  + already-added extras) and gates the Add button on it.
- New computeAddButtonHint helper returns a per-state title string for
  the disabled cases.
- Tests cover: dropdown shows the unused workspace repos when opened
  (the click path the user reported wasn't actually broken — the gate
  was just missing); Add button disabled when only the primary exists.

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

* fix(agentctl): workspace tracker falls back to first repo subdir for multi-repo task roots

The "Changes" panel was empty for multi-repo tasks because the
agent's workspace path is the task root — a plain holder directory
that holds one git worktree per repository as siblings. The workspace
tracker tried to run git commands against that bare root and silently
failed, so no git status updates ever reached the UI.

The tracker now detects this at construction: when the configured
workDir isn't itself a git repo, it falls back to the first immediate
child directory that is. The user gets the primary repo's changes in
the panel; cross-repo aggregation is a follow-up.

- preferGitRepoChildIfRootIsBare scans os.ReadDir entries (skipping
  hidden / non-directory entries) and returns the first that has a
  valid git index. Logs an info line when the rewrite happens so it's
  visible in the agentctl log.
- Repo-less tasks (e.g. quick chat) and single-repo tasks keep their
  previous behavior verbatim — the fallback only fires when the root
  itself fails resolveGitIndexPath.
- Tests cover: fallback when the workdir is a multi-repo holder,
  no-fallback for a real git repo, no-fallback when no git child
  exists.

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

* feat(multi-repo): per-repo workspace tracking — Changes panel shows all repos

The agent's workspace tracker is now split per-repository for multi-repo
task roots. Each repo gets its own tracker that polls git independently
and stamps its repository name onto every emitted event. The Changes
panel aggregates files from every repo and groups them under per-repo
headers using the file-tree machinery from Phase 6.

Backend (agentctl):
- GitStatusUpdate / FileListUpdate gain a RepositoryName field. Empty
  for single-repo workspaces (legacy clients see no change).
- WorkspaceTracker carries repositoryName and stamps it onto every
  emitted status/file-list. Two constructors: NewWorkspaceTracker for
  the legacy single-repo path (with the bare-root fallback) and
  NewWorkspaceTrackerForRepo for explicit per-repo trackers.
- Manager scans direct child directories for git repos at construction.
  When 2+ repos are found, builds one tracker per repo subdir on top of
  the root tracker (which stays bound to the bare task root and emits
  nothing useful). Single-repo workspaces are unchanged.
- Manager.SubscribeWorkspaceStream / UnsubscribeWorkspaceStream fan out
  one client subscription across the root + all per-repo trackers via
  AttachWorkspaceStreamSubscriber / DetachWorkspaceStreamSubscriber on
  the tracker. The WS handler switched from the single-tracker subscribe
  to the manager fan-out.
- Tests cover: per-repo trackers spawn for multi-repo task roots,
  single-repo doesn't, both repos emit GitStatusUpdates tagged with
  RepositoryName.

Frontend:
- GitStatusEntry / GitStatusData carry repository_name (optional).
- session-runtime gitStatus.byEnvironmentRepo new map keyed by env id
  then repository name; setGitStatus routes per-repo updates into it
  while still updating the legacy byEnvironmentId for single-repo
  consumers. clearGitStatus drops both maps.
- migrateEnvKeyedData migrates the new per-repo map alongside the
  existing keys when sessionId resolves to an environmentId.
- WS git-status handler forwards repository_name from event.status.
- New useSessionGitStatusByRepo hook returns sorted [{repo, status}]
  pairs for the active session.
- task-changes-panel mergeReviewFiles iterates statusByRepo when
  available, stamping repository_name onto every uncommitted file so
  the Changes panel groups them under per-repo headers (using the
  ReviewFile.repository_name plumbing from Phase 6).
- Tests: per-repo routing, no overwrite on sibling repo update,
  legacy single-repo fallback, clear-on-cleanup.

Verified: pnpm exec vitest run 623/623, eslint --max-warnings 0 clean,
tsc --noEmit clean, make test (backend) clean, make lint 0 issues.

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

* feat(web/changes-panel): per-repo group headers above each repo's files

The Changes panel now renders a sticky "frontend / N files" header above
the diffs from each repository so the user can visually distinguish
where each change lives. Single-repo tasks render unchanged (no header
shown when there's only one unnamed group).

- ReviewDiffList groups its file list by repository_name (preserving the
  caller's order within each group), then renders one RepoGroupHeader
  per group followed by the existing FileDiffSection list. Header gating
  rule: show when len(groups) > 1 OR the single group has a non-empty
  repository name.
- Group key uses "repo:path" so a file with the same path in two repos
  no longer collides on React's reconciliation.
- groupFilesByRepository + RepoGroupHeader live in a sibling
  review-diff-list-groups.tsx file to keep review-diff-list.tsx under
  the 600-line lint cap.
- Tests cover: no header for plain (no repo_name) files, one header per
  repo for 2+ groups, header shown even for a single named repo, file
  paths land under the right group's section.

Verified: vitest 627/627, eslint clean, tsc clean.

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

* fix(lifecycle): propagate RepositoryName through PublishGitStatus

The agentctl WorkspaceTracker stamps every per-repo GitStatusUpdate
with RepositoryName, but the lifecycle PublishGitStatus translation
into GitEventPayload was dropping that field. The orchestrator then
forwarded a status with empty repository_name, the frontend store
routed everything into the empty-key bucket, and the Changes panel's
multi-repo grouping never triggered — no header, no per-repo split.

- GitStatusData gains RepositoryName (omitempty so single-repo
  payloads stay byte-identical on the wire).
- PublishGitStatus copies update.RepositoryName onto the payload.
- Test asserts the field survives the publish → subscribe round trip
  via the in-memory event bus on the GitEvent.<sessionID> subject.

Restart the agentctl process for any running multi-repo task to pick
up the new field; new launches get it automatically.

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

* feat(web/changes-panel): per-repo group headers in the dockview Changes tab

The Changes tab in the dockview panel (the Files / Changes (N) tab the
user actually interacts with — not the standalone Review dialog) was
still rendering a flat file list, so multi-repo task changes either
collided onto the legacy single-status (showing only one repo's files)
or rendered without grouping.

- FileInfo gains repository_name (optional, single-repo unchanged).
- useSessionGit aggregates files from useSessionGitStatusByRepo when
  multi-repo data is present, stamping each FileInfo with its
  repository_name. Falls back to the legacy single status for
  single-repo workspaces. Aggregation extracted into
  aggregateFilesAcrossRepos to keep useSessionGit under the 100-line
  function lint cap.
- ChangedFile (changes-panel.tsx + changes-panel-timeline.tsx) carries
  repositoryName through to the renderer.
- FileListSection delegates to a new FileListBody that groups rows by
  repositoryName; renders a small per-repo subheader when 2+ repos OR
  any single named repo is present, otherwise falls back to the
  pre-multi-repo flat list (same DOM shape).
- Tests cover: no header for plain (no repo_name) files, headers
  per repo for 2+ groups, single named repo still gets a label.

Restart the backend so the per-repo trackers spawn for in-flight
multi-repo tasks; new launches are unaffected.

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

* feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create

Bundles a string of multi-repo fixes and refactors that came up while
testing the feature end-to-end:

- workspace_path stored on multi-repo TaskEnvironment was one level too
  high, so agentctl booted with the wrong WorkDir on cold start; fixed
  in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession.
- Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo`
  through agentctl client → WS handler → frontend hook → SessionGit
  fan-out, so the right git repo is targeted (was running at the task
  root, which isn't a git repo for multi-repo tasks).
- agentctl GitLog is multi-repo aware: when no `repo` is specified and
  per-repo trackers exist, fans out one log per repo and merges sorted
  newest-first with RepositoryName stamped on each commit.
- Per-repo grouping for files (Changes panel) and commits, with
  collapsible group headers; commits section defaults to collapsed.
  Algorithm extracted into one generic groupByRepositoryName helper
  (was copy-pasted three times).
- Task creation dialog rewritten around a unified `fs.repositories[]`
  list — no more "primary vs extras" split. Chip-style row with one
  pill per repo + branch, "+" to add, x to remove, GitHub URL toggle
  inline. Discovered (on-machine) repos appear in any chip's dropdown.
  Removes ~6 fields and 3 helpers from the dialog state.
- Branch listing endpoints collapsed to one:
  GET /workspaces/:id/branches?repository_id=X | path=Y
  Frontend collapses to a single useBranches hook with a tagged-union
  source. Drops two HTTP routes, two service methods, two API client
  funcs, and one whole hook file.

Tests added across the chain: workspace_path resolver, sort-commits-
by-date, manager poll-mode propagation, RepoSubpaths, chip grouping
+ collapse + per-repo isLatest, group-by-repo helper, dialog payload
builder + chip wiring + discovered-repo flow.

* feat(multi-repo): unified per-repo git controls for all VCS operations

Changes panel and top-bar VCS button render per-repo Stage all, Commit,
Unstage all, Push, Pull, Rebase, Merge, and Create PR uniformly across
single-repo and multi-repo workspaces. Operations route to the correct
git repo subpath; fan-outs collect per-repo results and surface partial
success in the toast layer.

Backend:
- Stamp RepositoryName on Reset/BranchSwitch/FileChange notifications
  and forward through orchestrator handlers
- Surface per-repo errors from handleGitLogMultiRepo via PerRepoErrors

Frontend:
- pull/push/rebase/merge/abort/commit fan out via PerRepoOperationResult
- Per-repo group headers always render with action buttons
- Top-bar dropdown lists per-repo Pull/Rebase/Merge entries
- pendingStageFiles keyed per-repo to prevent cross-repo clears
- useRepoDisplayName distinguishes loading from multi-repo
- Commit dialog file summary respects stageAll
- New helpers: changes-panel-helpers.ts, changes-panel-repo-groups.tsx,
  use-repo-display-name.ts

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

* feat(multi-repo): plumb repo through file/commit/diff endpoints; per-repo PRs and review UI

- backend: thread `repo` subpath through pull/push/rebase/merge/abort, file
  content/update, cumulative-diff, and commit-show endpoints; per-repo
  fan-out for log + cumulative-diff with each repo's own merge-base; emit
  full `repositories` array on task.updated WS payload
- github: store/list TaskPRs as []*TaskPR (per-repo); orchestrator/web store
  rekey to `Record<taskId, TaskPR[]>`
- web: aggregate PR icon (count badge + worst-of color, multi-PR tooltip);
  branch hover card renders one row per repo with each repo's task base
  branch; review dialog merges per-repo file maps with composite keys and
  propagates repository_name through diff/expand/revert; commit-detail
  panel scopes to the owning repo
- kanban card: chip row of repo names (up to 2 + "+N" overflow, tooltip
  lists all)
- task-create dialog: gate non-worktree executors to multi-repo only; move
  fresh-branch toggle inline next to the repo chips (icon-only, with
  title/aria-label)

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

* fix(multi-repo): scope PR watches/associations and review UI per repository

Schema: add repository_id to UNIQUE constraints on github_pr_watches and
github_task_prs (with idempotent copy-and-rename migration) so one task
spanning N repos can hold one watch/PR per repo without collisions.

Plumb repository_id through CreatePRWatch/EnsurePRWatch/AssociatePRWithTask
/AssociatePRByURL, fan TriggerPRSync across all per-repo watches, and
disambiguate status sync by (task, owner, repo, pr_number) instead of
"first match". Add Repo to wsCreateFile/Delete/Rename and wsCreatePR/
RenameBranch so file and git ops target the right working tree.

Frontend review dialog: switch reviewedFiles, staleFiles, fileRefs,
selectedFile and comment counts to NUL-separated `${repo}\x00${path}`
keys so same-named files in different repos no longer share state.
useTaskPR consumes the new {prs: TaskPR[]} envelope.

Also: snapshot repoTrackers slice before background refresh in
SetWorkspacePollMode to prevent races with Stop(), and key
mergeCumulativeFiles by NUL-separated (repo, path) to match.

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

* chore: apply prettier formatting

Unblock CI prettier check on files modified earlier on this branch
that pre-date the current prettier config.

* fix: address PR review feedback and unblock E2E

Backend:
- ListWorkspaceTaskPRs background sweep now uses TriggerPRSyncAll so
  multi-repo tasks refresh every per-repo PR (was only the most recently
  updated watch). Caught by claude review on #767.

Frontend (no behaviour change):
- Replace literal NUL byte (0x00) in FILE_KEY_SEP and the inline key
  builder in use-review-dialog with the explicit   escape so the
  source is plain ASCII. The runtime separator is unchanged; this just
  removes a footgun that GitHub's diff renders as a visible space.
- Show the auto-resolved branch as a small read-only pill next to the
  GitHub URL input so the URL-mode dialog surfaces the branch the task
  will run on (previously only the URL was visible).

E2E:
- changes-panel-focus / pr-detection / pr-switcher-changes: expand the
  commits-section before asserting on commit text -- the section starts
  collapsed since the chip-style refactor.
- repository-selector-scroll: open the popover via repo-chip-trigger
  instead of the legacy single repository-selector testid.

* test(e2e): align task-create dialog tests with chip refactor

Updates three more tests that broke when the dialog migrated from a
single repository/branch combobox to per-row repo/branch chips and the
URL-mode toggle relabel:

- create-task.spec.ts (pre-select + remembers-selections): switch
  repository-selector / branch-selector lookups to repo-chip-trigger /
  branch-chip-trigger.
- create-task-github-url.spec.ts (toggle test): the inverse-state label
  is now "use a workspace repository", not the legacy
  "or select a repository".

No code changes — purely test alignment with the existing UI.

* fix(web): re-apply workbench-chrome topbar + restore branch-selector behaviour

Two regressions surfaced by the multi-repo merge into main:

1. task-top-bar.tsx had been resolved in our favour at the workbench-chrome
   merge (#761), losing the TopbarActionOverflow wiring that the new test
   asserts on. Re-applies main's topbar structure (TopbarCluster groups,
   TopbarActionOverflow, IntegrationsMenu, MoreToolsMenu, SettingsButton)
   on top of our multi-repo additions: repositoryCount + RepositoryBreadcrumb
   "+N" chip, gitStatus + isMultiRepo plumbing, and the GitAheadBehindBadges
   suppression for multi-repo. GitAheadBehindBadges moved to its own file to
   stay under the file-length cap.

2. The chip-style task-create dialog dropped the legacy branch-selector
   semantics. Restored:
   - Local-executor branch lock: the per-row branch chip is now disabled
     when isLocalExecutor and fresh-branch is off. The user's checkout
     dictates the branch for local execution; fresh-branch unlocks it.
   - Branch override: when locked, the chip shows currentLocalBranch
     instead of the value the user picked under a different executor
     (so worktree -> local doesn't display a stale "develop" pick).
   - Branch refresh: useBranches now exposes a refresh() callback for
     id-based sources (calls listRepositoryBranches with refresh=true),
     and Pill renders BranchRefreshButton in its popover header when
     onRefresh is wired.
   - URL-mode branch picker: GitHubUrlSection now renders a real
     branch-chip-trigger Pill backed by fs.githubBranches instead of a
     read-only span, restoring the "branch selectable in URL mode"
     behaviour (URL mode is always remote-clone, so the local-executor
     lock does not apply).

E2E spec updated: bulk-rename legacy branch-selector / repository-selector
testids to branch-chip-trigger / repo-chip-trigger.first(). All 13
create-task-branch-selector tests pass locally.

* ci: force workflow re-run

* fix(web): unblock E2E shard 2

Three issues showed up only after the merge with main, which brought back
the E2E shard 2 tests touching git/changes-panel and improve-kandev:

1. Wire the TaskCreateDialog slot props (extraFormSlot, aboveDescriptionSlot,
   bottomSlot, descriptionPlaceholder) through to DialogPromptSection. They
   were declared on the public API but dropped on the floor before this
   change, so Improve Kandev's contributor banner ("@octocat" /
   "@kandev-maint") never rendered. Also gate the "or paste a GitHub URL"
   toggle on lockedFields.repository so the locked-repo dialog hides it
   (matches main's behaviour and the existing test expectation).

2. Revert "commits section starts collapsed" (introduced in 9e8079de). The
   collapsed-by-default tradeoff is genuine, but it broke 14+ E2E tests
   that assert on commit text in the changes panel — the win wasn't worth
   the test churn. Updated the unit test to match.

3. Update the git-changes-panel E2E tests to use the stable
   `[data-testid^="commit-row-"]` selector instead of `commitsList li`.
   The chip-style refactor introduced a per-repo group <li> wrapper, so
   counting all <li> descendants now over-counts (1 wrapper + N commits).
   Same fix in the "distinguishes pushed and unpushed" test which queried
   `li` with text and matched both the wrapper and the commit row.

Locally verified: full shard 2 (45 tests) passes; full unit-test suite
(943 tests) passes; lint + typecheck clean.

* fix: address claude review feedback

Three findings from claude[bot] review on PR #767:

- types.ts: add 13 unit tests for reviewFileKey / splitReviewFileKey
  covering bare paths, composite keys, slashes/spaces in paths, repo
  names containing slashes, and full round-trip. Asks were repeated
  across five review rounds; the round-trip coverage is what catches
  the FILE_KEY_SEP NUL-vs-space class of bug at the type level.

- mergeCumulativeFiles (api/git.go): shallow-copy each per-file payload
  before stamping repository_name + path so the source map isn't
  mutated. The previous code wrote directly into the per-repo result
  map, which would have permanently rewritten the original entry if
  any future caller iterated the same source twice.

- buildRepoSpecs (executor_execute.go / executor_resume.go): drop the
  unused execCfg parameter (and the speculative "reserved for future
  per-repo config" suppression). Per repo overrides do not exist
  today and the speculative scaffolding masked an actual dead arg.

* docs(readme): mention multi-repository task support

* fix(web/topbar): drop standalone ahead/behind badges (duplicate of split button)

The Create PR / Push button already shows the inline ↑N ↓N counts, so
the separate "3 ahead" / "2 behind" badges next to the PR icon were
just visual noise. Removes the GitAheadBehindBadges component (now
unused) and the gitStatus / isMultiRepo prop chain that fed it.

* fix(github): heal legacy repository_id='' rows on github_task_prs

A task that pre-dated the multi-repo schema kept its github_task_prs row
with repository_id=''. After the schema migration, a subsequent sync
would resolve the task's primary repository_id and CreateTaskPR with the
new value -- but GetTaskPRByRepository(task, repo) returned nil because
the legacy row had an empty repository_id, so the new code inserted a
duplicate row instead of updating the legacy one. Net effect: two rows
for the same PR, surfacing as a "+2" PR badge on a single-repo task.

Adds backfillTaskPRsRepositoryID, run on schema init right after
migratePRTablesForMultiRepo:

  1. Dedup pass: when a (task, owner, repo, pr_number) tuple has both a
     legacy (repository_id='') and a per-repo row, drop the legacy one.
  2. Backfill pass: any remaining empty-repo rows get stamped with the
     task's primary repository_id from task_repositories.

The backfill is gated on task_repositories existing -- github-store unit
tests init only this package's schema, and the dedup pass alone is the
load-bearing fix for the symptom.

Tests cover the dedup path, the cross-package-skip path, and idempotency.

* test(github): cover UPDATE backfill path for orphan legacy rows

Adds TestBackfillTaskPRsRepositoryID_BackfillsOrphanRow per claude
review feedback. The two prior tests cover the DELETE dedup path and
the early-exit when task_repositories is absent, but the UPDATE path
that stamps the primary repository_id onto an orphan legacy row had
no coverage -- a wrong column name or off-by-one WHERE would have
slipped through silently.

* feat(web/changes-panel): group PR Changes by repository

Multi-repo tasks now have one TaskPR per repo, but the PR Changes
section was still rendering files flat using only the primary PR.
Adds useActiveTaskPRsWithFiles, which fans out one WS request per
TaskPR and tracks each fetch by (owner/repo/pr_number/last_synced_at)
so a server-side sync invalidates the cache.

Each PR's files are stamped with the resolved repository_name (looked
up from the workspace repos slice), and PRFilesGroupedList groups by
that name -- mirroring how Commits and Changes already render. Single-
repo tasks (one group, empty name) still render flat.

Mechanics:

- PRChangedFile gains an optional repository_name.
- mapPRFilesToChangedFiles takes a repository_name and stamps it.
- New PRFilesGroupedList + PRFilesRepoGroup live in
  changes-panel-pr-files.tsx so changes-panel-timeline.tsx stays
  under the 600-line cap; PRFilesSection delegates to them.
- Hook uses refs (not state) for the in-flight / fetched dedup so
  setState is only called when results arrive.
- Stable EMPTY_PRS sentinel avoids the new-array-each-render trap
  that would re-trigger every effect downstream.
- Workspace repo lookup memoised once via useMemo so the merged file
  list isn't rebuilt on every render.

* fix(web/changes-panel): show per-repo PR header when task is multi-repo

Previous logic only stamped repository_name on PR files when prs.length > 1
(2+ PRs). For a multi-repo task with only ONE PR yet (one repo doesn't
have a branch / hasn't been pushed), no per-repo header rendered, even
though STAGED and COMMITS already grouped under their repo names. Now
gates the stamp on the task itself being multi-repo, matching how the
other sections decide.

* fix(executor): serialise concurrent LaunchPreparedSession per session

Two paths in task_operations.go call LaunchPreparedSession on the same
session_id:

  1. PrepareTaskSession spawns a background goroutine with launchWorkspace=
     true the moment a session is created (so file browsing works before
     the user clicks Start).
  2. StartTaskWithSession runs a foreground launch when the agent is
     actually started (workflow auto-start, user click).

Without serialisation both reach agent_manager.LaunchAgent in parallel.
The fast-path check at the top of LaunchPreparedSession (session.Agent
ExecutionID != "") only kicks in once the first call has *persisted*
its execution id; the second call grabs a stale snapshot, runs the full
env-prep + builds runtime, then loses the executionStore.Add race and
fails with "already has an agent running (race resolved during register)".
Visible in the UI as "Environment setup failed".

Multi-repo amplifies the window because env prep runs sequentially per
repo (~2x the time for two repos), so what was a rare flake on
single-repo became consistent on multi-repo tasks.

Fix: acquire the per-session lock (the same sync.Map used by the resume
path) at the entry of LaunchPreparedSession and re-fetch the session
under the lock so the fast-path check sees the AgentExecutionID the
first holder just persisted. Second caller now takes the existing-
workspace path and starts only the agent subprocess, matching the
intended single-launch semantics.

Adds TestLaunchPreparedSession_SerialisesConcurrentLaunches that fires
two concurrent LaunchPreparedSession calls with the first one blocked
inside LaunchAgent. Without the lock, both reach LaunchAgent and the
test fails with call_count == 2; with the lock only the first does.

* fix(orchestrator): per-repo push detection so each repo's PR gets associated

Multi-repo tasks would only get one PR detected per task: the agent
pushes branches in BOTH repos, but the pushTracker keyed by sessionID
alone meant agentctl's per-repo git status events overwrote each
other's ahead counts. Whichever repo's events interleaved unfavourably
lost its >0 -> 0 transition and silently dropped its push detection.
Net effect: only the first/last repo's PR got an associated TaskPR row;
the other PR existed on GitHub but Kandev never knew about it.

Fix:

- pushTracker is now keyed by "<sessionID>|<repository_name>" so each
  repo's transitions are tracked independently. Empty repository_name
  preserves the legacy single-repo behaviour.
- trackPushAndAssociatePR forwards data.Status.RepositoryName to
  detectPushAndAssociatePR.
- detectPushAndAssociatePR resolves owner/name/repository_id for the
  specific repo via new resolvePushRepo helper (looks up the task's
  task_repository whose repository.Name matches the event), then uses
  GetPRWatchBySessionAndRepo to scope the existing-watch check.
- New associatePRFromPushScoped takes the per-repo repository_id
  directly instead of always resolving the primary one. CheckSessionPR
  (manual "check now") wired through the same scoped helper.

Existing tests pass with the explicit empty repository_name argument.
The user's existing task with the missing kandev PR row needs a manual
push retrigger or DB heal — the migration only deduped legacy empty
rows; this fix prevents the gap from recurring on future tasks.

* feat(web): one PR button per repo in topbar + per-PR detail panels

PRTopbarButton previously rendered just the primary TaskPR via
useActiveTaskPR(), so multi-repo tasks with N PRs only surfaced one.
Switch to useTaskPR(activeTaskId).prs and render one button per PR --
each opens its own dockview panel keyed by owner/repo/pr_number.

Plumbing:

- dockview-store / dockview-panel-actions: addPRPanel(prKey?: string)
  builds id "pr-detail|<key>" when a key is supplied; the legacy
  single-repo "pr-detail" id stays for the auto-add path.
- dockview-desktop-layout: forwards `params` to PRDetailPanelComponent
  so the panel can receive its prKey.
- pr-detail-panel: new prTaskKey helper + the panel looks up the
  matching TaskPR from the per-task list when params.prKey is set,
  falling back to useActiveTaskPR() for legacy panels and tooltips.
- pr-topbar-button: rewritten to render a button per PR, each labelled
  with #pr_number and tooltipped with owner/repo + title; click opens
  the per-PR panel.

* feat(web): collapse multi-PR topbar to a dropdown + show every PR in the + menu

Two follow-ups on top of per-PR panels:

- The dockview "+" panel-add menu still listed only state.pr (the primary
  TaskPR), so a 2-repo task only offered "PR #2" -- never the kandev
  one. Switch to state.prs[] and render one menu item per PR; multi-repo
  rows are labelled "PR #N -- repo" so the user can tell them apart.
  Each item opens its own panel via addPRPanel(prTaskKey(pr)).

- The topbar grew one outline button per PR after the previous commit,
  which doesn't scale (3+ PRs would crowd out other actions). Single PR
  keeps the existing single-button rendering; 2+ collapses to one
  outline button labelled "N PRs" with the aggregated status colour
  (red if any failing, etc.) and a dropdown listing each PR with repo,
  number, title, and per-PR status icon. Click an item -> open that
  PR's panel.

useLeftHeaderState now reads useTaskPR(taskId).prs and forwards the
full list through AddPanelMenuState as `prs`.

* fix(web/changes-panel): PR files arrive after race-cancelled fetch

useActiveTaskPRsWithFiles dropped responses from the previous effect
instance via a `cancelled` flag, but since the dedup tracking refs
(inFlightRef / fetchedRef) carry across renders, the next effect's
early-continue still saw the key in inFlightRef and skipped a fresh
request. The ref then got populated by the cancelled response and
fetchedRef permanently locked the key out of any future fetch.

Net effect: every render after the first prs reference change left
the merged prFiles array empty -- the PR Changes section never showed
up on multi-repo tasks even though both PRs existed and the WS request
would have returned files.

Drop the cancellation flag entirely. Per-key dedup is enough to prevent
duplicate requests, and the .then handlers use functional setState so
they're safe to land after the effect re-runs.

* test: cover review-feedback gaps (mapPRFilesToChangedFiles, push multi-repo, lock test channel-sync)

Three follow-ups from claude review on PR #767:

1. apps/web/components/task/changes-panel-helpers.test.ts: 5 new tests
   for mapPRFilesToChangedFiles. Covers status mapping (added / removed /
   renamed / modified / fallback), forwarding of plus/minus/oldPath, and
   the new repository_name stamping behaviour (multi-repo path stamps
   every row; single-repo path defaults to "" so PRFilesGroupedList
   skips per-repo sub-headers).

2. event_handlers_github_test.go: TestDetectPushAndAssociatePR gains
   two multi-repo cases. resolvePushRepo must scope CreatePRWatch +
   AssociatePRWithTask to the repository_id matching the event's
   repository_name (not silently fall back to primary); unknown
   repository_name must drop the event rather than mis-associate.
   mockGitHubService captures lastCreateWatchRepositoryID /
   lastAssociateRepositoryID so the per-repo scoping is asserted
   directly.

3. executor_test.go: TestLaunchPreparedSession_SerialisesConcurrentLaunches
   replaces the time.Sleep(50ms) coordination with a buffered `entered`
   channel + bounded select wait per CLAUDE.md. The test now waits for
   the first launch to enter LaunchAgent, then asserts no second entry
   arrives within a 100ms window — without the lock the second
   goroutine reaches LaunchAgent in microseconds, so the timeout serves
   as the "didn't race in" oracle, not a synchronisation primitive.

* fix(orchestrator): detect push when first poll lands post-sync

trackPushAndAssociatePR only fired on a >0→0 ahead transition, which
required two distinct status events to be observed. In multi-repo tasks,
any repo whose first agentctl status poll happened to land after the
push had already completed (ahead=0 with remote branch set on the very
first observation) silently dropped the push event — the watch was
never created, the PR never associated. Reproduced on task
4fdff41b-095a-4158-a311-4a1a23abe064 where 2/3 repos pushed but only
one PR appeared.

Now also fire on first observation when the status already reflects a
post-push state (ahead=0 + RemoteBranch set). detectPushAndAssociatePR
is idempotent against an existing watch, so a spurious fire on a
worktree that started from an already-synced remote just costs one
GitHub FindPRByBranch call.

The deferred pushTracker.Delete in detectPushAndAssociatePR is removed:
keeping ahead=0 stored is what suppresses repeated firing on subsequent
status events. Cleanup now happens at session-delete time via a new
pushTrackerForget helper that walks all (session|repo) entries.

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

* fix(web/topbar): restore pr-topbar-button testid for single-PR case

The multi-PR refactor renamed PRSingleButton's testid from
pr-topbar-button to pr-topbar-button-${pr_number}, breaking E2E tests
that relied on the stable selector (task-topbar-overflow.spec.ts and
the prTopbarButton page-object helper used by vcs-button-context).

Restore the canonical testid and expose pr_number via data-pr-number
for any test that needs to discriminate. PRMultiButton already uses
the same testid; with at most one PR-area control rendered at a time,
sharing the testid is unambiguous.

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

* fix(orchestrator): create one PR watch per repo on multi-repo tasks

The orchestrator only ever ensured a watch for a task's PRIMARY
repository. Both call sites that create watches were primary-only:

  - ensureSessionPRWatch (called when a session starts/resumes) used
    resolvePrimaryTaskRepositoryID and made one EnsurePRWatch call.

  - buildTaskBranchList (used by the poller's reconciler) keyed dedup
    by sessionID alone, so as soon as the primary repo got a watch
    the whole session was marked "watched" and the poller skipped
    every other repo.

Result: in a multi-repo task, the secondary repos never got a watch
row, so the periodic GitHub poller never searched for their PRs and
never associated them. Push detection (which fires on >0→0 ahead
transitions vs origin/main) doesn't help here either because Ahead is
measured against the integration branch, not the upstream tracking
branch — pushing doesn't change Ahead.

Reproduced on task a7a59bd8: PRs existed on github for both kandev
and aistats, but only the primary (kandev) was associated.

Refactor:

  - resolveSessionWatchTargets walks task_repositories × session
    worktrees and returns one (repo_id, owner, repo, branch) target
    per repo. Branch priority preserves the legacy precedence
    (checkout_branch > primary fallback > worktree branch) so PR-
    review-style synthetic worktree names don't clobber the real one.

  - ensureSessionPRWatch loops over targets and ensures one watch per
    repo.

  - buildTaskBranchList emits one TaskBranchInfo per (session, repo)
    and dedups against (session_id, repository_id) pairs from
    ListActivePRWatches — replacing the old session-only dedup that
    suppressed non-primary repos.

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-02 20:26:42 +01:00
José Almeida 45ee2ff48f feat: key terminals + dockview layout to TaskEnvironment (#755)
* feat(backend): key user shells by TaskEnvironmentID

Sessions in the same task share a TaskEnvironment. Re-key the user shell
runner so terminals are scoped per env, not per session — switching between
sessions of the same task now sees the same shell list.

- Add TaskEnvironmentID to AgentExecution / LaunchRequest / ExecutorCreateRequest
  / executor.LaunchAgentRequest, propagated through ToAgentExecution and the
  orchestrator launch path.
- Add Manager.ResolveScopeKey(sessionID) — returns env when execution carries
  it, falls back to sessionID for legacy paths.
- Rename runner param sessionID -> scopeID in interactive_shells.go; map keys
  are now scopeID:terminalID.
- Shell handlers + WS terminal handler resolve sid -> scopeID via
  ResolveScopeKey before calling the runner.

* feat(web): key dockview layout + portals to TaskEnvironmentID

Switching between sessions of the same task no longer rebuilds the dockview
layout — terminals and panels persist because layouts are env-scoped, not
session-scoped. The bottom terminal also reuses a stable sessionId across
same-env switches so it doesn't reconnect to a different shell.

- Rename layout persistence: setSessionLayout/getSessionLayout/*SessionMaximizeState
  -> setEnvLayout/getEnvLayout/*EnvMaximizeState (kandev.dockview.env-layout. prefix).
- Rename dockview-session-switch.ts -> dockview-env-switch.ts; performSessionSwitch
  -> performEnvSwitch with oldEnvId/newEnvId/activeSessionId.
- dockview-store.ts: currentLayoutSessionId -> currentLayoutEnvId; switchSessionLayout
  -> switchEnvLayout(oldEnvId, newEnvId, activeSessionId). Same-env switches no-op.
  Drop _skipLayoutSwitchForSession + consumeSkipFlag — env-keyed action handles it
  structurally.
- panel-portal-manager: PortalEntry.envId; releaseBySession -> releaseByEnv.
  ENV_SCOPED_COMPONENTS in dockview-desktop-layout (renamed from SESSION_SCOPED).
- useEnvSwitchCleanup replaces useSessionSwitchCleanup; tracks env changes only.
- All performLayoutSwitch callers (sidebar, dropdown, header, dialog) resolve
  sid -> envId via environmentIdBySessionId before switching.
- bottom-terminal-panel: drop sessionId prop, use useEnvironmentSessionId().
- cleanupTaskStorage now takes envIds[] alongside sessionIds[] for env-scoped
  layout/maximize cleanup.

* test: add coverage for env-keyed terminals + layout

Playwright e2e (terminal-env-keyed.spec.ts) — switching between sessions of
the same task keeps terminal scrollback intact and asserts both sessions
share one task_environment_id. Update the comment in
sessionless-task-switch.spec.ts to reference performEnvSwitch.

* fix: address env layout PR feedback

* test: fix flaky handshake auth check

* fix: address env scope review findings
2026-05-01 11:00:33 +01:00
Carlos Florêncio d431688557 feat(web): auto-open plan panel with unseen-changes indicator (#650) 2026-04-25 18:21:33 +01:00
Carlos Florêncio 1fc3544625 fix: subtask sessions inherit agent profile from parent task (#692) 2026-04-24 21:31:36 +01:00
Carlos Florêncio 31140c8b9f fix(web): follow workflow step session switches in chat UI (#625) 2026-04-20 09:26:53 +01:00
Carlos Florêncio 4b55a69145 feat(web): vscode-style preview tabs for files, diffs, and commits (#622) 2026-04-19 17:39:07 +01:00
José Almeida c08d68c0fe refactor: re-key dockview panel state by environmentId instead of sessionId (#491)
* refactor: re-key dockview panel state by environmentId instead of sessionId

- Remove terminal from SESSION_SCOPED_COMPONENTS (uses useEnvironmentSessionId internally)
- Convert ChangesContent and file-editor-panel to useEnvironmentSessionId()
- Re-key sessionCommits store from bySessionId to byEnvironmentId
- Re-key useCumulativeDiff cache by environmentId
- Update WS handler to resolve envKey before invalidating cumulative diff cache
- Update comments across portal manager, session switch, and layout files

* fix: revert useEnvironmentSessionId in file-editor-panel

file-editor is session-scoped and ephemeral — portal is destroyed on
session switch, so useEnvironmentSessionId() has no practical effect.

* fix: address CodeRabbit review comments

- Pass appStore to setupPortalCleanup so terminal tab close still calls
  stopUserShell via activeSessionId fallback (entry.sessionId is now
  undefined for global panels)
- Add request version guard to useCumulativeDiff to discard stale
  responses after environment switches

* fix: scope activeSessionId fallback to terminal only in portal cleanup

vscode is session-scoped and always has entry.sessionId — the
activeSessionId fallback should only apply to terminal (now global).

* fix: migrate stranded env-keyed data on registerSessionEnvironment

When registerSessionEnvironment(sessionId, environmentId) is called,
any data previously stored under the fallback sessionId key is now
migrated to the proper environmentId key. This prevents data from
"vanishing" when selectors start reading by environmentId after the
mapping is registered.

Applies to: sessionCommits, gitStatus, shell outputs/statuses,
userShells.

* fix: extract migrateEnvKeyedData helper to fix lint max-lines

* fix: address CodeRabbit review comments

- Always delete orphaned sessionId key in migrateEnvKeyedData even when
  environmentId key already exists (prevents stale accumulation)
- Reset loading state when switching to an envKey with warm cache to
  avoid brief spinner flash

* fix: always reset loading on envKey switch in useCumulativeDiff

Prevents stuck loading state on rapid A→B→A env switches where the
stale in-flight response is discarded by version guard but loading
is never reset.

* fix: move sync effect before fetch effect in useCumulativeDiff

React 18 batches effects in declaration order. With the sync effect
after the fetch effect, setLoading(false) from the sync overrode
setLoading(true) from the fetch on cold-cache env switches. Moving
the sync effect first ensures the fetch effect's setLoading(true)
wins the batch.

* test: add migrateEnvKeyedData coverage for registerSessionEnvironment

Tests 4 cases: normal migration, don't-clobber existing data,
no-op when sessionId equals environmentId, and partial sub-key
migration.

* fix: close stale-diff gaps in useCumulativeDiff sync effect

- Bump requestVersionRef on envKey change so in-flight fetches for
  the previous envKey are discarded
- Clear loadingState[envKey] so the fetch effect isn't blocked on
  re-entry (A→B→A scenario)
2026-04-14 11:44:38 +01:00
Carlos Florêncio 52d6f7e9bf fix(web): persist PR panel dismissal across page refreshes (#547) 2026-04-06 13:52:53 +01:00
José Almeida d1a028fdb6 feat: introduce subtasks, allow sessions to task reuse executor (#419) 2026-03-29 15:33:40 +01:00
Carlos Florêncio 0c77056304 fix: server-side task ID injection for plan tools and UI polish (#431) 2026-03-13 11:53:42 +00:00
José Almeida 17bfc1c923 fix: allow dockview layout to shrink on window resize (#418)
The grid item wrapping DockviewReact had min-width: auto (CSS Grid
default), which equals the content-based minimum width. Dockview's
internal gridview element uses inline pixel widths, inflating the
grid item's minimum to the original viewport size and preventing it
from shrinking. This blocked the ResizeObserver from firing, so
dockview never redistributed panel sizes on window resize.

Add overflow-hidden and min-w-0 to the grid item so it can shrink
freely with the viewport.
2026-03-11 23:30:54 +00:00
João Salavisa a405e13530 feat(web): add bottom terminal panel with Cmd+J hotkey (#414) 2026-03-10 23:10:16 +00:00
Carlos Florêncio e2fae8f8cf feat(ui): seamless session switching without dockview layout flash (#395) 2026-03-08 21:50:32 +00:00
Carlos Florêncio d0b5237070 feat: improve chat input ux (#350) 2026-03-02 21:32:21 +00:00
Carlos Florêncio 76c1dcce89 fix: sidebar task switcher shows outdated session state and custom maximize layout (#345) 2026-03-02 14:12:11 +00:00
Carlos Florêncio 80fe0bff60 feat: improve layout manager (#290) 2026-02-26 11:26:30 +00:00
Carlos Florêncio 2782d826ec feat: improve PR review watcher + PR info panel (#281) 2026-02-26 08:48:11 +00:00
Carlos Florêncio 3a95a43990 fix: vscode (#279) 2026-02-24 23:01:41 +00:00
Carlos Florêncio edf1f6faa5 fix: layout switching messing panels (#278) 2026-02-24 20:33:45 +00:00
Carlos Florêncio 96b4a03ef9 feat: improve claude code normalized messages + review ux (#271) 2026-02-23 18:59:21 +00:00
Carlos Florêncio dff0b49c51 docs: add github integration (pr watcher) (#262) 2026-02-23 00:16:37 +00:00
Carlos Florêncio 6b57e5a01f feat: vscode integration (#256) 2026-02-20 18:30:33 +00:00
Carlos Florêncio 01b0e9edf1 feat: improve acp + tracing (#258) 2026-02-20 17:30:14 +00:00
Carlos Florêncio 61ef224ec7 style: fix format issues (#255) 2026-02-19 22:18:01 +00:00
Carlos Florêncio fe9298a7ad feat: improve ux (#254) 2026-02-19 18:05:52 +00:00
Carlos Florêncio a0720957c4 feat: improved dockview layouts (#253) 2026-02-19 15:34:01 +00:00
Carlos Florêncio 53ae8d57e3 fix: resolve all web app ESLint linter warnings (#246) 2026-02-18 18:24:41 +00:00
Carlos Florêncio 77cac23395 feat: add more file editor keybindings (#242) 2026-02-17 22:12:30 +00:00
Carlos Florêncio 2b05ed0735 feat: improved passthrough and workflow (#237) 2026-02-17 12:32:02 +00:00
Carlos Florêncio bfdd48b7ad feat: command panel (#235) 2026-02-16 22:54:28 +00:00
Carlos Florêncio 633c4e0e55 feat: improve changes panel (#234) 2026-02-16 22:43:47 +00:00
Carlos Florêncio 658bd349b5 feat: improve workflows (#228) 2026-02-16 13:48:58 +00:00
Carlos Florêncio c4ef2d9ba1 feat: improve editors (#226) 2026-02-16 09:55:04 +00:00