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.
This commit is contained in:
Sylwester Liljegren
2026-07-10 02:11:42 +02:00
parent 3f8b4443c3
commit 64dd8e3a2b
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
@@ -1840,4 +1840,5 @@ export const dict = {
"chat.search.previousMatch": "المطابقة السابقة",
"chat.search.nextMatch": "المطابقة التالية",
"chat.search.close": "إغلاق البحث",
"chat.search.invalidRegex": "تعبير عادي غير صالح",
}
+1
View File
@@ -1889,4 +1889,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
@@ -1881,4 +1881,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
@@ -1873,4 +1873,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",
}
@@ -1911,4 +1911,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>>
@@ -1868,4 +1868,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
@@ -1897,4 +1897,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
@@ -1921,4 +1921,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
@@ -1806,4 +1806,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
@@ -1866,4 +1866,5 @@ export const dict = {
"chat.search.previousMatch": "前の一致",
"chat.search.nextMatch": "次の一致",
"chat.search.close": "検索を閉じる",
"chat.search.invalidRegex": "正規表現が無効です",
}
+1
View File
@@ -1848,4 +1848,5 @@ export const dict = {
"chat.search.previousMatch": "이전 검색 결과",
"chat.search.nextMatch": "다음 검색 결과",
"chat.search.close": "검색 닫기",
"chat.search.invalidRegex": "정규식이 잘못되었습니다",
}
+1
View File
@@ -1908,4 +1908,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
@@ -1866,4 +1866,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
@@ -1879,4 +1879,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
@@ -1877,4 +1877,5 @@ export const dict = {
"chat.search.previousMatch": "Предыдущее совпадение",
"chat.search.nextMatch": "Следующее совпадение",
"chat.search.close": "Закрыть поиск",
"chat.search.invalidRegex": "Недопустимое регулярное выражение",
}
+1
View File
@@ -1846,4 +1846,5 @@ export const dict = {
"chat.search.previousMatch": "รายการที่ตรงกันก่อนหน้า",
"chat.search.nextMatch": "รายการที่ตรงกันถัดไป",
"chat.search.close": "ปิดการค้นหา",
"chat.search.invalidRegex": "นิพจน์ทั่วไปไม่ถูกต้อง",
}
+1
View File
@@ -1895,4 +1895,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
@@ -1892,4 +1892,5 @@ export const dict = {
"chat.search.previousMatch": "Попередній збіг",
"chat.search.nextMatch": "Наступний збіг",
"chat.search.close": "Закрити пошук",
"chat.search.invalidRegex": "Недійсний регулярний вираз",
}
+1
View File
@@ -1795,4 +1795,5 @@ export const dict = {
"chat.search.previousMatch": "上一个匹配项",
"chat.search.nextMatch": "下一个匹配项",
"chat.search.close": "关闭搜索",
"chat.search.invalidRegex": "正则表达式无效",
} satisfies Partial<Record<Keys, string>>
+1
View File
@@ -1801,4 +1801,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;