Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/components/message/find-in-chat.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest"
import { extractFindableText } from "./find-in-chat"
import type { ThreadRenderItem } from "./message-list-view"

function turnItem(
parts: { type: string; text?: string; [k: string]: unknown }[],
role: "user" | "assistant" = "assistant"
): ThreadRenderItem {
return {
key: `turn-${role}-${Math.random().toString(36).slice(2)}`,
kind: "turn",
group: {
id: "g1",
role,
parts: parts as never,
resources: [],
images: [],
},
phase: "persisted",
isResponseComplete: true,
showStats: false,
isRoleTransition: false,
previousUserIndex: null,
isLastAssistantRun: false,
isThreadTail: false,
sourceTurns: [],
} as unknown as ThreadRenderItem
}

describe("extractFindableText", () => {
it("joins text parts and ignores tool calls and reasoning", () => {
const item = turnItem([
{ type: "text", text: "first paragraph" },
{ type: "tool-call", toolCallId: "t1", toolName: "bash" },
{ type: "reasoning", content: "thinking hard", isStreaming: false },
{ type: "text", text: "second paragraph" },
])
expect(extractFindableText(item)).toBe("first paragraph\nsecond paragraph")
})

it("returns empty string for non-turn items", () => {
expect(extractFindableText({ key: "typing", kind: "typing" })).toBe("")
expect(
extractFindableText({ key: "compaction", kind: "compaction", meta: null })
).toBe("")
})

it("returns empty string for a turn with no text parts", () => {
const item = turnItem([{ type: "tool-call", toolCallId: "t1" }])
expect(extractFindableText(item)).toBe("")
})
})
134 changes: 134 additions & 0 deletions src/components/message/find-in-chat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"use client"

import { useEffect, useRef } from "react"
import { useTranslations } from "next-intl"
import { ArrowDown, ArrowUp, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { ThreadRenderItem } from "@/components/message/message-list-view"
import { cn } from "@/lib/utils"

/**
* Plain-text projection of one thread item for find-in-chat. Only message
* prose is searchable (text parts of the adapted group) — tool calls, tool
* results, reasoning traces and chrome (typing indicator, compaction
* divider) are excluded, mirroring what a reader scans visually.
*
* Mirrors the `ThreadRenderItem` contract in `message-list-view.tsx`; keep in
* sync when new item kinds gain user-visible text.
*/
export function extractFindableText(item: ThreadRenderItem): string {
if (item.kind !== "turn") return ""
const texts: string[] = []
for (const part of item.group.parts) {
if (part.type === "text") texts.push(part.text)
}
return texts.join("\n")
}

interface FindInChatBarProps {
query: string
onQueryChange: (query: string) => void
/** Total matches in the loaded transcript window. */
count: number
/** Zero-based index of the match currently in view. */
index: number
onNext: () => void
onPrev: () => void
onClose: () => void
}

/**
* Find bar for the open conversation transcript (⌘F / Ctrl+F). A compact
* overlay pinned by the parent — matches are jumped to by the parent via the
* virtualizer's `scrollToIndex`, and the hit row is highlighted there too
* (this bar stays stateless beyond its own input focus).
*/
export function FindInChatBar({
query,
onQueryChange,
count,
index,
onNext,
onPrev,
onClose,
}: FindInChatBarProps) {
const t = useTranslations("Folder.chat.messageList")
const inputRef = useRef<HTMLInputElement>(null)

// autoFocus misses the case where the bar mounts while the window itself
// regains focus; re-assert on open is cheap and idempotent.
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])

const hasQuery = query.trim().length > 0

