Files
kandev/apps/web/components/task/task-chat-panel.tsx
T
José Almeida 046ac5c1b6 feat(web): add Ctrl+F search to session, plan, and terminal panels (#686)
* feat(backend): add session-scoped message search endpoint

Adds SearchMessages across repository/service/handler layers and a new
WebSocket action "message.search". Returns lightweight hits with a
60-char snippet around the first match. Session-scoped only: results
never leak across sessions. Uses dialect-aware LIKE/ILIKE so it works
on SQLite today and Postgres later.

* feat(web): add shared panel search bar and Ctrl+F hook

Introduces the reusable PanelSearchBar overlay and usePanelSearch hook
used by the per-panel search features. The hook listens on the window
and fires only when focus is within the panel root, preventDefaults the
browser find-in-page, and wires Esc to close. PanelRoot becomes a
forwardRef so panel components can expose their root element to the
hook. Adds SHORTCUTS.FIND_IN_PANEL plus shared highlight/flash styles.

* feat(web): add Ctrl+F search to terminal panels

Mounts the xterm addon-search on both passthrough and shell terminals,
exposing next/prev plus case-sensitivity and regex toggles. Invalid
regex surfaces an inline error without firing the search. Because xterm
consumes keystrokes before React, the Ctrl/Cmd+F intercept is added to
attachCustomKeyEventHandler so the shortcut opens the bar from inside
the terminal. Results counter comes from onDidChangeResults; decorations
clear on close.

* feat(web): add Ctrl+F search to plan panel

Adds a custom TipTap extension that builds a ProseMirror DecorationSet
over the document and exposes setPlanSearchQuery / planSearchNext /
planSearchPrev / clearPlanSearch commands. The current match gets an
extra class and scrolls into view. Match count is read from the plugin
state via transaction subscription, so edits to the document update the
counter automatically. Find-only — no replace.

* feat(web): add Ctrl+F search to session chat panel

Wires the new backend message.search action into a debounced client
hook and renders a hits dropdown under the search bar, with the query
highlighted in each snippet. Clicking a hit scrolls the message into
view and flashes it. When the target message is older than what is
currently loaded, the hook loops through loadOlder pages until the
message renders, making search work across lazy-loaded history without
needing to scroll-and-wait manually.

* fix(web): lazy-load xterm addon-search to avoid SSR crash

addon-search is published as a UMD bundle that references `self` at
module load. Statically importing it breaks the Next.js static
prerender of /. Deferring the import to an effect keeps the module out
of the SSR graph without changing runtime behaviour.

* fix(web): propagate plan editor via state so plan-search hook re-runs

The plan-search hook subscribed to editor transactions inside a
useEffect that depended on a ref object. Because ref.current changes do
not trigger effect re-runs, if the Editor instance was not yet ready
when the hook first mounted, the transaction handler never attached and
the match counter stayed at 0 / 0. Tracking the editor via state (in
addition to the ref) causes the deps to change when the Editor becomes
available, so the handler attaches and the counter updates.

* feat(web): allow overriding message list renderer via ?renderer= query

Exposes the "native" / "virtuoso" choice at runtime so E2E coverage can
exercise both paths without a redeploy or code change. Defaults to the
existing "native" strategy when no override is present.

* test(web): add Playwright coverage for panel-scoped Ctrl+F search

Adds an e2e/tests/search/ suite with five specs:

- panel-search-shared: cross-panel contract (Ctrl+F opens, autofocus,
  Esc closes, close button, state reset on reopen) parametrised over
  session, plan, and terminal.
- terminal-search: xterm addon-search integration — key intercept
  without leaking "f" to the PTY, case + regex toggles (incl. invalid
  regex error), Esc does not reach the shell, backspace adjusts count.
- plan-search: TipTap plugin — highlight count, exactly one current
  match, counter, Enter / Shift+Enter wrapping, close clears
  decorations, no case/regex toggles in plan bar.
- session-search: WebSocket-backed hits list, "No matches" state,
  click-to-flash, cross-session isolation, close, and virtuoso renderer
  coverage via the new ?renderer= override.
- panel-focus-routing: bar mounts inside the focused panel only.

Three tests capture GIFs for the PR description via prCapture when
CAPTURE_PR_ASSETS is set (one per panel). Helpers live in
e2e/helpers/panel-search.ts and e2e/helpers/seed-session-messages.ts;
SessionPage gains search-bar locators.

* fix(web): prevent plan-search update loop on spurious editor transactions

TipTap fires `transaction` on every cursor move/click. The match-info
handler allocated a fresh `{current, total}` object each time, so React
always saw new identity, re-rendered, and could cascade with downstream
effects (e.g. `rehydrateCommentMarks`) into a "Maximum update depth
exceeded" loop. Use a functional setState with equality bail-out so the
handler only schedules a render when the counter actually changes.

* fix(web): retry terminal search once xterm addon-search finishes loading

The SearchAddon is dynamic-imported and was stored in a ref. The
query→findNext effect called `addonRef.current?.findNext(...)` — a
silent no-op if the addon wasn't loaded yet — and its deps didn't
include addon readiness, so a query typed before the import resolved
was dropped forever. Track the addon via state so the search effect
re-runs when it becomes available (and re-binds cleanly if the terminal
is ever re-created). Also deduplicate match-info updates via functional
setState + equality check.

* fix(web): route Ctrl+F to the last-clicked panel when focus is ambient

The chat panel's message list has no focus-capturing child, so clicking
on a message left `document.activeElement === <body>` and the existing
`isFocusWithin` gate silently fell through to the browser's native find.
Fix with two complementary mechanisms:

- `usePanelSearch`: track the most-recently-clicked panel via a
  window-level pointerdown capture listener. When focus is ambient
  (body/html), that panel claims Ctrl+F. Window-level capture dodges
  any descendant `stopPropagation` that would skip a container-level
  listener.
- Chat panel root: tabIndex=-1 + onMouseDown that focuses the root when
  the click target isn't already focusable. Normal path matches how
  plan/terminal already work (TipTap/xterm capture focus on click).

* feat(web): show agent profile name and logo in session search hits

The hit list showed uppercase "AGENT"/"USER" with vivid blue/green
colors that clashed with the app's muted metadata aesthetic. Replace
with the actual author identity:

- Agent hits render the profile name (falling back to "Agent") with
  the profile's <AgentLogo> inline — same mechanism session tabs use.
- User hits render "You" with a subtle `text-primary/80` tint that
  echoes the user bubble's `bg-primary/30`, keeping a light color
  differentiator without reintroducing loud blue/green.

Added `useSessionAgentIdentity` (plus a `useSessionAgentProfile` helper)
that reads from the store via two primitive selectors — the id, then
the profile — and composes the final object inside `useMemo`. A naive
single selector returning `{label, name}` triggers the "getSnapshot
should be cached" infinite loop in `useSyncExternalStore`.

* style: apply prettier formatting to search files

* fix(web): inline resolveStrategy in useMemo to satisfy react-hooks/use-memo

* fix(backend): escape LIKE metacharacters in message search

User query of `%` previously built LIKE pattern `%%` which matched every
message in the session; `file_name` also matched `filename`, `file name`,
etc. Escape the three LIKE metacharacters (`\`, `%`, `_`) before wrapping
the pattern, and declare `ESCAPE '\'` on the clause. Add regression tests
covering wildcard literals and the literal-percent happy path.

* fix(backend): slice search snippet in rune space to keep UTF-8 valid

`buildSnippet` indexed and sliced on byte offsets, so content containing
multi-byte characters (emoji, CJK, accented letters) could be cut
mid-rune — `encoding/json` then replaced the invalid bytes with `\ufffd`
in the websocket response. Switch to rune slicing and per-rune
case-folding via `unicode.ToLower`; this also avoids the
`strings.ToLower` byte-length-change pitfall (e.g. Turkish İ, German ß).
Add a handler test covering emoji, CJK, and large multi-byte content.

* fix(web): cancel backfill loop when user clicks a newer session hit

`setActiveHit` awaits up to 40 `loadOlder()` iterations when the target
message isn't in the DOM. If the user clicked a second hit while those
awaits were in flight, the in-progress loop kept running and eventually
scrolled to the original target, overriding the user's intent. Gate the
loop on a generation counter so only the most recent call's backfill
drives the scroll.

* fix(web): gate plan-search scroll side-effect on dispatch

TipTap invokes commands with `dispatch === undefined` during `can()`
checks to test applicability (e.g. before enabling a menu item). The
next/prev commands called `scrollToMatch` outside the `if (dispatch)`
guard, so a dry-run invocation would scroll the editor to a match the
user didn't navigate to. Move the scroll inside the dispatch block.

* fix(web): address review nits on search UX

- formatTime now checks Number.isNaN(d.getTime()) before formatting;
  Invalid Date doesn't throw, so the catch branch was unreachable and
  hits with a malformed created_at rendered the literal "Invalid Date".
- prefers-reduced-motion now silences the .search-flash animation
  alongside the other custom keyframe animations.
- planScript escapes `title` symmetrically with `content` so callers
  passing arbitrary strings don't produce malformed JSON.

* fix(web): fold description into opts to keep seedTask under param limit

`seedTask` had 6 parameters, violating the repo's 5-parameter cap and
failing CI lint. Fold `description` into the existing `opts` object and
update all callsites.

* test(web): add unit tests for useSessionSearch + remove redundant ternary

Cover the non-trivial hook logic called out in review: debounced fetch
coalescing, request-ID cancellation for superseded queries, null
sessionId short-circuit, and close() resets. Also simplify the
`matches.length ? 0 : 0` ternary flagged as a dead branch — both sides
were already 0.

* fix(web): exclude PanelRoot (tabindex=-1) from the focus short-circuit

`target.closest("[tabindex]")` was matching PanelRoot itself (which has
`tabIndex={-1}` so it can receive programmatic focus), causing the guard
to early-return and the `panelRef.focus()` call to never fire — defeating
the Ctrl+F focus-routing that this handler was added for.

Narrow the selector to `[tabindex]:not([tabindex='-1'])` so it only
short-circuits on genuinely interactive elements.

* fix(web): read isReadOnlyMode from live ref in shell-terminal key handler

`attachCustomKeyEventHandler` is called once and has no dispose hook, so
the inner handler keeps its initial closure forever. Other values
(sessionId, send, onFindInPanel) are read via `stateRef` so they stay
current, but `isReadOnlyMode` was captured by closure — if the prop ever
toggled mid-session, the Cmd+Arrow→Home/End path would continue firing
against a read-only terminal. Include it in the state ref for
consistency.

* fix(web): flatten doubly-nested seedTask opts in virtuoso spec; drop unused MODIFIER

The regex-based transform in 7152b936 over-nested one call in
session-search.spec.ts — `opts.description` received an object rather
than a string, so the virtuoso renderer test never received a proper
task description. Flatten it.

Also remove MODIFIER imports/declarations in terminal-search.spec.ts and
panel-search-shared.spec.ts that were left dangling behind `void` suppressors.

* fix(web): abort session-search backfill loop on close/unmount

Move reset logic into `close()` so the lifecycle is explicit (and avoids
the set-state-in-effect lint path the previous reset effect triggered).
Bump the `activeHitGenRef` generation counter on both `close()` and
unmount so an in-flight `setActiveHit` backfill can no longer issue
`loadOlder()` calls for a hidden UI.

Also extract `useDebouncedSearch`, `useSetActiveHit`, and a
`focusMessageElement` helper to keep `useSessionSearch` under the
100-line function cap.

* perf(web): skip PanelSearchBar debounce for session search

Session search already debounces the WS call inside `useDebouncedSearch`
(180ms), but the shared bar adds its own 150ms debounce on top — giving
a 330ms keystroke→request latency instead of the intended ~180ms.
Pass `debounceMs={0}` so the bar forwards queries immediately and the
hook-level debounce is the single source of timing truth.
2026-04-24 17:14:03 +01:00

300 lines
11 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 {
QueuedMessageIndicator,
type QueuedMessageIndicatorHandle,
} from "@/components/task/chat/queued-message-indicator";
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";
type QueuedOverlayProps = {
isQueued: boolean;
queuedMessage: { content: string } | null | undefined;
isArchived: boolean;
indicatorRef: React.RefObject<QueuedMessageIndicatorHandle | null>;
onCancel: () => void;
onUpdate: (content: string) => Promise<void>;
onEditComplete: () => void;
};
function QueuedMessageOverlay({
isQueued,
queuedMessage,
isArchived,
indicatorRef,
onCancel,
onUpdate,
onEditComplete,
}: QueuedOverlayProps) {
if (!isQueued || !queuedMessage || isArchived) return null;
return (
<div className="flex-shrink-0 border-t border-blue-400/30 bg-card px-3 py-1">
<QueuedMessageIndicator
ref={indicatorRef}
content={queuedMessage.content}
onCancel={onCancel}
onUpdate={onUpdate}
isVisible={true}
onEditComplete={onEditComplete}
/>
</div>
);
}
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);
const queuedMessageRef = useRef<QueuedMessageIndicatorHandle>(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,
cancelQueue,
pendingClarification,
isQueued,
queuedMessage,
updateQueueContent,
} = panelState;
const { handleCancelTurn, handleCancelQueue, handleQueueEditComplete } = useChatPanelHandlers(
resolvedSessionId,
cancelQueue,
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
message={pendingClarification}
onResolved={handleClarificationResolved}
/>
</div>
)}
<QueuedMessageOverlay
isQueued={isQueued}
queuedMessage={queuedMessage}
isArchived={isArchived}
indicatorRef={queuedMessageRef}
onCancel={handleCancelQueue}
onUpdate={updateQueueContent}
onEditComplete={handleQueueEditComplete}
/>
{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>
);
});