fix(web): converge session search boundaries (round 9)

This commit is contained in:
Hypatia May
2026-07-27 14:46:08 +08:00
parent ba2925c704
commit 0aa7f8c5cf
29 changed files with 629 additions and 157 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 80228a180faba0c556ff720e999b29b5bb1635b6
README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: e40f360cdb5cb7d624fc338e85bc33a2cd569578
README.zh.md: 96a77db8b881a9f6b981847d8f3926db3985531e

View File

@@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
## Model Experience

View File

@@ -6,7 +6,7 @@
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token短语行为并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
## 模型体验

View File

@@ -307,33 +307,95 @@ function searchEventText(event: SessionEvent): string {
return event.data.content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n')
}
interface FixtureSearchToken {
value: string
/** Inclusive code-point offset in the whitespace-normalized display text. */
start: number
/** Exclusive code-point offset in the whitespace-normalized display text. */
end: number
}
/**
* Browser-safe approximation of SQLite FTS5 unicode61 token boundaries.
* Keeping phrase matching token-based prevents the development fixture from
* promising arbitrary within-token substring behavior that production lacks.
*/
function searchTokens(value: string): string[] {
return value
.normalize('NFD')
.replace(/\p{M}+/gu, '')
.toLowerCase()
.match(/[\p{L}\p{N}\p{Co}]+/gu) ?? []
}
/** Count exact contiguous token-phrase occurrences in one fixture document. */
function phraseMatchCount(document: readonly string[], phrase: readonly string[]): number {
if (phrase.length === 0 || phrase.length > document.length) return 0
let count = 0
for (let start = 0; start <= document.length - phrase.length; start++) {
if (phrase.every((token, offset) => document[start + offset] === token)) count++
function searchTokenSpans(value: string): { text: string; tokens: FixtureSearchToken[] } {
const text = value.replace(/\s+/gu, ' ').trim()
const characters = Array.from(text)
const tokens: FixtureSearchToken[] = []
let start: number | undefined
let raw = ''
const flush = (end: number): void => {
if (start !== undefined) {
const folded = raw.normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase()
if (folded !== '') tokens.push({ value: folded, start, end })
}
start = undefined
raw = ''
}
return count
for (let index = 0; index < characters.length; index++) {
const character = characters[index] as string
const tokenBase = character.normalize('NFD').replace(/\p{M}+/gu, '')
if (tokenBase === '') {
if (start !== undefined) raw += character
continue
}
if (/^[\p{L}\p{N}\p{Co}]+$/u.test(tokenBase)) {
start ??= index
raw += character
} else {
flush(index)
}
}
flush(characters.length)
return { text, tokens }
}
/** One-line fixture excerpt, bounded so the sidebar remains readable. */
function searchSnippet(value: string): string {
const oneLine = value.replace(/\s+/gu, ' ').trim()
return oneLine.length <= 120 ? oneLine : `${oneLine.slice(0, 117)}`
interface FixturePhraseMatch {
count: number
start: number
end: number
}
/** Count exact contiguous token-phrase occurrences and retain the first display span. */
function phraseMatch(document: readonly FixtureSearchToken[], phrase: readonly string[]): FixturePhraseMatch {
if (phrase.length === 0 || phrase.length > document.length) return { count: 0, start: 0, end: 0 }
let count = 0
let firstStart = 0
let firstEnd = 0
for (let start = 0; start <= document.length - phrase.length; start++) {
if (!phrase.every((token, offset) => document[start + offset]?.value === token)) continue
count++
if (count === 1) {
firstStart = document[start]?.start ?? 0
firstEnd = document[start + phrase.length - 1]?.end ?? firstStart
}
}
return { count, start: firstStart, end: firstEnd }
}
/** Match-centered fixture excerpt, bounded by Unicode code points for the sidebar. */
function searchSnippet(value: string, matchStart: number, matchEnd: number): string {
const characters = Array.from(value)
if (characters.length <= 120) return value
const boundedStart = Math.min(Math.max(0, matchStart), characters.length - 1)
const boundedEnd = Math.min(
characters.length,
Math.max(boundedStart + 1, matchEnd),
)
const center = Math.floor((boundedStart + boundedEnd) / 2)
let start = Math.min(
characters.length - 118,
Math.max(0, center - Math.floor(118 / 2)),
)
let end = start + 118
if (start === 0) {
end = 119
} else if (end === characters.length) {
start = characters.length - 119
}
return `${start > 0 ? '…' : ''}${characters.slice(start, end).join('')}${end < characters.length ? '…' : ''}`
}
interface FixtureSearchCandidate {
@@ -342,6 +404,8 @@ interface FixtureSearchCandidate {
time: number
text: string
matchCount: number
matchStart: number
matchEnd: number
documentLength: number
}
@@ -628,21 +692,24 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
details: {},
})
}
const query = searchTokens(request.payload.query)
const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value)
const matches = sessions.flatMap((summary) => {
const log = logs.get(summary.sessionId) ?? []
const current = new Set(foldSurface(log).nodes)
const best = log.flatMap((event): FixtureSearchCandidate[] => {
if (!current.has(event.seq)) return []
const eventText = searchEventText(event)
const matchCount = phraseMatchCount(searchTokens(eventText), query)
if (matchCount === 0) return []
const document = searchTokenSpans(eventText)
const match = phraseMatch(document.tokens, query)
if (match.count === 0) return []
return [{
sessionId: summary.sessionId,
seq: event.seq,
time: event.time,
text: eventText,
matchCount,
text: document.text,
matchCount: match.count,
matchStart: match.start,
matchEnd: match.end,
documentLength: Array.from(eventText).length,
}]
}).sort(compareSearchCandidates)[0]
@@ -651,7 +718,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return ok(request, {
items: matches.slice(0, 20).map(match => ({
sessionId: match.sessionId,
snippet: searchSnippet(match.text),
snippet: searchSnippet(match.text, match.matchStart, match.matchEnd),
})),
hasMore: matches.length > 20,
})

View File

@@ -62,6 +62,23 @@ describe('createFixtureApi', () => {
if (!phrase.result.ok) throw new Error('search failed')
expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息')
timing().appendUser(
'fx-alpha',
`${'leading context '.repeat(20)}late café token${' trailing context'.repeat(20)}`,
)
const late = await api.sessions.search(req({ query: 'LATE CAFE TOKEN' }), signal)
if (!late.result.ok) throw new Error('late search failed')
const lateSnippet = late.result.value.items[0]?.snippet ?? ''
expect(lateSnippet).toContain('late café token')
expect(lateSnippet.startsWith('…')).toBe(true)
expect(lateSnippet.endsWith('…')).toBe(true)
expect(Array.from(lateSnippet).length).toBeLessThanOrEqual(120)
timing().appendUser('fx-alpha', 'Greek final sigma: ος')
const finalSigma = await api.sessions.search(req({ query: 'ΟΣ' }), signal)
if (!finalSigma.result.ok) throw new Error('final sigma search failed')
expect(finalSigma.result.value.items[0]?.snippet).toContain('ος')
const substring = await api.sessions.search(req({ query: 'ixtur' }), signal)
expect(substring.result).toEqual({
ok: true,

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: 9cb919a1a64394d5e116d35bdddfdee738994a02
README.zh.md: b3add7f89cb0feb7f44238b7199d0633cdfbf641
README.md: badcfc704b456a62a921cb93f6cf637f255fca1f
README.zh.md: 53c43f880ea4ce4f0cfbf633d32f163662e9271f

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation modals.
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization.

View File

@@ -4,7 +4,7 @@
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot两个表层使用同一套 Workspace 菜单和创建模态框。
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace再将其选中。新建操作会禁用列表中已有的名称而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。

View File

@@ -27,6 +27,19 @@ import css from './WorkspaceBrowser.module.css'
const EXPAND_SLIDE_MS = 300
/** Pause between the latest keystroke and a Host content-search request. */
const SEARCH_DEBOUNCE_MS = 250
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
const SEARCH_QUERY_MAX_CODE_UNITS = 500
/** Keep controlled input and RPC payload inside the session.search wire contract. */
function sanitizeSearchQuery(value: string): string {
const withoutNul = value.replaceAll('\0', '')
if (withoutNul.length <= SEARCH_QUERY_MAX_CODE_UNITS) return withoutNul
let end = SEARCH_QUERY_MAX_CODE_UNITS
const last = withoutNul.charCodeAt(end - 1)
const next = withoutNul.charCodeAt(end)
if (last >= 0xD800 && last <= 0xDBFF && next >= 0xDC00 && next <= 0xDFFF) end--
return withoutNul.slice(0, end)
}
const GROUP_BY_ITEMS = [
{ type: 'label' as const, id: 'group-by', text: 'Group by' },
@@ -255,7 +268,7 @@ function SearchResults({
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="搜索结果">
<div className={css.list} role="tree" aria-label="Search results">
{results.items.map(result => (
<SearchResultItem
key={result.id}
@@ -265,18 +278,18 @@ function SearchResults({
/>
))}
{pending && (
<div className={css.searchStatus} role="status"></div>
<div className={css.searchStatus} role="status">Searching session history</div>
)}
{failed && (
<div className={css.searchWarning} role="status">
Content search is temporarily unavailable. Showing name matches.
</div>
)}
{!pending && results.items.length === 0 && (
<div className={css.empty}></div>
<div className={css.empty}>No matching sessions</div>
)}
{results.hasMore && (
<div className={css.searchStatus}> 20 </div>
<div className={css.searchStatus}>Showing the first 20 results. Narrow your search.</div>
)}
</div>
<span className={css.fade} />
@@ -308,7 +321,7 @@ export function WorkspaceBrowser({
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
const [query, setQuery] = useState('')
const normalizedQuery = query.trim()
const normalizedQuery = sanitizeSearchQuery(query).trim()
const [remoteSearch, setRemoteSearch] = useState<RemoteSearchState>({
query: '',
status: 'idle',
@@ -439,11 +452,11 @@ export function WorkspaceBrowser({
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Rail: the icon is the region's search control. */}
<div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}>
<Tooltip label="搜索" disabled={wide}>
<Tooltip label="Search" disabled={wide}>
<button
type="button"
className={css.searchButton}
aria-label="搜索会话"
aria-label="Search sessions"
tabIndex={wide ? -1 : 0}
onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }}
>
@@ -455,16 +468,17 @@ export function WorkspaceBrowser({
ref={searchInput}
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder="搜索名称或关键词…"
placeholder="Search names or content…"
maxLength={SEARCH_QUERY_MAX_CODE_UNITS}
value={query}
onChange={(e) => { setQuery(e.target.value) }}
onChange={(e) => { setQuery(sanitizeSearchQuery(e.target.value)) }}
/>
)}
{wide && query !== '' && (
<button
type="button"
className={clsx(css.clearButton, css.wide)}
aria-label="清除搜索"
aria-label="Clear search"
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />

View File

@@ -155,11 +155,13 @@ describe('deriveSearchResults', () => {
[
workspace('a', ['title-hit'], 'Alpha'),
workspace('b', ['workspace-hit'], 'Needle Workspace'),
workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'),
],
' NEEDLE ',
{
items: [
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
{ sessionId: contentHit.id, snippet: 'ignored duplicate excerpt' },
{ sessionId: titleHit.id, snippet: 'title session body excerpt' },
{ sessionId: sid('unknown'), snippet: 'not in session.list' },
],

View File

@@ -199,7 +199,7 @@ describe('WorkspaceBrowser', () => {
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getAllByText('New Session')).toHaveLength(1)
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'new session' } })
fireEvent.change(screen.getByPlaceholderText('Search names or content…'), { target: { value: 'new session' } })
expect(screen.getAllByText('New Session')).toHaveLength(1)
})
@@ -214,17 +214,17 @@ describe('WorkspaceBrowser', () => {
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
})
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称或关键词…')
const input = screen.getByPlaceholderText<HTMLInputElement>('Search names or content…')
fireEvent.change(input, { target: { value: 'needle' } })
expect(screen.getByRole('tree', { name: '搜索结果' })).toBeTruthy()
expect(screen.getByRole('tree', { name: 'Search results' })).toBeTruthy()
expect(screen.getByText('Needle row')).toBeTruthy()
expect(screen.queryByText('Other row')).toBeNull()
expect(screen.getByText('正在搜索历史…')).toBeTruthy()
expect(screen.getByText('Searching session history…')).toBeTruthy()
fireEvent.change(input, { target: { value: 'zzz' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('没有匹配结果')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
expect(screen.getByText('No matching sessions')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
expect(input.value).toBe('')
expect(screen.getByRole('tree', { name: 'Sessions' })).toBeTruthy()
// Clicking the field row focuses the input (wide mode).
@@ -253,9 +253,9 @@ describe('WorkspaceBrowser', () => {
open,
searchSessions,
})
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称或关键词…')
const input = screen.getByPlaceholderText<HTMLInputElement>('Search names or content…')
fireEvent.change(input, { target: { value: 'waterfall token' } })
expect(screen.getByText('正在搜索历史…')).toBeTruthy()
expect(screen.getByText('Searching session history…')).toBeTruthy()
expect(screen.queryByText('Research notes')).toBeNull()
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
@@ -264,7 +264,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.getByText('Research notes')).toBeTruthy()
expect(screen.getByText('Research Workspace')).toBeTruthy()
expect(screen.getByText('…the waterfall token appears here…')).toBeTruthy()
expect(screen.getByText('仅显示前 20 项,请缩小搜索范围。')).toBeTruthy()
expect(screen.getByText('Showing the first 20 results. Narrow your search.')).toBeTruthy()
fireEvent.click(screen.getByRole('treeitem'))
expect(open).toHaveBeenCalledWith(sid('body-hit'))
expect(input.value).toBe('waterfall token')
@@ -273,6 +273,31 @@ describe('WorkspaceBrowser', () => {
}
})
it('bounds programmatic search input to a schema-valid request without splitting an astral character', async () => {
vi.useFakeTimers()
try {
const searchSessions = vi.fn(async () => ({ items: [], hasMore: false }))
mount({ searchSessions })
const input = screen.getByPlaceholderText<HTMLInputElement>('Search names or content…')
expect(input.maxLength).toBe(500)
fireEvent.change(input, { target: { value: 'y'.repeat(501) } })
expect(input.value).toBe('y'.repeat(500))
const expected = `prefix${'x'.repeat(493)}`
fireEvent.change(input, {
target: { value: `prefix\0${'x'.repeat(493)}😀tail` },
})
expect(input.value).toBe(expected)
expect(input.value.length).toBe(499)
expect(input.value).not.toContain('\0')
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(searchSessions).toHaveBeenCalledOnce()
expect(searchSessions).toHaveBeenCalledWith(expected, expect.any(AbortSignal))
} finally {
vi.useRealTimers()
}
})
it('keeps local matches and shows a lightweight warning when Host search fails', async () => {
vi.useFakeTimers()
try {
@@ -284,14 +309,14 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])),
searchSessions,
})
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), {
fireEvent.change(screen.getByPlaceholderText('Search names or content…'), {
target: { value: 'needle' },
})
expect(screen.getByText('Needle title')).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('Needle title')).toBeTruthy()
expect(screen.getByText('历史内容搜索暂时不可用,仍显示名称匹配。')).toBeTruthy()
expect(screen.queryByText('没有匹配结果')).toBeNull()
expect(screen.getByText('Content search is temporarily unavailable. Showing name matches.')).toBeTruthy()
expect(screen.queryByText('No matching sessions')).toBeNull()
} finally {
vi.useRealTimers()
}
@@ -321,7 +346,7 @@ describe('WorkspaceBrowser', () => {
])),
searchSessions,
})
const input = screen.getByPlaceholderText('搜索名称或关键词…')
const input = screen.getByPlaceholderText('Search names or content…')
fireEvent.change(input, { target: { value: 'first' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal
@@ -346,6 +371,30 @@ describe('WorkspaceBrowser', () => {
}
})
it('ignores a rejected request after it has been superseded', async () => {
vi.useFakeTimers()
try {
let rejectFirst!: (reason: Error) => void
const first = new Promise<never>((_resolve, reject) => { rejectFirst = reject })
const searchSessions = vi.fn((query: string) => query === 'first'
? first
: Promise.resolve({ items: [], hasMore: false }))
mount({ searchSessions })
const input = screen.getByPlaceholderText('Search names or content…')
fireEvent.change(input, { target: { value: 'first' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
fireEvent.change(input, { target: { value: 'second' } })
await act(async () => {
rejectFirst(new Error('stale failure'))
await Promise.resolve()
})
expect(screen.queryByText('Content search is temporarily unavailable. Showing name matches.')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('shows the no-sessions empty state in both modes and resolves an empty search', async () => {
vi.useFakeTimers()
try {
@@ -354,10 +403,10 @@ describe('WorkspaceBrowser', () => {
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getByText('No sessions yet')).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'x' } })
expect(screen.getByText('正在搜索历史…')).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText('Search names or content…'), { target: { value: 'x' } })
expect(screen.getByText('Searching session history…')).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('没有匹配结果')).toBeTruthy()
expect(screen.getByText('No matching sessions')).toBeTruthy()
} finally {
vi.useRealTimers()
}
@@ -370,16 +419,16 @@ describe('WorkspaceBrowser', () => {
const b = mount({ wide: false, expandSidebar })
// No wide chrome in rail state.
expect(screen.queryByText('Workspaces')).toBeNull()
expect(screen.queryByPlaceholderText('搜索名称或关键词…')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
expect(screen.queryByPlaceholderText('Search names or content…')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
// The wide flip mounts the input and focuses it after the slide.
rerender(b, { wide: true })
const input = screen.getByPlaceholderText('搜索名称或关键词…')
const input = screen.getByPlaceholderText('Search names or content…')
act(() => { vi.advanceTimersByTime(300) })
expect(document.activeElement).toBe(input)
// Wide search button is decorative (tabIndex -1, no expand call).
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
@@ -590,7 +639,7 @@ describe('WorkspaceBrowser', () => {
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
})
fireEvent.change(screen.getByPlaceholderText('搜索名称或关键词…'), { target: { value: 'needle' } })
fireEvent.change(screen.getByPlaceholderText('Search names or content…'), { target: { value: 'needle' } })
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
expect(row.hasAttribute('draggable')).toBe(false)
})