feat(vscode): toggle chat search from Command Palette, jump focus on close, and auto-expand matched blocks

This commit is contained in:
Sylwester Liljegren
2026-07-14 03:38:59 +02:00
parent 00e1646129
commit 46fe0a91d9
12 changed files with 361 additions and 24 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Toggle chat search from the Command Palette, jump focus back to the chat input when it closes, and auto-expand the collapsed tool call or reasoning block containing the current search match.
@@ -142,6 +142,10 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
/** True when this part contains the transcript search's current match —
* forces a collapsed tool/reasoning block open so the user can see the
* highlighted match without manually expanding it first. */
forceOpen?: boolean
reasoningAutoCollapse?: boolean
showAssistantCopyPartID?: string | null
showTurnDiffSummary?: boolean
@@ -974,6 +978,7 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
forceOpen={props.forceOpen}
reasoningAutoCollapse={props.reasoningAutoCollapse}
showAssistantCopyPartID={props.showAssistantCopyPartID}
showTurnDiffSummary={props.showTurnDiffSummary}
@@ -1198,6 +1203,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
status={part.state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
forceOpen={props.forceOpen}
animate
reveal={props.animate}
/>
@@ -1265,6 +1271,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
attachments={part.state.attachments}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
forceOpen={props.forceOpen}
animate
reveal={props.animate}
/>
@@ -1519,6 +1526,18 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
setOpen(value)
}
// Reasoning has no built-in "force open" hook (unlike BasicTool's forceOpen
// ratchet) — mirror that one-way-open behavior here so jumping a chat
// search match to a collapsed reasoning block reveals it, the same as it
// does for tool calls. Recorded into userOpened/userCollapsed the same way
// a manual open would be, so it stays open across remounts/re-renders.
createEffect(() => {
if (!props.forceOpen || open()) return
if (props.reasoningAutoCollapse) rememberReasoningState(userOpened, id)
else userCollapsed.delete(id)
setOpen(true)
})
createEffect(() => {
if (!props.reasoningAutoCollapse) return
// Skip auto-collapse for blocks the user explicitly opened.
@@ -2588,6 +2607,15 @@ ToolRegistry.register({
seeded = true
setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath))
})
// Deleted files start collapsed above; a chat search match could be
// inside one, and there's no per-file tracking of which file a match
// falls in, so force-opening this tool expands every file's accordion
// rather than guessing — better to over-reveal than leave the match
// hidden behind a still-collapsed file.
createEffect(() => {
if (!props.forceOpen) return
setExpanded(files().map((f) => f.filePath))
})
const subtitle = createMemo(() => {
const count = files().length
if (count === 0) return ""
+5
View File
@@ -408,6 +408,11 @@
"title": "Focus Chat Input",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.toggleChatSearch",
"title": "Toggle Chat Search",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.cycleAgentMode",
"title": "Cycle Agent Mode",
+1 -1
View File
@@ -557,7 +557,7 @@ export function activate(context: vscode.ExtensionContext) {
)
// Register code actions (editor context menus, terminal context menus, keyboard shortcuts)
registerCodeActions(context, provider, agentManagerProvider)
registerCodeActions(context, provider, agentManagerProvider, activeTabProvider)
registerTerminalActions(context, provider, agentManagerProvider)
// Register CodeActionProvider (lightbulb quick fixes)
@@ -8,8 +8,9 @@ export function registerCodeActions(
context: vscode.ExtensionContext,
provider: KiloProvider,
agentManager?: AgentManagerProvider,
activeTabProvider?: () => KiloProvider | undefined,
): void {
const target = () => (agentManager?.isActive() ? agentManager : provider)
const target = () => (agentManager?.isActive() ? agentManager : (activeTabProvider?.() ?? provider))
const reveal = async () => {
await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus")
await provider.waitForReady()
@@ -82,5 +83,19 @@ export function registerCodeActions(
}
view.postMessage({ type: "action", action: "focusInput" })
}),
// Command Palette only — no keybinding. A keybinding would need to
// route through VS Code's keybinding-to-focused-webview forwarding,
// which doesn't reliably reach a webview whose own input already has
// focus; invoking straight from the palette sidesteps that path
// entirely, the same way terminalAddToContext etc. do. Toggles: the
// webview closes the search bar itself if it's already open.
vscode.commands.registerCommand("kilo-code.new.toggleChatSearch", async () => {
const view = target()
if (view === provider) {
await reveal()
}
view.postMessage({ type: "action", action: "focusSearch" })
}),
)
}
@@ -267,6 +267,10 @@ const AppContent: Component = () => {
case "cyclePreviousAgentMode":
if (document.hasFocus()) cycleAgent(-1)
break
case "focusSearch":
setCurrentView("newTask")
window.dispatchEvent(new CustomEvent("focusTranscriptSearch"))
break
}
}
@@ -125,6 +125,10 @@ interface AssistantMessageProps {
parts?: SDKPart[]
showAssistantCopyPartID?: string | null
feedback?: MessageFeedbackControls
/** id of the part containing the current chat-search match, if any — forces
* that part's collapsed tool/reasoning content open so the user can see
* the highlighted match without manually expanding it first. */
forceOpenPartID?: string
}
type ToolStateProps = {
@@ -136,7 +140,7 @@ type ToolStateProps = {
type MemoryItem = MemoryMarkerMeta.Decoded
function TodoToolCard(props: { part: ToolPart }) {
function TodoToolCard(props: { part: ToolPart; forceOpen?: boolean }) {
const render = ToolRegistry.render(props.part.tool)
const state = () => props.part.state as ToolStateProps
return (
@@ -152,6 +156,7 @@ function TodoToolCard(props: { part: ToolPart }) {
output={state()?.output}
status={state()?.status}
defaultOpen
forceOpen={props.forceOpen}
reveal={false}
/>
)}
@@ -159,7 +164,7 @@ function TodoToolCard(props: { part: ToolPart }) {
)
}
function BashToolCard(props: { part: ToolPart; defaultOpen: boolean }) {
function BashToolCard(props: { part: ToolPart; defaultOpen: boolean; forceOpen?: boolean }) {
const render = ToolRegistry.render(props.part.tool)
const state = () => props.part.state as ToolStateProps
return (
@@ -176,6 +181,7 @@ function BashToolCard(props: { part: ToolPart; defaultOpen: boolean }) {
output={state()?.output}
status={state()?.status}
defaultOpen={props.defaultOpen}
forceOpen={props.forceOpen}
animate
reveal={state()?.status === "pending" || state()?.status === "running"}
/>
@@ -279,6 +285,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
if (!planExitInfo(part)) return
return part as unknown as ToolPart
})
const forceOpen = createMemo(() => !!props.forceOpenPartID && part.id === props.forceOpenPartID)
return (
<Show
@@ -291,7 +298,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
PART_MAPPING[part.type]
}
>
<div data-component="tool-part-wrapper" data-part-type={part.type}>
<div data-component="tool-part-wrapper" data-part-type={part.type} data-part-id={part.id}>
<Show
when={activeQuestion()}
fallback={
@@ -312,6 +319,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
message={props.message as SDKMessage}
showAssistantCopyPartID={props.showAssistantCopyPartID}
defaultOpen={editOpen(part, edit())}
forceOpen={forceOpen()}
reasoningAutoCollapse={display.reasoningAutoCollapse()}
feedback={props.feedback}
animate={
@@ -322,11 +330,17 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
/>
}
>
<TodoToolCard part={part as unknown as ToolPart} />
<TodoToolCard part={part as unknown as ToolPart} forceOpen={forceOpen()} />
</Show>
}
>
{(tool) => <BashToolCard part={tool() as unknown as ToolPart} defaultOpen={open()} />}
{(tool) => (
<BashToolCard
part={tool() as unknown as ToolPart}
defaultOpen={open()}
forceOpen={forceOpen()}
/>
)}
</Show>
}
>
@@ -27,6 +27,7 @@ import { createAutoScroll } from "@kilocode/kilo-ui/hooks"
import { useSession } from "../../context/session"
import { useServer } from "../../context/server"
import { useLanguage } from "../../context/language"
import { useI18n } from "@kilocode/kilo-ui/context/i18n"
import { useProvider } from "../../context/provider"
import { WelcomeEmptyState } from "./WelcomeEmptyState"
import { TranscriptRowView } from "./TranscriptRow"
@@ -96,6 +97,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
const server = useServer()
const language = useLanguage()
const provider = useProvider()
const i18n = useI18n()
const autoScroll = createAutoScroll({
working: () => session.status() !== "idle",
@@ -163,9 +165,21 @@ export const MessageList: Component<MessageListProps> = (props) => {
const search = useTranscriptSearch()
function rowText(row: TranscriptRow): string {
if (row.type === "error") return errorText(row.error)
if (row.type === "diff") return ""
interface RowTextRange {
start: number
end: number
partId: string
}
// Returns the row's full searchable text plus, for every chunk that came
// from a specific part (tool call/reasoning/text/file), the character
// range it occupies within that text. Lets a match's character index be
// attributed back to the part it came from, so navigation can force that
// exact collapsed tool/reasoning block open instead of just scrolling to
// the row.
function rowText(row: TranscriptRow): { text: string; ranges: RowTextRange[] } {
if (row.type === "error") return { text: errorText(row.error), ranges: [] }
if (row.type === "diff") return { text: "", ranges: [] }
// User message text is rendered by UserMessageDisplay/HighlightedText
// (message-part.tsx), which never parses markdown at all — [label](url)
// always shows literally, brackets and all, unlike assistant text/
@@ -174,13 +188,22 @@ export const MessageList: Component<MessageListProps> = (props) => {
// visible occurrences (the literal label and the literal URL) into one.
const markdown = row.type !== "user"
const chunks: string[] = []
const ranges: RowTextRange[] = []
let pos = 0
const push = (text: string, partId: string) => {
if (!text) return
if (chunks.length > 0) pos += 1 // account for the "\n" chunk joiner below
chunks.push(text)
ranges.push({ start: pos, end: pos + text.length, partId })
pos += text.length
}
for (const part of row.parts) {
switch (part.type) {
case "text":
if (!part.synthetic) chunks.push(markdown ? stripMarkdownLinkUrls(part.text) : part.text)
if (!part.synthetic) push(markdown ? stripMarkdownLinkUrls(part.text) : part.text, part.id)
break
case "reasoning":
chunks.push(stripMarkdownLinkUrls(part.text))
push(stripMarkdownLinkUrls(part.text), part.id)
break
case "tool":
// Bash output is rendered via escapeHtml + syntax highlighting
@@ -190,14 +213,18 @@ export const MessageList: Component<MessageListProps> = (props) => {
// Stripping it here would search text that no longer matches the
// literal characters on screen, the same class of mismatch this
// rewrite fixes elsewhere.
chunks.push(...toolText(part))
for (const chunk of toolText(part)) push(chunk, part.id)
break
case "file":
if (part.filename) chunks.push(part.filename)
if (part.filename) push(part.filename, part.id)
break
}
}
return chunks.join("\n")
return { text: chunks.join("\n"), ranges }
}
function partIdAt(ranges: RowTextRange[], index: number): string | undefined {
return ranges.find((r) => index >= r.start && index < r.end)?.partId
}
// Markdown link/image URLs are part of the raw source text but are never
@@ -273,13 +300,51 @@ export const MessageList: Component<MessageListProps> = (props) => {
// the same class of bug this rewrite fixes for every other tool.
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
// edit/write/apply_patch render their actual diff content through
// @pierre/diffs inside a shadow-DOM <diffs-container> (packages/ui/src/
// pierre/file-runtime.ts's getViewerRoot()), which a light-DOM text scan
// can never reach — and diff-mode rendering is virtualized by default, so
// even piercing the shadow root wouldn't guarantee off-screen lines are
// mounted. state.input/state.metadata also duplicate the full before/
// after file content and the path itself several times over (a unified
// patch string with the path repeated in its `---`/`+++` headers, a
// separate raw `metadata.diff` copy, write's extra top-level
// `metadata.filepath`), none of which corresponds 1:1 with what's on
// screen. Rather than collect+dedupe those redundant fields, mirror the
// renderer's fixed, known layout directly: edit/write always show one
// file's path in exactly two places (the BasicTool trigger's
// ToolMetaLine and that file's own accordion header); apply_patch's
// trigger only adds a third, single-file ToolMetaLine when there's
// exactly one file (message-part.tsx's `single()`) — for a multi-file
// patch each file's name only appears once, in its own accordion header.
const DIFF_TOOLS = new Set(["edit", "write"])
// todowrite/todoread's renderer resolves the shown list from a fallback
// chain (metadata.view.todos, else metadata.todos, else input.todos) —
// packages/opencode/src/tool/todo.ts sets metadata.todos to the exact
// same array as input.todos, and metadata.view.todos to the exact same
// content again whenever the view is in "full" mode (the common case,
// see packages/opencode/src/kilocode/todo-view.ts). Recursively collecting
// both input and metadata would count every todo's text 2-3x even though
// shown() only ever renders it once.
const TODO_TOOLS = new Set(["todowrite", "todoread"])
function toolText(part: Part & { type: "tool" }): string[] {
const state = part.state
if (state.status === "running") return state.title ? [state.title] : []
if (state.status === "error") return state.error ? [state.error] : []
if (state.status !== "completed") return []
// task's trigger (title "{type} Agent" + input.description subtitle) and
// question's dock (question text + full option list) render the same
// way whether the call is still pending/running or already completed —
// unlike every other tool, where only a bare title shows until
// completion — so both need handling before the completed-only gate
// below, or an in-progress task/question would index nothing at all.
if (part.tool === "task") return taskText(part, state)
if (part.tool === "question") return questionText(state)
if (state.status !== "completed") return "title" in state && state.title ? [state.title] : []
if (CONTEXT_GROUP_TOOLS.has(part.tool)) return state.title ? [state.title] : []
if (part.tool === "bash") return bashText(state)
if (part.tool === "apply_patch") return applyPatchText(state)
if (DIFF_TOOLS.has(part.tool)) return editWriteText(state)
if (TODO_TOOLS.has(part.tool)) return todoText(state)
const chunks: string[] = []
if (state.title) chunks.push(state.title)
collectStrings(state.input, chunks)
@@ -288,6 +353,98 @@ export const MessageList: Component<MessageListProps> = (props) => {
return chunks
}
function todoText(state: Extract<ToolState, { status: "completed" }>): string[] {
const metadata = state.metadata as { todos?: unknown; view?: unknown } | undefined
const input = state.input as { todos?: unknown } | undefined
const view = metadata?.view
const viewTodos = isTodoView(view) ? view.todos : undefined
const todos =
viewTodos ??
(Array.isArray(metadata?.todos) ? metadata.todos : undefined) ??
(Array.isArray(input?.todos) ? input.todos : undefined) ??
[]
return (todos as { content?: unknown }[])
.map((todo) => todo?.content)
.filter((content): content is string => typeof content === "string" && content.length > 0)
}
function isTodoView(value: unknown): value is { todos?: { content?: unknown }[] } {
return !!value && typeof value === "object" && Array.isArray((value as { todos?: unknown }).todos)
}
// Matches TaskToolExpanded.tsx (the renderer this webview actually
// registers for "task", overriding kilo-ui's default) exactly: title is
// always `i18n.t("ui.tool.agent", { type })` regardless of status — the
// "capitalize" CSS class only changes how it *looks*, the DOM text node
// itself is the raw, lowercase subagent_type. The "(N)" child-tool-count
// suffix shown there is a live value from session.getSessionToolCount(),
// not stored on the part at all, so it can't be indexed from a snapshot —
// searching for that count isn't meaningful content anyway.
function taskText(part: Part & { type: "tool" }, state: ToolState): string[] {
const input = state.input as { subagent_type?: string; description?: string } | undefined
const type = input?.subagent_type || part.tool
const chunks = [i18n.t("ui.tool.agent", { type })]
if (input?.description) chunks.push(input.description)
return chunks
}
// QuestionDock renders very different content depending on whether the
// question is still awaiting an answer or already resolved: while
// pending/running it shows the full clickable option list (label +
// description per option); once completed it only shows the question
// text plus whichever answer was actually given (dismissed questions show
// neither the options nor a real answer, just a static "dismissed"
// label that isn't meaningful content to index).
function questionText(state: ToolState): string[] {
const input = state.input as
| { questions?: { question?: string; options?: { label?: string; description?: string }[] }[] }
| undefined
const questions = input?.questions ?? []
const done = state.status === "completed"
const metadata = done ? (state.metadata as { answers?: unknown; dismissed?: unknown } | undefined) : undefined
const answers = Array.isArray(metadata?.answers) ? (metadata!.answers as unknown[][]) : undefined
const dismissed = metadata?.dismissed === true
const chunks: string[] = []
questions.forEach((q, i) => {
if (q.question) chunks.push(q.question)
if (!done) {
for (const option of q.options ?? []) {
if (option.label) chunks.push(option.label)
if (option.description) chunks.push(option.description)
}
return
}
if (dismissed) return
for (const value of answers?.[i] ?? []) {
if (typeof value === "string" && value) chunks.push(value)
}
})
return chunks
}
function editWriteText(state: Extract<ToolState, { status: "completed" }>): string[] {
const input = state.input as { filePath?: string } | undefined
const metadata = state.metadata as { filepath?: string; filediff?: { file?: string } } | undefined
const path = input?.filePath ?? metadata?.filediff?.file ?? metadata?.filepath
if (!path) return []
return [path, path]
}
function applyPatchText(state: Extract<ToolState, { status: "completed" }>): string[] {
const files = ((state.metadata as { files?: { filePath?: string; relativePath?: string }[] } | undefined)?.files ??
[]) as { filePath?: string; relativePath?: string }[]
// Only when there's exactly one file does the trigger also show a
// ToolMetaLine for it, on top of that file's own accordion header.
const perFile = files.length === 1 ? 2 : 1
const chunks: string[] = []
for (const file of files) {
const path = file.relativePath ?? file.filePath
if (!path) continue
for (let i = 0; i < perFile; i += 1) chunks.push(path)
}
return chunks
}
// Mirrors McpTool's formattedOutput(): if `output` parses as JSON, the
// renderer pretty-prints it inside a fenced ```json block, so it's shown
// literally, same as bash. If it isn't valid JSON — the common case for a
@@ -409,7 +566,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
const list = rows()
const result: SearchMatch[] = []
for (const row of list) {
const text = rowText(row)
const { text, ranges } = rowText(row)
p.lastIndex = 0
let occurrence = 0
let hit = p.exec(text)
@@ -419,7 +576,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
hit = p.exec(text)
continue
}
result.push({ key: row.key, messageId: row.message.id, occurrence })
result.push({ key: row.key, occurrence, partId: partIdAt(ranges, hit.index) })
occurrence += 1
hit = p.exec(text)
}
@@ -482,6 +639,26 @@ export const MessageList: Component<MessageListProps> = (props) => {
const activeMatch = createMemo(() => matches()[search.index()])
// Maps a row's key to the part ids the data model could attribute matches
// to there. A row with NO entry here has zero data-level matches, so the
// highlighter must scan nothing in it at all — otherwise unindexed text (a
// static button label, a sibling part that didn't match) could get
// highlighted despite never being counted. An entry always exists for
// every row that has at least one match, even with an empty part-id set
// (a match that couldn't be attributed to a specific part, e.g. error/diff
// rows) — the highlighter treats "entry exists" as "this row has a real
// match" and falls back to scanning the whole row whenever the part-id
// lookup doesn't resolve to a mounted element.
const matchedPartsByRow = createMemo(() => {
const map = new Map<string, Set<string>>()
for (const match of matches()) {
const set = map.get(match.key) ?? new Set<string>()
if (match.partId) set.add(match.partId)
map.set(match.key, set)
}
return map
})
// Highlights every rendered occurrence of the query (not just matching
// rows) via the CSS Custom Highlight API, and returns the precise Range of
// the current occurrence so navigation can judge whether it needs to
@@ -496,7 +673,12 @@ export const MessageList: Component<MessageListProps> = (props) => {
return
}
const active = activeMatch()
const range = applyTranscriptHighlights(el, pattern(), active && { key: active.key, occurrence: active.occurrence })
const range = applyTranscriptHighlights(
el,
pattern(),
active && { key: active.key, occurrence: active.occurrence },
matchedPartsByRow(),
)
if (!pendingCenter) return
pendingCenter = false
if (!range) return
@@ -820,6 +1002,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
index={index()}
onForkMessage={props.onForkMessage}
activeSearch={activeKey() === row.key}
activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined}
/>
)}
</Virtualizer>
@@ -830,6 +1013,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
row={lookup().get(key)!}
onForkMessage={props.onForkMessage}
activeSearch={activeKey() === key}
activeSearchPartID={activeKey() === key ? activeMatch()?.partId : undefined}
/>
)}
</For>
@@ -839,7 +1023,13 @@ export const MessageList: Component<MessageListProps> = (props) => {
<RevertBanner />
</Show>
<For each={partition().queued}>
{(row) => <TranscriptRowView row={row} activeSearch={activeKey() === row.key} />}
{(row) => (
<TranscriptRowView
row={row}
activeSearch={activeKey() === row.key}
activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined}
/>
)}
</For>
<WorkingIndicator />
<TurnOutcome />
@@ -8,7 +8,7 @@
* session activity) and a context window progress bar.
*/
import { Component, For, Show, createMemo, createSignal, createEffect, onMount, onCleanup } from "solid-js"
import { Component, For, Show, createMemo, createSignal, createEffect, on, onMount, onCleanup } from "solid-js"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { Icon } from "@kilocode/kilo-ui/icon"
@@ -111,6 +111,34 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
window.addEventListener("message", handler)
onCleanup(() => window.removeEventListener("message", handler))
// "Kilo Code: Search Current Chat" (Command Palette) toggles the search
// bar from here rather than TranscriptSearch.tsx itself: that component
// only mounts once search.active() is already true (it's behind a
// <Show>), so it can never be what turns search on in the first place —
// and it also wouldn't exist anymore to react to a request to close it.
// TaskHeader is mounted the whole time there's an active chat, so it's
// the right place to react to the external toggle request.
const toggleSearch = () => search.setActive(!search.active())
window.addEventListener("focusTranscriptSearch", toggleSearch)
onCleanup(() => window.removeEventListener("focusTranscriptSearch", toggleSearch))
// Whenever search closes — the header toggle button, the command palette
// toggle above, the search bar's own "X", or Escape — send focus back to
// the chat input rather than leaving it stranded on whatever control was
// just clicked/removed. `defer: true` skips the initial run so mounting
// with search already inactive doesn't steal focus from wherever it
// already was.
createEffect(
on(
() => search.active(),
(active) => {
if (active) return
window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } }))
},
{ defer: true },
),
)
const toggle = () => {
const next = !expanded()
setExpanded(next)
@@ -18,6 +18,9 @@ interface TranscriptRowViewProps {
index?: number
onForkMessage?: (sessionId: string, messageId: string) => void
activeSearch?: boolean
/** id of the part (tool call/reasoning block) containing the current chat
* search match within this row, if any. */
activeSearchPartID?: string
}
export const TranscriptRowView: Component<TranscriptRowViewProps> = (props) => {
@@ -78,6 +81,7 @@ export const TranscriptRowView: Component<TranscriptRowViewProps> = (props) => {
message={row().message as unknown as SDKAssistantMessage}
parts={row().parts as unknown as SDKPart[]}
showAssistantCopyPartID={row().copy}
forceOpenPartID={props.activeSearchPartID}
feedback={{
enabled: feedback.telemetryEnabled(),
rating: feedback.getRating(row().message.id),
@@ -90,11 +90,24 @@ export function scanScope(scope: HTMLElement, pattern: RegExp): Range[] {
* actually found in the DOM, so a data/DOM count mismatch (e.g. content the
* renderer collapses or reformats) still always highlights *something* in
* the active row rather than silently highlighting nothing.
*
* `matchedParts` maps a row key to the part ids MessageList's data model
* could attribute a match to there. A row with **no entry** has zero
* data-level matches, so nothing in it is scanned at all — otherwise some
* unindexed text (a static button label, a different non-matching part in
* the same message) could get highlighted despite never being counted. A
* row *with* an entry (even one with an empty part-id set, e.g. an
* error/diff row with no per-part attribution) always has SOME genuine
* match, so scanning falls back to the whole row whenever the part-scoped
* DOM lookup doesn't resolve to anything mounted (a part id with no
* `[data-part-id]` marker at all — e.g. user messages — or not yet
* expanded) — that's a lookup failure, not a signal to scan nothing.
*/
export function applyTranscriptHighlights(
root: HTMLElement,
pattern: RegExp | undefined,
active: { key: string; occurrence: number } | undefined,
matchedParts?: Map<string, Set<string>>,
): Range | undefined {
const api = highlightApi()
if (!api) return undefined
@@ -107,7 +120,13 @@ export function applyTranscriptHighlights(
const current: Range[] = []
let currentRange: Range | undefined
for (const scope of scopes) {
const ranges = scanScope(scope, pattern)
// Every search scope within a row contributes to ONE combined range
// list before the active-occurrence index is resolved — clamping it
// per search-scope instead would treat `active.occurrence` as local to
// whichever part happened to be scanned first, misattributing which
// occurrence is "current" for any row with more than one contributing
// part (e.g. a reasoning block followed by a tool call).
const ranges = resolveSearchScopes(scope, matchedParts).flatMap((searchScope) => scanScope(searchScope, pattern))
if (ranges.length === 0) continue
const isActiveRow = !!active && scope.dataset.rowKey === active.key
const activeIdx = isActiveRow ? Math.min(active!.occurrence, ranges.length - 1) : -1
@@ -125,6 +144,28 @@ export function applyTranscriptHighlights(
return currentRange
}
/**
* Decides which element(s) within a row to actually scan for text. A row
* with no entry in `matchedParts` has zero data-level matches, so it's
* skipped entirely. A row with an entry scans just its known parts' DOM
* subtrees when they're mounted, and falls back to the whole row whenever
* that lookup comes up empty — whether because a match couldn't be
* attributed to a specific part at all, or because the part it WAS
* attributed to has no `[data-part-id]` marker (or isn't mounted yet) — a
* row with a real match should never end up scanning nothing.
*/
function resolveSearchScopes(scope: HTMLElement, matchedParts: Map<string, Set<string>> | undefined): HTMLElement[] {
const rowKey = scope.dataset.rowKey
const partIds = rowKey ? matchedParts?.get(rowKey) : undefined
if (matchedParts && !partIds) return []
const partScopes = partIds
? Array.from(partIds)
.map((id) => scope.querySelector<HTMLElement>(`[data-part-id="${CSS.escape(id)}"]`))
.filter((el): el is HTMLElement => !!el)
: []
return partScopes.length > 0 ? partScopes : [scope]
}
export function clearTranscriptHighlights(): void {
const api = highlightApi()
if (!api) return
@@ -2,9 +2,12 @@ import { createContext, useContext, createSignal, type Accessor, type ParentComp
export interface SearchMatch {
key: string
messageId: string
/** Index (0-based) of this occurrence among all matches within the same row. */
occurrence: number
/** id of the part (tool call/reasoning block/text) this occurrence falls
* within, if it could be attributed to one — lets navigation force a
* collapsed part open instead of just scrolling to the row. */
partId?: string
}
interface TranscriptSearchContextValue {