fix(vscode): address CI failure and review feedback on chat search

- Wrap StoryProviders in TranscriptSearchProvider - fixes the failing
  Visual Regression check (5 stories threw because TaskHeader/MessageList
  call useTranscriptSearch() unconditionally with no fallback context).
- Reset the whole search widget when the current session changes so
  stale query/matches don't linger across a session switch.
- Search error rows via the same unwrapped error.data.message shown by
  ErrorDisplay, instead of the internal, never-rendered error.name.
- Reorder bash tool search text to description -> command -> output,
  matching the actual DOM order in shell-rolling-results.tsx, so
  occurrence numbering lines up with what gets highlighted.
- Surface invalid regular expressions explicitly instead of leaving them
  indistinguishable from "no matches".
- Track both legs of the chained highlight rAF so cleanup can cancel
  either one, preventing a stray callback from touching state after
  unmount.
- Widen Escape/Enter handling to the whole search widget, not just the
  text input.
- Add an aria-label to the search input and type the search provider's
  children as ParentComponent instead of any.

New chat.search.invalidRegex i18n key added across all 20 locales.

(cherry picked from commit 64dd8e3a2b)
This commit is contained in:
Sylwester Liljegren
2026-07-10 02:11:42 +02:00
committed by marius-kilocode
parent 7a7c28c271
commit 628ce6da93
25 changed files with 119 additions and 24 deletions
@@ -46,11 +46,13 @@ import {
partitionRows,
retainTurn,
transcriptRows,
type TranscriptErrorRow,
type TranscriptHold,
type TranscriptRow,
} from "../../context/transcript-rows"
import { useTranscriptSearch, type SearchMatch } from "../../context/transcript-search"
import { applyTranscriptHighlights, clearTranscriptHighlights } from "./transcript-search-highlight"
import { unwrapError } from "../../utils/errorUtils"
import type { Part, QuestionRequest, SuggestionRequest } from "../../types/messages"
interface MessageListProps {
@@ -141,7 +143,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
const search = useTranscriptSearch()
function rowText(row: TranscriptRow): string {
if (row.type === "error") return row.error.name
if (row.type === "error") return errorText(row.error)
if (row.type === "diff") return ""
const chunks: string[] = []
for (const part of row.parts) {
@@ -163,6 +165,15 @@ export const MessageList: Component<MessageListProps> = (props) => {
return chunks.join("\n")
}
// Matches what ErrorDisplay.tsx actually shows in its default card body —
// the unwrapped `error.data.message`, not the internal `error.name` code,
// which is never rendered as visible text.
function errorText(error: TranscriptErrorRow["error"]): string {
const msg = error.data?.message
if (typeof msg !== "string") return ""
return unwrapError(msg)
}
// Extracts only the text kilo-ui's tool renderers actually put on screen —
// matched field-by-field rather than reading `state.title` generically.
// The bash/shell renderer never shows `state.title` (its header is a
@@ -183,8 +194,12 @@ export const MessageList: Component<MessageListProps> = (props) => {
const command = input?.command ?? metadata?.command
const description = input?.description ?? metadata?.description
const chunks: string[] = []
if (command) chunks.push(command)
// DOM order: description renders as the header subtitle (above the
// command box), command renders below it, output last — keep this in
// sync with shell-rolling-results.tsx so occurrence numbering lines up
// with what's actually highlighted on screen.
if (description) chunks.push(description)
if (command) chunks.push(command)
if (state.output) chunks.push(state.output)
return chunks
}
@@ -205,27 +220,39 @@ export const MessageList: Component<MessageListProps> = (props) => {
}
}
const matches = createMemo(() => {
const pattern = createMemo(() => {
const q = search.query()
if (!search.active() || !q) return []
const pattern = buildPattern(q, search.matchCase(), search.wholeWord(), search.regex())
if (!pattern) return []
if (!search.active() || !q) return undefined
return buildPattern(q, search.matchCase(), search.wholeWord(), search.regex())
})
// An invalid regex (e.g. an unbalanced group) compiles to `undefined` from
// buildPattern, which otherwise looks identical to "no matches" — surface
// it explicitly so the widget can show a real error instead.
createEffect(() => {
const q = search.query()
search.setInvalid(search.active() && !!q && search.regex() && !pattern())
})
const matches = createMemo(() => {
const p = pattern()
if (!p) return []
const list = rows()
const result: SearchMatch[] = []
for (const row of list) {
const text = rowText(row)
pattern.lastIndex = 0
p.lastIndex = 0
let occurrence = 0
let hit = pattern.exec(text)
let hit = p.exec(text)
while (hit) {
if (hit[0].length === 0) {
pattern.lastIndex += 1
hit = pattern.exec(text)
p.lastIndex += 1
hit = p.exec(text)
continue
}
result.push({ key: row.key, messageId: row.message.id, occurrence })
occurrence += 1
hit = pattern.exec(text)
hit = p.exec(text)
}
}
return result
@@ -250,6 +277,27 @@ export const MessageList: Component<MessageListProps> = (props) => {
),
)
// Closing/switching to a different session leaves stale query/matches
// bound to a transcript that's no longer displayed if left untouched —
// reset the whole widget whenever the current session changes. `defer:
// true` skips the initial run so mounting doesn't immediately "reset" a
// session that was never open in this search widget.
createEffect(
on(
() => session.currentSessionID(),
() => {
search.setActive(false)
search.setQuery("")
search.setMatchCase(false)
search.setWholeWord(false)
search.setRegex(false)
search.setIndex(0)
search.setCount(0)
},
{ defer: true },
),
)
const activeKey = createMemo(() => {
const m = matches()
const idx = search.index()
@@ -263,6 +311,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
// the current occurrence so navigation can judge whether it needs to
// scroll at all (several occurrences can share one message).
let highlightFrame: number | undefined
let highlightFrameInner: number | undefined
let pendingCenter = false
const paintHighlights = () => {
const el = scrollEl()
@@ -270,9 +319,8 @@ export const MessageList: Component<MessageListProps> = (props) => {
clearTranscriptHighlights()
return
}
const pattern = buildPattern(search.query(), search.matchCase(), search.wholeWord(), search.regex())
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 })
if (!pendingCenter) return
pendingCenter = false
if (!range) return
@@ -298,11 +346,16 @@ export const MessageList: Component<MessageListProps> = (props) => {
// Two frames of margin so the virtualizer has settled the DOM for the new
// scroll position before we scan it for the precise occurrence to center.
// Both frame ids are tracked so cleanup can cancel whichever leg of the
// chain hasn't fired yet — cancelling only the outer id left the inner,
// already-scheduled frame free to fire (and touch reactive state) after
// the component had already unmounted.
const scheduleHighlight = () => {
if (highlightFrame !== undefined) return
highlightFrame = requestAnimationFrame(() => {
requestAnimationFrame(() => {
highlightFrameInner = requestAnimationFrame(() => {
highlightFrame = undefined
highlightFrameInner = undefined
paintHighlights()
})
})
@@ -345,6 +398,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
onCleanup(() => {
if (highlightFrame !== undefined) cancelAnimationFrame(highlightFrame)
if (highlightFrameInner !== undefined) cancelAnimationFrame(highlightFrameInner)
clearTranscriptHighlights()
})
@@ -63,19 +63,19 @@ export const TranscriptSearch: Component = () => {
return (
<Show when={search.active()}>
<div data-component="transcript-search">
<div data-component="transcript-search" onKeyDown={onKeyDown}>
<div data-slot="transcript-search-box">
<input
ref={inputRef}
type="text"
data-slot="transcript-search-input"
placeholder={language.t("chat.search.placeholder")}
aria-label={language.t("chat.search.toggle")}
value={search.query()}
onInput={(e) => {
search.setQuery(e.currentTarget.value)
search.setIndex(0)
}}
onKeyDown={onKeyDown}
/>
<div data-slot="transcript-search-inline-options">
<Tooltip value={language.t("chat.search.matchCase")} placement="bottom">
@@ -113,7 +113,10 @@ export const TranscriptSearch: Component = () => {
</Tooltip>
</div>
</div>
<Show when={search.count() > 0}>
<Show when={search.invalid()}>
<span data-slot="transcript-search-error">{language.t("chat.search.invalidRegex")}</span>
</Show>
<Show when={!search.invalid() && search.count() > 0}>
<span data-slot="transcript-search-counter">
{search.index() + 1} / {search.count()}
</span>
@@ -1,4 +1,4 @@
import { createContext, useContext, createSignal, type Accessor, type Component } from "solid-js"
import { createContext, useContext, createSignal, type Accessor, type ParentComponent } from "solid-js"
export interface SearchMatch {
key: string
@@ -27,11 +27,16 @@ interface TranscriptSearchContextValue {
* off this instead of `index` so navigation always jumps to the match. */
jump: Accessor<number>
requestJump: () => void
/** True when "Use Regular Expression" is on and the current query fails to
* compile — lets the widget show an explicit error instead of looking
* indistinguishable from a plain "no matches". */
invalid: Accessor<boolean>
setInvalid: (value: boolean) => void
}
const TranscriptSearchContext = createContext<TranscriptSearchContextValue>()
export const TranscriptSearchProvider: Component<{ children: any }> = (props) => {
export const TranscriptSearchProvider: ParentComponent = (props) => {
const [query, setQuery] = createSignal("")
const [matchCase, setMatchCase] = createSignal(false)
const [wholeWord, setWholeWord] = createSignal(false)
@@ -40,6 +45,7 @@ export const TranscriptSearchProvider: Component<{ children: any }> = (props) =>
const [index, setIndex] = createSignal(0)
const [count, setCount] = createSignal(0)
const [jump, setJump] = createSignal(0)
const [invalid, setInvalid] = createSignal(false)
return (
<TranscriptSearchContext.Provider
@@ -60,6 +66,8 @@ export const TranscriptSearchProvider: Component<{ children: any }> = (props) =>
setCount,
jump,
requestJump: () => setJump((n) => n + 1),
invalid,
setInvalid,
}}
>
{props.children}
+1
View File
@@ -1843,4 +1843,5 @@ export const dict = {
"chat.search.previousMatch": "المطابقة السابقة",
"chat.search.nextMatch": "المطابقة التالية",
"chat.search.close": "إغلاق البحث",
"chat.search.invalidRegex": "تعبير عادي غير صالح",
}
+1
View File
@@ -1892,4 +1892,5 @@ export const dict = {
"chat.search.previousMatch": "Correspondência anterior",
"chat.search.nextMatch": "Próxima correspondência",
"chat.search.close": "Fechar pesquisa",
"chat.search.invalidRegex": "Expressão regular inválida",
}
+1
View File
@@ -1884,4 +1884,5 @@ export const dict = {
"chat.search.previousMatch": "Prethodno podudaranje",
"chat.search.nextMatch": "Sljedeće podudaranje",
"chat.search.close": "Zatvori pretragu",
"chat.search.invalidRegex": "Nevažeći regularni izraz",
}
+1
View File
@@ -1876,4 +1876,5 @@ export const dict = {
"chat.search.previousMatch": "Forrige match",
"chat.search.nextMatch": "Næste match",
"chat.search.close": "Luk søgning",
"chat.search.invalidRegex": "Ugyldigt regulært udtryk",
}
@@ -1914,4 +1914,5 @@ export const dict = {
"chat.search.previousMatch": "Vorheriger Treffer",
"chat.search.nextMatch": "Nächster Treffer",
"chat.search.close": "Suche schließen",
"chat.search.invalidRegex": "Ungültiger regulärer Ausdruck",
} satisfies Partial<Record<Keys, string>>
@@ -1871,4 +1871,5 @@ export const dict = {
"chat.search.previousMatch": "Previous match",
"chat.search.nextMatch": "Next match",
"chat.search.close": "Close search",
"chat.search.invalidRegex": "Invalid regular expression",
}
+1
View File
@@ -1900,4 +1900,5 @@ export const dict = {
"chat.search.previousMatch": "Coincidencia anterior",
"chat.search.nextMatch": "Coincidencia siguiente",
"chat.search.close": "Cerrar búsqueda",
"chat.search.invalidRegex": "Expresión regular no válida",
}
+1
View File
@@ -1924,4 +1924,5 @@ export const dict = {
"chat.search.previousMatch": "Résultat précédent",
"chat.search.nextMatch": "Résultat suivant",
"chat.search.close": "Fermer la recherche",
"chat.search.invalidRegex": "Expression régulière non valide",
}
+1
View File
@@ -1809,4 +1809,5 @@ export const dict = {
"chat.search.previousMatch": "Risultato precedente",
"chat.search.nextMatch": "Risultato successivo",
"chat.search.close": "Chiudi ricerca",
"chat.search.invalidRegex": "Espressione regolare non valida",
} as const
+1
View File
@@ -1869,4 +1869,5 @@ export const dict = {
"chat.search.previousMatch": "前の一致",
"chat.search.nextMatch": "次の一致",
"chat.search.close": "検索を閉じる",
"chat.search.invalidRegex": "正規表現が無効です",
}
+1
View File
@@ -1851,4 +1851,5 @@ export const dict = {
"chat.search.previousMatch": "이전 검색 결과",
"chat.search.nextMatch": "다음 검색 결과",
"chat.search.close": "검색 닫기",
"chat.search.invalidRegex": "정규식이 잘못되었습니다",
}
+1
View File
@@ -1911,4 +1911,5 @@ export const dict = {
"chat.search.previousMatch": "Vorige overeenkomst",
"chat.search.nextMatch": "Volgende overeenkomst",
"chat.search.close": "Zoeken sluiten",
"chat.search.invalidRegex": "Ongeldige reguliere expressie",
}
+1
View File
@@ -1869,4 +1869,5 @@ export const dict = {
"chat.search.previousMatch": "Forrige treff",
"chat.search.nextMatch": "Neste treff",
"chat.search.close": "Lukk søk",
"chat.search.invalidRegex": "Ugyldig regulært uttrykk",
} satisfies Partial<Record<Keys, string>>
+1
View File
@@ -1882,4 +1882,5 @@ export const dict = {
"chat.search.previousMatch": "Poprzednie dopasowanie",
"chat.search.nextMatch": "Następne dopasowanie",
"chat.search.close": "Zamknij wyszukiwanie",
"chat.search.invalidRegex": "Nieprawidłowe wyrażenie regularne",
}
+1
View File
@@ -1880,4 +1880,5 @@ export const dict = {
"chat.search.previousMatch": "Предыдущее совпадение",
"chat.search.nextMatch": "Следующее совпадение",
"chat.search.close": "Закрыть поиск",
"chat.search.invalidRegex": "Недопустимое регулярное выражение",
}
+1
View File
@@ -1849,4 +1849,5 @@ export const dict = {
"chat.search.previousMatch": "รายการที่ตรงกันก่อนหน้า",
"chat.search.nextMatch": "รายการที่ตรงกันถัดไป",
"chat.search.close": "ปิดการค้นหา",
"chat.search.invalidRegex": "นิพจน์ทั่วไปไม่ถูกต้อง",
}
+1
View File
@@ -1898,4 +1898,5 @@ export const dict = {
"chat.search.previousMatch": "Önceki eşleşme",
"chat.search.nextMatch": "Sonraki eşleşme",
"chat.search.close": "Aramayı kapat",
"chat.search.invalidRegex": "Geçersiz normal ifade",
}
+1
View File
@@ -1895,4 +1895,5 @@ export const dict = {
"chat.search.previousMatch": "Попередній збіг",
"chat.search.nextMatch": "Наступний збіг",
"chat.search.close": "Закрити пошук",
"chat.search.invalidRegex": "Недійсний регулярний вираз",
}
+1
View File
@@ -1798,4 +1798,5 @@ export const dict = {
"chat.search.previousMatch": "上一个匹配项",
"chat.search.nextMatch": "下一个匹配项",
"chat.search.close": "关闭搜索",
"chat.search.invalidRegex": "正则表达式无效",
} satisfies Partial<Record<Keys, string>>
+1
View File
@@ -1804,4 +1804,5 @@ export const dict = {
"chat.search.previousMatch": "上一個相符項",
"chat.search.nextMatch": "下一個相符項",
"chat.search.close": "關閉搜尋",
"chat.search.invalidRegex": "規則運算式無效",
} satisfies Partial<Record<Keys, string>>
@@ -35,6 +35,7 @@ import { LanguageContext } from "../context/language"
import { IndexingProvider } from "../context/indexing"
import { KiloEmbeddingModelsProvider } from "../context/kilo-embedding-models"
import { MemoryProvider } from "../context/memory"
import { TranscriptSearchProvider } from "../context/transcript-search"
import { dict as uiEn } from "@kilocode/kilo-ui/i18n/en"
import { dict as appEn } from "../i18n/en"
import { dict as amEn } from "../../agent-manager/i18n/en"
@@ -453,11 +454,13 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
<CodeComponentProvider component={Code}>
<FileComponentProvider component={File}>
<MarkedProvider>
{props.noPadding ? (
props.children
) : (
<div style={{ padding: "12px" }}>{props.children}</div>
)}
<TranscriptSearchProvider>
{props.noPadding ? (
props.children
) : (
<div style={{ padding: "12px" }}>{props.children}</div>
)}
</TranscriptSearchProvider>
</MarkedProvider>
</FileComponentProvider>
</CodeComponentProvider>
@@ -343,6 +343,13 @@
flex-shrink: 0;
}
[data-slot="transcript-search-error"] {
font-size: var(--kilo-font-size-11);
color: var(--vscode-errorForeground, #f14c4c);
white-space: nowrap;
flex-shrink: 0;
}
[data-slot="transcript-search-nav"] {
display: flex;
align-items: center;