Files
kandev/apps/web/components/task/task-chat-panel.tsx
T
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

248 lines
9.3 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react";
import { PanelRoot, PanelBody } from "./panel-primitives";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { type ChatInputContainerHandle } from "@/components/task/chat/chat-input-container";
import { QueuedGhostList } from "@/components/task/chat/queued-ghost-list";
import { MessageList } from "@/components/task/chat/message-list";
import { useIsTaskArchived } from "./task-archived-context";
import { useChatPanelState } from "./chat/use-chat-panel-state";
import { ChatInputArea, useSubmitHandler, useChatPanelHandlers } from "./chat/chat-input-area";
import { ClarificationInputOverlay } from "./chat/clarification-input-overlay";
import { PanelSearchBar } from "@/components/search/panel-search-bar";
import { SessionSearchHits } from "@/components/task/chat/session-search-hits";
import { usePanelSearch } from "@/hooks/use-panel-search";
import { useSessionSearch } from "@/hooks/domains/session/use-session-search";
import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
import { useAppStore } from "@/components/state-provider";
function useClarificationKey(agentMessageCount: number) {
const lastCountRef = useRef(agentMessageCount);
const [clarificationKey, setClarificationKey] = useState(0);
useEffect(() => {
lastCountRef.current = agentMessageCount;
}, [agentMessageCount]);
const handleClarificationResolved = useCallback(() => setClarificationKey((k) => k + 1), []);
return { clarificationKey, handleClarificationResolved };
}
function SessionSearchOverlay({
search,
agentLabel,
agentName,
}: {
search: ReturnType<typeof useSessionSearch>;
agentLabel: string | null;
agentName: string | null;
}) {
const currentIdx = search.activeHitId
? search.hits.findIndex((h) => h.id === search.activeHitId)
: -1;
const total = search.hits.length;
const handleNext = useCallback(() => {
if (!total) return;
const next = search.hits[(Math.max(currentIdx, -1) + 1) % total];
if (next) search.setActiveHit(next.id);
}, [search, currentIdx, total]);
const handlePrev = useCallback(() => {
if (!total) return;
const prevIdx = (Math.max(currentIdx, 0) - 1 + total) % total;
const prev = search.hits[prevIdx];
if (prev) search.setActiveHit(prev.id);
}, [search, currentIdx, total]);
if (!search.isOpen) return null;
return (
<div className="absolute top-2 right-2 z-20 flex flex-col items-end gap-1">
<PanelSearchBar
className="static"
value={search.query}
onChange={search.setQuery}
onNext={handleNext}
onPrev={handlePrev}
onClose={search.close}
matchInfo={{ current: currentIdx >= 0 ? currentIdx + 1 : 0, total }}
isLoading={search.isSearching}
// Session search already debounces in useDebouncedSearch; skip the
// bar's debounce so we don't stack 150ms + 180ms per keystroke.
debounceMs={0}
/>
<SessionSearchHits
hits={search.hits}
query={search.query}
activeHitId={search.activeHitId}
onSelect={search.setActiveHit}
isSearching={search.isSearching}
agentLabel={agentLabel}
agentName={agentName}
/>
</div>
);
}
/** Returns the AgentProfileOption for the session's profile, or null. Uses
* primitive profile id to avoid getSnapshot-cache errors from returning
* fresh objects on every selector call. */
function useSessionAgentProfile(sessionId: string | null | undefined) {
const profileId = useAppStore((state) =>
sessionId ? (state.taskSessions.items[sessionId]?.agent_profile_id ?? null) : null,
);
return useAppStore((state) =>
profileId
? (state.agentProfiles.items.find((p: { id: string }) => p.id === profileId) ?? null)
: null,
);
}
/** Resolves the agent profile name + registry slug for the given session.
* Label is "Profile Name" from the "Agent • Profile Name" store label; slug
* feeds <AgentLogo> which fetches the logo by agent type. */
function useSessionAgentIdentity(sessionId: string | null | undefined): {
label: string | null;
name: string | null;
} {
const profile = useSessionAgentProfile(sessionId);
return useMemo(() => {
if (!profile) return { label: null, name: null };
const parts = profile.label.split(" \u2022 ");
const label = parts[1] || parts[0] || profile.label;
return { label, name: profile.agent_name ?? null };
}, [profile]);
}
type TaskChatPanelProps = {
onSend?: (message: string) => void;
sessionId?: string | null;
onOpenFile?: (path: string) => void;
showRequestChangesTooltip?: boolean;
onRequestChangesTooltipDismiss?: () => void;
/** Callback to open a file at a specific line (for comment clicks) */
onOpenFileAtLine?: (filePath: string) => void;
/** Hide the sessions dropdown (session tabs in dockview replace it) */
hideSessionsDropdown?: boolean;
};
// eslint-disable-next-line max-lines-per-function -- composes many sub-panels; each concern already factored into its own hook
export const TaskChatPanel = memo(function TaskChatPanel({
onSend,
sessionId = null,
onOpenFile,
showRequestChangesTooltip = false,
onRequestChangesTooltipDismiss,
onOpenFileAtLine,
hideSessionsDropdown,
}: TaskChatPanelProps) {
const isArchived = useIsTaskArchived();
const chatInputRef = useRef<ChatInputContainerHandle>(null);
useSettingsData(true);
const panelState = useChatPanelState({ sessionId, onOpenFile, onOpenFileAtLine });
const { isSending, handleSubmit } = useSubmitHandler(panelState, onSend);
const {
resolvedSessionId,
session,
task,
taskId,
isWorking,
messagesLoading,
groupedItems,
allMessages,
footerActionMessages,
permissionsByToolCallId,
childrenByParentToolCallId,
agentMessageCount,
clearQueue,
pendingClarification,
pendingClarificationGroup,
} = panelState;
const { handleCancelTurn } = useChatPanelHandlers(resolvedSessionId, clearQueue, chatInputRef);
const { clarificationKey, handleClarificationResolved } = useClarificationKey(agentMessageCount);
const panelRef = useRef<HTMLDivElement>(null);
const { loadMore } = useLazyLoadMessages(resolvedSessionId);
const search = useSessionSearch(resolvedSessionId, loadMore);
const { label: agentLabel, name: agentName } = useSessionAgentIdentity(resolvedSessionId);
usePanelSearch({
containerRef: panelRef,
isOpen: search.isOpen,
onOpen: search.open,
onClose: search.close,
});
// The message list has no focus-capturing child (unlike TipTap/xterm in the
// plan/terminal panels), so clicking a message leaves focus on <body>. Make
// the panel root itself focusable and route non-interactive clicks to it so
// Ctrl+F can detect focus within the session panel.
const handlePanelMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement | null;
if (!target) return;
// Exclude `tabindex="-1"` so we don't match PanelRoot itself (which is
// marked focus-receivable but shouldn't short-circuit this handler).
if (
target.closest(
"input, textarea, select, button, a, [contenteditable], [tabindex]:not([tabindex='-1'])",
)
) {
return;
}
panelRef.current?.focus({ preventScroll: true });
}, []);
return (
<PanelRoot
ref={panelRef}
data-testid="session-chat"
data-panel-kind="session"
tabIndex={-1}
onMouseDown={handlePanelMouseDown}
className="outline-none"
>
<PanelBody padding={false} className="relative">
<MessageList
items={groupedItems}
messages={allMessages}
footerActionMessages={footerActionMessages}
permissionsByToolCallId={permissionsByToolCallId}
childrenByParentToolCallId={childrenByParentToolCallId}
taskId={taskId ?? undefined}
sessionId={resolvedSessionId}
messagesLoading={messagesLoading}
isWorking={isWorking}
sessionState={session?.state}
taskState={task?.state}
worktreePath={session?.worktree_path}
onOpenFile={onOpenFile}
/>
<SessionSearchOverlay search={search} agentLabel={agentLabel} agentName={agentName} />
</PanelBody>
{pendingClarification && !isArchived && (
<div className="flex-shrink-0 border-t border-sky-400/30 bg-card px-1">
<ClarificationInputOverlay
messages={pendingClarificationGroup}
onResolved={handleClarificationResolved}
/>
</div>
)}
<QueuedGhostList sessionId={resolvedSessionId} isArchived={isArchived} />
{isArchived ? (
<div className="bg-muted/50 flex-shrink-0 px-4 py-3 text-center text-sm text-muted-foreground border-t">
This task is archived and read-only.
</div>
) : (
<ChatInputArea
chatInputRef={chatInputRef}
clarificationKey={clarificationKey}
onClarificationResolved={handleClarificationResolved}
handleSubmit={handleSubmit}
handleCancelTurn={handleCancelTurn}
showRequestChangesTooltip={showRequestChangesTooltip}
onRequestChangesTooltipDismiss={onRequestChangesTooltipDismiss}
panelState={panelState}
isSending={isSending}
hideSessionsDropdown={hideSessionsDropdown}
/>
)}
</PanelRoot>
);
});