return (
<div
className="absolute end-4 top-3 z-30 flex items-center gap-1 rounded-lg border bg-background/95 px-2 py-1.5 shadow-md backdrop-blur"
role="search"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
onClose()
} else if (e.key === "Enter" && hasQuery) {
e.preventDefault()
if (e.shiftKey) onPrev()
else onNext()
}
}}
>
<Input
ref={inputRef}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder={t("findPlaceholder")}
className="h-7 w-52 border-none bg-transparent text-sm shadow-none focus-visible:ring-0"
aria-label={t("findPlaceholder")}
/>
<span
className={cn(
"min-w-14 text-center text-xs tabular-nums text-muted-foreground",
hasQuery && count === 0 && "text-destructive"
)}
>
{hasQuery
? count > 0
? t("findMatchOf", { index: index + 1, count })
: t("findNoResults")
: ""}
</span>
<Button
size="icon"
variant="ghost"
className="size-6"
disabled={count === 0}
onClick={onPrev}
aria-label={t("findPrev")}
>
<ArrowUp className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="size-6"
disabled={count === 0}
onClick={onNext}
aria-label={t("findNext")}
>
<ArrowDown className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="size-6"
onClick={onClose}
aria-label={t("findClose")}
>
<X className="size-3.5" />
</Button>
</div>
)
}
134 changes: 129 additions & 5 deletions src/components/message/message-list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,18 @@ import {
extractLatestPlanEntriesFromMessages,
} from "@/lib/agent-plan"
import type { AgentType, ConnectionStatus, MessageTurn } from "@/lib/types"
import { copyTextToClipboard } from "@/lib/utils"
import { copyTextToClipboard, cn } from "@/lib/utils"
import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread"
import { SelectionActionBubble } from "@/components/message/selection-action-bubble"
import {
ConversationMessageNav,
type MessageNavEntry,
} from "@/components/message/conversation-message-nav"
import type { MessageScrollContextValue } from "@/components/message/message-scroll-context"
import {
extractFindableText,
FindInChatBar,
} from "@/components/message/find-in-chat"
import { extractSessionFilesGrouped } from "@/lib/session-files"
import { unescapeComposerText } from "@/lib/composer-copy-text"
import { useStickToBottomContext } from "use-stick-to-bottom"
Expand Down Expand Up @@ -1322,6 +1326,97 @@ export function MessageListView({
[historicalPlanEntries]
)

// --- Find in chat (⌘F / Ctrl+F) -----------------------------------------
// Searches the message prose of the LOADED transcript window only — the same
// accepted degradation as the message navigator (paging in older history
// extends what's findable; match indices are recomputed per prepend, so
// they never go stale).
const [findOpen, setFindOpen] = useState(false)
const [findQuery, setFindQuery] = useState("")
const [findHit, setFindHit] = useState(0)

const findClose = useCallback(() => {
setFindOpen(false)
setFindQuery("")
}, [])

// New query → restart at the first hit. Adjusted during render (the React
// pattern for state derived from other state) — an effect here would paint
// a stale hit first.
const [prevFindQuery, setPrevFindQuery] = useState(findQuery)
if (prevFindQuery !== findQuery) {
setPrevFindQuery(findQuery)
setFindHit(0)
}

const findMatches = useMemo(() => {
const q = findQuery.trim().toLowerCase()
if (!findOpen || q.length === 0) {
return [] as { threadIndex: number; key: string }[]
}
const out: { threadIndex: number; key: string }[] = []
// Occurrence-level granularity: one row can hold several hits. The cap
// keeps a pathological query ("e") over a huge window bounded.
const MAX_MATCHES = 500
for (let i = 0; i < threadItems.length && out.length < MAX_MATCHES; i++) {
const item = threadItems[i]
if (item.kind !== "turn") continue
const hay = extractFindableText(item).toLowerCase()
let pos = hay.indexOf(q)
while (pos !== -1 && out.length < MAX_MATCHES) {
out.push({ threadIndex: i, key: item.key })
pos = hay.indexOf(q, pos + q.length)
}
}
return out
}, [findOpen, findQuery, threadItems])

const findMatchCount = findMatches.length
const activeFindHit =
findMatchCount > 0
? findMatches[Math.min(findHit, findMatchCount - 1)]
: null
const activeFindThreadIndex = activeFindHit?.threadIndex ?? null

// Scoped to the active transcript so background tabs never steal the
// shortcut. Declines inside terminal regions, where ⌘F may belong to the
// multiplexer (same precedent as the tab-switch chord decline in
// workspace-chrome-controller).
useEffect(() => {
if (!isActive) return
const onKeyDown = (e: KeyboardEvent) => {
if (findOpen && e.key === "Escape") {
e.preventDefault()
setFindOpen(false)
setFindQuery("")
return
}
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "f") return
const target = e.target as Element | null
if (target && target.closest('[data-terminal-panel-region="true"]')) {
return
}
e.preventDefault()
setFindOpen(true)
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [isActive, findOpen])

// Jump the virtualizer to the current hit; re-runs when the hit moves
// (next/prev) or a new query recomputes the match list.
useEffect(() => {
if (!findOpen || activeFindThreadIndex == null) return
scrollApiRef.current?.scrollToIndex(activeFindThreadIndex, {
align: "center",
})
}, [findOpen, activeFindThreadIndex])

// Lifted scroll handle — shared by find-in-chat above and the message
// navigator panel below (both live outside the MessageScrollProvider
// subtree and drive scrollToIndex).
const scrollApiRef = useRef<MessageScrollContextValue | null>(null)

// A turn in flight doesn't take the fork affordance away, it greys it out:
// the host keeps `onForkFromTurn` set for the whole "prompting" window (see
// its gate in `conversation-detail-panel`), and every reply's footer says
Expand All @@ -1337,8 +1432,16 @@ export function MessageListView({
item.group.role === "user" && userTurnHeader
? userTurnHeader(item.group)
: null
const isFindHit = findOpen && activeFindHit?.key === item.key
return (
<div style={pt > 0 ? { paddingTop: pt } : undefined}>
<div
style={pt > 0 ? { paddingTop: pt } : undefined}
className={cn(
"rounded-lg",
isFindHit &&
"ring-2 ring-amber-400/70 ring-offset-2 ring-offset-background"
)}
>
{phaseLabel ? (
<div className="flex items-center gap-2 px-1 pb-3 pt-1">
<span aria-hidden="true" className="h-px flex-1 bg-border" />
Expand Down Expand Up @@ -1387,6 +1490,8 @@ export function MessageListView({
handleRoundOpenChange,
onForkFromTurn,
forkBusy,
findOpen,
activeFindHit?.key,
]
)

Expand Down Expand Up @@ -1441,9 +1546,6 @@ export function MessageListView({
: `subagents-history-${conversationId}`

// --- Message navigator panel ------------------------------------------------
// Lifted scroll handle so the panel (which lives in the overlay stack, outside
// the MessageScrollProvider subtree) can drive scrollToIndex.
const scrollApiRef = useRef<MessageScrollContextValue | null>(null)
// Collapse state is owned here (not in the panel) so the expensive per-file
// `navEntries` is computed only while the panel is open.
const [navExpanded, setNavExpanded] = useState(false)
Expand Down Expand Up @@ -1635,6 +1737,28 @@ export function MessageListView({
{t("loadBackgroundActivity")}
</Button>
)}
{findOpen && (
<FindInChatBar
query={findQuery}
onQueryChange={setFindQuery}
count={findMatchCount}
index={activeFindHit ? Math.min(findHit, findMatchCount - 1) : 0}
onNext={
findMatchCount > 0
? () => setFindHit((h) => (h + 1) % findMatchCount)
: () => {}
}
onPrev={
findMatchCount > 0
? () =>
setFindHit(
(h) => (h - 1 + findMatchCount) % findMatchCount
)
: () => {}
}
onClose={findClose}
/>
)}
</MessageThread>
{liveMessage && connStatus === "prompting" && (
<LiveTurnStats
Expand Down
8 changes: 7 additions & 1 deletion src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -3420,7 +3420,13 @@
"completedAt": "وقت الإنجاز",
"jumpToPreviousUserMessage": "الانتقال إلى رسالة المستخدم",
"showMore": "عرض المزيد",
"showLess": "طي"
"showLess": "طي",
"findPlaceholder": "Find in conversation…",
"findMatchOf": "{index} of {count}",
"findNoResults": "No results",
"findPrev": "Previous match",
"findNext": "Next match",
"findClose": "Close find"
},
"liveTurnStats": {
"thinking": "جارٍ التفكير...",
Expand Down
Loading
Loading