Commit Graph

30 Commits

Author SHA1 Message Date
Carlos Florêncio dfc132c6f1 fix(orchestrator): block workflow advance during pending clarifications (#1251) 2026-06-02 23:38:41 +01:00
Carlos Florêncio 5591cdc0bf fix(web): add button to load older chat messages reliably (#1223) 2026-06-01 19:12:49 +01:00
João Salavisa aea266ee71 fix(web): fix hold-to-talk on mobile and add coarse-pointer toggle fallback (#1231)
* fix(web): make voice-mode hold gesture survive touch and add mobile coverage

Mobile users with the chat composer's mic button in hold-to-talk mode lost
their recording the moment a finger crept off the 28px target, because
`buildHoldHandlers` aborted on `pointerleave`/`pointercancel` without ever
claiming the pointer.

- `buildHoldHandlers` now claims the pointer on `pointerdown` via
  `setPointerCapture` and releases it on `pointerup`/`pointercancel`. The
  `pointerleave` handler is dropped — capture keeps the gesture alive across
  small finger movement, OS gesture handoff, and soft-keyboard reflows.
- `EnabledVoiceInputButton` resolves an `effectiveMode` via a coarse-pointer
  check (`matchMedia("(pointer: coarse)")`); when the stored preference is
  `hold` and the device is touch-first, the mic falls back to toggle so a
  bad stored preference can't trap users in a broken UX. Persisted
  `voiceMode.mode` is left untouched so docking a keyboard restores the
  user's choice without a save.
- The button bumps to 40×40 on coarse-pointer devices to meet the 44pt touch-
  target floor without disturbing the desktop submit/voice pair.
- The unsupported fallback path (`UnsupportedVoiceButton`) is unchanged so
  LAN-HTTP mobile users still get the HTTPS explanation toast.

Adds `voice-input-button.test.tsx` covering capture/release on pointer
events, the absence of `pointerleave` regression, the coarse-pointer toggle
override, and the touch-target class swap.

Adds `mobile-voice-mode.spec.ts` (Playwright mobile-chrome / Pixel 5)
verifying the Voice Mode settings page is reachable from the mobile sidebar
trigger and that the mic button mounts with `data-effective-mode="toggle"`
on the mobile chat composer.

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

* test(web): drop unused testPage param from mobile-voice-mode seedTask helper

The helper only calls apiClient and does not touch the Playwright Page
object — the parameter was carried over from the neighbour spec's pattern
where the helper also navigates. Removing it also drops the now-unused
`Page` type import.

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

* refactor(web): drop redundant Element type cast from safePointerRelease

`Element.hasPointerCapture` and `releasePointerCapture` are already declared
in lib.dom.d.ts, so the `target as Element & { hasPointerCapture?: ... }`
casts were over-defensive. Replace with a direct `typeof` runtime check,
which still guards against the headless DOMs that strip these methods.

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

* test(web): assert coarse-pointer touch-target class in mobile voice spec

The previous assertion (`data-effective-mode === "toggle"`) would have
passed even if the coarse-pointer override were reverted, since the default
stored `voiceMode.mode` is already `"toggle"`. Add a check on the `h-10
w-10` class swap — that class pair is only emitted when
`useIsCoarsePointer()` returned true, so the test now genuinely exercises
the override on the e2e surface.

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

* test(web): extend voice-input-button unit coverage for toggle paths

- Toggle-mode + fine-pointer: pointerdown must not start recording; click
  drives the toggle. Guards against an accidental future change that wires
  hold pointer handlers when stored mode is "toggle".
- Recording state + click: stop() fires (not start()), exercising the
  buildToggleHandler branch the original suite skipped.

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

* test(web): seed hold mode in E2E and fix misleading test title

- Add `voice_mode` field to `saveUserSettings` in api-client.ts so E2E
  tests can PATCH the user's stored activation mode via the API.
- Seed `mode: "hold"` before the mic-button assertion so
  `data-mode="hold"` + `data-effective-mode="toggle"` together prove the
  coarse-pointer override fired (not that the default was already toggle).
- Rename settings-page test to drop the "from mobile sidebar" claim —
  the test navigates directly to the URL; sidebar existence is still
  asserted as a regression guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 18:45:50 +01:00
João Salavisa 1fdeda904b feat: add voice mode for chat input (#1159)
* feat: add voice mode for chat input

Adds a voice input button to the chat composer with three transcription engines:
in-browser Web Speech API, in-browser Whisper via @huggingface/transformers, and
a server-side OpenAI Whisper fallback gated on KANDEV_VOICE_OPENAI_API_KEY.

User-configurable from a new /settings/voice-mode page:
- Master on/off toggle (defaults to on; opt-out, not opt-in)
- Engine preference (auto / Web Speech / Whisper Web / Whisper Server)
- Language hint (auto-detect or BCP-47 pinned)
- Activation mode (click-to-toggle or hold-to-talk)
- Auto-send after transcription
- Whisper Web model size (tiny / base / small)
- Editable Cmd+Shift+M keyboard shortcut

Voice settings live under user_settings.voice_mode as a nested JSON object —
no schema migration. Setting changes broadcast via the existing
user.settings.updated WS event so any open chat tab updates live.

The mic button is rendered on the right of the composer alongside the send
button (round primary fill; destructive fill with pulse ring while recording).
On browsers without a usable engine (e.g. iOS Chrome over HTTP) the button
stays visible as a muted greyed icon — tapping it surfaces a toast with the
reason (typically a missing secure context).

Whisper Web runs in a Web Worker with fp16 decoder weights (avoids the
MatMulNBits scale-missing crash that q4/q8 hit on WASM). Models are pulled
from the onnx-community/* mirror and cached by transformers.js in the Cache
Storage on first use.

* fix: address voice mode PR review feedback and E2E selector clash

Bot-review fixes from #1159:
- Map http.MaxBytesError from c.FormFile to 413 instead of bare 400.
- Strip BCP-47 region suffix before passing language hint to Whisper Web.
- Recover from worker crash: terminate + null worker + reset ready flag so
  next init/transcribe creates a fresh worker instead of hanging.
- Read live store state via storeApi.getState() in voice-mode settings
  savers to avoid stale-closure clobbering concurrent settings updates.
- Detach Web Speech callbacks before abort() so trailing onend/onerror
  events don't mutate hook state after cancel().
- Spread caller init before method/body in voice-api fetch so a caller-
  supplied init can't override the multipart POST.
- Replace t.Fatalf with t.Errorf+return inside test HTTP handler goroutine
  (FailNow from non-test goroutine is unsafe).
- Add Enabled/AutoSend zeroing assertions in partial-update test so the
  bool-overwrite-on-PATCH behavior is locked in explicitly.

E2E fix:
- toolbar-overflow spec used button.rounded-full which now matches both
  the send and voice buttons (strict-mode violation). Switched to the
  submit-message-button testid for an unambiguous locator.

* fix: scope voice settings rollback + whisper worker error to changed keys

Round 2 of PR review feedback on #1159:
- voice-mode settings rollback now restores only the keys patched by the
  failing request (not the whole snapshot), preserving concurrent edits
  to unrelated voiceMode fields.
- Shortcut rollback diffs previous vs next and reverts only the changed
  keys (restore prior value or delete if absent), preserving other
  concurrent shortcut edits.
- Whisper worker error handler captures the worker reference at attach
  time and only clears refs when this.worker still matches it — a stale
  error from a previously replaced worker no longer nukes the active one.
- Update stale whisper-web-models JSDoc that still pointed at Xenova
  mirrors.

* fix: voice mode progress scale, hold-mode race, race-aware rollback

Round 3 of PR review feedback on #1159:
- Normalise Whisper progress to 0–1 in ensureWhisperClient (transformers.js
  emits 0–100, the button's display + the `ready: 1` convention expect
  0–1 — fixes the 5000% mid-download display).
- Claim driverRef synchronously at the top of finishCapture so concurrent
  pointerup + pointerleave invocations early-return instead of racing and
  clobbering a freshly-started recording's ref.
- Race-aware rollback: voice-mode and shortcut catch branches now skip
  reverting a key when latest[key] !== next[key]. A failed earlier request
  no longer overwrites a later successful save to the same key.
- Update the Worker wire-protocol comment to show onnx-community/whisper-base
  instead of the stale Xenova/* example.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-01 14:32:39 +01:00
Carlos Florêncio b2faab430b fix(web): remove agent id from subagent metadata chips (#1205) 2026-05-31 20:16:26 +01:00
Carlos Florêncio d1c73d2f2c feat: surface subagent task tool calls as cards in task chat (#1132) 2026-05-31 17:53:21 +01:00
Carlos Florêncio 8e1f55a0ac fix: keep env prep out of partial tool history (#1189) 2026-05-31 11:48:21 +01:00
Carlos Florêncio dcde3da74c feat: surface an inline notice when an agent turn produces no output (#1179) 2026-05-31 11:14:23 +01:00
Carlos Florêncio 72441fb69a feat(web): add PR closed banner and hide CI chip on terminal state (#1161) 2026-05-30 12:54:32 +01:00
Carlos Florêncio 2a68a9b7c7 feat(web): persist PR-merged banner dismissal in sessionStorage (#1129) 2026-05-29 00:10:33 +01:00
Carlos Florêncio f7eff241b9 ci: faster e2e via image split, shard rebalance, and flake fixes (#1126) 2026-05-28 19:16:30 +01:00
Luan Martins 1a467b969f fix(web): wire permission approval UI for Kandev MCP tools (#1037)
* fix(web): wire permission approval UI for Kandev MCP tools

KandevToolMessage adapter ignored the permissionMessage prop, so MCP tools
that block on a permission request rendered the result row but no Approve/
Deny buttons — users could not unblock the agent. Extract the shared
permission handlers into use-permission-handlers.ts and reuse them from
both ToolCallMessage and KandevToolMessage. KandevRow now also shows an
amber pending clock icon and hides the result-count summary while waiting
for approval so the row reads correctly before the result arrives.

Adds a mock-agent scenarioKandevMCPPermission emitting
mcp__kandev__list_workspaces_kandev plus a Playwright e2e and unit tests
covering the pending UI and the absence of "0 workspaces" while pending.

* fix(web): collapse tool + permission status into resolved Kandev row

After approve/reject the orchestrator stamps the decision onto the
permission_request message immediately, but the tool_call's own
meta.status stays "pending" until the next tool_call update lands.
KandevToolMessage previously passed meta.status straight to the renderer,
so the row kept the amber pending clock and the hidden-summary state for
the brief window between the two events. resolveKandevStatus now prefers
the permission decision once present, mapping approved -> complete and
rejected -> error.

Drops a redundant Playwright `.first()` after a toHaveCount(1) assertion
on the same locator. Adds unit coverage for the resolved-decision state.

* fix(web): keep Kandev tool status independent of permission decision

The previous fix collapsed an approved permission into tool status
"complete" and a rejected permission into "error". That was wrong on two
counts: an approved tool that's still running rendered as already done
(green check + visible result summary built from an empty payload), and
any later tool_call error was silently masked because the status had
already been forced to "complete".

Replace the status override with a separate display overlay. KandevRow
now reads a `KandevPermissionUIContext` ("pending" | "rejected" |
undefined) and uses it only to drive the waiting-on-user affordances —
amber clock, hidden summary — and a rejection mark that wins over the
tool's own success state. The renderer always receives the real
tool_call status, so a tool that's approved-but-pending still reads as
pending and a tool that errors after approval still renders the red X.

Adds explicit regression tests for the two race-window failure modes
plus the new rejected overlay.

* test(web): tolerate duplicate-render backend race in Kandev MCP e2e

CI shard 1 sometimes renders two approve buttons for the same Kandev MCP
permission because the orchestrator can create a second generic tool_call
message for the same tool_call_id alongside the kandev-routed one. Both
buttons resolve the same pending_id, so clicking the first unblocks the
agent either way.

The bug the test guards against is "no approve button at all" — the
strict toHaveCount(1) assertion was tying the test to that separate
backend issue. Relax to at-least-one and click the first.

* fix: persist Deny clicks as rejected, not approved

Backend RespondToPermission previously derived status purely from cancelled:
cancelled=true -> "rejected", otherwise "approved". Deny clicks sent
cancelled=false with a reject_* option_id, so they were recorded as
"approved". Add an explicit rejected flag from the WS request so the
backend can record "rejected" without taking the cancellation path (which
races against permission_cancelled -> "expired").

Also refactor permission-request-message.tsx to consume the shared
usePermissionResponseHandlers hook (the duplicated inline handler had the
same bug) and introduce a typed models.PermissionStatus enum on the
backend so status strings are not scattered literals.

* test(web): scope Kandev MCP permission e2e to the Kandev row

Previously the test clicked the first matching permission-approve button,
which could be a duplicate generic ToolCallMessage fallback rendered for
the same pending_id by a backend race -- a green test no longer proved the
custom Kandev approval UI rendered.

Add a `data-testid="kandev-tool-permission"` wrapper around the Kandev
action row and a `kandevPermissionApproveButtons` page-object locator that
scopes through it, then assert `toHaveCount(1)` so the test only passes
when the Kandev custom row actually mounts.

* chore(orchestrator): log rejected flag in RespondToPermission

Debug log was missing the new rejected param, making it impossible to
distinguish explicit Deny from dialog cancellation when triaging
permission-flow issues.
2026-05-26 00:22:45 +01:00
Carlos Florêncio 2ca48fc7df feat(repositories): guard delete behind active-session check + UI fixes (#968) 2026-05-21 09:24:34 +01:00
José Almeida f2479aabce feat(web): collapsible queue chip with animated panel (#957) 2026-05-20 01:22:25 +01:00
Carlos Florêncio a74409af3c fix(web): cap clarification overlay height and add mock agent ask commands (#956) 2026-05-19 22:27:24 +01:00
José Almeida af9d3547a6 feat(web): clearer custom-answer state and Cmd+Enter submit on clarifications (#934)
* feat(web): add clearAnswer + submit re-entry guard to useClarificationGroup

clearAnswer removes a stored answer so emptying a clarification draft can
revert the stepper to "unanswered". A small inflight ref in submitCollected
/ skipAll prevents duplicate POSTs when multiple submit paths race (e.g.
Cmd+Enter inside the custom input commits the draft and also asks the
parent to finalize the batch).

* feat(web): show selected state and Cmd+Enter submit on clarification custom input

The "Or type a custom answer…" input previously had no selected styling and
needed an Enter keypress before the carousel stepper marked the question as
answered, which made it easy to lose track of which path was the active
answer. Now:

- Typing into the custom input live-records the draft, so the stepper and
  group progress update immediately and the input picks up the same blue
  ring + check icon used by selected options. When custom is active, any
  previously picked option visually deselects (they are mutually exclusive
  at commit time).
- Picking an option after typing wipes that question's draft so the visible
  input and committed answer stay in sync.
- Cmd/Ctrl+Enter submits the bundle when every question is answered — from
  inside the custom input (commits the draft then finalises) and from
  anywhere else in the overlay. Inside the input is handled locally so
  submitCollected does not fire twice.

* test(web): cover live-record + Cmd+Enter clarification UX

Adds four cases over the existing clarification-multi mock scenario:
typing auto-selects custom and deselects the option, emptying the draft
reverts the stepper, Cmd+Enter from the last-step custom input submits
the bundle, and Cmd+Enter from outside the input submits when all answered.
Adds a session-page locator for the new custom-input container so the
data-active attribute can be asserted.

* fix(web): address bot review feedback on clarification UX

- Cmd/Ctrl+Enter inside the custom-text input no longer calls the
  per-question commit path. The draft is already live-recorded via
  onChange, so calling onSubmit here only added a stale advance-to-next
  step before the bundle was submitted. Now Cmd+Enter just finalises.
- handleSubmit depends on group.submitCollected (which the hook memoises)
  rather than the whole group object, so it no longer re-creates on every
  keystroke through the live-record path.
- Add a unit test that fires submitCollected twice concurrently and
  asserts the inflight ref keeps the wire count at 1 — direct coverage
  for the guard introduced in the previous commit instead of relying on
  the 409 round-trip test.
2026-05-18 22:32:16 +01:00
Carlos Florêncio adcde7c89e fix(backend): persist kandev-system wrap on first task prompt (#940) 2026-05-18 20:08:15 +01:00
Carlos Florêncio 8d593bcddb fix(web): scope pending-permission scan to current turn (#899) 2026-05-13 20:57:03 +01:00
José Almeida 0743477d48 feat: persist N-entry FIFO message queue (#864)
* feat(orchestrator): persist N-entry FIFO message queue

Replaces the single-slot, latest-wins, in-memory queue with a SQLite-backed
FIFO queue (default cap 10 per session, configurable via
KANDEV_QUEUE_MAX_PER_SESSION). Drain stays one-per-turn so each entry gets its
own user message and `on_turn_*` workflow cycle. Inter-task `message_task_kandev`
returns a structured `queue_full` error with sender attribution when the
target session is saturated, so the calling LLM can decide whether to retry.

Surfaces a new WS action `message.queue.remove` for single-entry deletion;
`message.queue.cancel` now means clear-all. Edit/remove are keyed on the
entry's UUID, returning `entry_not_found` when atomic-take won the drain race.

* feat(web): N-entry queue state, API, and optimistic hook

Replaces the single-slot QueueStatus with an ordered `entries[]` per session
plus capacity meta (`count`, `max`). API exposes typed `QueueFullError` and
`QueueEntryNotFoundError`; the hook surfaces optimistic edit/remove with
drain-race rollback (refetch on `entry_not_found`).

* feat(web): inline ghost-message queue UI with sender chips

Replaces the floating QueuedMessageIndicator overlay with a stack of ghost
messages rendered above the chat input. Each ghost shows a sender chip
(user / agent) and per-entry edit/remove. A small "n/max queued · clear all"
pill summarizes capacity. Inter-task entries are visually distinct and edit
is gated to the original sender.

* fix(queue): address review — auth guard, TOCTOU, error UX

- Add `messagequeue.QueuedByUser` / `QueuedByAgent` constants and centralize
  the `queue_full` error code so the WS and MCP handlers stay in sync.
- WS `message.queue.{add,append,update}` default empty user_id to QueuedByUser
  so the UpdateContent ownership guard always runs against a non-empty owner;
  inter-task entries (`queued_by="agent"`) can no longer be overwritten by
  user-initiated edits even when `user_id` is omitted from the payload.
- memoryRepository: hold the mutex through the whole append-or-insert path
  (prior split released the lock between tail-check and Insert, allowing two
  same-sender callers to race past coalescing). TransferSession now shifts
  source positions past the destination's max so transferred entries always
  drain after pre-existing destination entries, matching SQLite semantics.
- Frontend: surface edit/clear/remove failures via `sonner` toasts (drain race
  → "message already sent"); exit edit mode on failure instead of leaving the
  textarea silently stuck. Hide the remove button on read-only (agent) ghosts.
- `useQueue.clearAll` no longer derives `max` from pre-clear entry count;
  resets to `metaMax ?? 0` so the next status_changed event lands authoritative
  capacity.
- `senderKindOf` drops the unreachable metadata fallback; route on `queued_by`
  alone since `dispatchTaskMessage` always sets it.
- Test loggers now write to stderr per CLAUDE.md "stderr for logs" guideline.

* test(web): align queue e2e selectors with new ghost-message UI

The ghost-message rewrite renamed button titles ("Cancel queued message"
→ "Remove queued message", "Edit message" → "Edit queued message") and
introduced a `data-testid="queued-ghost-list"` container. The
`message-queue.spec.ts` Playwright tests still pointed at the old titles.

* fix(queue): address second review pass — auth, drain race, tests

- WS handlers (`add` / `append` / `update`) now reject `user_id="agent"`
  outright, closing the gap a hostile WS client could exploit by
  impersonating the inter-task sender identity to overwrite agent
  entries (greptile P1).
- WS `update` / `remove` now require `session_id` so the post-mutation
  `MessageQueueStatusChanged` broadcast can never be silently skipped.
- `sqliteRepository.TakeHead` checks `RowsAffected()` after DELETE;
  when two concurrent take callers race past the busy-timeout the
  loser sees 0 affected rows and now returns `nil, nil` instead of
  the already-drained message (would otherwise dispatch the same
  prompt twice).
- `Service.GetStatus` returns `Entries: []` instead of `nil` on the
  error path so the wire shape stays consistent (frontend already
  guards but strict consumers wouldn't tolerate the type mismatch).
- New tests: `queue-api.test.ts` covers `rethrowQueueError` mappings
  (queue_full / entry_not_found / generic / Error pass-through);
  `session-slice.upsert.test.ts` covers `setQueueEntries`,
  `removeQueueEntry`, and `clearQueueStatus`. Backend gains an
  agent-impersonation handler test.
- `rethrowQueueError` now surfaces `WSError.message` for non-queue
  errors instead of stringifying the whole object.
- Cleanup: replace unused `a, c := ...` discards in service_test
  with `_, _ = ...`.

* fix(queue): scope update/remove by session_id (cross-session deletion guard)

The MCP `queue_full` error payload deliberately discloses sibling-task
entry IDs (so the calling agent's LLM can reason about the wedge state).
Without a session_id filter on `DeleteByID` / `UpdateContent`, a hostile
agent that received that payload could call `wsRemoveEntry` /
`wsUpdateMessage` against another task's entries and delete or rewrite
them — only the per-entry UUID was being checked.

- `Repository.UpdateContent` and `Repository.DeleteByID` now require
  `sessionID` and add `AND session_id = ?` to the SQL guard. The memory
  repo mirrors the scope (lookup keyed on session before the entry scan).
- `Service.UpdateMessage` / `Service.RemoveEntry` take `sessionID`; the
  WS handlers thread `req.SessionID` through (already validated as
  non-empty in the previous fixup).
- TypeScript: `session_id` is now required on `updateQueuedMessage` /
  `removeQueuedEntry` params so callers fail at compile time, matching
  the backend contract.
- Tests cover the cross-session attack: `RemoveEntry`/`UpdateMessage`
  with an attacker-supplied session_id but a real entry id from another
  session must return `ErrEntryNotFound` and leave the victim entry
  untouched. SQLite repo gets parallel coverage.

* fix(queue): propagate transfer error + tighten WSError narrowing

- `Service.TransferSession` now returns an error so callers can fail
  closed instead of silently leaving entries orphaned on the previous
  session. The two existing call sites pick the right policy:
  `switchSessionForStep` (reusing an existing session) propagates the
  error so the workflow switch aborts; the new-session path logs and
  continues, since the new session is already committed and rolling
  back here would leave a half-switched workflow.
- `asWSError` now also narrows on `message` / `details`, so WS payloads
  without a `code` field are still mapped to a meaningful `Error.message`
  via `rethrowQueueError` (instead of "[object Object]"). Native
  `Error` instances explicitly skip the WS-error branch so they pass
  through untouched, preserving the existing pass-through guarantee.
- `queue-api.test.ts`: `expect.assertions(N)` on the catch-only tests
  so a missing throw fails the test instead of silently passing.

* fix(queue): check TransferSession error in service tests

The TransferSession signature change in 83abe6d7 broke errcheck on two
test sites that hadn't been updated. Wrap them in require.NoError.
2026-05-11 22:40:36 +01:00
Luan Martins 1066ac5b16 feat: extend implement plan button with fresh-agent path and server-side plan mode (#832)
* feat: extend implement plan button with fresh-agent path and server-side plan mode

- Split-button: primary click = same-session, chevron = fresh agent with clean context window
- Forward draft attachments and context files on implement (adds getAttachments() to handle)
- Authoritatively clear plan_mode via new session.set_plan_mode WS endpoint, not a message flag
- Add on_exit:disable_plan_mode to plan-and-build.yml plan step (mirrors feature-dev.yml)
- Drop localStorage plan-mode rehydration; server session metadata is the single source of truth
- Delete dead DocumentPanel component (no callers)
- Add SetSessionPlanModeByID on orchestrator Service; wire wsSetPlanMode handler

* test: fix store isolation in useImplementFresh test + extract duplicate strings

- Fix test isolation: reset store directly and clear mocks before rendering
- Extract sessionId, appFilePath, appFileName to constants to fix ESLint duplicate-string warnings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(e2e): cover implement-plan fresh-agent split-button path

Adds Playwright coverage for the new chevron menu on the violet Implement
button: asserts a brand-new TaskSession is launched on the same task with
the planning session's agent profile inherited, and that the planning
session is left running in parallel. Second spec verifies the split-button
is gated behind plan mode.

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

* fix: defer plan-mode exit and surface error toast on implement failures

Move handlePlanModeChange(false) into the message.add success callback in
useImplementPlan so a failed send leaves the plan layout intact for retry.
Add error toast to useImplementFresh catch so launchSession failures are
visible to the user instead of silently logged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: guard fresh-agent launch on planning session executor_id

TaskSession.executor_id is optional, so the fresh-agent path could call
launchSession with an undefined executor and silently inherit the
workspace default instead of the planning session's executor. Add the
field to the early-return guard so the hook no-ops when the planning
session is missing either an agent profile or an executor.

* feat: mark fresh-agent session as primary and focus it

When useImplementFresh launches a new implementation session, the fresh
session is now automatically:
1. Set as primary via session.set_primary WS endpoint
2. Focused/selected as the active session in the UI

This improves UX by switching the focus to the fresh implementation context
immediately rather than keeping the planning session active.

Addresses comment in PR #832 from carlosflorencio.

- Extract setupFreshSession helper to keep complexity under 15
- Add tests for primary session marking and UI focus
- Add test for graceful handling of set_primary failures
- Update JSDoc to document the new behavior
- Extract test constants to avoid duplicate string warnings

* fix(test): replace self-referential test constants

`const TASK_ID = TASK_ID;` (and SESS_PLAN, SESS_FRESH) caused a TDZ
ReferenceError at module load, so vitest reported `(0 test)` and none of
the new fresh-agent coverage actually ran. Replace with literal values
so the 13 tests in this file execute again.

* fix: drop strict executor_id guard and skip composer clear on missing session_id

Two related issues:

1. e2e regression — the planning session in the FE store does not always
   carry executor_id (the field is omitempty on the backend DTO and isn't
   populated for fresh task sessions in the WS push path). The earlier
   guard required it before calling launchSession, so the fresh-agent
   path silently no-op'd in real flows. Drop executor_id from the guard
   and only forward it when set; backend resolves it from the agent
   profile / workspace default when omitted.

2. PR review (Greptile P1) — when launchSession resolves without a
   session_id, the previous flow still cleared the composer and draft
   even though no fresh session was activated. Bail out before the
   clear so the user keeps their typed text and can retry.

Tests updated:
- replace the no-op-on-missing-executor test with one that asserts the
  hook still launches and omits the key from the payload
- add coverage for the no-session_id branch keeping the composer intact

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 17:30:24 +01:00
José Almeida bce9fd266a feat: multi-question support for ask_user_question_kandev (#828)
* feat(backend): multi-question support for ask_user_question_kandev

Reshape the MCP tool from a single prompt+options pair to a questions[]
array (1-4 entries, single-choice each, custom text fallback). The
clarification HTTP handler gates the response until every question has an
answer, fans out one chat message per question (sharing pending_id, with
question_index/question_total in metadata), and returns a JSON map keyed
by question id back to the agent. The orchestrator resume prompt now
formats multi-line Q1/A1, Q2/A2 summaries when the bundle has more than
one question. Mock-agent gets a clarification-multi scenario and
ask_user_question_test.go covers the new schema + map response. No
back-compat shim — local-first tool, single-PR breaking change.

* feat(web): refresh clarification overlay with multi-question + modern UI

Each per-question chat message gets its own card with a "Question N of M"
progress chip, a ✓ Answered badge once committed, and shadcn-aligned
option buttons with kbd hints. A new useClarificationGroup hook coordinates
the per-question commits (decision A) and posts every answer in a single
batch once the user finishes the bundle; X rejects the whole group. The
single-question case keeps the 1-9 / Esc keyboard shortcuts. Playwright
e2e adds 6 multi-question scenarios (rendering, full happy path, partial
state, mixed option/custom, skip-rejects-bundle, sibling-state) on top of
the existing single-question regression suite.

* fix(web): clarification carousel UX with stepper navigation + race fix

Replaces the vertically stacked card layout with a horizontal stepper:
numbered circle dots up top show step state (active/answered/pending),
the single active question card sits in the middle, and Back / Next /
Submit buttons sit at the bottom. Picking an option auto-advances to the
next step (single-question case auto-submits, no explicit Submit button).
The stepper steps are clickable so users can revise prior answers.
Keyboard shortcuts: 1-9 for option pick, ArrowLeft/Right for navigation,
Esc for skip.

Also fixes a race that surfaced as a 400 BadRequest on the
/api/v1/clarification/:id/respond endpoint when the user clicked an
option before all N messages of a multi-question bundle had streamed in:
findPendingClarificationGroup now gates on metadata.question_total and
returns an empty array until the full bundle has arrived.

Playwright suite expanded from 6 to 12 multi-question scenarios:
stepper rendering, auto-advance, Back, stepper jump, Submit gating,
happy path, mixed custom-text, revise-via-jump, skip-from-mid-step,
number-key shortcuts, Esc skip, first-step Back disabled.

* fix(clarification): address CodeRabbit/Greptile/Claude review comments

Backend:
- Single source of truth for question validation: rename/export
  NormalizeAndValidateQuestions and call it from both the HTTP and the
  WebSocket-side MCP handlers, so duplicate question ids and bad option
  counts can't slip through either path.
- httpRespond now validates that every answer references a real question
  id from the bundle and that no id is answered twice (regression tests
  added).
- questionsFromMessages reorders by metadata.question_index so the
  reconstructed bundle preserves the original question order.
- adapters.CreateClarificationRequestMessages rolls back already-created
  messages when a later write in the bundle fails.
- extractQuestionAnswers always returns a JSON map (even on rejection,
  with an envelope { rejected, reject_reason }) and simplifyAnswer
  preserves both selected_option and custom_text when both are present.
- MCP tool description: rewrite the response example to match the actual
  output shape; add minItems / maxItems to the questions and options
  arrays in the JSON Schema.
- Test helper: drop the local contains() shim, use strings.Contains.

Frontend:
- useMemo([props]) was a no-op (props is always a fresh ref); split into
  [message, messages] so it actually memoizes.
- Drop the dead "expired" SubmitState branch — the backend never returns
  410 from this endpoint.
- Remove the asymmetric option-list dimming: the agent now receives both
  selected_option and custom_text together, so a half-grey state was
  misleading.
- New tests: pending-clarification.test.ts covers
  findPendingClarificationGroup including the question_total gate, and
  use-clarification-group.test.ts covers the hook's record/submit/skip
  flows and the override-merge path used by the single-question case.

* fix(web): treat clarification 409 as success so duplicate submit doesn't stick the overlay

* fix(clarification): unblock agent on persistence failure + nil-guard rejected branch

- Add Store.CancelRequest(pendingID) and call it from both the WebSocket
  MCP handler and the HTTP create-request handler when message creation
  fails. Without it, the agent's WaitForResponse parked for the full
  2-hour MCP timeout while no clarification cards ever reached the user.
  Both handlers now return an explicit error response on failure.
- applyAnswersToMessages now guards h.repo == nil in the rejected branch
  (parity with sibling expectedQuestionIDs).
- extractQuestionAnswers stubs for unanswered questions in a rejected
  bundle now carry { answered: false, rejected: true } so agents reading
  per-question payloads see the rejection without inspecting the
  envelope.
- Hook test: added 409-as-ok regression coverage for both submit and
  skip paths so the previous fix can't drift.
- Store test: covers CancelRequest unblocking a parked WaitForResponse.

* fix: drop unused e2e helper + use channel sync in CancelRequest test

* refactor(web): drop dead 'message' prop from clarification overlay

Both call sites pass 'messages' (the bundle group), so the legacy
single-message prop was unused. Tightening the prop type also lets the
useMemo depend on a single value.

* fix(web): ignore modifier-keyed events in clarification carousel shortcuts

Bare 1..9 / ArrowLeft / ArrowRight / Esc still drive the carousel, but
Cmd/Ctrl+1..9 (browser tab switch) and Alt+ArrowLeft (browser back) now
pass through as the user expects.
2026-05-05 19:14:57 +01:00
José Almeida a4e0fc0452 feat: attribute cross-task agent messages with a sender badge (#819)
* feat(orchestrator): carry sender_task metadata through user message paths

Add WithSenderTask builder to UserMessageMeta and a metadata-aware
QueueMessageWithMetadata variant on the message queue. The drain path
in event_handlers_agent merges queued metadata into the recorded user
message so attribution survives the queue → DB transition.

* feat(mcp): attribute message_task_kandev calls to the sending agent

The MCP server inside agentctl now injects sender_task_id and
sender_session_id from its own server context. The backend handler
validates the sender (rejecting missing IDs, self-messages, and unknown
tasks), looks up the sender title, wraps the prompt in a <kandev-system>
attribution block, and propagates structured metadata across all three
delivery paths (queue, created-session, record+prompt). The wrapped
content lets the receiving agent identify the peer both live and on ACP
session resume; the existing sysprompt strip pipeline hides the wrapper
from the UI.

* feat(web): render sender-task badge on cross-task agent messages

User messages whose metadata carries sender_task_id now render a
clickable purple badge above the bubble, linking to the source task.
The badge live-resolves the sender's title from the kanban store and
falls back to a greyed snapshot when the task isn't loaded
(cross-workspace, deleted, etc.). Adds a non-subscribing useTaskById
selector so badges don't fan out WS subscriptions per message.

* test(e2e): cover cross-task agent message attribution

Eight scenarios driven through the mock-agent's MCP script directive:
queue path, prompt path, self-message rejection, unknown-target error,
badge link target, live title re-resolution after rename, kandev-system
wrapper preservation in raw_content, and prompt bodies containing their
own kandev-system tags. Adds an updateTaskTitle helper to ApiClient.

* fix: address CodeRabbit + Greptile P1 findings on PR #819

- requeueMessage now forwards queuedMsg.Metadata via QueueMessageWithMetadata
  so sender attribution survives transient delivery failures and retries.
- CREATED-session branch in handleMessageTask records the user message AFTER
  StartCreatedSession succeeds, so a launch failure no longer leaves an
  orphaned attributed chat row.
- SenderTaskBadge: collapse identical displayTitle/fullTitle locals.
- E2E: extract waitForTargetIdle helper; replace the 1.5s raw sleep in the
  collision-check test with the same poll the rest of the suite uses.

* fix: address Claude review suggestions on PR #819

- wrapAgentMessage strips </kandev-system> from the embedded sender title
  before formatting. Without this, a title containing the closing tag
  would short-circuit the non-greedy strip regex and leak the attribution
  tail into the visible UI bubble. Metadata snapshot keeps the original
  title so the badge still displays it.
- Add a regression test covering the malicious-title case.
- Document the deliberate cross-workspace messaging policy on the sender
  lookup so future readers don't add a workspace-scope check (Q13 of the
  feature plan locked this as intentional).
2026-05-04 19:30:10 +01:00
José Almeida fcc7ceb8b6 fix(approvals): drop stuck pending permission_request messages (#723)
* fix(web): exclude permission_request from turn-complete safety net

The session.turn.completed handler iterated over messages and forced
metadata.status="complete" on anything carrying tool_call_id. Permission
prompts also carry tool_call_id (for FE pairing with their tool_call),
so this loop overwrote the user's "approved" decision on every prompt
in the turn — and the FE component re-rendered the Approve/Deny buttons.

* fix(backend): protect permission_request rows from tool_call sweeps

Three permission_request invariants the prior code violated, mirroring
the FE bug fix:

- CompletePendingToolCallsForTurn: SQL UPDATE filtered by tool_call_id
  presence, also catching permission_request rows. Add type filter so
  the safety net only touches actual tool messages.
- GetMessageByToolCallID: returned the oldest row carrying that
  tool_call_id, which races to the permission_request when timing
  shifts. Exclude permission_request type so a tool_update can never
  land on the user's approve/reject row.
- applyToolCallMessageUpdate: defensive guard that refuses to overwrite
  a permission_request, making the invariant explicit at the layer that
  does the writing.

Adds a regression test for CompletePendingToolCallsForTurn covering
approved/rejected/pending permission rows.

* test(e2e): cover approval-reappear regression with mock-agent scenario

Adds a multi-permission mock-agent scenario (three sequential bash
tools, each requiring approval) and an E2E test that approves all
three and asserts no permission action row remains visible after
the agent's turn ends, and that the resolved state survives a reload.

Supporting test infrastructure:
- data-testid hooks on PermissionActionRow.
- permissionActionRows() / permissionApproveButtons() on SessionPage.
- backend fixture force-disables AGENTCTL_AUTO_APPROVE_PERMISSIONS=false
  so a developer's local AUTO_APPROVE=true env doesn't silently bypass
  the permission UI in tests that exercise the approve/reject flow.

* test(backend): cover GetMessageByToolCallID permission_request exclusion

Addresses claude-review feedback on the approvals fix:
- Adds a regression test that seeds both a permission_request and a
  tool_call with the same tool_call_id (permission_request created
  first, so an ORDER BY created_at ASC sweep without the type filter
  would race to it) and asserts the lookup returns the tool_call row.
- Bumps the applyToolCallMessageUpdate invariant guard from Warn to
  Error: reaching that branch means a caller bug, not a transient
  condition, and Warn would silently swallow the violation.

* fix(e2e): scope auto-approve override to permission-approval test only

The previous fixture force-disabled AGENTCTL_AUTO_APPROVE_PERMISSIONS
globally, which broke session-isolation.spec.ts and other tests that
use scenarios with permission-required tools (e.g. /e2e:read-and-edit)
and rely on auto-approve to let the agent finish without UI clicks.

Default the env to "true" in the fixture; let permission-approval.spec
flip it to "false" via a backend.restart() override in beforeAll, then
restore in afterAll. Adds an envOverrides parameter to backend.restart.
2026-05-01 00:25:51 +01:00
José Almeida 7e8b4bb30e feat(quick-chat): eager-init agent on profile selection (#747)
* feat(quick-chat): eager-init agent on profile selection

Switch quick-chat backend from `IntentPrepare` to `IntentStart` so the
agent process boots immediately when the user picks a profile, instead
of waiting for the first prompt. The ACP `session/new` handshake fires
before the user types anything, so the slash menu, mode dropdown, and
model selector are populated up-front. Fixes the empty-slash-menu UX
in quick chat for ACP agents (OpenCode, Claude, Codex, etc.).

Frontend picker shows a per-card spinner + disables sibling cards
during boot. Rapid profile switches (and other picker-bypassing
actions like opening a new tab, switching tabs, or closing the modal)
supersede the in-flight start via a monotonic request id; the
resolved task is cleaned up via `deleteQuickChatTask` so we never
orphan an ephemeral task or its agent process.

Backend rollback uses a fresh background context so `DeleteTask`
runs even when the request context has already been cancelled by
the client.

`mock-agent` now emits `available_commands_update` from `NewSession`
and `LoadSession` (matching real OpenCode/Claude) so the eager-init
pipeline is exercised in e2e. Existing prompt-time emit kept as an
idempotent fallback.

* test+cleanup(quick-chat): unit tests for useAgentSelection; minor cleanups

- Add 5 unit tests for useAgentSelection covering happy path, rapid-pick
  supersession, reset() during in-flight, and error toast vs silent-on-superseded.
  Exercises the orphan-task cleanup that the eager-init flow depends on.
  (claude bot review)
- Drop redundant isDisabled alias in AgentPickerView. (claude bot review)
- Drop unused ctx parameter from emitAvailableCommandsAfterDelay — it was
  always called with context.Background() so the Done() branch was dead
  code. (claude bot review)
- Re-format with prettier after the rebase.

* fix(quick-chat): use constants.TaskDeleteTimeout for rollback ctx

Match the other DeleteTask call sites in task_http_handlers.go (line 851)
instead of an ad-hoc 10s timeout. (claude bot review)
2026-05-01 00:21:53 +01:00
Carlos Florêncio 74d44bd26b feat(acp): support claude-acp Monitor tool and fix incremental tool_call updates (#698) 2026-04-25 16:26:20 +01:00
Carlos Florêncio 99a8407dd8 fix: close clarification overlay when agent moves on (#608) 2026-04-16 15:58:01 +01:00
Carlos Florêncio 204fac1cca feat: acp-first profiles, models, and modes (#566) 2026-04-15 13:22:32 +01:00
Carlos Florêncio a704f4baca fix: add paragraph spacing to markdown body for visible line breaks (#540) 2026-04-06 09:01:09 +01:00
Carlos Florêncio 1587203c2e fix(web): add max-height and scrollbar to queue message editor textarea (#519) 2026-04-01 23:03:20 +01:00
Carlos Florêncio be1b3a41db refactor(web): reorganize E2E tests into feature-based subdirectories (#499) 2026-03-30 14:34:24 +01:00