mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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:
@@ -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
@@ -1840,4 +1840,5 @@ export const dict = {
|
||||
"chat.search.previousMatch": "المطابقة السابقة",
|
||||
"chat.search.nextMatch": "المطابقة التالية",
|
||||
"chat.search.close": "إغلاق البحث",
|
||||
"chat.search.invalidRegex": "تعبير عادي غير صالح",
|
||||
}
|
||||
|
||||
+1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1866,4 +1866,5 @@ export const dict = {
|
||||
"chat.search.previousMatch": "前の一致",
|
||||
"chat.search.nextMatch": "次の一致",
|
||||
"chat.search.close": "検索を閉じる",
|
||||
"chat.search.invalidRegex": "正規表現が無効です",
|
||||
}
|
||||
|
||||
+1
@@ -1848,4 +1848,5 @@ export const dict = {
|
||||
"chat.search.previousMatch": "이전 검색 결과",
|
||||
"chat.search.nextMatch": "다음 검색 결과",
|
||||
"chat.search.close": "검색 닫기",
|
||||
"chat.search.invalidRegex": "정규식이 잘못되었습니다",
|
||||
}
|
||||
|
||||
+1
@@ -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
@@ -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
@@ -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
@@ -1877,4 +1877,5 @@ export const dict = {
|
||||
"chat.search.previousMatch": "Предыдущее совпадение",
|
||||
"chat.search.nextMatch": "Следующее совпадение",
|
||||
"chat.search.close": "Закрыть поиск",
|
||||
"chat.search.invalidRegex": "Недопустимое регулярное выражение",
|
||||
}
|
||||
|
||||
+1
@@ -1846,4 +1846,5 @@ export const dict = {
|
||||
"chat.search.previousMatch": "รายการที่ตรงกันก่อนหน้า",
|
||||
"chat.search.nextMatch": "รายการที่ตรงกันถัดไป",
|
||||
"chat.search.close": "ปิดการค้นหา",
|
||||
"chat.search.invalidRegex": "นิพจน์ทั่วไปไม่ถูกต้อง",
|
||||
}
|
||||
|
||||
+1
@@ -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
@@ -1892,4 +1892,5 @@ export const dict = {
|
||||
"chat.search.previousMatch": "Попередній збіг",
|
||||
"chat.search.nextMatch": "Наступний збіг",
|
||||
"chat.search.close": "Закрити пошук",
|
||||
"chat.search.invalidRegex": "Недійсний регулярний вираз",
|
||||
}
|
||||
|
||||
+1
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user