Files
kandev/apps/web/components/task/chat/queued-ghost-message.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

279 lines
8.7 KiB
TypeScript

"use client";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { IconCheck, IconClock, IconEdit, IconRobot, IconUser, IconX } from "@tabler/icons-react";
import { toast } from "sonner";
import { Button } from "@kandev/ui";
import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { QueueEntryNotFoundError } from "@/lib/api/domains/queue-api";
import type { QueuedMessage } from "@/lib/state/slices/session/types";
/** Strip internal <kandev-system>...</kandev-system> blocks from display text. */
function stripSystemTags(text: string): string {
return text.replace(/<kandev-system>[\s\S]*?<\/kandev-system>/g, "").trim();
}
/** Imperative handle for the ghost row, used by chat input "edit last queued" affordance. */
export type QueuedGhostMessageHandle = {
startEdit: () => void;
};
type SenderKind = "user" | "agent" | "system";
function senderKindOf(entry: QueuedMessage): SenderKind {
if (!entry.queued_by) return "system";
// Inter-task messages dispatched via dispatchTaskMessage hardcode
// queued_by="agent"; that's the only signal needed.
if (entry.queued_by === "agent") return "agent";
return "user";
}
function senderLabel(entry: QueuedMessage): string {
const kind = senderKindOf(entry);
if (kind === "agent") {
const title = entry.metadata?.sender_task_title;
return typeof title === "string" && title.length > 0 ? `From ${title}` : "From agent";
}
if (kind === "system") return "System";
return "You";
}
type SenderChipProps = { entry: QueuedMessage };
function SenderChip({ entry }: SenderChipProps) {
const kind = senderKindOf(entry);
const Icon = kind === "agent" ? IconRobot : IconUser;
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
kind === "agent"
? "bg-amber-500/15 text-amber-600 dark:text-amber-400"
: "bg-primary/10 text-primary",
)}
>
<Icon className="h-3 w-3" />
{senderLabel(entry)}
</span>
);
}
type EditViewProps = {
value: string;
saving: boolean;
onChange: (v: string) => void;
onSave: () => void;
onCancel: () => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
};
function EditView({ value, saving, onChange, onSave, onCancel, textareaRef }: EditViewProps) {
const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel();
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
onSave();
}
};
return (
<div className="space-y-2 p-2">
<Textarea
ref={textareaRef}
data-testid="queue-edit-textarea"
value={value}
disabled={saving}
placeholder="Enter message content..."
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
className={cn(
"min-h-[60px] max-h-[200px] resize-none overflow-y-auto bg-background border-border",
)}
/>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="default"
onClick={onSave}
disabled={saving || !value.trim()}
className="h-7 cursor-pointer"
>
<IconCheck className="mr-1 h-3.5 w-3.5" />
Save
</Button>
<Button
size="sm"
variant="ghost"
onClick={onCancel}
disabled={saving}
className="h-7 cursor-pointer"
>
Cancel
</Button>
<span className="ml-auto text-xs text-muted-foreground">
Press Esc to cancel, Cmd+Enter to save
</span>
</div>
</div>
);
}
type DisplayViewProps = {
entry: QueuedMessage;
canEdit: boolean;
onStartEdit: () => void;
onRemove: () => void;
};
function DisplayView({ entry, canEdit, onStartEdit, onRemove }: DisplayViewProps) {
const visible = stripSystemTags(entry.content);
const display = visible.length > 200 ? visible.slice(0, 200) + "..." : visible;
return (
<div className="flex items-start gap-2 px-3 py-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="mt-0.5 flex items-center gap-1 text-muted-foreground">
<IconClock className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent side="top">Will run on the next turn</TooltipContent>
</Tooltip>
<div className="flex-1 min-w-0 space-y-1">
<SenderChip entry={entry} />
<div className="text-sm text-foreground/80 whitespace-pre-wrap break-words">{display}</div>
</div>
<div className="flex items-center gap-0.5 flex-shrink-0">
{canEdit && (
<>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 cursor-pointer p-0 text-muted-foreground hover:text-foreground"
onClick={onStartEdit}
title="Edit queued message"
>
<IconEdit className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 cursor-pointer p-0 text-muted-foreground hover:text-foreground"
onClick={onRemove}
title="Remove queued message"
>
<IconX className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
);
}
type QueuedGhostMessageProps = {
entry: QueuedMessage;
/**
* Edit is only allowed when the caller's identity (currentUserId) matches the
* entry's queued_by. Inter-task entries are visible but read-only.
*/
canEdit: boolean;
onSave: (content: string) => Promise<void>;
onRemove: () => void | Promise<void>;
/** Called after edit save/cancel so the parent can refocus the chat input. */
onEditComplete?: () => void;
};
export const QueuedGhostMessage = forwardRef<QueuedGhostMessageHandle, QueuedGhostMessageProps>(
function QueuedGhostMessage({ entry, canEdit, onSave, onRemove, onEditComplete }, ref) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(entry.content);
const [saving, setSaving] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (!editing) setValue(entry.content);
}, [entry.content, editing]);
useEffect(() => {
if (editing && textareaRef.current) {
const el = textareaRef.current;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
}, [editing]);
const startEdit = useCallback(() => {
if (!canEdit) return;
setValue(entry.content);
setEditing(true);
}, [entry.content, canEdit]);
useImperativeHandle(ref, () => ({ startEdit }), [startEdit]);
const handleCancel = useCallback(() => {
setValue(entry.content);
setEditing(false);
onEditComplete?.();
}, [entry.content, onEditComplete]);
const handleSave = useCallback(async () => {
const trimmed = value.trim();
if (!trimmed || trimmed === entry.content) {
setEditing(false);
onEditComplete?.();
return;
}
setSaving(true);
try {
await onSave(trimmed);
setEditing(false);
onEditComplete?.();
} catch (err) {
console.error("Failed to update queued entry:", err);
// Exit edit mode so the user sees the current state instead of being
// stuck in a textarea with no signal that the save failed (drain race
// or transient network error).
if (err instanceof QueueEntryNotFoundError) {
toast.error("Message already sent — agent picked it up before your edit landed.");
} else {
toast.error("Failed to save edit. Please try again.");
}
setEditing(false);
onEditComplete?.();
} finally {
setSaving(false);
}
}, [value, entry.content, onSave, onEditComplete]);
return (
<div
className={cn(
"rounded-md border-l-2 bg-muted/40 text-sm",
senderKindOf(entry) === "agent" ? "border-amber-400/60" : "border-primary/40",
)}
>
{editing ? (
<EditView
value={value}
saving={saving}
onChange={setValue}
onSave={handleSave}
onCancel={handleCancel}
textareaRef={textareaRef}
/>
) : (
<DisplayView
entry={entry}
canEdit={canEdit}
onStartEdit={startEdit}
onRemove={onRemove}
/>
)}
</div>
);
},
);