* feat(web): add app status bar
* feat(web): add app status bar visibility toggle
* feat(web): persist status bar item order
* fix(web): refine app status bar behavior
* fix(web): polish app status bar presentation
* test(web): split plugin slot coverage groups
* fix(web): address status surface review feedback
* fix(web): stabilize status bar overlay tests
* feat: add chat-top-bar plugin slot
Adds a new named plugin UI slot, chat-top-bar, rendered in the session
top bar beside the CPU/DB metrics and document/editor/debug controls.
Mirrors the existing chat-input-actions slot pattern: a wrapper
component (TaskTopBarPluginActions) assembles slotProps from the store
and renders <PluginSlot name="chat-top-bar" />, forwarding
{ taskId, taskTitle, workspaceId, activeSessionId, sessionIds }.
Updates PLUGIN-API.md, the public authoring guide, and the
PluginSlotName JSDoc to document the new slot. No backend changes
needed since slot names are an open string type.
* fix: address PR review — mobile chat-top-bar slot, gate by archived state
- Wire the chat-top-bar plugin slot into the mobile session top bar
(session-mobile-top-bar.tsx), matching desktop's slotProps shape.
- Gate the plugin cluster behind !isArchived in TopBarRight, matching
every other interactive control in that group (port-forward, PR/MR
buttons, layout preset, editors menu).
- Extract RemoteExecutorIndicator, ApproveButton, and MobileTopBarDialogs
to keep the mobile top bar under lint complexity/length limits.
* fix: gate mobile chat-top-bar plugin slot by archived state
Threads isArchived through TaskLayout -> SessionMobileLayout ->
SessionMobileTopBar -> MobileTopBarActions so the mobile top bar
hides plugin content on archived tasks, matching the desktop
TopBarRight behavior fixed in the previous commit.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* feat(web): improve mobile task and kanban navigation
* feat(web): refine mobile task navigation
* fix(web): address mobile navigation review feedback
* test(web): cover task title field modes
* fix(web): keep mobile board navigator open across workflows
* fix(web): address mobile workflow review feedback
* feat(web): add quick chat entry points on mobile
Quick chat was unreachable on touch devices: every trigger was
desktop-only (keyboard shortcut, command palette, app sidebar) and the
full-screen dialog had no touch dismissal.
- add quick chat button to the mobile kanban/tasks header
- add quick chat button to the tablet header (640-767px has no sidebar)
- add chat button to the mobile session task-switcher sheet
- add a mobile-only close control to the quick chat tab bar
- cover the touch entry points with a mobile Playwright spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(web): address review feedback on mobile quick chat spec
- reuse the shared assertNoDocumentHorizontalOverflow layout helper in
both quick chat mobile specs instead of local duplicates
- assert the task switcher sheet is dismissed after opening quick chat
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(scripts): launch dev-isolated backend via __backend subcommand
The kandev binary became a bundle launcher with the restart supervisor
(ADR 0019) and exits without KANDEV_BUNDLE_DIR when invoked bare; the
isolated dev instance must run the backend entrypoint directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): session spawning, sibling messaging, and renameable tabs
- spawn_session_kandev spawns an additional agent session on an existing
task (default: the caller's own) via LaunchSession(IntentStart), with
optional agent_profile_id (defaults to the spawner's profile) and a
session name; the initial prompt carries spawner attribution and reply
instructions.
- message_task_kandev accepts an optional session_id to target a specific
session, enabling same-task sibling messaging. Explicit targets are
pinned through dispatch so the idle-path primary-session re-resolution
cannot reroute a sibling message back to its sender (regression-tested).
- task_sessions gains a name column (migration ordered after the
agent_execution_id-drop rebuild), a session.rename WS action, and inline
tab rename UI; custom names take precedence over derived agent/model
labels across the tab, agents dropdown, mobile list, chat header, and
dockview panel titles.
- Inter-session messages stamp sender_session_name into metadata so the
sender badge shows which session sent the message.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mcp): address review findings on session spawn/rename/messaging
- include name in task-session DTOs (list + full, no omitempty) so
persisted tab names survive list hydration and clears propagate
- sanitize sender task/session ids embedded in <kandev-system> blocks
- reject cross-workspace spawn targets; require sender_task_id
- truncate session names by runes, not bytes
- distinguish not-found from internal errors in message target lookup
- guard TabRenameInput blur-commit after Enter/Escape resolution
- cap optimistic rename length client-side and toast on rename failure
- add tests: spawn launch-failure and cross-workspace paths, renameSession
API client
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mcp): round-two review fixes for spawn/rename lint and hardening
- extract spawn validation/authorization helpers (cyclop/nestif limits)
- make stripSystemTag replace until stable to block nested tag evasion
- load session before rename so a durable rename can never skip its
broadcast; surface lookup failures to the caller instead
- drop redundant sessionLauncher nil check (guarded registration)
- add maxLength to the rename input matching the server cap
- add tests: nested sanitizer evasion, spawn rename-failure tolerance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(web): add sidebar view system to mobile task switcher
* test(web): scope mobile view-system gear indicator to the sheet
* fix(web): disable view-chip drag-reorder on touch to keep row scrollable
* fix(web): use mouse+touch sensors so chip drag works on hybrid devices
* fix(web): match kanban PointerSensor+TouchSensor convention for chip dnd
* fix(web): use MouseSensor+TouchSensor so touch swipe scrolls chip row
* fix(web): wire group collapse + align/clean view bar in mobile sheet
* feat(diff): use stored base_branch for diff stats + per-task compare picker
Task-card additions/deletions and the Changes panel were computed against a
hardcoded origin/main → master priority list inside agentctl. For tasks
branched off a release/staging branch this inflated the counts by every
commit since the actual fork point, and the Changes panel grouped unrelated
files alongside the task's real work.
This wires the per-repo task_repositories.base_branch end-to-end into the
agentctl WorkspaceTracker so each repo's diff stats resolve against the
recorded base instead of the integration-branch fallback. A picker on the
Changes panel branch hover card lets the user change the base mid-task; the
new value is persisted via PATCH /tasks/:id/repositories/:repo_id, pushed
live to the running agentctl instance, and the session's cached
base_commit_sha is cleared so the commits panel + cumulative diff refetch
against the new base without a session restart.
Falls back to the existing origin/main → master list for legacy tasks
(empty base_branch) and to the branch tip when no merge-base exists with
HEAD (unrelated histories — local rebase backups, etc.) so the stats and
commits panels stay anchored on the same SHA in every failure mode.
* fix(agentctl): sanitize user-controlled base_branch refs before git exec
CodeQL flagged the picker's user-controlled base_branch payload reaching
exec.Command("git", ..., baseBranch) inside workspace_git_cmd.go as a
command-built-from-user-controlled-source vulnerability. A malicious caller
could smuggle a leading-"-" argument like "-upload-pack=..." that git
itself reinterprets as a flag, even though exec.Command keeps args
separate from the shell.
Adds an IsSafeGitRef sanitizer at every boundary the value crosses on its
way to git: the service write path, the agentctl HTTP handlers
(/workspace/base-branches, /git/log, /git/cumulative-diff), and
WorkspaceTracker.SetBaseBranch itself. Unsafe values are rejected at the
service tier and silently downgraded to the no-override fallback inside
agentctl so a misbehaving caller can't half-break the tracker.
* fix(diff): address PR review blockers and CI lint
- Split UpdateRepositoryBaseBranch into validate/load/apply helpers to drop
cyclomatic complexity below the cyclop limit that gated Run Backend Tests.
- Replace direct m.cfg.BaseBranches reads/writes with getBaseBranches /
setBaseBranches under a dedicated RWMutex. UpdateBaseBranches used to
write under repoTrackersMu while rescan + lazy-subpath callers read under
workspaceTrackersMu, leaving an unsynchronized data race on the map.
- Drop the false 'PR target is auto-wired' claim from the MCP tool
description — GitOperator.CreatePR takes baseBranch as an explicit arg
and never reads the tracker's value.
- Picker no-op guard now compares against currentBase instead of storedBase
so selecting the displayed fallback (legacy task before any picker
change) is treated as no-op instead of triggering a spurious save.
- HTTP handler now maps non-validation service errors to 500 instead of
400, classifying via the existing isValidationError helper plus the new
unsafe-git-ref message.
* fix(diff): regex-based ref sanitiser at every git subprocess sink
CodeQL kept flagging workspace_git_cmd.go:43 even after SetBaseBranch
rejected unsafe values — the taint tracker doesn't follow sanitised stores
through struct fields, so it still saw user input flowing into
exec.Command("git", ..., baseBranch).
Switches IsSafeGitRef to a single regex check (a pattern CodeQL recognises
as a sanitiser barrier) and adds SanitizeGitRef calls inline at every
agentctl call site that splices a user-controlled ref into a git arg:
- workspace_git_status.resolveStoredRef (before the rev-parse probes)
- workspace_git_status.computeBaseCommit (before merge-base/rev-parse)
- workspace_git_status.getAheadBehindCounts (before rev-list)
- agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
(before computeMergeBase/GetRevParse on the HTTP TargetBranch)
Behaviour is unchanged for valid refs — only narrows the allowlist (now
[A-Za-z0-9_./-]) so the sanitiser barrier is recognisable to static
analysis. SetBaseBranch's field-store sanitiser stays as defence in depth.
* fix(diff): transform sanitiser at every map read for CodeQL taint flow
CodeQL still flagged workspace_git_cmd.go:43 because:
- handleSetBaseBranches used an IsSafeGitRef bool guard before a verbatim
map[k] = v copy. CodeQL recognises regex-transforming sanitisers but not
bool-guarded passthroughs.
- lookupBaseBranch returned m.cfg.BaseBranches[k] verbatim. Even though
the map was sanitised on write, taint flow across map stores + field
stores loses the sanitised state in the analyser.
Switches both sites to the transforming SanitizeGitRef helper so every
read produces a freshly-sanitised value before it flows to a downstream
git argument. Behaviour is unchanged for valid refs.
* fix(diff): regex transformer sanitiser + guard live push from empty map
CodeQL still flagged the previous SanitizeGitRef because it returned the
input string unchanged on success — an identity passthrough that the taint
tracker doesn't recognise as a sanitiser barrier.
Switches to safeGitRefPattern.FindString so the output comes from the
regex match (not the parameter). IsSafeGitRef now wraps SanitizeGitRef so
the predicate and the transformer share one allowlist.
Also addresses two Claude review blockers:
- collectTaskBaseBranches returns a nil/empty map when the task has no
recorded base_branches; pushing that to agentctl wipes every tracker's
override. Now skipped — the DB row is the source of truth for the next
session launch.
- applyBaseBranchSideEffects now runs on context.WithoutCancel so an HTTP
/ WS request cancel after the task_repositories UPDATE commits can't
leave session.base_commit_sha stale + the live agentctl tracker map
un-pushed, drifting from the persisted row.
* fix(diff): share securityutil.IsValidBranchName + harden error classification
CodeQL still flagged the picker's regex sanitiser as taint passthrough.
Refactors SanitizeGitRef / isSafeBaseBranchRef to delegate to the existing
securityutil.IsValidBranchName helper that agentctl's Rebase / Merge /
RenameBranch already use without being flagged. The shared allowlist also
fixes a Claude review blocker: 'feature/@2024' used to pass the service
validator but get dropped by the agentctl-side check at push time,
silently wiping every tracker's override.
Plus the two error-classification blockers Claude raised on the previous
HEAD:
- WS handler (task_ws_handlers.go wsUpdateTaskRepository) now distinguishes
validation errors (surfaced verbatim) from internal faults (opaque
500-style message). HTTP path already did this via the existing
isValidationError helper.
- MCP handler (handlers.handleUpdateRepositoryBaseBranch) gets the same
treatment via a new isValidationError local to the mcp/handlers
package so raw service error strings stop leaking across the MCP
boundary.
* fix(diff): inline securityutil.IsValidBranchName at every git subprocess sink
CodeQL's interprocedural taint tracking wasn't recognising SanitizeGitRef
as a sanitiser even after it delegated to securityutil.IsValidBranchName
— the wrapper helper + map/field hops between source and sink kept the
flow tainted in the analyser.
Inlines the IsValidBranchName allowlist check (already used inline by
Rebase/Merge in git.go without being flagged) at every call site that
splices a user-controlled ref into a git subprocess argument:
- workspace_git_status.resolveStoredRef (before rev-parse --verify)
- workspace_git_status.computeBaseCommit (before merge-base / rev-parse)
- workspace_git_status.getAheadBehindCounts (before rev-list)
- agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
- agentctl/server/api/workspace_base_branches.go handleSetBaseBranches
Behaviour unchanged: same allowlist as Rebase / Merge / RenameBranch
already use. SanitizeGitRef / IsSafeGitRef survive as defence-in-depth
helpers for callers that want a one-line check; they delegate to the same
securityutil helper so the rules stay aligned.
* fix(diff): inline regexp.MatchString at every git subprocess sink
CodeQL still flagged workspace_git_cmd.go:43 even with the inline
securityutil.IsValidBranchName helper — the taint tracker apparently does
not follow into helper functions for the go/command-injection rule on
this path.
Hoists the regex to package-scope vars (safeBranchRefPattern in both
process and api packages) and calls regexp.MatchString directly at each
sink site. Behaviour is identical to securityutil.IsValidBranchName; only
the call form changes.
* fix(diff): SHA-shape allowlist on BaseCommit before downstream git diff
CodeQL still flagged workspace_git_cmd.go:43 because update.BaseCommit
is the OUTPUT of an earlier merge-base / rev-parse call whose argument
list contained a user-controlled branch ref — the analyser treats that
command's output as still tainted even when the input ref was sanitised.
Adds an inline 40-hex SHA-shape allowlist check in enrichWithBranchDiff
right before `git diff --numstat <update.BaseCommit>` so the sanitiser
barrier sits in the same function as the next subprocess call. Behaviour
is unchanged: merge-base / rev-parse already produce SHA-shaped output.
* fix(diff): async tracker refresh + tighten init spread order
Two CodeRabbit/Claude review fixes:
- UpdateBaseBranches stamps each tracker's new baseBranch synchronously
then runs RefreshGitStatus on a background goroutine instead of inside
the picker-save HTTP handler. Per-tracker git subprocesses can each
take ~3-5 calls; serialising them on the request goroutine made the
PATCH response wait for every refresh to finish. The picker UI re-fetches
via the existing WS stream once Refresh emits, so the handler doesn't
need to block.
- updateTaskRepositoryBaseBranch now spreads options.init BEFORE the
required method/body fields so an upstream caller's init overrides
can't replace the PATCH method or the base_branch payload. Matches
the convention deleteTask / moveTask already follow.
* fix(diff): narrow mcp isValidationError + opaque 500 + post-sanitize log count
Three final Claude review threads on the latest commit:
- mcp/handlers isValidationError drops the broad 'invalid' substring so
DB / IO errors whose messages happen to contain 'invalid' don't
misclassify as ErrorCodeValidation.
- task_http_handlers httpUpdateTaskRepository now logs the raw service
error server-side and returns an opaque 'failed to update task
repository' on the 500 path. Stops potential DB / IO detail leakage
through the HTTP boundary.
- workspace_base_branches debug log reports accepted + rejected counts
(post-sanitisation) instead of the pre-filter total so the metric
actually reflects what hit UpdateBaseBranches.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* 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
* feat(terminal): add touch-scroll helper for xterm scrollback
Pure helper that translates single-finger vertical drags into
`terminal.scrollLines(n)` calls. Handles threshold gating so taps still
reach xterm, ignores multi-touch, yields to horizontal-dominant drags.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(terminal): wire mobile touch scrollback into passthrough terminal
Adds an `enableTouchScroll` prop on PassthroughTerminal and a thin hook
that calls `attachTouchScroll` while the terminal is mounted. The two
mobile mount points (terminal panel + chat-panel passthrough embed) opt
in; desktop callers are unaffected. Fixes the bug where xterm.js's
canvas absorbed touch gestures and left the scrollback unreachable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(terminal): mobile e2e for passthrough touch-scroll
Pixel 5-emulated Playwright spec: produce 200 lines of output, simulate
a single-finger down-swipe, assert `terminal.buffer.active.viewportY`
moved into the scrollback, then swipe back to return. Extends the e2e
buffer reader to expose viewportY alongside `__xtermReadBuffer`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(terminal): relax mobile swipe-back assertion against late shell output
Replace the exact `.toBe(bottomViewportY)` check with
`.toBeGreaterThanOrEqual` so the test doesn't flake if shell echo
arrives between the snapshot and the post-swipe read. xterm clamps at
the buffer boundary, so overshooting back-to-bottom is impossible.
Addresses PR #1046 Claude review thread 3293258085.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(terminal): document useTouchScroll's isTerminalReady invariant
Adds a comment next to the eslint-disable explaining why the deps
array can safely omit terminalRef/xtermRef: useTerminalInit only flips
isTerminalReady false→true once per mount. A future refactor that
recreates the terminal mid-mount must add a new dep.
Addresses PR #1046 Claude review thread 3293258175.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(terminal): clear all exposed buffer-reader fields on dispose
clearBufferReader now nulls `__xtermGetFontFamily` and
`__xtermGetFontSize` alongside the buffer readers. The two predated
this PR and were never cleaned up; keeping the teardown symmetric with
exposeBufferReader prevents stale references if the container is ever
reused.
Addresses PR #1046 Claude review thread 3293274674.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(terminal): reset document.body between touch-scroll cases
Adds an afterEach to wipe jsdom's body so orphan divs from one test
don't bleed into the next. Avoids cross-test pollution if a future
case queries document.body for containers.
Addresses PR #1046 Claude review thread 3293274769.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): show tasks from all workflows in the mobile task-switcher sheet
The mobile sheet read tasks via useTasks(workflowId), which gated on
kanban.workflowId === workflowId and returned [] otherwise. On a fresh
/t/[id] load the gate often failed (or the snapshot fetch was still in
flight / silently failing) and the sheet rendered "No tasks yet."
Align mobile with desktop: read from kanbanMulti.snapshots via a new
shared useWorkspaceSidebarTasks hook that does the per-workspace
aggregation (incl. the active-kanban fallback), and pass workflows +
stepsByWorkflowId through so the existing TaskSwitcher grouping kicks
in. The desktop sidebar now delegates to the same hook.
* refactor(web): address review comments on PR #1025
- Extract findTaskInSnapshots to task-select-helpers.ts (shared between
desktop sidebar and mobile sheet). Mobile passes kanban.tasks as the
optional fallback so it still locates tasks that arrived via WS before
the snapshot fetch landed.
- Build workflowNameById from the already-scoped `workflows` array instead
of the raw, unscoped kanbanMulti.snapshots. Stops the memo from re-running
on snapshot updates from other workspaces and removes the risk of a stale
workflow name leaking across a workspace switch. Same fix in both the
desktop sidebar and the mobile sheet.
- Collapse the duplicated workflow filter in useWorkspaceSidebarTasks into
a single `filteredWorkflows` memo.
* fix(web): empty scope when workspaceId is unresolved in mobile/desktop sidebar hook
When `useWorkspaceSidebarTasks` was called with a null workspaceId (the
initial SSR / pre-hydration state on /t/[id]) it would widen to every
workflow in the store, briefly leaking snapshots from previously-active
workspaces into the sidebar. Return an empty scope until the workspace
resolves instead, matching the "no cross-workspace bleed" intent of the
original useScopedAggregation comment.
Adds a regression test, plus splits the test file's describe block so the
linter's 100-lines-per-function cap doesn't trip.
* refactor(web): consolidate findTaskInSnapshots and drop unused useSheetData.steps
- Reuse the canonical helper in `lib/kanban/find-task.ts` instead of
duplicating it in `task-select-helpers.ts`. Extend the canonical one
with an optional `fallbackTasks` parameter so the mobile sheet can still
locate tasks via `state.kanban.tasks` when the snapshot fetch hasn't
landed yet — desktop call sites continue to omit the fallback.
- `useSheetData` was returning the raw active-kanban `steps` slice next
to the already-mapped `dialogSteps`. The sheet component only consumes
`dialogSteps`; drop the unused field.
* test(web): add unit tests for findTaskInSnapshots fallback path
* fix(web): pass active-kanban fallback into desktop sidebar task lookups
useWorkspaceSidebarTasks can render tasks from the active kanban slice
before kanbanMulti.snapshots catches up, but the desktop sidebar's
archive/delete/select handlers were searching snapshots only — leaving
fallback-only tasks unresolved during the snapshot race window. Pass
state.kanban.tasks into findTaskInSnapshots in all three handlers so
the dialogs find the right title and selectTaskWithLayout sees the task.
Also trims the multi-line comment in lib/kanban/find-task.ts to a single
line per CLAUDE.md style guidance.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
fix(vcs): default the commit dialog's "stage all" checkbox to unchecked
Opening a commit dialog now commits only already-staged files by
default. Users can opt in to "Stage all changes before committing"
per commit.
Co-authored-by: Kandev Agent <agent@kandev.dev>
* feat(web): drag-reorder subtasks within a parent task
Adds per-parent subtask ordering in the sidebar. Drag a subtask under its
parent to reorder; the chosen order is independent of the global sort and
persists per browser via localStorage.
- New `sidebarTaskPrefs.subtaskOrderByParentId` slice field and matching
`setSubtaskOrder(parentId, ids)` action, persisted to localStorage under
`kandev.sidebar.subtaskOrderByParentId`.
- `applyView` applies the per-parent override after `separateSubtasks`, so
it never touches root order or the active sort spec.
- TaskSwitcher exposes `onReorderSubtasks(parentId, ids)`; each parent's
subtask list lives in its own nested DndContext / SortableContext using
the existing dnd-kit setup.
- Task-deletion cleanup strips the deleted id as both parent key and
subtask entry from `subtaskOrderByParentId` in store + localStorage.
* refactor(web): address PR review feedback on subtask reordering
- Extract `pruneSubtaskOrder(map, taskId)` helper in `local-storage.ts`;
use it in both `cleanupTaskStorage` and `removeTaskFromSidebarPrefs`
to keep the two cleanup paths in lockstep.
- Move `SortableSubtaskRow`, `SortableSubtaskList`, and `useSubtaskDnd`
into a new sibling file `task-switcher-subtask-dnd.tsx` so the
switcher stays under the 600-line soft limit.
- `SortableSubtaskList` now short-circuits to a plain row map when
`onReorderSubtasks` is undefined — read-only callers no longer get a
misleading grab cursor or unused DnD scaffolding.
* fix(web): add stable keys to non-DnD subtask render path
When `onReorderSubtasks` is undefined, the early-return branch in
`SortableSubtaskList` mapped subtasks without keys, falling back to
React's index-based reconciliation. Wrap each child in a keyed
`Fragment` so reorders or size changes reconcile correctly.
* refactor(web): single-source DRAG_ACTIVATION_DISTANCE
Export the constant from `task-switcher-subtask-dnd.tsx` and import it
in `task-switcher.tsx` instead of declaring the same value twice.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>