feat(search): add categorized global search and contextual previews (#2362)

* feat(search): add categorized global search and detail previews

* fix(search): consolidate dropdowns in filter toolbar

* test(search): cover file preview navigation recovery

* fix(search): refine filters and paged result feedback

* fix(search): preserve dialog when dismissing preview menus

* fix(search): add bottom border to search input

* feat(search): enhance details and search uploaded text

* fix(search): simplify recent content limit hints

* fix(search): align recent content with artifact styling

* fix(search): group message results by response turn

* fix(search): isolate queries and preserve complete paged results

* fix(ci): stabilize browser fixtures and update search API inventory

* fix(ci): include every browser fixture page in preview builds

* fix(search): keep the clear chip above the input underline
This commit is contained in:
Roxi
2026-09-10 01:01:29 -07:00
committed by GitHub
parent 5f59f8ed77
commit 76f8dc49b3
86 changed files with 8357 additions and 3553 deletions
+590
View File
@@ -0,0 +1,590 @@
import { expect } from '@playwright/test'
import { suppressWorkspaceStarNudge, test } from './fixtures/electron-app'
test('refreshes global search when another renderer edits a Library collection', async ({
app
}) => {
const page = await app.completeOnboarding()
await page.evaluate(() => window.api.locale.setPreference({ preference: 'en' }))
const other = await app.openAdditionalRenderer()
await page.getByRole('button', { name: 'Search', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Global search' })
await dialog.getByRole('combobox', { name: 'Global search' }).fill('Shared collection')
await dialog.locator('[data-category="library"]').click()
const created = await other.evaluate(() =>
window.api.literature.transact({ kind: 'create-collection', name: 'Shared collection' })
)
const row = dialog.getByRole('listbox').getByRole('option')
await expect(row).toHaveCount(1)
await expect(row).toContainText('Shared collection')
await other.evaluate(async (id) => {
const result = await window.api.literature.search({
scope: 'global-search',
query: 'Shared collection'
})
const collection = result.entries.find((entry) => 'id' in entry && entry.id === id)!
if (!('revision' in collection)) throw new Error('Collection revision is missing')
await window.api.literature.transact({
kind: 'update-collection',
collectionId: id,
expectedRevision: collection.revision,
name: 'Shared collection revised',
description: ''
})
}, created.id)
await expect(row).toContainText('Shared collection revised')
await other.evaluate(
(id) => window.api.literature.transact({ kind: 'delete-collection', collectionId: id }),
created.id
)
await expect(row).toHaveCount(0)
await expect(dialog).toBeVisible()
})
test('opens a Library PDF from search without leaving the current results', async ({
app
}, testInfo) => {
await app.completeOnboarding()
const page = await app.configureFakeAgent()
await suppressWorkspaceStarNudge(page)
await page.evaluate(async () => {
const { id } = await window.api.literature.transact({
kind: 'create-item',
item: {
itemType: 'journalArticle',
title: 'Search PDF paper',
abstract: 'Search PDF abstract',
issuedText: '',
containerTitle: '',
shortTitle: '',
language: '',
rights: '',
url: '',
extra: '',
typeFields: {},
creators: [],
identifiers: []
}
})
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Contents 5 0 R >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Contents 5 0 R >>',
'<< /Length 35 >>\nstream\n0.2 0.6 0.5 rg 60 540 492 160 re f\n\nendstream'
]
let body = '%PDF-1.4\n'
const offsets = objects.map((object, index) => {
const offset = body.length
body += `${index + 1} 0 obj\n${object}\nendobj\n`
return offset
})
const xrefOffset = body.length
body += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`
const chunk = new TextEncoder().encode(body)
const transferId = crypto.randomUUID()
await window.api.uploads.beginTransfer({
transferId,
name: 'search-paper.pdf',
mimeType: 'application/pdf',
size: chunk.length
})
await window.api.uploads.appendTransfer({ transferId, offset: 0, chunk })
const attachment = await window.api.uploads.finishTransfer({ transferId })
await window.api.literature.importPdf({ itemId: id, attachment })
})
await page.getByRole('button', { name: 'Search', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Global search' })
await dialog.getByRole('combobox', { name: 'Global search' }).fill('Search PDF paper')
await dialog.locator('[data-category="library"]').click()
await dialog.getByRole('listbox').getByRole('option').click()
await expect(dialog.getByText('Search PDF abstract')).toBeVisible()
await dialog.getByRole('tab', { name: 'Preview', exact: true }).click()
await expect(
dialog.getByRole('region', { name: 'search-paper.pdf scrollable preview' })
).toBeVisible()
const pdf = dialog.locator('[data-pdf-preview-root]')
await expect
.poll(async () =>
pdf
.locator('canvas')
.first()
.evaluate((canvas: HTMLCanvasElement) => {
const context = canvas.getContext('2d')!
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data
let colored = 0
for (let i = 0; i < pixels.length; i += 4)
if (pixels[i + 1] > pixels[i] + 40 && pixels[i + 3] > 0) colored++
return colored
})
)
.toBeGreaterThan(1000)
const scroll = dialog.locator('.search-detail-content')
const tabsTop = (await dialog.getByRole('tablist').boundingBox())!.y
await scroll.evaluate((element) => {
element.scrollTop = element.scrollHeight
})
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(100)
expect((await dialog.getByRole('tablist').boundingBox())!.y).toBe(tabsTop)
await dialog.screenshot({ path: testInfo.outputPath('search-pdf-continuous.png') })
await dialog.getByRole('tab', { name: 'Details', exact: true }).click()
expect(await scroll.evaluate((element) => element.scrollTop)).toBe(0)
await dialog.getByRole('tab', { name: 'Preview', exact: true }).click()
await dialog.getByRole('button', { name: 'Open file' }).click()
const preview = page.getByRole('dialog', { name: 'Preview search-paper.pdf' })
await expect(preview).toBeVisible()
await expect(
preview.getByRole('region', { name: 'search-paper.pdf scrollable preview' })
).toBeVisible()
await preview.getByRole('button', { name: 'Close preview of search-paper.pdf' }).click()
await expect(preview).toBeHidden()
await expect(dialog.getByTestId('global-search-detail')).toHaveAttribute('data-open', 'true')
})
test('searches projects, sessions, message bodies and Library with paged disclosure previews', async ({
app
}, testInfo) => {
await app.completeOnboarding()
let page = await app.configureFakeAgent()
await suppressWorkspaceStarNudge(page)
const projectId = await page.evaluate(async () => {
const project = await window.api.projects.create({
name: 'Search research',
description: 'Search fixture project',
agentContext: 'Cite primary sources in every answer.'
})
const now = Date.now()
for (let i = 0; i < 23; i++) {
await window.api.sessions.saveSession({
id: `search-session-${i}`,
projectId: project.id,
title: `Search session ${String(i).padStart(2, '0')}`,
cwd: '/tmp',
status: 'idle',
createdAt: now - i,
updatedAt: now - i,
messages:
i === 0
? Array.from({ length: 120 }, (_, index) => ({
id: `search-message-${index}`,
role: 'user' as const,
status: 'complete' as const,
content:
index === 8
? [
'First context line',
...Array.from(
{ length: 400 },
(_, line) => `Background paragraph ${line}.`
),
'**Historical needle** in the message body',
'Final context paragraph.'
].join('\n\n')
: `Transcript entry ${index}`,
eventIds: [],
createdAt: now - 120 + index,
updatedAt: now - 120 + index
}))
: []
})
}
await window.api.literature.transact({ kind: 'create-collection', name: 'Search references' })
return project.id
})
page = await app.restart()
const concurrentSearchCounts = await page.evaluate(async (projectId) => {
const pages = await Promise.all([
window.api.sessions.searchMessages({
clientId: 'surface-a',
projectIds: [projectId],
query: 'Transcript entry',
limit: 10
}),
window.api.sessions.searchMessages({
clientId: 'surface-b',
projectIds: [projectId],
query: 'Historical needle',
limit: 10
})
])
return pages.map(({ totalCount, isComplete }) => ({ totalCount, isComplete }))
}, projectId)
expect(concurrentSearchCounts).toEqual([
{ totalCount: 119, isComplete: true },
{ totalCount: 1, isComplete: true }
])
await page.getByRole('button', { name: 'Search', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Global search' })
const search = dialog.getByRole('combobox', { name: 'Global search' })
const details = dialog.getByTestId('global-search-detail')
await expect(details).toHaveAttribute('data-open', 'false')
await search.fill('Search')
const sessions = dialog.locator('[data-search-group="sessions"]')
await expect(sessions.getByRole('option')).toHaveCount(10)
const sessionHeading = sessions.locator('.search-group-heading')
await expect(sessionHeading).toHaveCSS('box-shadow', 'none')
await sessions.getByRole('button', { name: 'Load more 10/23' }).click()
await expect(sessions.getByRole('option')).toHaveCount(20)
const resultsViewport = dialog.locator('.global-search-list')
await resultsViewport.evaluate((el) => {
el.scrollTop = 180
})
await expect(sessionHeading).not.toHaveCSS('box-shadow', 'none')
expect(
Math.abs(
(await sessions.locator('.search-group-heading').boundingBox())!.y -
(await resultsViewport.boundingBox())!.y
)
).toBeLessThanOrEqual(1)
await dialog.screenshot({ path: testInfo.outputPath('global-search-sticky-heading.png') })
await resultsViewport.evaluate((el) => {
el.scrollTop = 0
})
await expect(sessionHeading).toHaveCSS('box-shadow', 'none')
await sessions.getByRole('option').first().click()
await dialog.locator('.global-search-body').evaluate(async (el) => {
await Promise.all(el.getAnimations().map((animation) => animation.finished))
})
await expect(details).toHaveAttribute('data-open', 'true')
const order = dialog.getByRole('combobox', { name: 'Result order' })
await order.focus()
await order.press('Enter')
await expect(page.getByRole('option', { name: 'Recently updated', exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('global-search-filter-menu.png') })
await page.keyboard.press('Escape')
await expect(order).toBeFocused()
await expect(order).toHaveAttribute('aria-expanded', 'false')
await expect(details).toHaveAttribute('data-open', 'true')
await expect(dialog).toBeVisible()
await expect(details.getByRole('tab', { name: 'Recent files' })).toBeVisible()
await expect(details.locator('.search-detail-context')).toContainText('Search research')
await expect(details.locator('.search-detail-context')).toContainText('#')
await expect(details.locator('.search-detail-metrics')).toContainText('120 messages')
await expect(details.locator('.search-detail-metrics')).toContainText('0 files')
const headerLayout = await details.locator('header').evaluate((header) => {
const title = header.querySelector('h3')!
const context = header.querySelector('.search-detail-context')!
const metrics = header.querySelector('.search-detail-metrics')!
return {
hasKindIcon: !!header.querySelector('.search-detail-kind-icon'),
hasContextIcons: context.querySelectorAll('svg').length,
contextBelowTitle:
context.getBoundingClientRect().top >= title.getBoundingClientRect().bottom,
metricsBelowContext:
metrics.getBoundingClientRect().top >= context.getBoundingClientRect().bottom,
titleFontSize: getComputedStyle(title).fontSize
}
})
expect(headerLayout).toEqual({
hasKindIcon: true,
hasContextIcons: 2,
contextBelowTitle: true,
metricsBelowContext: true,
titleFontSize: '15px'
})
await page.screenshot({ path: testInfo.outputPath('global-search-session-header.png') })
await dialog.locator('[data-search-group="projects"]').getByRole('option').click()
expect(
await dialog.locator('.global-search-body').evaluate((el) => el.getAnimations().length)
).toBe(0)
await expect(details.getByRole('tab', { name: 'Recent sessions' })).toBeVisible()
await expect(details.locator('.search-detail-context')).toContainText('23 sessions')
await expect(details.locator('.search-detail-context')).toContainText('0 files')
await expect(details.getByRole('tabpanel').getByRole('button')).toHaveCount(10)
await expect(details.getByRole('tabpanel')).toContainText(
'Only the 10 most recent items are shown'
)
await details.getByRole('tab', { name: 'Recent files' }).click()
await expect(details.getByRole('tabpanel')).not.toContainText('Recent files')
await expect(details.getByRole('tabpanel')).not.toContainText(
'Only the 10 most recent items are shown'
)
await details.getByRole('tab', { name: 'Details', exact: true }).click()
await expect(details.getByRole('tabpanel')).toContainText('Search fixture project')
await expect(details.getByRole('tabpanel')).toContainText('Cite primary sources in every answer.')
await dialog.getByRole('button', { name: 'Collapse details' }).click()
await dialog.locator('[data-category="sessions"]').click()
await expect.poll(() => sessions.getByRole('option').count()).toBeGreaterThanOrEqual(10)
await sessions.getByRole('option').last().scrollIntoViewIfNeeded()
await dialog.locator('.global-search-list').hover()
await page.mouse.wheel(0, 1200)
await expect.poll(() => sessions.getByRole('option').count()).toBeGreaterThanOrEqual(20)
await dialog.locator('[data-category="library"]').click()
await dialog.locator('[data-search-group="library"]').getByRole('option').click()
await expect(details.getByRole('tab', { name: 'Recent literature' })).toBeVisible()
await details.getByRole('tab', { name: 'Details', exact: true }).click()
await expect(details.getByRole('tabpanel')).toBeVisible()
await dialog.locator('[data-category="all"]').click()
await search.fill('Historical needle')
const hit = dialog.locator('[data-search-group="messages"]').getByRole('option')
await expect(hit).toHaveCount(1)
await hit.click()
await expect(details.getByRole('heading', { level: 3 })).toHaveText('First context line')
await expect(details.locator('.search-detail-context')).toContainText('Search session 00')
await expect(details.locator('.search-detail-context time')).toBeVisible()
const message = details.locator('.search-message-content')
await expect(message).toContainText('First context line')
await expect(message).toContainText('Final context paragraph.')
await expect(message).toHaveAttribute('data-search-match-count', '1')
await expect
.poll(() => details.locator('.search-detail-content').evaluate((element) => element.scrollTop))
.toBeGreaterThan(100)
await expect
.poll(() =>
message.evaluate((element) => {
const range = [...CSS.highlights.get('global-search-content')!][0] as Range
const rect = range.getBoundingClientRect()
const viewport = element.closest('.search-detail-content')!.getBoundingClientRect()
return (
range.toString() === 'Historical needle' &&
rect.top >= viewport.top &&
rect.bottom <= viewport.bottom
)
})
)
.toBe(true)
await page.screenshot({ path: testInfo.outputPath('global-search-desktop.png') })
await page.setViewportSize({ width: 390, height: 844 })
await expect(details.getByRole('button', { name: 'Back to results' })).toBeVisible()
await expect(details.getByRole('button', { name: 'Collapse details' })).toBeHidden()
await expect(details.getByRole('button', { name: 'Jump to message' })).toBeVisible()
expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
await page.screenshot({ path: testInfo.outputPath('global-search-mobile.png') })
await details.getByRole('button', { name: 'Back to results' }).click()
await expect(details).toHaveAttribute('data-open', 'false')
await hit.click()
await page.setViewportSize({ width: 1280, height: 900 })
await details.getByRole('button', { name: 'Jump to message' }).click()
await expect(dialog).toBeHidden()
const target = page.locator('[data-message-id="search-message-8"]').first()
await expect(target).toBeVisible()
await expect(target).toBeInViewport()
expect(
await page.evaluate(
async (id) =>
(await window.api.sessions.loadOne({ projectId: id, sessionId: 'search-session-0' }))
?.messages.length,
projectId
)
).toBe(120)
})
test('opens uploaded files from search using the existing file preview dialog', async ({ app }) => {
await app.completeOnboarding()
const page = await app.configureFakeAgent()
await suppressWorkspaceStarNudge(page)
await page.getByRole('button', { name: 'New project', exact: true }).click()
const create = page.getByRole('dialog', { name: 'New project' })
await create.getByLabel('Name').fill('Search files')
await create.getByRole('button', { name: 'Create project' }).click()
await page.locator('input[type="file"][multiple]').setInputFiles({
name: 'search-notes.md',
mimeType: 'text/markdown',
buffer: Buffer.from('# Search findings\n\nVerified file preview content.')
})
await page.getByRole('textbox', { name: 'Ask anything' }).fill('Read the attached notes.')
await page.getByRole('button', { name: 'Send message' }).click()
await expect(page.getByText('Deterministic reply:', { exact: false })).toBeVisible()
await page.keyboard.press('ControlOrMeta+k')
const dialog = page.getByRole('dialog', { name: 'Global search' })
await dialog.getByRole('combobox', { name: 'Global search' }).fill('search-notes')
await dialog.locator('[data-category="uploads"]').click()
await dialog.getByRole('listbox').getByRole('option').click()
await expect(
dialog.getByTestId('global-search-detail').getByText('Verified file preview content.')
).toBeVisible()
await dialog.getByRole('combobox', { name: 'Global search' }).fill('Verified file preview')
await dialog.getByRole('listbox').getByRole('option').click()
const content = dialog
.getByTestId('global-search-detail')
.getByText('Verified file preview content.')
await expect(dialog.locator('.search-file-preview [data-search-match-count]')).toHaveAttribute(
'data-search-match-count',
'1'
)
await content.click()
await expect(page.getByRole('dialog', { name: 'Preview search-notes.md' })).toBeHidden()
const bounds = (await content.boundingBox())!
await page.mouse.move(bounds.x + 2, bounds.y + bounds.height / 2)
await page.mouse.down()
await page.mouse.move(bounds.x + Math.min(bounds.width - 2, 160), bounds.y + bounds.height / 2, {
steps: 10
})
await page.mouse.up()
expect(await page.evaluate(() => window.getSelection()?.toString().length)).toBeGreaterThan(0)
await dialog.getByRole('button', { name: 'Open full screen preview' }).click()
const unhighlightedPreview = page.getByRole('dialog', { name: 'Preview search-notes.md' })
await expect(unhighlightedPreview.getByText('Verified file preview content.')).toBeVisible()
expect(
await unhighlightedPreview.evaluate((element) =>
[...(CSS.highlights.get('global-search-content') ?? [])].some((range) =>
element.contains(range.startContainer)
)
)
).toBe(false)
await unhighlightedPreview
.getByRole('button', { name: 'Close preview of search-notes.md' })
.click()
await expect(dialog.locator('.search-file-preview [data-search-match-count]')).toHaveAttribute(
'data-search-match-count',
'1'
)
await dialog.getByRole('combobox', { name: 'Global search' }).fill('search-notes')
await dialog.getByRole('listbox').getByRole('option').click()
await dialog
.getByTestId('global-search-detail')
.getByText('Verified file preview content.')
.click({ button: 'right' })
await expect(page.getByTestId('search-preview-context-menu')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByTestId('search-preview-context-menu')).toBeHidden()
await expect(dialog.getByTestId('global-search-detail')).toHaveAttribute('data-open', 'true')
await dialog.getByRole('tab', { name: 'File information' }).click()
await expect(dialog.getByText('File size', { exact: true })).toBeVisible()
await dialog.getByRole('button', { name: 'Open full screen preview' }).click()
await expect(page.getByRole('dialog', { name: 'Preview search-notes.md' })).toBeVisible()
await expect(
page
.getByRole('dialog', { name: 'Preview search-notes.md' })
.getByText('Verified file preview content.')
).toBeVisible()
const preview = page.getByRole('dialog', { name: 'Preview search-notes.md' })
expect(await preview.locator('[data-search-match-count]').count()).toBe(0)
const menu = page.getByTestId('preview-content-context-menu')
for (const outsidePreview of [false, true]) {
await preview.getByText('Verified file preview content.').click({ button: 'right' })
await expect(menu).toBeVisible()
const bounds = (await preview.boundingBox())!
await page.mouse.click(
outsidePreview ? 5 : bounds.x + bounds.width - 30,
outsidePreview ? bounds.y + bounds.height / 2 : bounds.y + bounds.height - 50
)
await expect(menu).toBeHidden()
await expect(preview).toBeVisible()
await expect(page.locator('.global-search-dialog')).toBeVisible()
}
await preview.getByRole('button', { name: 'Close preview of search-notes.md' }).click()
await expect(preview).toBeHidden()
await expect(dialog.getByRole('combobox', { name: 'Global search' })).toHaveValue('search-notes')
await expect(dialog.getByTestId('global-search-detail')).toHaveAttribute('data-open', 'true')
await page.mouse.click(5, 200)
await expect(dialog).toBeHidden()
})
test('keeps saved Notebook output and structured file previews visible inside search', async ({
app
}, testInfo) => {
await app.completeOnboarding()
const page = await app.configureFakeAgent()
await suppressWorkspaceStarNudge(page)
await page.getByRole('button', { name: 'New project', exact: true }).click()
const create = page.getByRole('dialog', { name: 'New project' })
await create.getByLabel('Name').fill('Search preview formats')
await create.getByRole('button', { name: 'Create project' }).click()
const notebook = JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
metadata: { language_info: { name: 'python' }, retained: 'x'.repeat(1_100_000) },
cells: [
{ cell_type: 'markdown', metadata: {}, source: ['# Search experiment\n', 'Saved analysis.'] },
{
cell_type: 'code',
metadata: {},
source: ['print(42)'],
execution_count: 3,
outputs: [{ output_type: 'stream', name: 'stdout', text: ['Saved output: 42\n'] }]
}
]
})
await page.locator('input[type="file"][multiple]').setInputFiles([
{
name: 'search-experiment.ipynb',
mimeType: 'application/x-ipynb+json',
buffer: Buffer.from(notebook)
},
{
name: 'search-data.csv',
mimeType: 'text/csv',
buffer: Buffer.from('sample,result\ncontrol,42\ntreatment,84')
},
{
name: 'search-molecule.smi',
mimeType: 'chemical/x-daylight-smiles',
buffer: Buffer.from('CCO')
}
])
await page
.getByRole('textbox', { name: 'Ask anything' })
.fill('Inspect the attached search files.')
await page.getByRole('button', { name: 'Send message' }).click()
await expect(page.getByText('Deterministic reply:', { exact: false })).toBeVisible()
await page.keyboard.press('ControlOrMeta+k')
const dialog = page.getByRole('dialog', { name: 'Global search' })
const search = dialog.getByRole('combobox', { name: 'Global search' })
await search.fill('search-')
await dialog.locator('[data-category="uploads"]').click()
await dialog.getByRole('combobox', { name: 'Refine category' }).click()
await page.getByRole('option', { name: 'Notebook', exact: true }).click()
await expect(dialog.getByRole('listbox').getByRole('option')).toHaveCount(1)
await dialog.getByRole('listbox').getByRole('option').click()
await expect(dialog.getByTestId('saved-notebook-preview')).toContainText('Saved output: 42')
await dialog.screenshot({ path: testInfo.outputPath('search-saved-notebook.png') })
await dialog.getByRole('combobox', { name: 'Refine category' }).click()
await page.getByRole('option', { name: 'Spreadsheets / CSV', exact: true }).click()
await expect(dialog.getByRole('listbox').getByRole('option')).toHaveCount(1)
await dialog.getByRole('listbox').getByRole('option').click()
await expect(dialog.locator('.search-csv-preview')).toContainText('treatment')
expect(
await dialog
.locator('.search-csv-preview td')
.first()
.evaluate((el) => getComputedStyle(el).borderBottomWidth)
).toBe('0px')
await dialog.getByRole('combobox', { name: 'Refine category' }).click()
await page.getByRole('option', { name: 'All formats', exact: true }).click()
await search.fill('search-molecule')
await dialog.getByRole('listbox').getByRole('option').click()
const structure = dialog.getByLabel('Structure preview of search-molecule.smi')
await expect
.poll(async () => structure.evaluate((el) => el.getBoundingClientRect().height))
.toBeGreaterThan(100)
await expect(structure.locator('svg')).toBeVisible()
expect(
await structure.locator('svg').evaluate((el) => el.getBoundingClientRect().height)
).toBeGreaterThan(20)
await dialog.screenshot({ path: testInfo.outputPath('search-molecule.png') })
})
test('uses the same preview and information tabs for generated files', async ({
app
}, testInfo) => {
await app.completeOnboarding()
const page = await app.configureFakeAgent()
await suppressWorkspaceStarNudge(page)
await page.getByRole('button', { name: 'New project', exact: true }).click()
const create = page.getByRole('dialog', { name: 'New project' })
await create.getByLabel('Name').fill('Generated search previews')
await create.getByRole('button', { name: 'Create project' }).click()
await page
.getByRole('textbox', { name: 'Ask anything' })
.fill('Create preview context menu artifacts.')
await page.getByRole('button', { name: 'Send message' }).click()
await expect(
page.getByText('Preview context menu artifacts created.', { exact: true })
).toBeVisible({ timeout: 90_000 })
await page.keyboard.press('ControlOrMeta+k')
const dialog = page.getByRole('dialog', { name: 'Global search' })
await dialog.getByRole('combobox', { name: 'Global search' }).fill('context-menu.html')
await dialog.locator('[data-category="generated"]').click()
await dialog.getByRole('listbox').getByRole('option').click()
await expect(
dialog
.frameLocator('iframe[title="Preview of context-menu.html"]')
.getByRole('heading', { name: 'HTML context menu fixture' })
).toBeVisible()
await dialog.screenshot({ path: testInfo.outputPath('search-generated-file.png') })
await dialog.getByRole('tab', { name: 'File information' }).click()
await expect(dialog.getByRole('tabpanel')).toContainText('Generated files')
await dialog.getByRole('button', { name: 'Open full screen preview' }).click()
await expect(page.getByRole('dialog', { name: 'Preview context-menu.html' })).toBeVisible()
})
+1
View File
@@ -14,6 +14,7 @@ test('synchronizes ordinary writes across two Electron renderers and a Web clien
}) => {
test.setTimeout(180_000)
const first = await app.completeOnboarding()
await first.evaluate(() => window.api.locale.setPreference({ preference: 'en' }))
const second = await app.openAdditionalRenderer()
const web = await browser.newPage()
try {
+95
View File
@@ -0,0 +1,95 @@
import { expect } from '@playwright/test'
import { suppressWorkspaceStarNudge, test } from './fixtures/electron-app'
test('restores app interaction after navigating from a search file preview to its conversation', async ({
app
}) => {
await app.completeOnboarding()
const page = await app.configureFakeAgent()
await page.emulateMedia({ reducedMotion: 'no-preference' })
await suppressWorkspaceStarNudge(page)
await page.getByRole('button', { name: 'New project', exact: true }).click()
const create = page.getByRole('dialog', { name: 'New project' })
await create.getByLabel('Name').fill('Search file navigation')
await create.getByRole('button', { name: 'Create project' }).click()
await page
.getByRole('textbox', { name: 'Ask anything' })
.fill('Create preview context menu artifacts.')
await page.getByRole('button', { name: 'Send message' }).click()
await expect(
page.getByText('Preview context menu artifacts created.', { exact: true })
).toBeVisible({ timeout: 90_000 })
await page.keyboard.press('ControlOrMeta+k')
const search = page.getByRole('dialog', { name: 'Global search' })
await search.getByRole('combobox', { name: 'Global search' }).fill('context-menu.html')
await search.locator('[data-category="generated"]').click()
await search.getByRole('listbox').getByRole('option').click()
await search.getByRole('combobox', { name: 'Global search' }).press('Enter')
const preview = page.getByRole('dialog', { name: 'Preview context-menu.html' })
await expect(preview).toBeVisible()
// A closing surface can leave the DOM without animationend (for example after styles change).
await page.addStyleTag({
content:
'[data-slot="file-preview-dialog"][data-state="closed"] { animation: none !important; }'
})
await preview.getByRole('button', { name: 'View in context for context-menu.html' }).click()
await expect(preview).toBeHidden()
await expect
.poll(() => page.locator('#root').evaluate((root) => (root as HTMLElement).inert))
.toBe(false)
await expect
.poll(() => page.locator('body').evaluate((body) => getComputedStyle(body).pointerEvents))
.toBe('auto')
const composer = page.getByRole('textbox', { name: 'Ask anything' })
await composer.fill('Interaction restored')
await expect(composer).toContainText('Interaction restored')
})
test('previews a recent upload without leaving search and locates its source message from the corner action', async ({
app
}, testInfo) => {
await app.completeOnboarding()
const page = await app.configureFakeAgent()
await page.emulateMedia({ reducedMotion: 'no-preference' })
await suppressWorkspaceStarNudge(page)
await page.getByRole('button', { name: 'New project', exact: true }).click()
const create = page.getByRole('dialog', { name: 'New project' })
await create.getByLabel('Name').fill('Recent upload navigation')
await create.getByRole('button', { name: 'Create project' }).click()
await page.locator('input[type="file"][multiple]').setInputFiles({
name: 'source-notes.md',
mimeType: 'text/markdown',
buffer: Buffer.from('# Source notes\n\nThe file opens in a large preview.')
})
await page.getByRole('textbox', { name: 'Ask anything' }).fill('Read the source notes.')
await page.getByRole('button', { name: 'Send message' }).click()
await expect(page.getByText('Deterministic reply:', { exact: false })).toBeVisible()
await page.keyboard.press('ControlOrMeta+k')
const search = page.getByRole('dialog', { name: 'Global search' })
await search.locator('[data-category="sessions"]').click()
await search.getByRole('listbox').getByRole('option').first().click()
await search.locator('.search-recent-file-open').filter({ hasText: 'source-notes.md' }).click()
const preview = page.getByRole('dialog', { name: 'Preview source-notes.md' })
await expect(preview.getByText('The file opens in a large preview.')).toBeVisible()
await preview.getByRole('button', { name: 'Close preview of source-notes.md' }).click()
await expect(preview).toBeHidden()
await expect(search).toBeVisible()
const locate = search.getByRole('button', { name: 'View in context for source-notes.md' })
await locate.hover()
await expect(page.getByRole('tooltip', { name: 'Jump to message' })).toBeVisible()
await search.screenshot({ path: testInfo.outputPath('recent-file-locate.png') })
await locate.click()
await expect(search).toBeHidden()
const sourceMessage = page
.locator('[data-message-id]')
.filter({ hasText: 'Read the source notes.' })
.first()
await expect(sourceMessage).toBeInViewport()
await expect
.poll(() => page.locator('#root').evaluate((root) => (root as HTMLElement).inert))
.toBe(false)
await page.getByRole('textbox', { name: 'Ask anything' }).fill('Still responsive')
await expect(page.getByRole('textbox', { name: 'Ask anything' })).toContainText(
'Still responsive'
)
})
+2 -1
View File
@@ -298,7 +298,8 @@ test('loads managed image previews from Project files', async ({ app }) => {
test.describe('Workspace dividers', () => {
test.beforeEach(async ({ app }) => {
const page = await app.completeOnboarding()
await app.completeOnboarding()
const page = await app.configureFakeAgent()
await createProject(page)
const directory = await realpath(await app.createTestDirectory('resize-preview'))
await writeFile(`${directory}/resize.txt`, 'Resize preview content')
+4 -1
View File
@@ -1,6 +1,7 @@
import { defineConfig } from '@playwright/test'
import base from './playwright.config'
// Replace Electron's Windows project list; defineConfig(base, override) merges projects by name.
export default defineConfig({
...base,
testDir: './e2e/browser',
@@ -12,7 +13,9 @@ export default defineConfig({
timeout: 60_000,
use: { ...base.use, baseURL: 'http://127.0.0.1:4178', headless: true },
webServer: {
command: 'node node_modules/vite/bin/vite.js --config vite.browser-test.config.ts',
// Bundled fixtures avoid hundreds of dev-module requests and React-refresh startup failures.
command:
'node node_modules/vite/bin/vite.js build --config vite.browser-test.config.ts && node node_modules/vite/bin/vite.js preview --config vite.browser-test.config.ts',
url: 'http://127.0.0.1:4178',
reuseExistingServer: false,
timeout: 120_000
+42 -2
View File
@@ -9,12 +9,17 @@ import type { JSONReport } from '@playwright/test/reporter'
import { describe, expect, it, vi } from 'vitest'
const require = createRequire(import.meta.url)
const loadConfig = async (platform: NodeJS.Platform): Promise<PlaywrightTestConfig> => {
const loadConfig = async (
platform: NodeJS.Platform,
browser = false
): Promise<PlaywrightTestConfig> => {
const original = process.platform
Object.defineProperty(process, 'platform', { value: platform })
try {
vi.resetModules()
return (await import('./playwright.config')).default
return browser
? (await import('./playwright.browser.config')).default
: (await import('./playwright.config')).default
} finally {
Object.defineProperty(process, 'platform', { value: original })
}
@@ -69,6 +74,41 @@ describe('Electron Playwright concurrency', () => {
)
})
it('schedules each Windows browser case once without inheriting the Electron project', async () => {
const config = await loadConfig('win32', true)
const root = mkdtempSync(join(tmpdir(), 'open-science-browser-config-'))
try {
const configPath = join(root, 'playwright.config.cjs')
writeFileSync(
configPath,
`module.exports = ${JSON.stringify({ ...config, testDir: root, webServer: undefined })}`
)
writeFileSync(
join(root, 'browser.spec.cjs'),
`const { test } = require(${JSON.stringify(require.resolve('@playwright/test'))});
test('browser fixture', async () => {});`
)
const run = spawnSync(
process.execPath,
[
require.resolve('@playwright/test/cli'),
'test',
'-c',
configPath,
'--list',
'--reporter=json'
],
{ encoding: 'utf8', timeout: 15_000 }
)
expect(run.status, run.stderr).toBe(0)
const report = JSON.parse(run.stdout) as JSONReport
const scheduled = report.suites.flatMap((suite) => suite.specs.flatMap((spec) => spec.tests))
expect(scheduled.map((test) => test.projectName)).toEqual(['chromium'])
} finally {
rmSync(root, { recursive: true, force: true })
}
}, 20_000)
it('partitions the selected Electron suites across three shards without losing or repeating tests', () => {
const collect = (files: string[], shard?: string): string[] => {
const run = spawnSync(
@@ -164,6 +164,7 @@ const createDependencies = () => {
messages: []
}
const sessions = {
searchMessages: vi.fn(),
editDetails: vi.fn(async () => session),
filterPdfContextCandidates: vi.fn(async () => ({
sources: [],
@@ -260,6 +261,7 @@ const WRAPPED_COMMAND_KEYS = [
'sessionList',
'sessionLoadAll',
'sessionLoadOne',
'sessionSearchMessages',
'sessionLoadUsage',
'sessionSaveManifest',
'sessionSave',
@@ -342,6 +344,7 @@ describe('Data and content application commands', () => {
'sessions:list',
'sessions:load-all',
'sessions:load-one',
'sessions:search-messages',
'sessions:load-usage',
'sessions:save-manifest',
'sessions:update-archive',
@@ -1572,6 +1575,9 @@ describe('Data and content application commands', () => {
deps.sessions.list.mockResolvedValueOnce(listResult)
deps.sessions.loadAll.mockResolvedValueOnce(loadResult)
deps.sessions.loadOne.mockResolvedValueOnce(loadedSession)
const searchRequest = { projectIds: ['project-1'], query: 'needle', limit: 10 }
const searchPage = { items: [], totalCount: 0, isComplete: true }
deps.sessions.searchMessages.mockResolvedValueOnce(searchPage)
deps.sessions.loadUsage.mockResolvedValueOnce(usageResult)
registerDataContentApplicationCommands(router.registrar, deps.dependencies)
const updateRequest = { id: 'project-1', name: 'Updated project', expectedUpdatedAt: 1 }
@@ -1611,6 +1617,13 @@ describe('Data and content application commands', () => {
invocation([deleteSessionRequest] as const)
)
).resolves.toBe(loadedSession)
await expect(
router.dispatcher.invoke(
dataContentApplicationCommands.sessionSearchMessages,
invocation([searchRequest])
)
).resolves.toBe(searchPage)
expect(deps.sessions.searchMessages).toHaveBeenCalledWith(searchRequest)
await expect(
router.dispatcher.invoke(dataContentApplicationCommands.sessionLoadUsage, invocation([]))
).resolves.toBe(usageResult)
@@ -1640,7 +1653,7 @@ describe('Data and content application commands', () => {
expect(deps.sessions.saveManifest).toHaveBeenCalledWith(manifestRequest)
expect(deps.sessions.deleteSession).toHaveBeenCalledWith(deleteSessionRequest)
expect(deps.sessions.editDetails).toHaveBeenCalledWith(editDetailsRequest)
expect(deps.withDataRootWrite).toHaveBeenCalledTimes(7)
expect(deps.withDataRootWrite).toHaveBeenCalledTimes(8)
expect(deps.events.publish).toHaveBeenCalledWith('project:updated', deps.project)
expect(deps.events.publish).not.toHaveBeenCalledWith('project:deleted', expect.anything())
expect(deps.events.publish).toHaveBeenCalledWith('session:deleted', deleteSessionRequest)
@@ -338,6 +338,7 @@ const dataContentApplicationCommands = Object.freeze({
),
sessionLoadAll: sessionCommand('sessions:load-all', 'loadAll'),
sessionLoadOne: sessionCommand('sessions:load-one', 'loadOne'),
sessionSearchMessages: sessionCommand('sessions:search-messages', 'searchMessages'),
sessionLoadUsage: sessionCommand('sessions:load-usage', 'loadUsage'),
sessionSaveManifest: sessionCommand(
'sessions:save-manifest',
@@ -468,6 +469,7 @@ const dataContentApplicationCommandGroups = Object.freeze([
dataContentApplicationCommands.sessionList,
dataContentApplicationCommands.sessionLoadAll,
dataContentApplicationCommands.sessionLoadOne,
dataContentApplicationCommands.sessionSearchMessages,
dataContentApplicationCommands.sessionLoadUsage,
dataContentApplicationCommands.sessionSaveManifest,
dataContentApplicationCommands.sessionUpdateArchive,
@@ -744,6 +746,8 @@ const registerDataContentApplicationCommands = (
),
'sessions:load-one': ({ args }) =>
dependencies.withDataRootWrite(() => dependencies.sessions.loadOne(args[0])),
'sessions:search-messages': ({ args }) =>
dependencies.withDataRootWrite(() => dependencies.sessions.searchMessages(args[0])),
'sessions:load-usage': () =>
dependencies.withDataRootWrite(() => dependencies.sessions.loadUsage()),
'sessions:save-manifest': ({ args }) =>
+10 -1
View File
@@ -1537,7 +1537,16 @@ const createApplicationModules = async (
const projectFilesHandlers = createProjectFilesHandlers(
projectFilesRepository,
sessionPersistenceCoordinator,
projectDeletionCoordinator
projectDeletionCoordinator,
(file) =>
managedFileVersionService.openVersion(
{
source: file.source,
projectId: file.projectId,
fileId: file.sourceFileId
},
file.sourceVersionId
)
)
const managedFileVersionHandlers = createManagedFileVersionHandlers(managedFileVersionService, {
withDataRootWrite,
+139
View File
@@ -88,6 +88,111 @@ describe('LiteratureCatalog', () => {
return new LiteratureCatalog(async () => client!)
}
it('pages global search across collections and literature without duplicates', async () => {
const catalog = await setup()
const collection = await catalog.transact({
kind: 'create-collection',
name: 'Corrective collection'
})
const paper = await catalog.transact({ kind: 'create-item', item: candidate().item })
await client!.literatureCollection.update({
where: { id: collection.id },
data: { updatedAt: new Date(10) }
})
await client!.literatureItem.update({
where: { id: paper.id },
data: { updatedAt: new Date(20) }
})
const first = await catalog.search({ scope: 'global-search', query: 'Corrective', limit: 1 })
expect(first).toMatchObject({ totalCount: 2, nextOffset: 1, entries: [{ id: paper.id }] })
const second = await catalog.search({
scope: 'global-search',
query: 'Corrective',
limit: 1,
offset: first.nextOffset
})
expect(second).toMatchObject({ totalCount: 2, entries: [{ id: collection.id, revision: 1 }] })
expect(second.nextOffset).toBeUndefined()
expect(
await catalog.search({ scope: 'global-search', query: 'Corrective', entryKind: 'collection' })
).toMatchObject({ totalCount: 1, entries: [{ id: collection.id }] })
expect(
await catalog.search({ scope: 'global-search', query: 'Corrective', updatedAfter: 15 })
).toMatchObject({ totalCount: 1, entries: [{ id: paper.id }] })
expect(
await catalog.search({ scope: 'global-search', query: 'Corrective', entryKind: 'pdf' })
).toMatchObject({ totalCount: 0 })
await catalog.transact({
kind: 'update-collection',
collectionId: collection.id,
expectedRevision: 1,
name: 'Corrective',
description: ''
})
await client!.literatureCollection.update({
where: { id: collection.id },
data: { updatedAt: new Date(10) }
})
expect(
await catalog.search({
scope: 'global-search',
query: 'Corrective',
searchSort: 'relevance',
limit: 1
})
).toMatchObject({ entries: [{ id: collection.id }] })
expect(
await catalog.search({
scope: 'global-search',
query: 'Corrective',
searchSort: 'recent',
limit: 1
})
).toMatchObject({ entries: [{ id: paper.id }] })
})
it('retains normalized matching and mixed-kind counts in global search', async () => {
const catalog = await setup()
const paper = await catalog.transact({
kind: 'create-item',
item: literatureItemInputSchema.parse({
itemType: 'book',
title: 'ÜBERBLICK gene_A',
creators: [
{ nameMode: 'person', givenName: 'Jane', familyName: 'Smith', creatorType: 'author' }
],
identifiers: [{ scheme: 'doi', value: '10.2468/global-reference' }]
})
})
const collection = await catalog.transact({
kind: 'create-collection',
name: 'gene_A collection'
})
await catalog.transact({
kind: 'create-item',
item: literatureItemInputSchema.parse({ itemType: 'book', title: 'geneXA' })
})
for (const query of [
'überblick',
'Jane Smith',
'Smith Jane',
'https://doi.org/10.2468/GLOBAL-REFERENCE'
]) {
const page = await catalog.search({ scope: 'global-search', query })
expect(page).toMatchObject({ totalCount: 1, entries: [{ id: paper.id }] })
}
const request = literatureCatalogSearchRequestSchema.parse({
scope: 'global-search',
query: 'gene_A',
countOnly: true
})
expect(await catalog.search(request)).toEqual({ entries: [], totalCount: 2 })
const page = await catalog.search({ ...request, countOnly: false })
expect(new Set(page.entries.map((entry) => 'id' in entry && entry.id))).toEqual(
new Set([paper.id, collection.id])
)
})
it('excludes deleted project membership from item views and counts', async () => {
const catalog = await setup()
const projects = new ProjectRepository(async () => client!)
@@ -132,9 +237,19 @@ describe('LiteratureCatalog', () => {
async (state) => {
const catalog = await setup()
const item = await catalog.transact({ kind: 'create-item', item: candidate().item })
const collection = await catalog.transact({ kind: 'create-collection', name: 'Reading' })
await catalog.transact({
kind: 'set-collection-item',
collectionId: collection.id,
itemId: item.id,
included: true
})
await client!.projectLiterature.create({
data: { projectId: 'project-1', itemId: item.id, source: 'library' }
})
expect(
(await catalog.search({ scope: 'global-search', projectId: 'project-1' })).totalCount
).toBe(2)
if (state === 'deleted')
await client!.project.update({
where: { id: 'project-1' },
@@ -147,6 +262,14 @@ describe('LiteratureCatalog', () => {
.soft((await catalog.search({ scope: 'library', projectId: 'project-1' })).totalCount)
.toBe(0)
expect.soft((await catalog.search({ scope: 'project-counts' })).entries).toEqual([])
// Global search retains Library records while hiding their unavailable Project membership.
expect.soft(await catalog.search({ scope: 'global-search' })).toMatchObject({
totalCount: 2,
entries: expect.arrayContaining([expect.objectContaining({ id: item.id, projectIds: [] })])
})
expect
.soft((await catalog.search({ scope: 'global-search', projectId: 'project-1' })).totalCount)
.toBe(0)
expect(await client!.projectLiterature.count()).toBe(1)
}
)
@@ -954,6 +1077,16 @@ describe('LiteratureCatalog', () => {
: 'id' in entry && entry.id === collection.id
)
expect(row && 'itemCount' in row ? row.itemCount : 0).toBe(list.totalCount)
if (scope === 'collection') {
const search = await catalog.search({
scope: 'global-search',
entryKind: 'collection',
query: 'Reading'
})
expect(search.entries).toMatchObject([
{ id: collection.id, itemCount: state === 'deleted' ? 0 : 1 }
])
}
}
}
)
@@ -3048,6 +3181,12 @@ describe('LiteratureCatalog', () => {
}
]
})
await expect(catalog.search({ scope: 'collections', itemId: item.id })).resolves.toMatchObject({
entries: [{ id: collection.id }]
})
await expect(
catalog.search({ scope: 'collections', itemId: 'missing-item' })
).resolves.toMatchObject({ entries: [], totalCount: 0 })
await catalog.transact({
kind: 'update-collection',
+130 -3
View File
@@ -13,6 +13,7 @@ import { createLogger } from '../logger'
import { normalizeLiteratureSearchTextV1 as normalizeSearchText } from '../../shared/literature-search-text'
import { findLiteratureDuplicateGroups } from './duplicates'
import { planLiteratureMerge, supplementLiteratureMetadata } from './duplicate-metadata'
import { searchTitleRank } from '../../shared/search-text'
import {
LITERATURE_IDENTITY_SCHEMES,
@@ -921,6 +922,18 @@ class LiteratureCatalog {
if (request.scope === 'collections') {
const where = {
...(request.parentId ? { parentId: request.parentId } : {}),
...(request.itemId || request.projectId
? {
items: {
some: {
...(request.itemId ? { itemId: request.itemId } : {}),
...(request.projectId
? { item: { projects: { some: { projectId: request.projectId } } } }
: {})
}
}
}
: {}),
...(query ? { name: { contains: query } } : {})
}
const [totalCount, rows] = await Promise.all([
@@ -1006,12 +1019,16 @@ class LiteratureCatalog {
private async searchLibrary(
request: LiteratureCatalogSearchRequest,
client: Pick<LiteratureCatalogClient, 'literatureItem' | '$queryRaw' | 'projectDeletionIntent'>,
client: Pick<
LiteratureCatalogClient,
'literatureItem' | 'literatureCollection' | '$queryRaw' | 'projectDeletionIntent'
>,
boundResponse = true
): Promise<LiteratureCatalogSearchPage> {
const offset = Math.max(0, request.offset ?? 0)
const limit = Math.min(100, Math.max(1, request.limit ?? 50))
const filter = request.filter
const query = normalizeSpace(request.query ?? '')
const predicates: Prisma.Sql[] = [
request.lifecycle === 'deleted'
? Prisma.sql`i."deletedAt" IS NOT NULL`
@@ -1078,10 +1095,120 @@ class LiteratureCatalog {
predicates.push(Prisma.sql`i."issuedYear" >= ${filter.yearFrom}`)
if (filter?.yearTo !== undefined)
predicates.push(Prisma.sql`i."issuedYear" <= ${filter.yearTo}`)
if (filter?.hasFullText !== undefined)
predicates.push(Prisma.sql`${filter.hasFullText ? Prisma.empty : Prisma.sql`NOT`} EXISTS (
if (request.updatedAfter !== undefined)
predicates.push(Prisma.sql`i."updatedAt" >= ${new Date(request.updatedAfter)}`)
const hasFullText = request.entryKind === 'pdf' ? true : filter?.hasFullText
if (hasFullText !== undefined)
predicates.push(Prisma.sql`${hasFullText ? Prisma.empty : Prisma.sql`NOT`} EXISTS (
SELECT 1 FROM "LiteratureAttachment" a JOIN "LiteratureAttachmentVersion" v ON v."attachmentId" = a.id WHERE a."itemId" = i.id)`)
const where = Prisma.join(predicates, ' AND ')
if (request.scope === 'global-search') {
// Rank lightweight identities across both kinds, then hydrate only the requested page.
const [papers, collections] = await Promise.all([
request.entryKind === 'collection'
? []
: client.$queryRaw<{ id: string; title: string; updatedAt: Date }[]>(
Prisma.sql`SELECT i.id, i.title, i."updatedAt" FROM "LiteratureItem" i WHERE ${where}`
),
request.entryKind && request.entryKind !== 'collection'
? []
: client.literatureCollection.findMany({
where: {
...(request.updatedAfter === undefined
? {}
: { updatedAt: { gte: new Date(request.updatedAfter) } }),
...(request.projectId
? {
items: {
some: {
item: {
deletedAt: null,
projects: {
some: {
projectId: request.projectId,
project: await availableProjectWhere(client)
}
}
}
}
}
}
: {}),
...(query
? { OR: [{ name: { contains: query } }, { description: { contains: query } }] }
: {})
},
include: {
_count: {
select: {
items: { where: { item: { deletedAt: null, mergedIntoItemId: null } } }
}
}
}
})
])
const ranked = [
...papers.map((item) => ({
id: item.id,
title: item.title,
updatedAt: item.updatedAt.getTime(),
kind: 'paper' as const
})),
...collections.map((item) => ({
id: item.id,
title: item.name,
updatedAt: item.updatedAt.getTime(),
kind: 'collection' as const
}))
].sort(
(a, b) =>
(request.searchSort !== 'recent'
? searchTitleRank(b.title, query) - searchTitleRank(a.title, query)
: 0) ||
b.updatedAt - a.updatedAt ||
a.id.localeCompare(b.id)
)
if (request.countOnly) return { entries: [], totalCount: ranked.length }
const page = ranked.slice(offset, offset + limit)
const selectedPapers = await client.literatureItem.findMany({
where: { id: { in: page.filter((item) => item.kind === 'paper').map((item) => item.id) } },
include: await activeItemInclude(client)
})
const paperViews = new Map(selectedPapers.map((item) => [item.id, toItemView(item)]))
const collectionViews = new Map(
collections.map((row) => [
row.id,
{
revision: row.revision,
id: row.id,
name: row.name,
description: row.description,
parentId: row.parentId ?? undefined,
itemCount: row._count.items,
createdAt: row.createdAt.getTime(),
updatedAt: row.updatedAt.getTime()
}
])
)
const entries = page.flatMap((item) => {
const view = item.kind === 'paper' ? paperViews.get(item.id) : collectionViews.get(item.id)
return view ? [view] : []
})
const bounded = boundedLiteraturePage(
entries,
0,
limit,
(row) =>
new ApplicationCommandError('command-failed', LITERATURE_OVERSIZED_REFERENCE + row.id)
)
const nextOffset = offset + bounded.entries.length
return {
entries: bounded.entries,
totalCount: ranked.length,
nextOffset: nextOffset < ranked.length ? nextOffset : undefined
}
}
const direction =
(request.sortDirection ?? (request.sortBy === 'title' ? 'asc' : 'desc')) === 'asc'
? Prisma.sql`ASC`
+44 -40
View File
@@ -71,41 +71,41 @@ async function rpc(
return { status: response.status, body: await response.json() }
}
it('opens a supported Library page containing long valid abstracts over Web RPC', async () => {
const client = createProjectDbClient(await directory())
cleanup.push(() => client.$disconnect())
await migrateApplicationDatabase(client)
const catalog = new LiteratureCatalog(async () => client)
const abstract = 'a'.repeat(180_000)
for (let index = 0; index < 100; index++) {
await catalog.transact({
kind: 'create-item',
item: literatureItemInputSchema.parse({
itemType: 'journalArticle',
title: `Reference ${index}`,
abstract
it.each(['library', 'global-search'] as const)(
'opens a %s page containing long valid abstracts over Web RPC',
async (scope) => {
const client = createProjectDbClient(await directory())
cleanup.push(() => client.$disconnect())
await migrateApplicationDatabase(client)
const catalog = new LiteratureCatalog(async () => client)
const abstract = 'a'.repeat(180_000)
for (let index = 0; index < 100; index++) {
await catalog.transact({
kind: 'create-item',
item: literatureItemInputSchema.parse({
itemType: 'journalArticle',
title: `Reference ${index}`,
abstract
})
})
})
}
const small = await rpc('literature:search', () =>
catalog.search({ scope: 'library', limit: 50 })
)
expect(small.status).toBe(200)
const result = await rpc('literature:search', () =>
catalog.search({ scope: 'library', limit: 100 })
)
expect(result, JSON.stringify(result)).toMatchObject({ status: 200 })
const ids: string[] = []
let offset: number | undefined = 0
do {
const page = await catalog.search({ scope: 'library', limit: 100, offset })
ids.push(...page.entries.flatMap((entry) => ('id' in entry ? [entry.id] : [])))
offset = page.nextOffset
} while (offset !== undefined)
expect(new Set(ids).size).toBe(100)
expect(ids).toHaveLength(100)
expect((await catalog.get(ids[0]))?.item.abstract).toBe(abstract)
}, 30_000)
}
const small = await rpc('literature:search', () => catalog.search({ scope, limit: 50 }))
expect(small.status).toBe(200)
const result = await rpc('literature:search', () => catalog.search({ scope, limit: 100 }))
expect(result, JSON.stringify(result)).toMatchObject({ status: 200 })
const ids: string[] = []
let offset: number | undefined = 0
do {
const page = await catalog.search({ scope, limit: 100, offset })
ids.push(...page.entries.flatMap((entry) => ('id' in entry ? [entry.id] : [])))
offset = page.nextOffset
} while (offset !== undefined)
expect(new Set(ids).size).toBe(100)
expect(ids).toHaveLength(100)
expect((await catalog.get(ids[0]))?.item.abstract).toBe(abstract)
},
30_000
)
async function completedJob(count: number): Promise<{
measured: { bytes: number; calls: number }
@@ -242,9 +242,11 @@ it('offers a lossless bounded export for an individually oversized reference', a
abstract: 'a'.repeat(10 * 1024 * 1024) + '😀'
})
})
await expect(catalog.search({ scope: 'library' })).rejects.toThrow(
'Literature reference exceeds the display budget: ' + receipt.id
)
for (const scope of ['library', 'global-search'] as const) {
await expect(catalog.search({ scope })).rejects.toThrow(
'Literature reference exceeds the display budget: ' + receipt.id
)
}
const first = await catalog.exportRecord({ itemId: receipt.id })
expect((await rpc('literature:export-record', async () => first)).status).toBe(200)
const chunks = [first.chunk]
@@ -328,9 +330,11 @@ it('counts matching oversized records without loading their metadata', async ()
abstract: 'A'.repeat(10 * 1024 * 1024 + 1)
})
})
expect(
await catalog.search({ scope: 'library', query: 'Oversized count reference', countOnly: true })
).toEqual({ entries: [], totalCount: 1 })
for (const scope of ['library', 'global-search'] as const) {
expect(
await catalog.search({ scope, query: 'Oversized count reference', countOnly: true })
).toEqual({ entries: [], totalCount: 1 })
}
})
it('downloads complete metadata for an oversized reference retained in Trash', async () => {
@@ -0,0 +1,139 @@
import { describe, expect, it, vi, type Mock } from 'vitest'
import { createProjectFilesHandlers, type ProjectFilesQueryRepository } from './ipc'
import type { SearchFileOpener } from './content-search'
import type { ProjectFileItem, SearchArtifactsRequest } from '../../shared/project-files'
const file = (index: number): ProjectFileItem => ({
id: `upload:${index}`,
source: 'upload',
sourceFileId: `file-${index}`,
sourceVersionId: `version-${index}`,
projectId: 'project',
sessionId: 'session',
name: `notes-${index}.md`,
path: `upload-version:version-${index}`,
size: 10,
sortAtMs: index
})
const request: SearchArtifactsRequest = {
primaryProjectIds: ['project'],
otherProjectIds: [],
source: 'upload',
filenameContains: 'needle',
searchContent: true,
primaryLimit: 10,
otherLimit: 0
}
const setup = (
items: ProjectFileItem[],
content: (item: ProjectFileItem) => string
): {
handlers: ReturnType<typeof createProjectFilesHandlers>
open: Mock<SearchFileOpener>
close: Mock<() => Promise<void>>
repository: ProjectFilesQueryRepository
} => {
const close = vi.fn(async () => {})
const open = vi.fn(async (item: ProjectFileItem) => {
const bytes = Buffer.from(content(item))
return {
size: bytes.length,
read: async (target: Uint8Array, offset: number, length: number, position: number) => {
const part = bytes.subarray(position, position + length)
target.set(part, offset)
return { bytesRead: part.length }
},
verifyUnchanged: vi.fn(async () => {}),
close
}
})
const repository = {
searchArtifacts: vi.fn(async (input: SearchArtifactsRequest) => ({
primary: {
items: items.filter(
(item) => !input.filenameContains || item.name.includes(input.filenameContains)
),
totalCount: items.length
},
other: [],
isIndexComplete: true
}))
} as unknown as ProjectFilesQueryRepository
const handlers = createProjectFilesHandlers(
repository,
{ repairProjectFiles: vi.fn() },
{ waitForProjectOperations: vi.fn(), recoverPendingDeletions: vi.fn() },
open
)
return { handlers, open, close, repository }
}
describe('global file content search', () => {
it('reuses content locations beyond 256 files while rechecking catalog membership', async () => {
const items = Array.from({ length: 300 }, (_, index) => file(index))
const { handlers, open } = setup(items, () => 'needle')
const first = await handlers.searchArtifacts(request)
expect(first.primary.totalCount).toBe(300)
expect(open).toHaveBeenCalledTimes(300)
items.splice(0, 1)
const second = await handlers.searchArtifacts({
...request,
primaryCursor: first.primary.nextCursor
})
expect(second.primary.totalCount).toBe(299)
expect(second.primary.items).toHaveLength(10)
expect(open).toHaveBeenCalledTimes(300)
const changed = { ...items[0]!, sourceVersionId: 'new-version' }
items[0] = changed
await handlers.searchArtifacts(request)
expect(open).toHaveBeenCalledTimes(301)
})
it('finds text-only matches and returns a location for the preview', async () => {
const { handlers, close } = setup([file(1)], () => '# Heading\nA needle in the body.')
const result = await handlers.searchArtifacts(request)
expect(result.primary.items).toHaveLength(1)
expect(result.primary.items[0]?.contentMatch).toEqual({ offset: 0, startingLineNumber: 1 })
expect(close).toHaveBeenCalledOnce()
})
it('searches beyond the initial preview page and across read boundaries', async () => {
const { handlers } = setup([file(1)], () => 'A line\n'.repeat(160000) + 'needle')
const result = await handlers.searchArtifacts(request)
expect(result.primary.items).toHaveLength(1)
expect(result.primary.items[0]!.contentMatch!.offset).toBeGreaterThan(1024 * 1024)
})
it('pages text matches ten at a time and binds the cursor to the query', async () => {
const { handlers } = setup(
Array.from({ length: 23 }, (_, index) => file(index)),
() => 'needle'
)
const first = await handlers.searchArtifacts(request)
expect(first.primary.totalCount).toBe(23)
expect(first.primary.items).toHaveLength(10)
const second = await handlers.searchArtifacts({
...request,
primaryCursor: first.primary.nextCursor
})
expect(second.primary.items).toHaveLength(10)
expect(
new Set([...first.primary.items, ...second.primary.items].map((item) => item.id)).size
).toBe(20)
await expect(
handlers.searchArtifacts({
...request,
filenameContains: 'changed',
primaryCursor: first.primary.nextCursor
})
).rejects.toThrow(/cursor/i)
})
it('skips binary formats and unreadable files while keeping filename matches', async () => {
const { handlers, open } = setup(
[{ ...file(1), name: 'figure.png' }, { ...file(2), name: 'needle.pdf' }, file(3)],
() => 'needle'
)
open.mockRejectedValueOnce(new Error('File unavailable'))
const result = await handlers.searchArtifacts(request)
expect(result.primary.items.map((item) => item.name)).toEqual(['needle.pdf'])
expect(open).toHaveBeenCalledTimes(1)
})
})
+241
View File
@@ -0,0 +1,241 @@
import { StringDecoder } from 'node:string_decoder'
import type {
ProjectFileItem,
SearchArtifactsRequest,
SearchArtifactsResult
} from '../../shared/project-files'
import { findSearchMatches, normalizeSearchText, searchTitleRank } from '../../shared/search-text'
import type { ManagedFilePreviewReadLease } from '../managed-file-preview'
import { normalizeLimit, normalizeSearch, requireIdentifier } from './query-support'
export type SearchFileOpener = (file: ProjectFileItem) => Promise<ManagedFilePreviewReadLease>
type SearchRepository = {
searchArtifacts(request: SearchArtifactsRequest): Promise<SearchArtifactsResult>
}
type Match = NonNullable<ProjectFileItem['contentMatch']>
const TEXT_EXTENSIONS = new Set(
'txt text md markdown csv tsv json jsonl yaml yml xml html htm css js jsx ts tsx py r rmd qmd ipynb log ini cfg conf toml sh bash zsh sql tex bib rst c h cpp hpp java go rs rb php swift kt m julia jl'.split(
' '
)
)
const isTextFile = (file: ProjectFileItem): boolean =>
file.mimeType?.startsWith('text/') === true ||
TEXT_EXTENSIONS.has(file.name.split('.').at(-1)?.toLowerCase() ?? '')
// Scan immutable Versions in bounded chunks. Preserve overlap for cross-chunk matches and return
// nearby context so the existing paged reader can open near a late-file hit.
const findContentMatch = async (
file: ProjectFileItem,
query: string,
open: SearchFileOpener
): Promise<Match | undefined> => {
const lease = await open(file)
try {
const buffer = Buffer.alloc(64 * 1024)
const decoder = new StringDecoder('utf8')
let position = 0
let decodedBytes = 0
let line = 1
let tail = ''
let tailOffset = 0
let tailLine = 1
while (position < lease.size) {
const { bytesRead } = await lease.read(
buffer,
0,
Math.min(buffer.length, lease.size - position),
position
)
if (!bytesRead) throw new Error('Managed search content ended before its declared size.')
position += bytesRead
const chunk =
decoder.write(buffer.subarray(0, bytesRead)) +
(position === lease.size ? decoder.end() : '')
if (chunk.includes('\0')) return undefined
const text = tail + chunk
const match = findSearchMatches(text, query, 1)[0]
if (match) {
let start = match.start
for (let context = 0; context < 4; context++) {
const newline = text.lastIndexOf('\n', start - 1)
if (newline < 0) {
start = 0
break
}
start = newline
}
if (start > 0) start++
const prefix = text.slice(0, start)
await lease.verifyUnchanged()
return {
offset: (tail ? tailOffset : decodedBytes) + Buffer.byteLength(prefix),
startingLineNumber: (tail ? tailLine : line) + (prefix.match(/\n/g)?.length ?? 0)
}
}
const keep = Math.max(256, query.length * 4)
let split = Math.max(0, text.length - keep)
if (split > 0 && /[\uDC00-\uDFFF]/.test(text[split]!)) split--
const prefix = text.slice(0, split)
tailOffset = (tail ? tailOffset : decodedBytes) + Buffer.byteLength(prefix)
tailLine = (tail ? tailLine : line) + (prefix.match(/\n/g)?.length ?? 0)
tail = text.slice(split)
decodedBytes += Buffer.byteLength(chunk)
line += chunk.match(/\n/g)?.length ?? 0
}
await lease.verifyUnchanged()
return undefined
} finally {
await lease.close()
}
}
// Metadata membership is re-read for every search; only immutable Version match locations are
// cached, so archived/deleted files cannot survive through a cached result page.
export const createFileContentSearch = (repository: SearchRepository, open: SearchFileOpener) => {
let cachedQuery = ''
let cache = new Map<string, Match | undefined>()
return async (request: SearchArtifactsRequest): Promise<SearchArtifactsResult> => {
const limit = normalizeLimit(request.primaryLimit)
if (
!Array.isArray(request.primaryProjectIds) ||
!request.primaryProjectIds.length ||
!Array.isArray(request.otherProjectIds)
)
throw new Error('Invalid content search Projects.')
for (const id of [...request.primaryProjectIds, ...request.otherProjectIds])
requireIdentifier(id, 'projectId')
if (!Number.isInteger(request.otherLimit) || request.otherLimit < 0 || request.otherLimit > 5)
throw new Error('Invalid content search otherLimit.')
normalizeSearch({
filenameContains: request.filenameContains ?? '',
excludedSessionIds: request.excludedSessionIds,
format: request.format,
updatedAfter: request.updatedAfter,
sort: request.sort
})
const query = request.filenameContains?.trim() ?? ''
const key = JSON.stringify({
projects: [...request.primaryProjectIds].sort(),
other: [...request.otherProjectIds].sort(),
excluded: [...(request.excludedSessionIds ?? [])].sort(),
source: request.source,
session: request.sessionId,
query: normalizeSearchText(query),
sort: request.sort,
format: request.format,
updatedAfter: request.updatedAfter
})
// Keep locations for the complete current query, rather than evicting the front of each scan.
// Each request captures its own map so an older query cannot populate its successor's cache.
if (cachedQuery !== key) {
cachedQuery = key
cache = new Map()
}
const locations = cache
const currentVersions = new Set<string>()
let cursor: { key: string; rank: number; sortAtMs: number; id: string } | undefined
if (request.primaryCursor) {
try {
cursor = JSON.parse(Buffer.from(request.primaryCursor, 'base64url').toString())
} catch {
throw new Error('Invalid content search cursor.')
}
if (
!cursor ||
cursor.key !== key ||
!Number.isFinite(cursor.rank) ||
!Number.isFinite(cursor.sortAtMs) ||
typeof cursor.id !== 'string'
)
throw new Error('Content search cursor does not match the requested search.')
}
const readSet = async (
projectIds: string[]
): Promise<{ items: ProjectFileItem[]; complete: boolean }> => {
if (!projectIds.length) return { items: [], complete: true }
const items: ProjectFileItem[] = []
let nextCursor: string | undefined
let complete = true
do {
const page = await repository.searchArtifacts({
...request,
searchContent: false,
filenameContains: '',
sort: 'recent',
primaryProjectIds: projectIds,
otherProjectIds: [],
primaryLimit: 100,
primaryCursor: nextCursor,
otherLimit: 0
})
complete &&= page.isIndexComplete
// Four concurrent leases bound disk pressure without serializing a project with many files.
for (let offset = 0; offset < page.primary.items.length; offset += 4) {
const batch = await Promise.all(
page.primary.items.slice(offset, offset + 4).map(async (file) => {
if (searchTitleRank(file.name, query)) return file
if (!isTextFile(file)) return undefined
try {
const cacheKey = JSON.stringify([
file.projectId,
file.source,
file.sourceFileId,
file.sourceVersionId
])
currentVersions.add(cacheKey)
const match = locations.has(cacheKey)
? locations.get(cacheKey)
: await findContentMatch(file, query, open)
locations.set(cacheKey, match)
return match ? { ...file, contentMatch: match } : undefined
} catch {
complete = false
return undefined
}
})
)
items.push(...batch.filter((file): file is ProjectFileItem => file !== undefined))
}
nextCursor = page.primary.nextCursor
} while (nextCursor)
return { items, complete }
}
const primary = await readSet(request.primaryProjectIds)
const other = await readSet(
request.otherLimit
? request.otherProjectIds.filter((id) => !request.primaryProjectIds.includes(id))
: []
)
// Removed files and superseded Versions leave no retained locations after a catalog scan.
for (const version of locations.keys())
if (!currentVersions.has(version)) locations.delete(version)
const rank = (file: ProjectFileItem): number =>
request.sort === 'recent' ? 0 : searchTitleRank(file.name, query)
const compare = (a: { rank: number; sortAtMs: number; id: string }, b: typeof a): number =>
b.rank - a.rank || b.sortAtMs - a.sortAtMs || a.id.localeCompare(b.id)
const sort = (a: ProjectFileItem, b: ProjectFileItem): number =>
compare({ ...a, rank: rank(a) }, { ...b, rank: rank(b) })
primary.items.sort(sort)
other.items.sort(sort)
const remaining = cursor
? primary.items.filter((file) => compare({ ...file, rank: rank(file) }, cursor!) > 0)
: primary.items
const items = remaining.slice(0, limit)
const last = items.at(-1)
return {
primary: {
items,
totalCount: primary.items.length,
nextCursor:
remaining.length > limit && last
? Buffer.from(
JSON.stringify({ key, rank: rank(last), sortAtMs: last.sortAtMs, id: last.id })
).toString('base64url')
: undefined
},
other: other.items.slice(0, request.otherLimit),
isIndexComplete: primary.complete && other.complete
}
}
}
+44 -35
View File
@@ -1,4 +1,5 @@
import { ipcMainHandle } from '../ipc-handler-registry'
import { createFileContentSearch, type SearchFileOpener } from './content-search'
import type {
ArtifactGroupPage,
@@ -47,42 +48,50 @@ type ProjectFilesHandlers = {
const createProjectFilesHandlers = (
repository: ProjectFilesQueryRepository,
repairBackend: ProjectFilesRepairBackend,
recoveryBackend: ProjectFilesRecoveryBackend
): ProjectFilesHandlers => ({
getOverview: async (request) => {
await recoveryBackend.waitForProjectOperations([request.projectId])
return repository.getOverview(request)
},
listFiles: async (request) => {
await recoveryBackend.waitForProjectOperations([request.projectId])
return repository.listFiles(request)
},
readExportFiles: async (request) => {
await recoveryBackend.waitForProjectOperations([request.projectId])
return repository.readExportFiles(request)
},
resolveFile: async (request) => {
await recoveryBackend.waitForProjectOperations([request.projectId])
return repository.resolveFile(request)
},
listArtifactGroups: async (request) => {
await recoveryBackend.waitForProjectOperations([request.projectId])
return repository.listArtifactGroups(request)
},
searchArtifacts: async (request) => {
await recoveryBackend.waitForProjectOperations([
...request.primaryProjectIds,
...request.otherProjectIds
])
return repository.searchArtifacts(request)
},
repairIndex: async ({ projectId }) => {
// repairProjectFiles performs a complete Session scan and global projection reconciliation.
// Keep it behind strict recovery so it cannot touch another Project with a failed deletion tail.
await recoveryBackend.recoverPendingDeletions()
return repairBackend.repairProjectFiles(projectId)
recoveryBackend: ProjectFilesRecoveryBackend,
openSearchFile?: SearchFileOpener
): ProjectFilesHandlers => {
const searchContent = openSearchFile
? createFileContentSearch(repository, openSearchFile)
: undefined
return {
getOverview: async (request) => {
await recoveryBackend.waitForProjectOperations([request.projectId])
return repository.getOverview(request)
},
listFiles: async (request) => {
await recoveryBackend.waitForProjectOperations([request.projectId])
return repository.listFiles(request)
},
readExportFiles: async (request) => {
await recoveryBackend.waitForProjectOperations([request.projectId])
return repository.readExportFiles(request)
},
resolveFile: async (request) => {
await recoveryBackend.waitForProjectOperations([request.projectId])
return repository.resolveFile(request)
},
listArtifactGroups: async (request) => {
await recoveryBackend.waitForProjectOperations([request.projectId])
return repository.listArtifactGroups(request)
},
searchArtifacts: async (request) => {
await recoveryBackend.waitForProjectOperations([
...request.primaryProjectIds,
...request.otherProjectIds
])
return request.searchContent && request.filenameContains?.trim() && searchContent
? searchContent(request)
: repository.searchArtifacts(request)
},
repairIndex: async ({ projectId }) => {
// repairProjectFiles performs a complete Session scan and global projection reconciliation.
// Keep it behind strict recovery so it cannot touch another Project with a failed deletion tail.
await recoveryBackend.recoverPendingDeletions()
return repairBackend.repairProjectFiles(projectId)
}
}
})
}
// All Files operations wait on Project-scoped deletion recovery before reading or repairing metadata.
// This prevents a query from observing its Project midway through crash recovery without coupling it
+21 -3
View File
@@ -20,6 +20,7 @@ import {
decodeGroupCursor,
decodeSearchArtifactCursor,
encodeCursor,
fileSearchRank,
getAuthoritativeOverviewCounts,
listAuthoritativeArtifactGroups,
listAuthoritativeFiles,
@@ -307,21 +308,34 @@ class ProjectFilesQueryOwner {
throw new Error('Project files otherLimit must be between 0 and 5.')
}
const source = request.source ?? 'artifact'
if (!['artifact', 'upload', 'all'].includes(source)) throw new Error('Invalid file source.')
if (request.sessionId !== undefined) requireIdentifier(request.sessionId, 'sessionId')
const primaryLimit = normalizeLimit(request.primaryLimit)
const search = normalizeSearch({
filenameContains: request.filenameContains ?? '',
updatedAfter: request.updatedAfter,
format: request.format,
sort: request.sort,
...(request.excludedSessionIds === undefined
? {}
: { excludedSessionIds: request.excludedSessionIds })
})
const cursor = request.primaryCursor
? decodeSearchArtifactCursor(request.primaryCursor, primaryProjectIds, search)
? decodeSearchArtifactCursor(
request.primaryCursor,
primaryProjectIds,
search,
source,
request.sessionId
)
: undefined
const client = await this.getClient()
const [primaryResult, otherRows] = await Promise.all([
listAuthoritativeFiles(client, {
projectIds: primaryProjectIds,
source: 'artifact',
source: source === 'all' ? undefined : source,
sessionId: request.sessionId,
search,
cursor,
limit: primaryLimit + 1
@@ -329,7 +343,8 @@ class ProjectFilesQueryOwner {
request.otherLimit > 0
? queryAuthoritativeFiles(client, {
projectIds: otherProjectIds,
source: 'artifact',
source: source === 'all' ? undefined : source,
sessionId: request.sessionId,
search,
limit: request.otherLimit
})
@@ -364,9 +379,12 @@ class ProjectFilesQueryOwner {
? encodeCursor({
version: 2,
kind: 'globalArtifacts',
source,
sessionId: request.sessionId,
primaryProjectIds,
queryKey: search?.queryKey ?? '',
sortAtMs: lastPrimaryRow.sortAtMs.toString(),
rank: fileSearchRank(lastPrimaryRow.displayName, search),
seq: lastPrimaryRow.seq
})
: undefined
+86 -6
View File
@@ -10,6 +10,11 @@ import type {
} from '../../shared/project-files'
import { createUploadVersionReference } from '../../shared/uploads'
import type { ProjectFilesClient } from './mutation-projection'
import {
SEARCH_FILE_EXTENSIONS,
type SearchFileFormat,
type SearchSort
} from '../../shared/search-text'
const MAX_PAGE_LIMIT = 100
@@ -35,19 +40,25 @@ type GroupCursor = {
type SearchArtifactCursor = {
version: 2
kind: 'globalArtifacts'
source?: ProjectFileSource | 'all'
sessionId?: string
primaryProjectIds: string[]
queryKey: string
sortAtMs: string
seq: number
rank?: number
}
type NormalizedSearch = {
filenameContains?: string
excludedSessionIds: string[]
queryKey: string
updatedAfter?: number
format?: SearchFileFormat
sort?: SearchSort
}
type CatalogCursor = { sortAtMs: string; seq: number }
type CatalogCursor = { sortAtMs: string; seq: number; rank?: number }
type AuthoritativeCatalogQuery = {
projectIds: string[]
@@ -89,11 +100,38 @@ const normalizeSearch = (search: unknown): NormalizedSearch | undefined => {
throw new Error('Project files search must be at most 256 characters.')
}
const excludedSessionIds = normalizeExcludedSessionIds(search.excludedSessionIds)
if (!filenameContains && excludedSessionIds.length === 0) return undefined
if (
search.updatedAfter !== undefined &&
(typeof search.updatedAfter !== 'number' ||
!Number.isSafeInteger(search.updatedAfter) ||
search.updatedAfter < 0)
)
throw new Error('Invalid search date.')
if (
search.format !== undefined &&
(typeof search.format !== 'string' || !Object.hasOwn(SEARCH_FILE_EXTENSIONS, search.format))
)
throw new Error('Invalid search file format.')
if (search.sort !== undefined && search.sort !== 'relevance' && search.sort !== 'recent')
throw new Error('Invalid search sort.')
const updatedAfter = search.updatedAfter as number | undefined
const format = search.format as SearchFileFormat | undefined
const sort = search.sort as SearchSort | undefined
if (
!filenameContains &&
excludedSessionIds.length === 0 &&
updatedAfter === undefined &&
!format &&
!sort
)
return undefined
return {
...(filenameContains ? { filenameContains } : {}),
excludedSessionIds,
queryKey: `${filenameContains ? foldAsciiCase(filenameContains) : ''}\u0000${excludedSessionIds.join('\u0000')}`
updatedAfter,
format,
sort,
queryKey: `${filenameContains ? foldAsciiCase(filenameContains) : ''}\u0000${excludedSessionIds.join('\u0000')}${updatedAfter !== undefined || format || sort ? `\u0000${JSON.stringify([updatedAfter, format, sort])}` : ''}`
}
}
@@ -316,22 +354,53 @@ const authoritativeCatalogPredicates = (
const excludedSessionsPredicate = query.search?.excludedSessionIds.length
? Prisma.sql`AND file."sessionId" NOT IN (${Prisma.join(query.search.excludedSessionIds)})`
: Prisma.empty
const cursorPredicate = query.cursor
const datePredicate =
query.search?.updatedAfter !== undefined
? Prisma.sql`AND file."sortAtMs" >= ${BigInt(query.search.updatedAfter)}`
: Prisma.empty
const formatPredicate = query.search?.format
? Prisma.sql`AND (${Prisma.join(
SEARCH_FILE_EXTENSIONS[query.search.format].map(
(ext) => Prisma.sql`lower(file."displayName") LIKE ${`%.${ext}`}`
),
' OR '
)})`
: Prisma.empty
const recentCursor = query.cursor
? Prisma.sql`AND (
file."sortAtMs" < ${BigInt(query.cursor.sortAtMs)}
OR (file."sortAtMs" = ${BigInt(query.cursor.sortAtMs)} AND file."seq" < ${query.cursor.seq})
)`
: Prisma.empty
const rank = fileRelevanceSql(query.search)
const cursorPredicate =
query.cursor?.rank !== undefined
? Prisma.sql`AND (${rank} < ${query.cursor.rank} OR (${rank} = ${query.cursor.rank} AND (1 = 1 ${recentCursor})))`
: recentCursor
return Prisma.sql`
${sourcePredicate}
${sourceFilePredicate}
${sessionPredicate}
${filenamePredicate}
${excludedSessionsPredicate}
${datePredicate}
${formatPredicate}
${cursorPredicate}
`
}
const fileRelevanceSql = (search: NormalizedSearch | undefined): Prisma.Sql =>
search?.sort === 'relevance' && search.filenameContains
? Prisma.sql`CASE WHEN lower(file."displayName") = lower(${search.filenameContains}) THEN 3 WHEN instr(lower(file."displayName"), lower(${search.filenameContains})) = 1 THEN 2 ELSE 1 END`
: Prisma.sql`CAST(0 AS INTEGER)`
const fileSearchRank = (name: string, search: NormalizedSearch | undefined): number | undefined => {
if (search?.sort !== 'relevance' || !search.filenameContains) return undefined
const title = foldAsciiCase(name),
query = foldAsciiCase(search.filenameContains)
return title === query ? 3 : title.startsWith(query) ? 2 : 1
}
const normalizeCatalogRows = (rows: Array<ManagedFile & { seq: number | bigint }>): ManagedFile[] =>
rows.map((row) => ({
...row,
@@ -351,7 +420,7 @@ const queryAuthoritativeFiles = async (
FROM "AuthoritativeFile" AS file
WHERE 1 = 1
${predicates}
ORDER BY file."sortAtMs" DESC, file."seq" DESC
ORDER BY ${fileRelevanceSql(query.search)} DESC, file."sortAtMs" DESC, file."seq" DESC
${limit}
`)
return normalizeCatalogRows(rows)
@@ -535,7 +604,9 @@ const decodeGroupCursor = (cursor: string, request: ListArtifactGroupsRequest):
const decodeSearchArtifactCursor = (
cursor: string,
primaryProjectIds: string[],
search: NormalizedSearch | undefined
search: NormalizedSearch | undefined,
source: ProjectFileSource | 'all' = 'artifact',
sessionId?: string
): SearchArtifactCursor => {
const value = parseCursor(cursor)
const expectedQueryKey = search?.queryKey ?? ''
@@ -544,6 +615,8 @@ const decodeSearchArtifactCursor = (
!isRecord(value) ||
value.version !== 2 ||
value.kind !== 'globalArtifacts' ||
(value.source ?? 'artifact') !== source ||
value.sessionId !== sessionId ||
JSON.stringify(value.primaryProjectIds) !== JSON.stringify(primaryProjectIds) ||
typeof value.queryKey !== 'string' ||
typeof value.sortAtMs !== 'string' ||
@@ -557,6 +630,12 @@ const decodeSearchArtifactCursor = (
throw new Error('Project files cursor does not match the requested search.')
}
if (
search?.sort === 'relevance' &&
search.filenameContains &&
(typeof value.rank !== 'number' || ![1, 2, 3].includes(value.rank))
)
throw new Error('Invalid search rank cursor.')
return value as SearchArtifactCursor
}
@@ -625,6 +704,7 @@ export {
decodeGroupCursor,
decodeSearchArtifactCursor,
encodeCursor,
fileSearchRank,
getAuthoritativeOverviewCounts,
listAuthoritativeArtifactGroups,
listAuthoritativeFiles,
+66
View File
@@ -2912,6 +2912,38 @@ describe('ManagedFileIndexRepository', () => {
otherLimit: 0 as const
}
const first = await repository.searchArtifacts(request)
const ranked = await repository.searchArtifacts({
...request,
filenameContains: 'sin-alpha',
format: 'image',
sort: 'relevance',
updatedAfter: 100
})
expect(ranked.primary).toMatchObject({ totalCount: 1, items: [{ name: 'sin-alpha.png' }] })
expect(
(await repository.searchArtifacts({ ...request, format: 'pdf' })).primary.totalCount
).toBe(0)
const relevancePage = await repository.searchArtifacts({ ...request, sort: 'relevance' })
const relevanceNext = await repository.searchArtifacts({
...request,
sort: 'relevance',
primaryCursor: relevancePage.primary.nextCursor
})
expect(
new Set(
[...relevancePage.primary.items, ...relevanceNext.primary.items].map((item) => item.id)
).size
).toBe(4)
await expect(
repository.searchArtifacts({
...request,
sort: 'recent',
primaryCursor: relevancePage.primary.nextCursor
})
).rejects.toThrow(/cursor.*search/i)
expect(
(await repository.searchArtifacts({ ...request, updatedAfter: 200 })).primary.totalCount
).toBe(0)
expect(first.primary.totalCount).toBe(6)
expect(first.primary.items).toHaveLength(2)
expect(first.primary.nextCursor).toBeDefined()
@@ -3107,6 +3139,40 @@ describe('ManagedFileIndexRepository', () => {
)
expect(first.isIndexComplete).toBe(true)
const uploads = await repository.searchArtifacts({
primaryProjectIds: [PROJECT_ID],
otherProjectIds: [],
filenameContains: 'SIN',
source: 'upload',
primaryLimit: 10,
otherLimit: 0
})
expect(uploads.primary).toMatchObject({
totalCount: 1,
items: [expect.objectContaining({ name: 'sin-input.csv', source: 'upload' })]
})
const sessionFiles = await repository.searchArtifacts({
primaryProjectIds: [PROJECT_ID, 'project-b'],
otherProjectIds: [],
source: 'all',
sessionId: SESSION_ID,
primaryLimit: 10,
otherLimit: 0
})
expect(sessionFiles.primary.totalCount).toBe(4)
expect(sessionFiles.primary.items.every((item) => item.sessionId === SESSION_ID)).toBe(true)
await expect(
repository.searchArtifacts({
primaryProjectIds: [PROJECT_ID],
otherProjectIds: [],
filenameContains: 'SIN',
source: 'upload',
primaryLimit: 2,
primaryCursor: first.primary.nextCursor,
otherLimit: 0
})
).rejects.toThrow(/cursor/i)
await expect(
repository.searchArtifacts({
primaryProjectIds: ['project-b'],
+18 -3
View File
@@ -426,7 +426,7 @@ describe('session persistence IPC handlers', () => {
>()
})
it.each(['list', 'loadAll'] as const)(
it.each(['list', 'loadAll', 'searchMessages'] as const)(
'rechecks failed Compute restoration through %s after Session recovery',
async (read) => {
let corrupt = true
@@ -493,14 +493,22 @@ describe('session persistence IPC handlers', () => {
await manager.startQueueReconciliation({ retryFailedOnly: true }).catch(() => undefined)
}
)
// The search catalog must retry the same recovery path as the ordinary Session list.
const readCatalog = async (): Promise<void> => {
if (read === 'searchMessages') {
await handlers.searchMessages({ query: '', projectIds: [], limit: 10 })
} else {
await handlers[read]()
}
}
await expect(manager.startQueueReconciliation()).rejects.toThrow('could not be restored')
await handlers[read]!()
await readCatalog()
await manager.reconcileQueuedJobs()
expect(dispatch).not.toHaveBeenCalled()
// A valid replacement now supersedes the retained quarantine; no deletion is involved.
corrupt = false
activeCount = 1
await handlers[read]!()
await readCatalog()
await manager.reconcileQueuedJobs()
expect(dispatch).not.toHaveBeenCalled()
expect(await manager.getStatus('new-session')).toMatchObject({ session_limit: 1 })
@@ -563,6 +571,7 @@ describe('session persistence IPC handlers', () => {
}
const saveSession = vi.fn(async () => ({ created: false, session }))
const handlers: SessionPersistenceHandlers = {
searchMessages: vi.fn(),
loadAll: vi.fn(),
list: vi.fn(),
loadUsage: vi.fn(),
@@ -911,6 +920,7 @@ describe('session persistence IPC handlers', () => {
'sessions:load-all',
'sessions:list',
'sessions:load-usage',
'sessions:search-messages',
'sessions:load-one',
'sessions:save-session',
'sessions:save-manifest',
@@ -960,6 +970,7 @@ describe('session persistence IPC handlers', () => {
saveManifest: vi.fn()
}
const injected: SessionPersistenceHandlers = {
searchMessages: vi.fn(),
loadAll: vi.fn().mockResolvedValue(loadResult),
list: vi.fn(),
loadUsage: vi.fn(),
@@ -989,6 +1000,7 @@ describe('session persistence IPC handlers', () => {
saveManifest: vi.fn()
}
const injected: SessionPersistenceHandlers = {
searchMessages: vi.fn(),
loadAll: vi.fn(),
list: vi.fn(),
loadUsage: vi.fn(),
@@ -1023,6 +1035,7 @@ describe('session persistence IPC handlers', () => {
saveManifest: vi.fn()
}
const injected: SessionPersistenceHandlers = {
searchMessages: vi.fn(),
loadAll: vi.fn().mockResolvedValue({ sessions: [], manifest: { version: 1 as const } }),
list: vi.fn(),
loadUsage: vi.fn(),
@@ -1091,6 +1104,7 @@ describe('session persistence IPC handlers', () => {
saveManifest: vi.fn()
}
const handlers: SessionPersistenceHandlers = {
searchMessages: vi.fn(),
loadAll: vi.fn(),
list: vi.fn(),
loadUsage: vi.fn(),
@@ -1125,6 +1139,7 @@ describe('session persistence IPC handlers', () => {
saveManifest: vi.fn()
}
const handlers: SessionPersistenceHandlers = {
searchMessages: vi.fn(),
loadAll: vi.fn(),
list: vi.fn(),
loadUsage: vi.fn(),
+15
View File
@@ -1,3 +1,5 @@
import { createMessageSearch } from './message-search'
import type { MessageSearchRequest, MessageSearchPage } from '../../shared/message-search'
import { ipcMainHandle } from '../ipc-handler-registry'
import type { ApplicationCommandOutcome } from '../../shared/application-command-contract'
@@ -57,6 +59,7 @@ type SessionPersistenceBackend = {
}
type SessionPersistenceHandlers = {
searchMessages: (request: MessageSearchRequest) => Promise<MessageSearchPage>
loadAll: () => Promise<LoadAllSessionsResult>
list: () => Promise<ListSessionSummariesResult>
loadUsage: () => Promise<SessionUsageProjection>
@@ -174,6 +177,15 @@ const createSessionPersistenceHandlersWithAttributionAuthority = (
// call it because Reviews belong to retained provenance.
void reviewRepository
return {
searchMessages: createMessageSearch({
list: async () => {
if (!repository.list) throw new Error('Session summary projection is unavailable.')
// Search must observe recovered queue limits before consuming the Session catalog too.
await beforeCatalogRead?.()
return repository.list()
},
loadOne: (request) => repository.loadOne(request)
}),
loadAll: async () => {
await beforeCatalogRead?.()
return repository.loadAll()
@@ -331,6 +343,9 @@ const registerSessionPersistenceIpcHandlers = (
return handlers.loadUsage()
})
)
ipcMainHandle('sessions:search-messages', (_event, request: MessageSearchRequest) =>
withDataRootWrite(() => handlers.searchMessages(request))
)
ipcMainHandle('sessions:load-one', (_event, request: LoadSessionRequest) =>
withDataRootWrite(() => handlers.loadOne(request))
)
@@ -0,0 +1,417 @@
import { describe, expect, it, vi } from 'vitest'
import { createMessageSearch } from './message-search'
import type { PersistedChatSession, SessionSummary } from '../../shared/session-persistence'
const summary = (id: string, overrides = {}): SessionSummary =>
({
id,
projectId: 'p',
title: id,
number: 1,
revision: 1,
updatedAt: 10,
activeMessageCount: 12,
...overrides
}) as SessionSummary
const session = (id: string, overrides = {}): PersistedChatSession =>
({
id,
projectId: 'p',
title: id,
cwd: '/workspace',
status: 'idle',
createdAt: 1,
updatedAt: 10,
messages: Array.from({ length: 12 }, (_, i) => ({
id: `${id}-${i}`,
role: 'user',
content: `before\nAI [query] ${i}\nafter`,
status: 'complete',
createdAt: i,
updatedAt: i,
eventIds: []
})),
...overrides
}) as PersistedChatSession
describe('message body search', () => {
it.each([false, true])(
'does not cancel independent callers (explicit identities: %s)',
async (identified) => {
let release!: () => void
const loadOne = vi.fn(async () => {
await new Promise<void>((resolve) => {
release = resolve
})
return session('s')
})
const search = createMessageSearch({
list: async () => ({ sessions: [summary('s')] }),
loadOne
})
const first = search({
projectIds: ['p'],
query: 'before',
limit: 10,
...(identified ? { clientId: 'window-a' } : {})
})
await vi.waitFor(() => expect(loadOne).toHaveBeenCalledOnce())
loadOne.mockImplementation(async () => session('s'))
const second = search({
projectIds: ['p'],
query: 'after',
limit: 10,
...(identified ? { clientId: 'window-b' } : {})
})
release()
for (const page of await Promise.all([first, second]))
expect(page).toMatchObject({ totalCount: 12, isComplete: true })
}
)
it('continues after a deleted page anchor without skipping remaining messages', async () => {
let revision = 1
const transcript = session('s')
const search = createMessageSearch({
list: async () => ({ sessions: [summary('s', { revision })] }),
loadOne: async () => transcript
})
const request = { projectIds: ['p'], query: 'query', limit: 10 }
const first = await search(request)
expect(first.items.map((item) => item.messageId)).toEqual([
's-11',
's-10',
's-9',
's-8',
's-7',
's-6',
's-5',
's-4',
's-3',
's-2'
])
transcript.messages = transcript.messages.filter((message) => message.id !== 's-2')
revision++
const second = await search({ ...request, cursor: first.nextCursor })
expect(second.items.map((item) => item.messageId)).toEqual(['s-1', 's-0'])
expect(second.nextCursor).toBeUndefined()
})
it('marks bounded search excerpts that need a complete detail read', async () => {
const content = 'Heading\n' + 'Context\n'.repeat(1200) + 'needle\nFinal paragraph.'
const search = createMessageSearch({
list: async () => ({ sessions: [summary('s')] }),
loadOne: async () => session('s', { messages: [{ id: 'long', role: 'user', content }] })
})
const page = await search({ projectIds: ['p'], query: 'needle', limit: 10 })
expect(page.items[0]).toMatchObject({ messageId: 'long', contentTruncated: true })
expect(page.items[0]!.content).toContain('needle')
expect(page.items[0]!.content.length).toBeLessThan(content.length)
})
it('rejects a cursor reused with another query, sort or project scope', async () => {
const search = createMessageSearch({
list: async () => ({ sessions: [summary('s')] }),
loadOne: async () => session('s')
})
const request = { projectIds: ['p'], query: 'query', limit: 10 }
const first = await search(request)
for (const changed of [
{ query: 'before' },
{ sort: 'recent' as const },
{ projectIds: ['other'] }
]) {
await expect(search({ ...request, ...changed, cursor: first.nextCursor })).rejects.toThrow(
/cursor/i
)
}
})
it('groups matching agent fragments by turn before counting and paging, retaining the best hit target', async () => {
const search = createMessageSearch({
list: async () => ({ sessions: [summary('s')] }),
loadOne: async () =>
session('s', {
messages: [
{ id: 'prompt', role: 'user', content: 'needle question', createdAt: 1 },
{
id: 'progress',
role: 'agent',
responseToMessageId: 'prompt',
content: 'Checking needle sources.',
createdAt: 2
},
{
id: 'answer',
role: 'agent',
responseToMessageId: 'prompt',
content: 'Answer heading\nneedle evidence and needle conclusion.',
createdAt: 3
},
{
id: 'follow-up',
role: 'agent',
responseToMessageId: 'another-prompt',
content: 'Another needle answer.',
createdAt: 4
}
]
})
})
const request = { projectIds: ['p'], query: 'needle', sort: 'relevance' as const, limit: 2 }
const first = await search(request)
expect(first).toMatchObject({
totalCount: 3,
nextCursor: expect.any(String),
items: [
{
messageId: 'answer',
title: 'Answer heading',
content: 'Answer heading\nneedle evidence and needle conclusion.'
},
{ messageId: 'follow-up' }
]
})
expect(await search({ ...request, cursor: first.nextCursor })).toMatchObject({
totalCount: 3,
nextCursor: undefined,
items: [{ messageId: 'prompt', role: 'user' }]
})
expect(await search({ ...request, sort: 'recent' })).toMatchObject({
totalCount: 3,
items: [{ messageId: 'follow-up' }, { messageId: 'answer' }]
})
})
it('prefers the later equally relevant fragment and applies filters before selecting a turn hit', async () => {
const search = createMessageSearch({
list: async () => ({ sessions: [summary('s')] }),
loadOne: async () =>
session('s', {
messages: [
{
id: 'strong',
role: 'agent',
responseToMessageId: 'prompt',
content: 'needle needle',
createdAt: 1
},
{
id: 'progress',
role: 'agent',
responseToMessageId: 'prompt',
content: 'needle progress',
createdAt: 5
},
{
id: 'final',
role: 'agent',
responseToMessageId: 'prompt',
content: 'needle final answer',
createdAt: 5
}
]
})
})
const request = { projectIds: ['p'], query: 'needle', role: 'agent' as const, limit: 10 }
expect(await search(request)).toMatchObject({ totalCount: 1, items: [{ messageId: 'strong' }] })
expect(await search({ ...request, updatedAfter: 4 })).toMatchObject({
totalCount: 1,
items: [{ messageId: 'final', content: 'needle final answer' }]
})
expect(await search({ ...request, query: '' })).toMatchObject({
totalCount: 1,
items: [{ messageId: 'final' }]
})
expect(await search({ ...request, role: 'user' })).toMatchObject({ totalCount: 0, items: [] })
})
it('keeps unlinked messages separate and never groups turns across sessions', async () => {
const search = createMessageSearch({
list: async () => ({ sessions: [summary('s1'), summary('s2')] }),
loadOne: async ({ sessionId }) =>
session(sessionId, {
messages: [
{ id: 'legacy-1', role: 'agent', content: 'needle', createdAt: 1 },
{ id: 'legacy-2', role: 'agent', content: 'needle', createdAt: 2 },
{
id: 'progress',
role: 'agent',
responseToMessageId: 'prompt',
content: 'needle',
createdAt: 3
},
{
id: 'final',
role: 'agent',
responseToMessageId: 'prompt',
content: 'needle',
createdAt: 4
}
]
})
})
const result = await search({ projectIds: ['p'], query: 'needle', limit: 10 })
expect(result.totalCount).toBe(6)
for (const sessionId of ['s1', 's2']) {
expect(
result.items.filter((item) => item.sessionId === sessionId).map((item) => item.messageId)
).toEqual(['final', 'legacy-2', 'legacy-1'])
}
})
it('filters senders and dates before pagination and ranks body matches when requested', async () => {
const search = createMessageSearch({
list: async () => ({ sessions: [summary('s')] }),
loadOne: async () =>
session('s', {
messages: [
{ id: 'old', role: 'user', content: 'needle needle needle', createdAt: 1 },
{ id: 'agent', role: 'agent', content: 'needle needle needle', createdAt: 9 },
{
id: 'relevant',
role: 'user',
content: 'Actual heading\nneedle needle',
createdAt: 5
},
{ id: 'recent', role: 'user', content: 'needle', createdAt: 8 }
]
})
})
const request = {
projectIds: ['p'],
query: 'needle',
limit: 1,
updatedAfter: 4,
role: 'user' as const,
sort: 'relevance' as const
}
const first = await search(request)
expect(first).toMatchObject({
totalCount: 2,
nextCursor: expect.any(String),
items: [{ messageId: 'relevant', title: 'Actual heading' }]
})
expect(await search({ ...request, cursor: first.nextCursor })).toMatchObject({
items: [{ messageId: 'recent' }],
nextCursor: undefined
})
expect(await search({ ...request, sort: 'recent' })).toMatchObject({
items: [{ messageId: 'recent' }]
})
expect(await search({ ...request, role: 'agent' })).toMatchObject({
totalCount: 1,
items: [{ messageId: 'agent' }]
})
})
it('starts a fresh scan when an in-flight query is revisited after cancellation', async () => {
const catalog = { sessions: [summary('s')] }
let releaseRead!: () => void
let releaseCatalog!: (value: typeof catalog) => void
const loadOne = vi.fn(async () => session('s'))
loadOne.mockImplementationOnce(async () => {
await new Promise<void>((resolve) => {
releaseRead = resolve
})
return session('s')
})
const list = vi.fn(async () => catalog)
const search = createMessageSearch({ list, loadOne })
const request = { clientId: 'same-window', projectIds: ['p'], query: 'query', limit: 10 }
const first = search(request)
await vi.waitFor(() => expect(loadOne).toHaveBeenCalledTimes(1))
list.mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseCatalog = resolve
})
)
const middle = search({ ...request, query: 'other' })
const latest = search(request)
await Promise.resolve()
releaseRead()
releaseCatalog(catalog)
await Promise.all([first, middle])
expect(await latest).toMatchObject({ totalCount: 12, isComplete: true })
expect(loadOne).toHaveBeenCalledTimes(2)
})
it('preserves incomplete catalog diagnostics and retries the catalog', async () => {
const loadOne = vi.fn(async () => session('s'))
const search = createMessageSearch({
list: async () => ({ sessions: [summary('s')], diagnostics: { isComplete: false } }),
loadOne
})
const request = { projectIds: ['p'], query: 'query', limit: 10 }
expect((await search(request)).isComplete).toBe(false)
await search(request)
expect(loadOne).toHaveBeenCalledTimes(2)
})
it('stops superseded scans and bounds concurrent reads across queries', async () => {
let active = 0
let maximum = 0
const loadOne = vi.fn(async ({ sessionId }: { sessionId: string }) => {
active++
maximum = Math.max(maximum, active)
await new Promise((resolve) => setTimeout(resolve, 5))
active--
return session(sessionId)
})
const search = createMessageSearch({
list: async () => ({ sessions: Array.from({ length: 12 }, (_, i) => summary(String(i))) }),
loadOne
})
const first = search({ clientId: 'same-window', projectIds: ['p'], query: 'old', limit: 10 })
await new Promise((resolve) => setTimeout(resolve, 1))
const second = search({ clientId: 'same-window', projectIds: ['p'], query: 'query', limit: 10 })
await Promise.all([first, second])
expect(maximum).toBeLessThanOrEqual(4)
expect(loadOne.mock.calls.length).toBeLessThan(24)
})
it('finds literal normalized body matches, pages deterministically and excludes archived/hidden messages', async () => {
const loadOne = vi.fn(async ({ sessionId }: { sessionId: string }) => session(sessionId))
const search = createMessageSearch({
list: async () => ({
sessions: [
summary('s'),
summary('archive', { archivedAt: 1 }),
summary('other', { projectId: 'other' })
]
}),
loadOne
})
const request = { projectIds: ['p'], query: 'ai [query]', limit: 10 }
const first = await search(request)
expect(first.totalCount).toBe(12)
expect(first.items).toHaveLength(10)
expect(first.items[0]).toMatchObject({ messageId: 's-11', sessionId: 's', projectId: 'p' })
const second = await search({ ...request, cursor: first.nextCursor })
expect(second.items.map((item) => item.messageId)).toEqual(['s-1', 's-0'])
expect(second.nextCursor).toBeUndefined()
expect(loadOne).toHaveBeenCalledTimes(1)
})
it('invalidates revision caches and reports partial failures without losing healthy results', async () => {
let revision = 1
const search = createMessageSearch({
list: async () => ({ sessions: [summary('s', { revision }), summary('broken')] }),
loadOne: async ({ sessionId }) => {
if (sessionId === 'broken') throw new Error('unreadable')
return session('s', {
messages: [
{ id: 'hidden', content: 'needle', role: 'user', turnIntent: 'save-as-skill' },
{ id: 'visible', content: revision === 1 ? 'needle' : 'changed', role: 'agent' }
]
})
}
})
expect(await search({ projectIds: ['p'], query: 'needle', limit: 10 })).toMatchObject({
totalCount: 1,
isComplete: false,
items: [{ messageId: 'visible' }]
})
revision++
expect(await search({ projectIds: ['p'], query: 'needle', limit: 10 })).toMatchObject({
totalCount: 0
})
await expect(search({ projectIds: ['p'], query: 'x', limit: -1 })).rejects.toThrow()
})
})
@@ -0,0 +1,212 @@
import {
messageSearchRequestSchema,
type MessageSearchItem,
type MessageSearchPage,
type MessageSearchRequest
} from '../../shared/message-search'
import {
isHiddenControlMessage,
type LoadSessionRequest,
type PersistedChatSession,
type SessionSummary
} from '../../shared/session-persistence'
import { findSearchMatches } from '../../shared/search-text'
import { z } from 'zod'
type SearchBackend = {
list: () => Promise<{
sessions: readonly SessionSummary[]
diagnostics?: { isComplete: boolean; isProjectDeletionRecoveryComplete?: boolean }
}>
loadOne: (request: LoadSessionRequest) => Promise<PersistedChatSession | undefined>
}
const cursorSchema = z
.object({
queryKey: z.string(),
rank: z.number().int().min(0).max(3),
createdAt: z.number(),
projectId: z.string(),
sessionId: z.string(),
messageId: z.string()
})
.strict()
type MessagePosition = Omit<z.infer<typeof cursorSchema>, 'queryKey'>
const comparePositions = (a: MessagePosition, b: MessagePosition): number =>
b.rank - a.rank ||
b.createdAt - a.createdAt ||
a.projectId.localeCompare(b.projectId) ||
a.sessionId.localeCompare(b.sessionId) ||
a.messageId.localeCompare(b.messageId)
type SearchSnapshot = {
items: MessageSearchItem[]
ranks: Map<MessageSearchItem, number>
isComplete: boolean
}
type ClientSearch = {
query: string
generation: number
cache?: { key: string; generation: number; page: Promise<SearchSnapshot> }
}
export const createMessageSearch = (backend: SearchBackend) => {
// Cancellation belongs to a search surface, not the shared main-process service. Callers without
// a client identity have independent queries; bounded retention also releases closed windows.
const clients = new Map<string, ClientSearch>()
let scanning: Promise<unknown> = Promise.resolve()
return async (input: MessageSearchRequest): Promise<MessageSearchPage> => {
const request = messageSearchRequestSchema.parse(input)
const queryKey = JSON.stringify([
request.query.trim(),
[...request.projectIds].sort(),
[...(request.excludedSessionIds ?? [])].sort(),
request.updatedAfter,
request.role,
request.sort
])
const clientKey = request.clientId ? `client:${request.clientId}` : `query:${queryKey}`
const client = clients.get(clientKey) ?? { query: '', generation: 0 }
clients.delete(clientKey)
clients.set(clientKey, client)
if (clients.size > 32) clients.delete(clients.keys().next().value!)
if (queryKey !== client.query) {
client.query = queryKey
client.generation++
}
const requestGeneration = client.generation
const cursor = request.cursor
? cursorSchema.parse(JSON.parse(Buffer.from(request.cursor, 'base64url').toString()))
: undefined
if (cursor && cursor.queryKey !== queryKey)
throw new Error('Message cursor does not match the requested search.')
const projects = new Set(request.projectIds)
const excluded = new Set(request.excludedSessionIds)
const { sessions: all, diagnostics } = await backend.list()
if (requestGeneration !== client.generation)
return { items: [], totalCount: 0, isComplete: false }
const catalogComplete =
diagnostics?.isComplete !== false && diagnostics?.isProjectDeletionRecoveryComplete !== false
const sessions = all
.filter((s) => projects.has(s.projectId) && s.archivedAt === undefined && !excluded.has(s.id))
.sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id))
const key = JSON.stringify([
request.query.trim(),
catalogComplete,
sessions.map((s) => [s.projectId, s.id, s.revision, s.updatedAt])
])
if (client.cache?.key !== key || client.cache.generation !== requestGeneration) {
const page = scanning
.catch(() => undefined)
.then(async () => {
const items: MessageSearchItem[] = []
const ranks = new Map<MessageSearchItem, number>()
let isComplete = catalogComplete
let next = 0
await Promise.all(
Array.from({ length: Math.min(4, sessions.length) }, async () => {
while (next < sessions.length && requestGeneration === client.generation) {
const summary = sessions[next++]!
try {
const session = await backend.loadOne({
projectId: summary.projectId,
sessionId: summary.id
})
if (!session) {
isComplete = false
continue
}
if (session.archivedAt !== undefined) continue
const turnHits = new Map<string, { item: MessageSearchItem; rank: number }>()
for (const message of session.messages) {
if (isHiddenControlMessage(message) || !message.content?.trim()) continue
const createdAt = message.createdAt ?? summary.updatedAt
if (request.role && message.role !== request.role) continue
if (request.updatedAfter !== undefined && createdAt < request.updatedAfter)
continue
const matches = findSearchMatches(message.content, request.query, 3)
const hit = matches[0]
if (request.query.trim() && !hit) continue
// Only an explicit turn link can combine fragments. Keep the winning Message's
// identity and content together so previews still jump to the actual match.
const turnKey =
message.role === 'agent' && message.responseToMessageId
? `turn:${message.responseToMessageId}`
: `message:${message.id}`
const previous = turnHits.get(turnKey)
const rank = matches.length
if (
previous &&
(previous.rank > rank ||
(previous.rank === rank && previous.item.createdAt > createdAt))
)
continue
const start = Math.max(0, (hit?.start ?? 0) - 4000)
const content = message.content.slice(
start,
Math.max(hit?.end ?? 0, start) + 4000
)
const item: MessageSearchItem = {
projectId: summary.projectId,
sessionId: summary.id,
sessionTitle: summary.title,
sessionNumber: summary.number,
messageId: message.id,
role: message.role,
title: message.content.trim().slice(0, 240).split(/\r?\n/, 1)[0],
content,
...(content.length < message.content.length
? { contentTruncated: true }
: {}),
createdAt
}
turnHits.set(turnKey, { item, rank })
}
for (const { item, rank } of turnHits.values()) {
items.push(item)
if (request.sort === 'relevance') ranks.set(item, rank)
}
} catch {
isComplete = false
}
}
})
)
items.sort((a, b) =>
comparePositions({ ...a, rank: ranks.get(a) ?? 0 }, { ...b, rank: ranks.get(b) ?? 0 })
)
return {
items,
ranks,
isComplete: isComplete && requestGeneration === client.generation
}
})
scanning = page
client.cache = { key, generation: requestGeneration, page }
}
const pending = client.cache
const result = await pending.page
if (!result.isComplete && client.cache === pending) client.cache = undefined
// A deleted/reordered earlier hit must not shift the next page's starting position.
const position = (item: MessageSearchItem): MessagePosition => ({
rank: result.ranks.get(item) ?? 0,
createdAt: item.createdAt,
projectId: item.projectId,
sessionId: item.sessionId,
messageId: item.messageId
})
const remaining = cursor
? result.items.filter((item) => comparePositions(position(item), cursor) > 0)
: result.items
const items = remaining.slice(0, request.limit)
const last = items.at(-1)
return {
items,
totalCount: result.items.length,
isComplete: result.isComplete,
nextCursor:
remaining.length > request.limit && last
? Buffer.from(JSON.stringify({ queryKey, ...position(last) })).toString('base64url')
: undefined
}
}
}
+1
View File
@@ -520,6 +520,7 @@ describe('preload bridge — public surface inventory', () => {
'sessions.openRecoveryFolder',
'sessions.saveManifest',
'sessions.saveSession',
'sessions.searchMessages',
'sessions.sendFlushResponse',
'sessions.setDelegationPolicy',
'sessions.unlinkPdfContext',
@@ -1,278 +1,57 @@
// @vitest-environment jsdom
// Proves the command palette reads its chrome, row metadata, and shortcut footer from the catalog:
// it renders in English, then re-renders in both Chinese locales after a language change. Catalog
// parity tests can't catch a component that never calls t() at all.
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
import { i18next } from '@/i18n'
import type { ChatSession } from '@/stores/session-store'
import {
createInitialPreviewWorkbenchState,
usePreviewWorkbenchStore
} from '@/stores/preview-workbench-store'
import { createInitialProjectState, useProjectStore } from '@/stores/project-store'
import { createInitialSessionState, useSessionStore } from '@/stores/session-store'
import { useNavigationStore } from '@/stores/navigation-store'
setupSearch,
teardownSearch,
renderSearch,
input,
clickRow,
detail
} from './global-search.test-support'
import { GlobalSearchDialog } from './GlobalSearchDialog'
vi.mock('@/pages/workspace/previews/PreviewFileContent', () => ({
PreviewFileContent: () => <div />
}))
vi.mock('@/pages/workspace/artifact-preview', () => ({ ArtifactPreview: () => <div /> }))
beforeEach(setupSearch)
afterEach(teardownSearch)
let container: HTMLDivElement
let root: Root
it.each([
[
'en',
'Global search',
'Search messages, projects, files and Library…',
'File information',
'15 messages'
],
['zh-Hans', '全局搜索', '搜索消息、项目、文件和 Library…', '文件信息', '15 条消息'],
['zh-Hant', '全域搜尋', '搜尋訊息、專案、檔案和 Library…', '檔案資訊', '15 則訊息']
])(
'renders search chrome, tabs and plural counts in %s',
async (locale, title, placeholder, fileInfo, messages) => {
await renderSearch()
await act(async () => {
await i18next.changeLanguage(locale)
})
expect(input().getAttribute('aria-label')).toBe(title)
expect(input().placeholder).toBe(placeholder)
clickRow('generated')
expect(detail().textContent).toContain(fileInfo)
clickRow('sessions')
expect(detail().textContent).toContain(messages)
}
)
const day = 24 * 60 * 60 * 1_000
const artifact = {
id: 'artifact-1',
source: 'artifact' as const,
sourceFileId: 'artifact-1',
sourceVersionId: 'version-1',
projectId: 'project-a',
sessionId: 'session-a',
name: 'sin.png',
path: 'artifact-version:project-a/session-a/artifact-1/version-1',
size: 12,
sortAtMs: Date.now() - 3 * day,
originSession: { state: 'active' as const }
}
const switchTo = (language: string): void => {
act(() => {
void i18next.changeLanguage(language)
})
}
const bodyText = (): string => document.body.textContent ?? ''
const ariaLabels = (): (string | null)[] =>
[...document.body.querySelectorAll('[role="group"]')].map((group) =>
group.getAttribute('aria-label')
it('translates partial failures without exposing the main-process error', async () => {
vi.mocked(window.api.sessions.searchMessages).mockRejectedValue(
new Error('Untranslated IPC failure')
)
const shortcutFooterText = (): string =>
document.body.querySelector('[data-testid="global-search-footer"]')?.textContent ?? ''
// Artifact rows carry a thumbnail and repeat their origin Session's title, so match on the absence
// of the thumbnail rather than on the title text.
const sessionRowText = (): string =>
[...document.body.querySelectorAll('[role="option"]')].find(
(option) => option.querySelector('[data-testid="global-search-artifact-thumbnail"]') === null
)?.textContent ?? ''
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
window.localStorage.clear()
useProjectStore.setState({
...createInitialProjectState(),
isLoaded: true,
projects: [
{
id: 'project-a',
name: 'Alpha',
description: '',
isExample: false,
createdAt: 1,
updatedAt: 2
}
]
})
useSessionStore.setState({
...createInitialSessionState(),
selectedSessionId: 'session-a',
sessions: [
{
id: 'session-a',
projectId: 'project-a',
title: 'Python 绘制 sin 函数图',
cwd: '/workspace',
status: 'idle',
createdAt: Date.now(),
updatedAt: Date.now() - 3 * day,
messages: [],
artifacts: []
}
] as ChatSession[]
})
useNavigationStore.setState({
view: 'workspace',
activeProjectId: 'project-a',
userNavigationRevision: 0,
explicitNavigationRevision: 0,
pendingCustomizePrefill: undefined,
pendingArtifactMention: undefined,
artifactMentionAvailability: { projectId: 'project-a', canMention: true }
})
usePreviewWorkbenchStore.setState(createInitialPreviewWorkbenchState())
Object.defineProperty(window, 'api', {
configurable: true,
value: {
projectFiles: {
searchArtifacts: vi.fn().mockResolvedValue({
primary: { items: [artifact], totalCount: 1 },
other: [],
isIndexComplete: true
})
},
literature: { search: vi.fn().mockResolvedValue({ entries: [] }) },
previewResources: {
acquire: vi.fn().mockResolvedValue({
id: 'preview-resource-1',
url: 'open-science-preview://preview-resource-1',
mimeType: 'image/png'
}),
release: vi.fn().mockResolvedValue(undefined)
}
}
})
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.restoreAllMocks()
switchTo('en')
})
const openPalette = async (): Promise<void> => {
await renderSearch()
await act(async () => {
root.render(<GlobalSearchDialog open onOpenChange={vi.fn()} isSessionPersistenceReady />)
await new Promise((resolve) => window.setTimeout(resolve, 20))
})
}
// React tracks the input's value on the node, so a plain assignment is invisible to it.
const search = async (value: string): Promise<void> => {
const input = document.body.querySelector<HTMLInputElement>('input[role="combobox"]')
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
)?.set
await act(async () => {
valueSetter?.call(input, value)
input?.dispatchEvent(new Event('input', { bubbles: true }))
await new Promise((resolve) => window.setTimeout(resolve, 180))
})
}
describe('GlobalSearchDialog i18n', () => {
it('translates the section headings and their group labels', async () => {
await openPalette()
expect(ariaLabels()).toEqual(['Recent artifacts', 'Recent sessions', 'Commands'])
expect(bodyText()).toContain('New session')
switchTo('zh-Hans')
expect(ariaLabels()).toEqual(['最近的产物', '最近会话', '命令'])
expect(bodyText()).toContain('新建会话')
switchTo('zh-Hant')
expect(ariaLabels()).toEqual(['最近的產物', '最近會話', '命令'])
expect(bodyText()).toContain('新增會話')
})
it('translates the search placeholder and the accessible dialog title', async () => {
await openPalette()
const placeholder = (): string | null =>
document.body
.querySelector<HTMLInputElement>('input[role="combobox"]')
?.getAttribute('placeholder') ?? null
expect(placeholder()).toBe('Search this project…')
expect(bodyText()).toContain('Command palette')
switchTo('zh-Hans')
expect(placeholder()).toBe('在此项目中搜索…')
expect(bodyText()).toContain('命令面板')
switchTo('zh-Hant')
expect(placeholder()).toBe('在這個專案中搜尋…')
})
it('translates the shortcut footer', async () => {
await openPalette()
expect(shortcutFooterText()).toBe('↑↓navigate↵open⇧↵mentionescclose')
switchTo('zh-Hans')
expect(shortcutFooterText()).toBe('↑↓切换↵打开⇧↵提及esc关闭')
switchTo('zh-Hant')
expect(shortcutFooterText()).toBe('↑↓切換↵開啟⇧↵提及esc關閉')
})
it('renders the row metadata — artifact count and elapsed time — through the catalog', async () => {
await openPalette()
expect(sessionRowText()).toContain('0 artifacts · 3 days ago')
switchTo('zh-Hans')
expect(sessionRowText()).toContain('0 个产物 · 3 天前')
switchTo('zh-Hant')
expect(sessionRowText()).toContain('0 個產物 · 3 天前')
})
it('pluralizes the English artifact count and elapsed unit, and keeps Chinese uninflected', async () => {
useSessionStore.setState({
...useSessionStore.getState(),
sessions: [
{
...useSessionStore.getState().sessions[0],
artifacts: [{ id: 'a-1' }],
updatedAt: Date.now() - day
}
] as ChatSession[]
})
await openPalette()
expect(sessionRowText()).toContain('1 artifact · 1 day ago')
switchTo('zh-Hans')
expect(sessionRowText()).toContain('1 个产物 · 1 天前')
})
it('translates the no-match empty state and interpolates the query', async () => {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
projectFiles: {
searchArtifacts: vi.fn().mockResolvedValue({
primary: { items: [], totalCount: 0 },
other: [],
isIndexComplete: true
})
},
literature: { search: vi.fn().mockResolvedValue({ entries: [] }) }
}
})
await openPalette()
await search('cosine')
expect(bodyText()).toContain('No results match “cosine”.')
switchTo('zh-Hans')
expect(bodyText()).toContain('没有结果匹配“cosine”。')
switchTo('zh-Hant')
expect(bodyText()).toContain('沒有結果符合「cosine」。')
})
it('translates the artifact load failure into a retry row', async () => {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
projectFiles: { searchArtifacts: vi.fn().mockRejectedValue('not an Error') },
literature: { search: vi.fn().mockResolvedValue({ entries: [] }) }
}
})
await openPalette()
await search('sin')
expect(bodyText()).toContain('Could not load artifacts — retry')
switchTo('zh-Hans')
expect(bodyText()).toContain('无法加载产物 —— 重试')
switchTo('zh-Hant')
expect(bodyText()).toContain('無法載入產物 —— 重試')
await i18next.changeLanguage('zh-Hans')
})
expect(document.body.textContent).toContain('无法加载搜索结果。')
expect(document.body.textContent).not.toContain('Untranslated IPC failure')
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { searchContentRanges } from './search-content-ranges'
describe('rendered search highlights', () => {
it('matches across Markdown inline formatting while preserving selectable DOM', () => {
const root = document.createElement('div')
root.innerHTML = '<p>Historical <strong>needle</strong> in a message.</p>'
const html = root.innerHTML
const ranges = searchContentRanges(root, 'historical needle')
expect(ranges.map((range) => range.toString())).toEqual(['Historical needle'])
expect(root.innerHTML).toBe(html)
})
it('uses normalized Unicode offsets and excludes preview controls', () => {
const root = document.createElement('div')
root.innerHTML = '<button>café</button><p>CAFÉ and cafe\u0301</p>'
expect(searchContentRanges(root, 'café').map((range) => range.toString())).toEqual([
'CAFÉ',
'cafe\u0301'
])
})
})
@@ -0,0 +1,85 @@
import { useLayoutEffect, useRef, type ReactNode } from 'react'
import { searchContentRanges } from './search-content-ranges'
const HIGHLIGHT_NAME = 'global-search-content'
const scrollToRange = (range: Range): void => {
let parent = range.startContainer.parentElement
while (parent) {
const style = getComputedStyle(parent)
if (/(auto|scroll)/.test(style.overflowY) && parent.scrollHeight > parent.clientHeight) {
const rect = range.getBoundingClientRect()
const viewport = parent.getBoundingClientRect()
parent.scrollTop += rect.top - viewport.top - parent.clientHeight / 2 + rect.height / 2
}
if (/(auto|scroll)/.test(style.overflowX) && parent.scrollWidth > parent.clientWidth) {
const rect = range.getBoundingClientRect()
const viewport = parent.getBoundingClientRect()
if (rect.left < viewport.left || rect.right > viewport.right)
parent.scrollLeft += rect.left - viewport.left - parent.clientWidth / 2
}
if (parent.classList.contains('search-detail-content')) break
parent = parent.parentElement
}
}
export const SearchContentHighlight = ({
query,
children,
className
}: {
query: string
children: ReactNode
className?: string
}): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const root = ref.current
if (!root) return
let ranges: Range[] = []
let positioned = false
let canceled = false
const highlight =
typeof Highlight !== 'undefined' && globalThis.CSS?.highlights
? (CSS.highlights.get(HIGHLIGHT_NAME) ?? new Highlight())
: undefined
if (highlight) CSS.highlights.set(HIGHLIGHT_NAME, highlight)
const viewport = root.closest<HTMLElement>('.search-detail-content')
if (viewport) viewport.scrollTop = 0
const position = (): void => {
if (!canceled && !positioned && ranges[0]) {
scrollToRange(ranges[0])
positioned = true
}
}
const update = (): void => {
ranges.forEach((range) => highlight?.delete(range))
ranges = searchContentRanges(root, query)
ranges.forEach((range) => highlight?.add(range))
root.dataset.searchMatchCount = String(ranges.length)
if (!positioned && ranges[0]) {
const animations = root.closest('.global-search-body')?.getAnimations?.() ?? []
if (animations.length)
void Promise.all(animations.map((animation) => animation.finished.catch(() => {}))).then(
position
)
else position()
}
}
update()
// File reads and optional Markdown renderers finish after the initial mount.
const observer = new MutationObserver(update)
observer.observe(root, { subtree: true, childList: true, characterData: true })
return () => {
canceled = true
observer.disconnect()
ranges.forEach((range) => highlight?.delete(range))
if (highlight?.size === 0) CSS.highlights.delete(HIGHLIGHT_NAME)
}
}, [query, children])
return (
<div ref={ref} className={className}>
{children}
</div>
)
}
@@ -0,0 +1,220 @@
import { useTranslation } from 'react-i18next'
import {
BookOpenText,
ChevronLeft,
File,
Folder,
MessageCircle,
Monitor,
PanelRightClose,
Upload
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatDateTime, formatRelativeTime } from '@/lib/format-datetime'
import { resolveLocaleFromTags } from '../../../../shared/locale'
import { formatBytes } from '../../../../shared/update'
import type { Project } from '../../../../shared/projects'
import { STANDALONE_UPLOAD_SESSION_ID } from '../../../../shared/uploads'
import {
resultTitle,
messageTitle,
fileFormatLabel,
type SearchResult,
type SearchSession
} from './search-result'
const icons = {
messages: MessageCircle,
sessions: MessageCircle,
projects: Folder,
uploads: Upload,
generated: File,
library: BookOpenText
}
type Props = {
result: SearchResult
projects: Project[]
sessions: SearchSession[]
fileCount?: number
fileCountUnavailable: boolean
onCollapse: () => void
}
export const SearchDetailHeader = ({
result,
projects,
sessions,
fileCount,
fileCountUnavailable,
onCollapse
}: Props): React.JSX.Element => {
const { t, i18n } = useTranslation()
const locale = resolveLocaleFromTags([i18n.resolvedLanguage ?? i18n.language])
const Icon = result.kind === 'library' && !('item' in result.item) ? Folder : icons[result.kind]
const title =
result.kind === 'messages'
? messageTitle(result.item) || t('Matched message')
: resultTitle(result)
const projectIds =
result.kind === 'library' && 'item' in result.item
? result.item.projectIds
: 'projectId' in result.item
? [result.item.projectId]
: []
const linkedProjects = projects.filter((project) => projectIds.includes(project.id))
const sessionItem = 'sessionId' in result.item ? result.item : undefined
const session =
result.kind === 'sessions'
? result.item
: sessionItem
? sessions.find(
(session) =>
session.id === sessionItem.sessionId && session.projectId === sessionItem.projectId
)
: undefined
const sessionLabel =
result.kind === 'messages'
? `#${result.item.sessionNumber} ${result.item.sessionTitle}`
: session
? `#${session.number}${result.kind === 'sessions' ? '' : ` ${session.title}`}`
: result.kind === 'uploads' || result.kind === 'generated'
? result.item.originSession?.title
: undefined
const fileCountLabel = fileCountUnavailable
? undefined
: fileCount === undefined
? t('Loading…')
: t('{{count}} files', { count: fileCount, defaultValue_one: '{{count}} file' })
const paper = result.kind === 'library' && 'item' in result.item ? result.item.item : undefined
const bibliography = paper
? [
paper.creators
.map((creator) =>
creator.nameMode === 'organization'
? creator.literalName
: [creator.givenName, creator.familyName].filter(Boolean).join(' ')
)
.join(', '),
paper.issuedText || paper.issuedYear,
paper.containerTitle
]
.filter(Boolean)
.join(' · ')
: undefined
return (
<header className="search-detail-header">
<button
type="button"
className="search-detail-back focus-visible:ring-2 focus-visible:ring-ring"
onClick={onCollapse}
>
<ChevronLeft aria-hidden="true" />
{t('Back to results')}
</button>
<div className="search-detail-title-row">
<Icon className="search-detail-kind-icon" aria-hidden="true" />
<h3 className="search-detail-title" title={title}>
{title}
</h3>
<Button
variant="ghost"
size="icon"
className="search-detail-collapse size-[26px] shrink-0 text-action-panel-toggle"
aria-label={t('Collapse details')}
title={t('Collapse details')}
onClick={onCollapse}
>
<PanelRightClose className="size-4" aria-hidden="true" />
</Button>
</div>
<div className="search-detail-context">
{result.kind === 'projects' ? (
<>
<span>
{t('{{count}} sessions', {
count: sessions.filter((session) => session.projectId === result.item.id).length,
defaultValue_one: '{{count}} session'
})}
</span>
{fileCountLabel && (
<>
<span aria-hidden="true">·</span>
<span>{fileCountLabel}</span>
</>
)}
<span aria-hidden="true">·</span>
<time
dateTime={new Date(result.item.updatedAt).toISOString()}
title={formatDateTime(result.item.updatedAt, locale, 'full')}
>
{formatRelativeTime(result.item.updatedAt, locale)}
</time>
</>
) : (
linkedProjects.map((project) => (
<span key={project.id} className="search-detail-context-part" title={project.name}>
<Folder aria-hidden="true" />
<span>{project.name}</span>
</span>
))
)}
{result.kind === 'library' && linkedProjects.length === 0 && (
<span className="search-detail-context-part">
<BookOpenText aria-hidden="true" />
<span>{t('Library')}</span>
</span>
)}
{result.kind === 'uploads' && result.item.sessionId === STANDALONE_UPLOAD_SESSION_ID ? (
<span className="search-detail-context-part">
<Monitor aria-hidden="true" />
<span>{t('Local computer')}</span>
</span>
) : (
sessionLabel && (
<span className="search-detail-context-part" title={sessionLabel}>
<MessageCircle aria-hidden="true" />
<span>{sessionLabel}</span>
</span>
)
)}
{result.kind === 'messages' && (
<span>
{result.item.role === 'user' ? t('You') : t('Agent')}
{' · '}
<time
dateTime={new Date(result.item.createdAt).toISOString()}
title={formatDateTime(result.item.createdAt, locale, 'full')}
>
{formatDateTime(result.item.createdAt, locale)}
</time>
</span>
)}
{(result.kind === 'uploads' || result.kind === 'generated') && (
<span>
{[fileFormatLabel(result.item), formatBytes(result.item.size)]
.filter(Boolean)
.join(' · ')}
</span>
)}
{bibliography && (
<span className="search-detail-bibliography" title={bibliography}>
{bibliography}
</span>
)}
</div>
{result.kind === 'sessions' && (
<div className="search-detail-metrics">
<span>
{t('{{count}} messages', {
count: result.item.activeMessageCount,
defaultValue_one: '{{count}} message'
})}
</span>
{fileCountLabel && <span>{fileCountLabel}</span>}
</div>
)}
</header>
)
}
@@ -0,0 +1,663 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowUpRight, File, LoaderCircle, MessageCircle } from 'lucide-react'
import { formatBytes } from '../../../../shared/update'
import { resolveLocaleFromTags } from '../../../../shared/locale'
import { formatDateTime, formatRelativeTime } from '@/lib/format-datetime'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { SearchFilePreview } from './SearchFilePreview'
import { SearchDetailHeader } from './SearchDetailHeader'
import { ArtifactPreview } from '@/pages/workspace/artifact-preview'
import { ExtensionPreservingFileName } from '@/pages/workspace/ExtensionPreservingFileName'
import {
createProjectFilePreviewArtifact,
useProjectFilePreviewReader,
useProjectFilePreviews
} from '@/pages/workspace/project-files-preview-owner'
import { ErrorNotice } from '@/components/error-notice'
import { FilePreviewDialog } from '@/pages/workspace/FilePreviewDialog'
import type { PreviewFileItem } from '@/stores/preview-workbench-store'
import type { ProjectFileItem } from '../../../../shared/project-files'
import type { LiteratureItemView } from '../../../../shared/literature'
import type { Project } from '../../../../shared/projects'
import { STANDALONE_UPLOAD_SESSION_ID } from '../../../../shared/uploads'
import { isHiddenControlMessage } from '../../../../shared/session-persistence'
import { AgentMarkdown } from '@/components/streamdown/AgentMarkdown'
import {
filePreviewItem,
literaturePreviewItem,
resultId,
fileFormatLabel,
type SearchResult,
type SearchSession
} from './search-result'
import { SearchHighlight } from './SearchHighlight'
import { SearchContentHighlight } from './SearchContentHighlight'
import { readLiteratureSelectionPage } from '@/pages/literature/literature-read-pages'
type Props = {
result: SearchResult
query: string
projects: Project[]
sessions: SearchSession[]
onOpen: (result: SearchResult) => void
onLocateFile: (file: ProjectFileItem) => void
onNavigate: () => void
onCollapse: () => void
}
export const SearchDetails = ({
result,
query,
projects,
sessions,
onOpen,
onLocateFile,
onNavigate,
onCollapse
}: Props): React.JSX.Element => {
const { t, i18n } = useTranslation()
const locale = resolveLocaleFromTags([i18n.resolvedLanguage ?? i18n.language])
const contentRef = useRef<HTMLDivElement>(null)
const [tab, setTab] = useState('content')
const [previewDialog, setPreviewDialog] = useState<PreviewFileItem>()
const [files, setFiles] = useState<ProjectFileItem[]>([])
const previewReader = useProjectFilePreviewReader()
const visibleFiles = useMemo(
() => (tab === 'files' || (tab === 'content' && result.kind === 'sessions') ? files : []),
[files, tab, result.kind]
)
const filePreviews = useProjectFilePreviews(visibleFiles, previewReader)
const [fileCount, setFileCount] = useState<number>()
const [fileCountUnavailable, setFileCountUnavailable] = useState(false)
const [papers, setPapers] = useState<LiteratureItemView[]>([])
const [hasMoreRecentItems, setHasMoreRecentItems] = useState(false)
const [collectionNames, setCollectionNames] = useState<string>()
const [completeMessage, setCompleteMessage] = useState<string>()
const initialStatus =
result.kind === 'sessions' ||
(result.kind === 'messages' && result.item.contentTruncated) ||
(result.kind === 'library' && !('item' in result.item))
? 'loading'
: 'idle'
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>(initialStatus)
const identity = resultId(result)
const [loadedIdentity, setLoadedIdentity] = useState(identity)
if (loadedIdentity !== identity) {
setLoadedIdentity(identity)
setTab('content')
setFiles([])
setPapers([])
setHasMoreRecentItems(false)
setCollectionNames(undefined)
setCompleteMessage(undefined)
setFileCount(undefined)
setFileCountUnavailable(false)
setStatus(initialStatus)
}
const projectId =
'projectId' in result.item
? result.item.projectId
: result.kind === 'projects'
? result.item.id
: undefined
const project = projects.find((item) => item.id === projectId)
const sessionId = 'sessionId' in result.item ? result.item.sessionId : undefined
const session = sessions.find((item) => item.id === sessionId && item.projectId === projectId)
const preview =
result.kind === 'generated' || result.kind === 'uploads'
? filePreviewItem(result.item)
: result.kind === 'library' && 'item' in result.item
? literaturePreviewItem(result.item)
: undefined
const isCollection = result.kind === 'library' && !('item' in result.item)
const recentSessions =
result.kind === 'projects'
? sessions
.filter((item) => item.projectId === result.item.id)
.sort((a, b) => b.updatedAt - a.updatedAt)
: []
useLayoutEffect(() => {
if (contentRef.current && result.kind !== 'messages') contentRef.current.scrollTop = 0
}, [identity, tab, result.kind])
useEffect(() => {
let active = true
if (result.kind === 'messages' && result.item.contentTruncated) {
// Search pages carry bounded excerpts. Hydrate only the selected Message, and discard a
// delayed read when the selection changes so it cannot replace another result's preview.
void window.api.sessions
.loadOne({ projectId: result.item.projectId, sessionId: result.item.sessionId })
.then((transcript) => {
if (!active) return
const message =
transcript?.archivedAt === undefined
? transcript?.messages.find(
(message) =>
message.id === result.item.messageId && !isHiddenControlMessage(message)
)
: undefined
if (!message) {
setStatus('error')
return
}
setCompleteMessage(message.content)
setStatus('idle')
})
.catch(() => {
if (active) setStatus('error')
})
} else if (result.kind === 'projects') {
void window.api.projectFiles
.getOverview({ projectId: result.item.id })
.then((overview) => {
if (active) {
setFileCount(overview.totalCount)
setFileCountUnavailable(!overview.isIndexComplete)
}
})
.catch(() => {
if (active) setFileCountUnavailable(true)
})
void window.api.projectFiles
.searchArtifacts({
primaryProjectIds: [result.item.id],
otherProjectIds: [],
source: 'all',
sort: 'recent',
primaryLimit: 10,
otherLimit: 0
})
.then((page) => {
if (active) {
setFiles(page.primary.items)
setHasMoreRecentItems(page.primary.totalCount > 10)
}
})
.catch(() => {
if (active) setFiles([])
})
} else if (result.kind === 'sessions') {
void window.api.projectFiles
.searchArtifacts({
primaryProjectIds: [result.item.projectId],
otherProjectIds: [],
source: 'all',
sessionId: result.item.id,
primaryLimit: 10,
otherLimit: 0
})
.then((page) => {
if (active) {
setFiles(page.primary.items)
setHasMoreRecentItems(page.primary.totalCount > 10)
setFileCount(page.primary.totalCount)
setFileCountUnavailable(!page.isIndexComplete)
setStatus('idle')
}
})
.catch(() => {
if (active) {
setStatus('error')
setFileCountUnavailable(true)
}
})
} else if (result.kind === 'library' && 'item' in result.item) {
const itemId = result.item.id
async function loadCollections(): Promise<void> {
try {
const names: string[] = []
let offset: number | undefined
do {
const page = await window.api.literature.search({
scope: 'collections',
itemId,
limit: 100,
offset
})
if (!active) return
for (const entry of page.entries) if ('name' in entry) names.push(entry.name)
offset = page.nextOffset
} while (offset !== undefined)
setCollectionNames(names.join(', ') || t('None'))
} catch {
if (active) setCollectionNames(t('Some results are unavailable.'))
}
}
void loadCollections()
} else if (result.kind === 'library' && !('item' in result.item)) {
void readLiteratureSelectionPage(
{
scope: 'library',
collectionId: result.item.id,
limit: 10,
sortBy: 'updated',
sortDirection: 'desc'
},
() => active
)
.then((page) => {
if (active) {
setPapers(page.entries.filter((item): item is LiteratureItemView => 'item' in item))
setHasMoreRecentItems(page.nextOffset !== undefined || (page.totalCount ?? 0) > 10)
setStatus('idle')
}
})
.catch(() => {
if (active) setStatus('error')
})
}
return () => {
active = false
}
// Result identity changes reset tabs and related content without remounting the outer pane.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity])
const tabs =
result.kind === 'messages'
? []
: [
{
id: 'content',
label:
result.kind === 'projects'
? t('Recent sessions')
: result.kind === 'sessions'
? t('Recent files')
: result.kind === 'library'
? isCollection
? t('Recent literature')
: t('Abstract')
: t('Content preview')
},
...(result.kind === 'projects' ? [{ id: 'files', label: t('Recent files') }] : []),
{
id: 'details',
label:
result.kind === 'uploads' || result.kind === 'generated'
? t('File information')
: t('Details')
},
...(result.kind === 'library' && preview ? [{ id: 'preview', label: t('Preview') }] : [])
]
const openPreview = (): void => {
if (preview) setPreviewDialog(preview)
else onOpen(result)
}
const isLibraryPreview = result.kind === 'library' && tab === 'preview' && !!preview
const openLabel = isLibraryPreview
? t('Open file')
: result.kind === 'messages'
? t('Jump to message')
: result.kind === 'projects'
? t('Open project')
: result.kind === 'sessions'
? t('Open session')
: result.kind === 'library'
? isCollection
? t('Open collection')
: t('Open literature')
: t('Open full screen preview')
// Match artifact tiles while keeping preview and source navigation owned by the search panel.
const renderFiles = (): React.JSX.Element => (
<div className="search-recent-file-grid">
{files.map((file) => (
<div key={file.id} className="search-recent-file relative">
<button
type="button"
className="search-recent-file-open"
title={file.name}
onClick={() => setPreviewDialog(filePreviewItem(file))}
>
<div className="search-recent-file-thumb">
<ArtifactPreview
projectId={file.projectId}
sessionId={file.sessionId}
source={file.source === 'upload' ? 'upload' : undefined}
managedFileId={file.sourceFileId}
selectedVersionId={file.sourceVersionId}
artifact={createProjectFilePreviewArtifact(file)}
preview={filePreviews.get(file.id)}
/>
</div>
<div className="search-recent-file-info">
<ExtensionPreservingFileName name={file.name} className="search-recent-file-name" />
<div className="search-recent-file-meta">
{fileFormatLabel(file)}
{' · '}
{formatBytes(file.size)}
</div>
</div>
</button>
{renderLocateFile(file)}
</div>
))}
</div>
)
// Preview and source navigation are separate controls so opening a file retains the search.
const renderLocateFile = (file: ProjectFileItem, compact = true): React.JSX.Element | null => {
if (file.sessionId === STANDALONE_UPLOAD_SESSION_ID) return null
if (file.originSession && file.originSession.state !== 'active') return null
if (!compact)
return (
<Button size="sm" variant="secondary" onClick={() => onLocateFile(file)}>
{t('Jump to message')}
<ArrowUpRight className="ml-1 size-3.5" />
</Button>
)
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="search-file-locate size-6"
aria-label={t('View in context for {{title}}', { title: file.name })}
onClick={() => onLocateFile(file)}
>
<ArrowUpRight className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('Jump to message')}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
const details = (): React.JSX.Element => {
switch (result.kind) {
case 'messages':
return <></>
case 'projects':
return (
<div className="space-y-5">
<div>
<p className="text-xs text-muted-foreground">{t('Description')}</p>
<p className="search-detail-abstract">
<SearchHighlight
text={result.item.description || t('No description')}
query={query}
/>
</p>
</div>
{result.item.agentContext && (
<div>
<p className="mb-2 text-xs text-muted-foreground">{t('Agent Context')}</p>
<AgentMarkdown content={result.item.agentContext} />
</div>
)}
</div>
)
case 'sessions':
return (
<dl className="search-details-metadata">
<dt>{t('Project')}</dt>
<dd>{project?.name}</dd>
<dt>{t('Session')}</dt>
<dd>{result.item.title}</dd>
<dt>{t('Updated')}</dt>
<dd>{formatDateTime(result.item.updatedAt, locale, 'dateTime')}</dd>
</dl>
)
case 'uploads':
case 'generated':
return (
<dl className="search-details-metadata">
<dt>{t('File name')}</dt>
<dd>{result.item.name}</dd>
<dt>{t('File size')}</dt>
<dd>{formatBytes(result.item.size)}</dd>
<dt>{t('Type')}</dt>
<dd>{fileFormatLabel(result.item)}</dd>
<dt>{t('Project')}</dt>
<dd>{project?.name}</dd>
<dt>{t('Session')}</dt>
<dd>
{result.item.sessionId === STANDALONE_UPLOAD_SESSION_ID
? t('Local computer')
: (session?.title ?? result.item.originSession?.title)}
</dd>
<dt>{t('Source')}</dt>
<dd>{result.kind === 'uploads' ? t('Uploaded files') : t('Generated files')}</dd>
<dt>{t('Updated')}</dt>
<dd>{formatDateTime(result.item.sortAtMs, locale, 'dateTime')}</dd>
</dl>
)
case 'library':
return 'item' in result.item ? (
<dl className="search-details-metadata">
<dt>{t('Title')}</dt>
<dd>{result.item.item.title}</dd>
<dt>{t('Authors')}</dt>
<dd>
{result.item.item.creators
.map((creator) =>
creator.nameMode === 'organization'
? creator.literalName
: [creator.givenName, creator.familyName].filter(Boolean).join(' ')
)
.join(', ')}
</dd>
<dt>{t('Year')}</dt>
<dd>{result.item.item.issuedText}</dd>
<dt>{t('Journal')}</dt>
<dd>{result.item.item.containerTitle}</dd>
<dt>{t('Projects')}</dt>
<dd>
{projects
.filter(
(project) =>
'projectIds' in result.item && result.item.projectIds.includes(project.id)
)
.map((project) => project.name)
.join(', ') || t('None')}
</dd>
<dt>{t('Collections')}</dt>
<dd>{collectionNames ?? t('Loading…')}</dd>
<dt>{t('Full text')}</dt>
<dd>{preview ? t('PDF available') : t('No PDF')}</dd>
{result.item.item.identifiers.map((identifier) => (
<div className="contents" key={identifier.scheme}>
<dt>{identifier.scheme}</dt>
<dd>{identifier.value}</dd>
</div>
))}
</dl>
) : (
<p className="search-detail-abstract">
<SearchHighlight text={result.item.description || t('No description')} query={query} />
</p>
)
}
}
return (
<div className="flex h-full min-h-0 flex-col pt-[5px]">
<SearchDetailHeader
result={result}
projects={projects}
sessions={sessions}
fileCount={fileCount}
fileCountUnavailable={fileCountUnavailable}
onCollapse={onCollapse}
/>
{tabs.length > 0 && (
<div
role="tablist"
aria-label={t('Details')}
className="search-detail-tabs"
onKeyDown={(event) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const index = tabs.findIndex((item) => item.id === tab)
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? tabs.length - 1
: (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length
setTab(tabs[next]!.id)
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]')[next]?.focus()
}}
>
{tabs.map((item) => (
<button
key={item.id}
id={`search-detail-tab-${item.id}`}
role="tab"
type="button"
aria-selected={tab === item.id}
aria-controls="search-detail-tabpanel"
tabIndex={tab === item.id ? 0 : -1}
onClick={() => setTab(item.id)}
className="transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
{item.label}
</button>
))}
</div>
)}
<div
ref={contentRef}
className="search-detail-content min-h-0 flex-1 overflow-auto"
role={tabs.length ? 'tabpanel' : undefined}
id="search-detail-tabpanel"
aria-labelledby={tabs.length ? `search-detail-tab-${tab}` : undefined}
>
{result.kind === 'messages' ? (
(!result.item.contentTruncated || completeMessage !== undefined) && (
<SearchContentHighlight query={query} className="search-message-content">
<AgentMarkdown
content={result.item.contentTruncated ? completeMessage! : result.item.content}
/>
</SearchContentHighlight>
)
) : tab === 'details' ? (
details()
) : tab === 'preview' ||
(preview && (result.kind === 'uploads' || result.kind === 'generated')) ? (
preview && (
<SearchFilePreview
key={preview.id}
item={preview}
query={query}
contentMatch={
result.kind === 'uploads' || result.kind === 'generated'
? result.item.contentMatch
: undefined
}
onOpen={openPreview}
/>
)
) : result.kind === 'projects' && tab === 'content' ? (
<div>
{recentSessions.slice(0, 10).map((item) => (
<button
key={item.id}
className="search-recent-session focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onOpen({ kind: 'sessions', item })}
>
<MessageCircle aria-hidden="true" />
<span className="search-recent-session-text">
<strong>{item.title}</strong>
<small>
#{item.number}
{' · '}
{formatRelativeTime(item.updatedAt, locale)}
</small>
</span>
<ArrowUpRight aria-hidden="true" className="search-recent-arrow" />
</button>
))}
{recentSessions.length === 0 && (
<p className="search-recent-empty">{t('No recent content')}</p>
)}
{recentSessions.length > 10 && (
<p className="search-recent-limit">{t('Only the 10 most recent items are shown')}</p>
)}
</div>
) : result.kind === 'sessions' || (result.kind === 'projects' && tab === 'files') ? (
<>
{renderFiles()}
{hasMoreRecentItems && (
<p className="search-recent-limit">{t('Only the 10 most recent items are shown')}</p>
)}
</>
) : result.kind === 'library' && 'item' in result.item ? (
<p className="search-detail-abstract">
<SearchHighlight
text={result.item.item.abstract || t('No abstract available')}
query={query}
/>
</p>
) : (
<div>
{papers.map((item) => (
<button
key={item.id}
onClick={() => onOpen({ kind: 'library', item })}
className="search-recent-literature focus-visible:ring-2 focus-visible:ring-ring"
>
<File className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="line-clamp-2">{item.item.title}</span>
<ArrowUpRight aria-hidden="true" className="search-recent-arrow" />
</button>
))}
{hasMoreRecentItems && (
<p className="search-recent-limit">{t('Only the 10 most recent items are shown')}</p>
)}
</div>
)}
{tab === 'content' && status === 'loading' && (
<div role="status" className="flex justify-center py-8">
<LoaderCircle className="size-5 animate-spin" aria-label={t('Loading…')} />
</div>
)}
{tab === 'content' && status === 'error' && (
<ErrorNotice
title={
result.kind === 'messages'
? t('The source message is no longer available.')
: t('Could not load recent content.')
}
/>
)}
{tab === 'content' &&
status === 'idle' &&
((result.kind === 'sessions' && files.length === 0) ||
(isCollection && papers.length === 0)) && (
<p className="py-6 text-center text-sm text-muted-foreground">
{t('No recent content')}
</p>
)}
</div>
<footer className="search-detail-actions">
{(result.kind === 'uploads' || result.kind === 'generated') &&
renderLocateFile(result.item, false)}
<Button
size="sm"
className="search-detail-open"
onClick={() =>
isLibraryPreview || result.kind === 'uploads' || result.kind === 'generated'
? openPreview()
: onOpen(result)
}
>
{openLabel}
<ArrowUpRight className="ml-1 size-3.5" />
</Button>
</footer>
<FilePreviewDialog
item={previewDialog}
allowReadingContext={false}
onClose={() => setPreviewDialog(undefined)}
onViewInContextNavigate={() => {
setPreviewDialog(undefined)
onNavigate()
}}
/>
</div>
)
}
@@ -0,0 +1,64 @@
import { useId } from 'react'
import { SearchContentHighlight } from './SearchContentHighlight'
import { PreviewInitialPosition } from '@/pages/workspace/previews/PreviewInitialPosition'
import { TextPreviewRenderer } from '@/pages/workspace/previews/renderers/TextPreview'
import type { ProjectFileItem } from '../../../../shared/project-files'
import { ActionMenuProvider, ActionMenuTarget } from '@/components/action-menu'
import { PreviewActionMenuAdapterProvider } from '@/pages/workspace/preview-actions/preview-action-adapter'
import {
PREVIEW_CAPABILITY_CATALOG,
shouldHandlePreviewContextMenu,
type PreviewCapabilityId
} from '@/pages/workspace/preview-actions/preview-action-model'
import { PreviewFileContent } from '@/pages/workspace/previews/PreviewFileContent'
import type { PreviewFileItem } from '@/stores/preview-workbench-store'
export const SearchFilePreview = ({
item,
query,
contentMatch,
onOpen
}: {
item: PreviewFileItem
query: string
contentMatch?: ProjectFileItem['contentMatch']
onOpen: () => void
}): React.JSX.Element => {
const targetId = useId()
return (
<ActionMenuProvider testId="search-preview-context-menu">
<PreviewActionMenuAdapterProvider targetId={targetId}>
<ActionMenuTarget<PreviewCapabilityId, undefined>
targetId={targetId}
identityKey={JSON.stringify([
item.id,
item.path,
item.managedFileId,
item.selectedVersionId
])}
catalog={PREVIEW_CAPABILITY_CATALOG}
recipe={[{ kind: 'action', action: 'open-fullscreen' }]}
bindings={{ 'open-fullscreen': { execute: onOpen } }}
invocation={undefined}
resolveInvocation={(event) =>
shouldHandlePreviewContextMenu(event.target) ? undefined : null
}
asChild
>
<div className="search-file-preview relative min-h-64" data-format={item.format}>
<SearchContentHighlight query={query} className="contents">
<PreviewInitialPosition value={contentMatch}>
{contentMatch &&
(contentMatch.offset > 0 || !['markdown', 'text', 'code'].includes(item.format)) ? (
<TextPreviewRenderer item={item} presentation="search" />
) : (
<PreviewFileContent item={item} presentation="search" />
)}
</PreviewInitialPosition>
</SearchContentHighlight>
</div>
</ActionMenuTarget>
</PreviewActionMenuAdapterProvider>
</ActionMenuProvider>
)
}
@@ -0,0 +1,28 @@
import { useMemo } from 'react'
import { findSearchMatches } from '../../../../shared/search-text'
export const SearchHighlight = ({
text,
query
}: {
text: string
query: string
}): React.JSX.Element => {
const matches = useMemo(() => findSearchMatches(text, query), [text, query])
return (
<>
{matches.map(({ start, end }, index) => {
const before = text.slice(matches[index - 1]?.end ?? 0, start)
return (
<span key={start}>
{before}
<mark data-search-match={index} className="search-match">
{text.slice(start, end)}
</mark>
</span>
)
})}
{text.slice(matches.at(-1)?.end ?? 0)}
</>
)
}
@@ -0,0 +1,108 @@
import { useTranslation } from 'react-i18next'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import type { SearchSort } from '../../../../shared/search-text'
import type { SearchCategory } from './search-result'
export const SearchResultFilters = ({
category,
scope,
canScopeToProject,
sort,
days,
subtype,
onScope,
onSort,
onDays,
onSubtype
}: {
category: SearchCategory | 'all'
scope: 'all' | 'current'
canScopeToProject: boolean
sort: SearchSort
days: number
subtype: string
onScope: (value: string) => void
onSort: (value: SearchSort) => void
onDays: (value: number) => void
onSubtype: (value: string) => void
}): React.JSX.Element => {
const { t } = useTranslation()
const options =
category === 'messages'
? [
['all', t('All senders')],
['user', t('Sent by me')],
['agent', t('Sent by agents')]
]
: category === 'uploads' || category === 'generated'
? [
['all', t('All formats')],
['pdf', 'PDF'],
['spreadsheet', t('Spreadsheets / CSV')],
['notebook', 'Notebook'],
['image', t('Images')]
]
: category === 'library'
? [
['all', t('All entries')],
['paper', t('Literature')],
['collection', t('Collections')],
['pdf', t('With PDF')]
]
: []
return (
<div className="search-subfilters">
<Select value={scope} onValueChange={onScope}>
<SelectTrigger
aria-label={t('Search scope')}
className="search-scope-select w-auto min-w-0 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('All projects and Library')}</SelectItem>
{canScopeToProject && <SelectItem value="current">{t('Current project')}</SelectItem>}
</SelectContent>
</Select>
<Select value={sort} onValueChange={(value) => onSort(value as SearchSort)}>
<SelectTrigger aria-label={t('Result order')} className="w-auto min-w-0 max-w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="relevance">{t('Relevance within categories')}</SelectItem>
<SelectItem value="recent">{t('Recently updated')}</SelectItem>
</SelectContent>
</Select>
<Select value={String(days)} onValueChange={(value) => onDays(Number(value))}>
<SelectTrigger aria-label={t('Time range')} className="w-auto max-w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t('Any time')}</SelectItem>
<SelectItem value="7">{t('Last 7 days')}</SelectItem>
<SelectItem value="30">{t('Last 30 days')}</SelectItem>
</SelectContent>
</Select>
{options.length > 0 && (
<Select value={subtype} onValueChange={onSubtype}>
<SelectTrigger aria-label={t('Refine category')} className="w-auto max-w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}
@@ -1,11 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
getNextBatchCount,
getRecentSessions,
searchSessionTitles,
type SearchableSession
} from './global-search-catalog'
import { searchSessionTitles, type SearchableSession } from './global-search-catalog'
const sessions = (count: number, projectId = 'project-a'): SearchableSession[] =>
Array.from({ length: count }, (_, index) => ({
@@ -19,6 +14,15 @@ const sessions = (count: number, projectId = 'project-a'): SearchableSession[] =
}))
describe('global search catalog', () => {
it('preserves session objects so result rows need no reverse lookup', () => {
const item = { ...sessions(1)[0]!, activeMessageCount: 42 }
const result = searchSessionTitles({
sessions: [item],
query: 'sin'
})
expect(result[0]).toBe(item)
})
it.each([
['Übersicht', 'übersicht'],
['ÉTUDE', 'étude'],
@@ -37,15 +41,12 @@ describe('global search catalog', () => {
])('finds title %s using query %s', (title, query) => {
const result = searchSessionTitles({
sessions: [{ ...sessions(1)[0], title }],
projectNames: new Map([['project-a', 'Alpha']]),
primaryProjectId: 'project-a',
query,
visiblePrimaryCount: 8
query
})
expect(result.primary.map((session) => session.id)).toEqual(['session-0'])
expect(result.primaryTotalCount).toBe(1)
expect(result.primary[0].title).toBe(title)
expect(result.map((session) => session.id)).toEqual(['session-0'])
expect(result.length).toBe(1)
expect(result[0].title).toBe(title)
})
it.each([
@@ -59,13 +60,10 @@ describe('global search catalog', () => {
])('keeps title %s distinct from query %s', (title, query) => {
const result = searchSessionTitles({
sessions: [{ ...sessions(1)[0], title }],
projectNames: new Map([['project-a', 'Alpha']]),
primaryProjectId: 'project-a',
query,
visiblePrimaryCount: 8
query
})
expect(result.primary).toEqual([])
expect(result).toEqual([])
})
it('uses the existing number lookup for full-width digits', () => {
@@ -75,151 +73,62 @@ describe('global search catalog', () => {
{ ...sessions(1)[0], id: 'exact', number: 12 },
{ ...sessions(1)[0], id: 'title-only', title: '12', number: 7 }
],
projectNames: new Map([['project-a', 'Alpha']]),
primaryProjectId: 'project-a',
query: '12',
visiblePrimaryCount: 8
query: '12'
})
expect(result.primary.map((session) => session.id)).toEqual(['exact', 'prefix'])
expect(result.map((session) => session.id)).toEqual(['exact', 'prefix'])
})
it('matches Session titles, partitions other projects, and reveals primary results in batches of eight', () => {
it('keeps pending sessions out and retains all matching scoped sessions for pagination', () => {
const result = searchSessionTitles({
sessions: [
...sessions(14),
...sessions(7, 'project-b'),
{
id: 'pending-match',
projectId: 'project-a',
title: 'Python sin pending',
updatedAt: 2_000,
artifactCount: 0,
isPending: true
},
{
id: 'body-only',
projectId: 'project-a',
title: 'Unrelated title',
updatedAt: 3_000,
artifactCount: 0,
isPending: false
}
],
projectNames: new Map([
['project-a', 'Alpha'],
['project-b', 'Beta']
]),
primaryProjectId: 'project-a',
query: 'SIN',
visiblePrimaryCount: 8
sessions: [...sessions(23), { ...sessions(1)[0]!, id: 'pending', isPending: true }],
query: 'sin'
})
expect(result.primary).toHaveLength(8)
expect(result.primaryTotalCount).toBe(14)
expect(result.other).toHaveLength(5)
expect(result.other[0]).toEqual(
expect.objectContaining({ id: 'session-0', projectId: 'project-b', projectName: 'Beta' })
)
expect(getNextBatchCount(result.primaryTotalCount, result.primary.length)).toBe(6)
expect(result).toHaveLength(23)
expect(result[0]!.id).toBe('session-0')
expect(result[22]!.id).toBe('session-22')
})
it('searches every Project as the primary result set when no Project scope is active', () => {
const result = searchSessionTitles({
sessions: [...sessions(3), ...sessions(3, 'project-b')],
projectNames: new Map([
['project-a', 'Alpha'],
['project-b', 'Beta']
]),
primaryProjectId: undefined,
query: 'sin',
visiblePrimaryCount: 5
})
expect(result.primary).toHaveLength(5)
expect(result.primaryTotalCount).toBe(6)
expect(result.other).toEqual([])
})
it('matches positive Session-number prefixes and ranks an exact number first', () => {
const result = searchSessionTitles({
sessions: [
{ ...sessions(1)[0], id: 'newer-prefix', number: 123, updatedAt: 3_000 },
{ ...sessions(1)[0], id: 'exact', number: 12, updatedAt: 1_000 },
{ ...sessions(1)[0], id: 'older-prefix', number: 120, updatedAt: 2_000 },
{ ...sessions(1)[0], id: 'title-only', title: 'Session 12', number: 7, updatedAt: 4_000 },
{ ...sessions(1)[0], id: 'missing', number: undefined, updatedAt: 5_000 },
{ ...sessions(1)[0], id: 'invalid', number: 0, updatedAt: 6_000 }
],
projectNames: new Map([['project-a', 'Alpha']]),
primaryProjectId: 'project-a',
query: '12',
visiblePrimaryCount: 8
})
expect(result.primary.map((session) => session.id)).toEqual([
it('orders exact numbers before prefixes while allowing explicit recency sorting', () => {
const input = [
{ ...sessions(1)[0]!, id: 'prefix', number: 123, updatedAt: 3000 },
{ ...sessions(1)[0]!, id: 'exact', number: 12, updatedAt: 1000 },
{ ...sessions(1)[0]!, id: 'title-only', title: 'Session 12', number: 7 },
{ ...sessions(1)[0]!, id: 'missing', number: undefined },
{ ...sessions(1)[0]!, id: 'invalid', number: 0 }
]
expect(searchSessionTitles({ sessions: input, query: '12' }).map((item) => item.id)).toEqual([
'exact',
'newer-prefix',
'older-prefix'
'prefix'
])
expect(
searchSessionTitles({ sessions: input, query: '12', sort: 'recent' }).map((item) => item.id)
).toEqual(['prefix', 'exact'])
})
it('promotes an exact Session-number match from another Project ahead of local prefixes', () => {
const result = searchSessionTitles({
sessions: [
{ ...sessions(1)[0], id: 'local-prefix', number: 123, updatedAt: 3_000 },
{
...sessions(1, 'project-b')[0],
id: 'cross-project-exact',
number: 12,
updatedAt: 1_000
},
{
...sessions(1, 'project-b')[0],
id: 'cross-project-prefix',
number: 120,
updatedAt: 2_000
}
],
projectNames: new Map([
['project-a', 'Alpha'],
['project-b', 'Beta']
]),
primaryProjectId: 'project-a',
query: '12',
visiblePrimaryCount: 8
})
expect(result.primary.map((session) => session.id)).toEqual([
'cross-project-exact',
'local-prefix'
])
expect(result.primary[0].projectName).toBe('Beta')
expect(result.other.map((session) => session.id)).toEqual(['cross-project-prefix'])
})
it('returns recent non-pending sessions in recency order with a five-row cap', () => {
const recent = getRecentSessions(
[
...sessions(7),
{
id: 'pending',
projectId: 'project-a',
title: 'Pending',
updatedAt: 9_999,
artifactCount: 0,
isPending: true
}
],
'project-a'
)
expect(recent.map((session) => session.id)).toEqual([
it('keeps large result sets linear in session identity reads', () => {
let identityReads = 0
const input = sessions(10000).map((session) => ({
...session,
get id() {
identityReads++
return session.id
}
}))
const result = searchSessionTitles({ sessions: input, query: 'sin' })
expect(result.slice(0, 10).map((item) => item.id)).toEqual([
'session-0',
'session-1',
'session-2',
'session-3',
'session-4'
'session-4',
'session-5',
'session-6',
'session-7',
'session-8',
'session-9'
])
expect(identityReads).toBeLessThan(100000)
expect(result[9999]).toBe(input[9999])
})
})
@@ -1,6 +1,9 @@
export const GLOBAL_SEARCH_PAGE_SIZE = 8
export const RECENT_SESSION_LIMIT = 5
export const OTHER_PROJECT_RESULT_LIMIT = 5
import {
findSearchMatches,
normalizeSearchText,
searchTitleRank,
type SearchSort
} from '../../../../shared/search-text'
export type SearchableSession = {
id: string
@@ -12,118 +15,36 @@ export type SearchableSession = {
isPending?: boolean
}
export type SessionSearchResult = SearchableSession & {
kind: 'session'
// Undefined when the Project is not in `projectNames`. This module stays pure and locale-free, so
// the placeholder wording belongs to the view that renders it, not here.
projectName?: string
}
export type SessionSearchGroups = {
primary: SessionSearchResult[]
primaryTotalCount: number
other: SessionSearchResult[]
}
// Normalize only comparison text: compatibility forms match, but accents remain significant.
const normalizeSearchText = (value: string): string => value.normalize('NFKC').toLowerCase()
const compareByRecency = (left: SearchableSession, right: SearchableSession): number =>
right.updatedAt - left.updatedAt || right.id.localeCompare(left.id)
const validSessionNumber = (number: number | undefined): number | undefined =>
number !== undefined && Number.isSafeInteger(number) && number > 0 ? number : undefined
const toResult = (
session: SearchableSession,
projectNames: Map<string, string>
): SessionSearchResult => ({
...session,
kind: 'session',
projectName: projectNames.get(session.projectId)
})
// Session titles and numbers are already hydrated in the renderer. Keep this local filter
// deliberately narrow so global search does not accidentally become message-body or metadata
// search. A numeric query is an identity lookup: exact number first, then number prefixes.
export const searchSessionTitles = ({
// Scope and pagination belong to the view. Retain original objects, including hydrated counts,
// so the renderer can consume matching Sessions without rebuilding them through an ID lookup.
export const searchSessionTitles = <Session extends SearchableSession>({
sessions,
projectNames,
primaryProjectId,
query,
visiblePrimaryCount
sort = 'relevance'
}: {
sessions: SearchableSession[]
projectNames: Map<string, string>
primaryProjectId: string | undefined
sessions: Session[]
query: string
visiblePrimaryCount: number
}): SessionSearchGroups => {
const foldedQuery = normalizeSearchText(query).trim()
const numericQuery = /^\d+$/.test(foldedQuery) ? foldedQuery : undefined
// A literal substring must include trailing marks (e.g. the dot added when İ lowercases).
const titlePattern = new RegExp(
`${foldedQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\p{M})`,
'u'
)
const matches = sessions
sort?: SearchSort
}): Session[] => {
const needle = normalizeSearchText(query.trim())
const numeric = /^\d+$/.test(needle)
const numberText = (session: Session): string =>
Number.isSafeInteger(session.number) && session.number! > 0 ? String(session.number) : ''
return sessions
.filter(
(session) =>
!session.isPending &&
projectNames.has(session.projectId) &&
(numericQuery
? String(validSessionNumber(session.number) ?? '').startsWith(numericQuery)
: titlePattern.test(normalizeSearchText(session.title)))
(numeric
? numberText(session).startsWith(needle)
: !needle || findSearchMatches(session.title, query, 1).length > 0)
)
.sort(
(a, b) =>
(sort === 'relevance'
? Math.max(searchTitleRank(b.title, query), searchTitleRank(numberText(b), query)) -
Math.max(searchTitleRank(a.title, query), searchTitleRank(numberText(a), query))
: 0) ||
b.updatedAt - a.updatedAt ||
a.id.localeCompare(b.id)
)
.sort((left, right) => {
if (numericQuery) {
const exactOrder =
Number(String(validSessionNumber(right.number)) === numericQuery) -
Number(String(validSessionNumber(left.number)) === numericQuery)
if (exactOrder !== 0) return exactOrder
}
return compareByRecency(left, right)
})
const promotedExactMatch =
primaryProjectId && numericQuery
? matches.find(
(session) =>
session.projectId !== primaryProjectId &&
String(validSessionNumber(session.number)) === numericQuery
)
: undefined
const primaryMatches = primaryProjectId
? [
...(promotedExactMatch ? [promotedExactMatch] : []),
...matches.filter((session) => session.projectId === primaryProjectId)
]
: matches
return {
primary: primaryMatches
.slice(0, visiblePrimaryCount)
.map((session) => toResult(session, projectNames)),
primaryTotalCount: primaryMatches.length,
other: primaryProjectId
? matches
.filter(
(session) => session.projectId !== primaryProjectId && session !== promotedExactMatch
)
.slice(0, OTHER_PROJECT_RESULT_LIMIT)
.map((session) => toResult(session, projectNames))
: []
}
}
export const getRecentSessions = (
sessions: SearchableSession[],
projectId?: string
): SearchableSession[] =>
sessions
.filter((session) => !session.isPending && (!projectId || session.projectId === projectId))
.sort(compareByRecency)
.slice(0, RECENT_SESSION_LIMIT)
// The count advertises what the next click reveals, never every remaining match.
export const getNextBatchCount = (totalCount: number, visibleCount: number): number =>
Math.max(0, Math.min(GLOBAL_SEARCH_PAGE_SIZE, totalCount - visibleCount))
@@ -0,0 +1,781 @@
.global-search-dialog {
width: min(1190px, 94vw);
height: min(800px, 88vh);
font-size: 13px;
line-height: 1.5;
letter-spacing: 0;
}
.global-search-searchbar {
display: flex;
align-items: center;
gap: 12px;
height: 61px;
padding: 0 21px;
}
.global-search-input-field {
display: flex;
flex: 1;
align-items: center;
gap: 12px;
min-width: 0;
height: 40px;
padding-right: 4px;
border-bottom: 1px solid var(--border);
}
.global-search-searchbar input {
height: 100%;
border-radius: 0;
font-size: 21px;
line-height: 1.5;
}
.search-scope-select {
max-width: 180px;
}
.global-search-chips {
display: flex;
gap: 6px;
padding: 0 20px 6px;
overflow-x: auto;
scrollbar-width: none;
}
.search-category-chip {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: 99px;
padding: 5px 11px;
font-size: 12px;
line-height: 19px;
color: var(--muted-foreground);
background: transparent;
}
.search-category-chip svg {
width: 13px;
height: 13px;
}
.search-category-chip small {
font-size: 10px;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
.search-category-chip:hover {
background: var(--bg-200);
}
.search-category-chip[aria-pressed='true'] {
color: var(--primary-foreground);
background: var(--primary);
border-color: var(--primary);
}
.search-subfilters {
min-height: 28px;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 20px 6px;
}
.global-search-list {
padding-bottom: 7px;
scroll-padding-top: 30px;
}
.search-group-heading {
display: flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 8px 18px 5px;
font-size: 11px;
font-weight: 500;
color: var(--muted-foreground);
background: var(--card);
}
.search-group-heading[data-stuck='true'] {
box-shadow: 0 4px 6px -4px color-mix(in oklab, var(--foreground) 25%, transparent);
}
.search-result-row {
position: relative;
display: flex;
align-items: flex-start;
gap: 9px;
min-height: 50px;
padding: 7px 20px;
}
.search-result-row:hover {
background: var(--bg-200);
}
.search-result-row[aria-selected='true'] {
background: color-mix(in oklab, var(--primary) 8%, transparent);
border-radius: 6px;
}
.search-result-row[aria-selected='true']::before {
content: '';
position: absolute;
left: 0;
top: 7px;
bottom: 7px;
width: 3px;
border-radius: 2px;
background: var(--primary);
}
.search-result-icon {
display: grid;
place-items: center;
width: 28px;
height: 31px;
flex-shrink: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-200);
color: var(--primary);
}
.search-result-icon[data-file='true'] {
font-size: 7px;
font-weight: 600;
border-radius: 3px;
}
.search-result-icon span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.search-result-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 550;
line-height: 19.5px;
}
.search-result-excerpt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 3px;
font-size: 12px;
line-height: 19.8px;
color: var(--muted-foreground);
}
.search-result-meta {
display: flex;
gap: 5px;
margin-top: 3px;
font-size: 10px;
line-height: 15px;
color: var(--muted-foreground);
}
.search-show-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 7px 0 11px;
color: var(--primary);
font-size: 11px;
line-height: 16.5px;
}
.search-show-more:hover span:first-child {
text-decoration: underline;
text-underline-offset: 3px;
}
.search-show-more span:last-child {
font-size: 9px;
color: var(--muted-foreground);
}
.search-result-notice {
margin: 6px 18px;
font-size: 11px;
}
.search-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 70px 18px;
text-align: center;
color: var(--muted-foreground);
}
.search-empty-state > svg {
width: 26px;
height: 26px;
opacity: 0.5;
}
.search-empty-state h3 {
font-size: 14px;
font-weight: 500;
color: var(--foreground);
}
.search-empty-state p {
font-size: 11px;
}
.search-empty-state button {
font-size: 11px;
color: var(--primary);
}
.global-search-footer {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 30px;
padding: 0 18px;
color: var(--muted-foreground);
font-size: 9px;
}
.global-search-detail-surface {
width: 55cqw;
height: 100%;
overflow: hidden;
border-left: 1px solid var(--border);
background: var(--card);
}
.global-search-body {
container-type: inline-size;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 0fr);
transition: grid-template-columns 240ms ease;
}
.global-search-body[data-expanded='true'] {
grid-template-columns: minmax(0, 0.45fr) minmax(0, 0.55fr);
}
.global-search-detail {
min-width: 0;
overflow: hidden;
opacity: 0;
transition: opacity 200ms ease;
}
.global-search-detail[data-open='true'] {
opacity: 1;
}
.search-detail-header {
flex-shrink: 0;
padding: 13px 23px 10px;
}
.search-detail-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.search-detail-kind-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--primary);
opacity: 0.7;
}
.search-detail-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
font-weight: 550;
line-height: 1.45;
letter-spacing: 0;
}
.search-detail-context {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px 10px;
margin-top: 8px;
font-size: 10px;
line-height: 17px;
color: var(--muted-foreground);
overflow-wrap: anywhere;
}
.search-detail-context > * {
max-width: 100%;
}
.search-detail-context-part {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.search-detail-context-part svg {
width: 11px;
height: 11px;
flex-shrink: 0;
opacity: 0.8;
}
.search-detail-context-part span {
min-width: 0;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-detail-bibliography {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-detail-metrics {
display: flex;
flex-wrap: wrap;
gap: 5px 15px;
margin-top: 7px;
font-size: 10px;
line-height: 15px;
color: var(--muted-foreground);
}
.search-detail-back {
display: none;
align-items: center;
gap: 4px;
margin-bottom: 8px;
color: var(--primary);
font-size: 10px;
}
.search-detail-back svg {
width: 14px;
height: 14px;
}
.search-detail-tabs {
display: flex;
flex-shrink: 0;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
padding: 1px 23px 12px;
}
.search-detail-tabs button {
border-radius: 6px;
padding: 5px 10px;
white-space: nowrap;
font-size: 11px;
line-height: 18px;
color: var(--muted-foreground);
}
.search-detail-tabs button:hover {
background: color-mix(in oklab, var(--primary) 5%, transparent);
}
.search-detail-tabs button[aria-selected='true'] {
color: var(--primary);
background: color-mix(in oklab, var(--primary) 8%, transparent);
font-weight: 500;
}
.search-detail-content {
padding: 6px 23px 20px;
}
.search-match {
padding: 0;
border-radius: 2px;
color: inherit;
background: color-mix(in oklab, var(--color-amber-200) 65%, var(--bg-000));
}
.dark .search-match {
background: var(--color-amber-900);
}
.search-detail-abstract {
margin-top: 4px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 1.95;
}
.search-recent-limit {
margin-top: 10px;
text-align: center;
color: var(--text-300);
font-size: 10px;
line-height: 15px;
}
.search-recent-session {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 6px 8px;
border-radius: 6px;
color: var(--text-000);
text-align: left;
transition: background-color 150ms ease;
}
.search-recent-session > svg,
.search-recent-literature > svg {
width: 15px;
height: 15px;
flex-shrink: 0;
color: var(--text-300);
}
.search-recent-session-text {
min-width: 0;
flex: 1;
}
.search-recent-session-text strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.search-recent-session-text small {
display: block;
margin-top: 2px;
font-size: 10px;
line-height: 15px;
color: var(--text-300);
}
.search-recent-arrow {
margin-left: auto;
opacity: 0.65;
}
.search-recent-literature {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 36px;
padding: 6px 8px;
border-radius: 6px;
color: var(--text-000);
text-align: left;
font-size: 12px;
transition: background-color 150ms ease;
}
.search-recent-session:hover,
.search-recent-literature:hover {
background: var(--bg-200);
}
.search-recent-file-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.search-recent-file {
display: block;
min-width: 0;
overflow: hidden;
border: 1px solid color-mix(in oklab, var(--color-border-300) 50%, transparent);
border-radius: 8px;
background: var(--bg-000);
box-shadow: var(--shadow-sm);
transition:
background-color 150ms ease,
border-color 150ms ease;
text-align: left;
}
.search-recent-file:hover {
border-color: var(--color-border-200);
background: var(--bg-100);
}
.search-recent-file:has(:focus-visible) {
outline: 2px solid color-mix(in oklab, var(--ring) 50%, transparent);
outline-offset: -2px;
}
.search-recent-file-open {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
height: 128px;
cursor: pointer;
text-align: left;
}
.search-recent-file-open:focus-visible {
outline: none;
}
.search-recent-file-thumb {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 82px;
flex-shrink: 0;
overflow: hidden;
background: var(--bg-200);
}
.search-recent-file-thumb > div {
background: transparent;
}
.search-recent-file-info {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
gap: 2px;
width: 100%;
min-width: 0;
padding: 6px 8px;
}
.search-recent-file-name {
font-size: 11px;
line-height: 18px;
color: var(--text-000);
}
.search-recent-file-meta {
font-size: 10px;
line-height: 12px;
color: var(--text-300);
}
.search-recent-file > .search-file-locate {
position: absolute;
top: 6px;
right: 6px;
opacity: 0;
border: 1px solid var(--border);
background: color-mix(in oklab, var(--bg-000) 95%, transparent);
box-shadow: var(--shadow-sm);
transition: opacity 150ms ease;
}
.search-recent-file:hover > .search-file-locate,
.search-recent-file:focus-within > .search-file-locate {
opacity: 1;
}
@media (hover: none) {
.search-recent-file > .search-file-locate {
opacity: 1;
}
}
.search-detail-actions > .search-file-locate {
margin-right: 8px;
align-self: center;
}
.search-recent-empty {
padding: 20px 0;
font-size: 11px;
color: var(--muted-foreground);
}
.search-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
justify-content: flex-end;
padding: 10px 22px 14px;
min-height: 51px;
}
.search-detail-open {
height: 33px;
padding: 8px 13px;
border-radius: 6px;
font-size: 11px;
}
.search-message-content {
padding-top: 6px;
overflow-wrap: anywhere;
}
::highlight(global-search-content) {
background-color: color-mix(in oklab, var(--color-amber-200) 65%, var(--bg-000));
color: var(--foreground);
}
.dark ::highlight(global-search-content) {
background-color: var(--color-amber-900);
}
.search-file-preview {
width: 100%;
}
.search-file-preview[data-format='html'],
.search-file-preview[data-format='image'],
.search-file-preview[data-format='tiff'],
.search-file-preview[data-format='molecule'],
.search-file-preview[data-format='pdb'],
.search-file-preview[data-format='word'],
.search-file-preview[data-format='spreadsheet'],
.search-file-preview[data-format='presentation'] {
height: 100%;
}
.search-page-count {
padding: 12px 18px;
text-align: center;
font-size: 9px;
color: var(--text-300);
}
.search-recent-queries {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding: 8px 17px 14px;
}
.search-recent-queries button {
max-width: 100%;
overflow-wrap: anywhere;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-300);
font-size: 10px;
}
.search-file-preview [data-pdf-preview-root] {
background: transparent;
}
.search-csv-preview {
width: 100%;
}
.search-csv-preview > div {
background: transparent;
border-top: 0;
border-bottom: 0;
padding-left: 0;
padding-right: 0;
}
.search-csv-preview th,
.search-csv-preview td {
border-bottom: 0;
border-right: 0;
}
.search-csv-preview table {
font-size: 11px;
}
.search-csv-preview tbody tr:nth-child(even) {
background: var(--bg-200);
}
.search-file-preview .saved-notebook-preview pre {
font-size: 11px;
}
.search-details-metadata {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: 8px 14px;
font-size: 11px;
line-height: 1.5;
}
.search-details-metadata dt {
color: var(--muted-foreground);
}
.search-details-metadata dd {
overflow-wrap: anywhere;
}
@media (max-width: 1399px) {
.search-result-title {
font-size: 12px;
line-height: 18px;
}
.search-result-excerpt {
font-size: 11px;
line-height: 18.15px;
}
.search-result-meta {
font-size: 9px;
line-height: 13.5px;
}
}
@media (max-width: 1050px) {
.search-result-icon,
.search-category-chip svg {
display: none;
}
.search-detail-header {
padding: 11px 18px 9px;
}
.search-detail-title {
font-size: 14px;
}
.search-detail-context {
font-size: 9px;
gap: 4px 8px;
}
.search-detail-context-part span {
max-width: 180px;
}
.search-detail-tabs {
padding: 1px 18px 10px;
}
.search-detail-tabs button {
font-size: 10px;
padding: 4px 8px;
}
.search-detail-content {
padding: 4px 18px 19px;
}
}
@media (max-width: 740px) {
.search-detail-actions {
padding: 13px 18px;
}
.global-search-dialog {
width: calc(100vw - 24px);
height: calc(100dvh - 56px);
}
.global-search-searchbar {
height: 58px;
padding: 0 14px;
gap: 8px;
}
.global-search-searchbar input {
font-size: 18px;
}
.global-search-chips {
padding: 0 13px 6px;
}
.search-subfilters {
padding-inline: 13px;
}
.search-category-chip {
padding: 4px 9px;
font-size: 11px;
gap: 5px;
}
.search-result-meta {
font-size: 10px;
line-height: 15px;
}
.global-search-list-pane {
height: 100%;
}
.global-search-detail-surface {
width: 100%;
border-left: 0;
}
.search-detail-header {
padding: 10px 18px 9px;
}
.search-detail-title {
font-size: 15px;
}
.search-detail-context {
font-size: 10px;
}
.search-detail-context-part span {
max-width: 210px;
}
.search-detail-back {
display: inline-flex;
}
.search-detail-collapse {
display: none;
}
.search-detail-tabs {
padding: 0 18px 10px;
}
.search-detail-tabs button {
font-size: 11px;
padding: 5px 9px;
}
.search-detail-content {
padding: 5px 18px 17px;
}
.global-search-body,
.global-search-body[data-expanded='true'] {
display: block;
position: relative;
}
.global-search-detail {
position: absolute;
inset: 0;
pointer-events: none;
}
.global-search-detail[data-open='true'] {
pointer-events: auto;
}
.global-search-body[data-expanded='true'] > .global-search-list-pane {
visibility: hidden;
}
}
@media (prefers-reduced-motion: reduce) {
.global-search-body,
.global-search-detail,
.search-recent-session,
.search-recent-literature,
.search-recent-file,
.search-recent-file > .search-file-locate {
transition: none;
}
}
@@ -0,0 +1,248 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { fireEvent } from '@testing-library/react'
import { vi } from 'vitest'
import { i18next } from '@/i18n'
import { createInitialProjectState, useProjectStore } from '@/stores/project-store'
import {
createInitialSessionState,
useSessionStore,
type ChatSession
} from '@/stores/session-store'
import {
createInitialPreviewWorkbenchState,
usePreviewWorkbenchStore
} from '@/stores/preview-workbench-store'
import { useNavigationStore } from '@/stores/navigation-store'
import { useSearchMessageFocusStore } from '@/stores/search-message-focus-store'
import { previewLeaveGuards } from '@/stores/preview-leave-guard'
import type { LiteratureItemView } from '../../../../shared/literature'
import { GlobalSearchDialog } from './GlobalSearchDialog'
export const artifact = {
id: 'artifact-1',
source: 'artifact' as const,
sourceFileId: 'artifact-1',
sourceVersionId: 'version-1',
projectId: 'project-a',
sessionId: 'session-a',
name: 'sin.png',
path: 'artifact-version:project-a/session-a/artifact-1/version-1',
size: 12,
sortAtMs: 123,
originSession: { state: 'active' as const }
}
export const upload = {
...artifact,
id: 'upload-1',
source: 'upload' as const,
sourceFileId: 'upload-1',
name: 'input.csv'
}
export const message = {
projectId: 'project-a',
sessionId: 'session-a',
sessionNumber: 12,
sessionTitle: 'Alpha session',
messageId: 'hit-message',
role: 'agent' as const,
content: 'Line one\nLine two\nLine three\nsin matched\nLine five\nLine six\nLine seven',
createdAt: 3
}
export const literature: LiteratureItemView = {
id: 'paper',
item: {
itemType: 'journalArticle',
title: 'Sine literature',
abstract: 'A wave abstract',
issuedText: '2024',
issuedYear: 2024,
containerTitle: 'Journal',
shortTitle: '',
language: '',
rights: '',
url: '',
extra: '',
typeFields: {},
creators: [],
identifiers: []
},
attachments: [],
projectIds: [],
collectionIds: [],
metadataRevision: 1,
createdAt: 1,
updatedAt: 2
}
export const collection = {
id: 'collection',
revision: 1,
name: 'Wave collection',
description: 'Wave references',
itemCount: 12,
createdAt: 1,
updatedAt: 2
}
export const makeSession = (index: number): ChatSession => ({
id: index ? `session-${index}` : 'session-a',
projectId: 'project-a',
title: `Alpha session ${index}`,
number: index + 12,
cwd: '/workspace',
status: 'idle',
createdAt: 1,
updatedAt: 100 - index,
messages: [],
activeMessageCount: 15,
contentLoaded: false,
artifacts: []
})
let container: HTMLDivElement
let root: Root
export const onClose = vi.fn()
export const setupSearch = (): void => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn()
})
previewLeaveGuards.clear()
onClose.mockReset()
window.localStorage.clear()
useProjectStore.setState({
...createInitialProjectState(),
isLoaded: true,
projects: [
{
id: 'project-a',
name: 'Alpha',
description: 'Wave research',
isExample: false,
createdAt: 1,
updatedAt: 2
},
{
id: 'project-b',
name: 'Beta',
description: 'Second research',
isExample: false,
createdAt: 1,
updatedAt: 1
}
]
})
useSessionStore.setState({
...createInitialSessionState(),
selectedSessionId: 'session-a',
sessions: [
makeSession(0),
{ ...makeSession(1), id: 'session-b', projectId: 'project-b', title: 'Beta session' }
]
})
useNavigationStore.setState({
view: 'workspace',
activeProjectId: 'project-a',
userNavigationRevision: 0,
explicitNavigationRevision: 0,
pendingArtifactMention: undefined,
pendingLiteratureItemId: undefined,
pendingProjectCreation: false,
artifactMentionAvailability: { projectId: 'project-a', canMention: true }
})
useSearchMessageFocusStore.setState({ pending: undefined })
usePreviewWorkbenchStore.setState(createInitialPreviewWorkbenchState())
Object.defineProperty(window, 'api', {
configurable: true,
value: {
sessions: {
searchMessages: vi
.fn()
.mockResolvedValue({ items: [message], totalCount: 1, isComplete: true })
},
projectFiles: {
getOverview: vi.fn().mockResolvedValue({
totalCount: 7,
uploadCount: 3,
artifactCount: 4,
artifactGroupCount: 4,
isIndexComplete: true
}),
searchArtifacts: vi.fn(async (request) => ({
primary: { items: request.source === 'upload' ? [upload] : [artifact], totalCount: 1 },
other: [],
isIndexComplete: true
}))
},
literature: {
search: vi.fn().mockResolvedValue({ entries: [literature, collection], totalCount: 2 })
},
managedFileVersions: {
inspect: vi.fn().mockResolvedValue({
ok: true,
value: {
sessionId: 'session-a',
displayName: 'current.png',
headVersionId: 'version-2',
versions: [
{
id: 'version-2',
checksum: 'head-checksum',
contentType: 'image/png',
sizeBytes: 24,
createdAt: '2026-01-01T00:00:00.000Z'
}
]
}
})
},
previewResources: {
acquire: vi.fn().mockResolvedValue({
id: 'preview',
url: 'data:image/png;base64,',
mimeType: 'image/png'
}),
release: vi.fn()
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
}
export const teardownSearch = (): void => {
act(() => root.unmount())
container.remove()
previewLeaveGuards.clear()
vi.restoreAllMocks()
vi.useRealTimers()
void i18next.changeLanguage('en')
}
export const renderSearch = async (open = true): Promise<void> => {
await act(async () => {
root.render(<GlobalSearchDialog open={open} onOpenChange={onClose} isSessionPersistenceReady />)
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20))
})
}
export const input = (): HTMLInputElement => document.body.querySelector('input[role="combobox"]')!
export const search = async (query: string): Promise<void> => {
act(() => fireEvent.change(input(), { target: { value: query } }))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 180))
})
}
export const rows = (category?: string): HTMLElement[] => [
...document.body.querySelectorAll<HTMLElement>(
`${category ? `[data-search-group="${category}"] ` : ''}[role="option"]`
)
]
export const clickRow = (category: string, index = 0): void => {
act(() => rows(category)[index]!.click())
}
export const button = (text: string): HTMLButtonElement =>
[...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
(element) => element.textContent?.trim() === text
)!
export const detail = (): HTMLElement =>
document.body.querySelector('[data-testid="global-search-detail"]')!
@@ -0,0 +1,28 @@
import { findSearchMatches } from '../../../../shared/search-text'
// Highlight rendered text with Ranges so Markdown, code coloring and text selection keep their DOM.
export const searchContentRanges = (root: HTMLElement, query: string): Range[] => {
const nodes: { node: Text; start: number; end: number }[] = []
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let text = ''
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.parentElement?.closest('button, script, style, [aria-hidden="true"]')) continue
const value = node.textContent ?? ''
nodes.push({ node: node as Text, start: text.length, end: text.length + value.length })
text += value
}
let firstIndex = 0
let lastIndex = 0
return findSearchMatches(text, query).flatMap(({ start, end }) => {
while (nodes[firstIndex] && nodes[firstIndex]!.end <= start) firstIndex++
lastIndex = Math.max(firstIndex, lastIndex)
while (nodes[lastIndex] && nodes[lastIndex]!.end < end) lastIndex++
const first = nodes[firstIndex]
const last = nodes[lastIndex]
if (!first || !last) return []
const range = document.createRange()
range.setStart(first.node, start - first.start)
range.setEnd(last.node, end - last.start)
return [range]
})
}
@@ -0,0 +1,92 @@
import type { Project } from '../../../../shared/projects'
import type { ProjectFileItem } from '../../../../shared/project-files'
import type { MessageSearchItem } from '../../../../shared/message-search'
import type { LiteratureCollectionView, LiteratureItemView } from '../../../../shared/literature'
import type { SearchableSession } from './global-search-catalog'
import {
createPreviewFileItem,
LITERATURE_PREVIEW_SESSION_ID
} from '@/pages/workspace/preview-file-item'
import { literatureItemToPdfOption } from '@/pages/workspace/literature-pdf-options'
export type SearchSession = SearchableSession & { activeMessageCount: number }
export type SearchResult =
| { kind: 'messages'; item: MessageSearchItem }
| { kind: 'sessions'; item: SearchSession }
| { kind: 'projects'; item: Project }
| { kind: 'uploads' | 'generated'; item: ProjectFileItem }
| { kind: 'library'; item: LiteratureItemView | LiteratureCollectionView }
export type SearchCategory = SearchResult['kind']
export const SEARCH_CATEGORIES = [
'messages',
'sessions',
'projects',
'uploads',
'generated',
'library'
] as const
export const SEARCH_GROUP_ORDER = [
'projects',
'messages',
'sessions',
'uploads',
'generated',
'library'
] as const
export const messageTitle = (item: MessageSearchItem): string =>
item.title || item.content.trim().split(/\r?\n/, 1)[0] || item.sessionTitle
export const fileFormatLabel = (item: ProjectFileItem): string =>
item.name.includes('.') ? item.name.split('.').at(-1)!.toUpperCase() : (item.mimeType ?? '')
export const resultId = (result: SearchResult): string =>
result.kind === 'messages'
? `messages:${result.item.projectId}:${result.item.sessionId}:${result.item.messageId}`
: `${result.kind}:${result.item.id}`
export const resultTitle = (result: SearchResult): string => {
switch (result.kind) {
case 'messages':
return result.item.content
case 'sessions':
return result.item.title
case 'projects':
return result.item.name
case 'uploads':
case 'generated':
return result.item.name
case 'library':
return 'item' in result.item ? result.item.item.title : result.item.name
}
}
export const filePreviewItem = (file: ProjectFileItem): ReturnType<typeof createPreviewFileItem> =>
createPreviewFileItem({
id: file.id,
projectId: file.projectId,
sessionId: file.sessionId,
path: file.path,
name: file.name,
mimeType: file.mimeType,
source: file.source === 'upload' ? 'upload' : undefined,
size: file.size,
mtimeMs: file.mtimeMs,
artifactId: file.source === 'artifact' ? file.sourceFileId : undefined,
managedFileId: file.sourceFileId,
// A text match refers to bytes in this immutable Version, even if the file head advances.
selectedVersionId: file.contentMatch ? file.sourceVersionId : undefined,
originSession: file.originSession
})
export const literaturePreviewItem = (
item: LiteratureItemView
): ReturnType<typeof createPreviewFileItem> | undefined => {
const pdf = literatureItemToPdfOption(item, { multiPageOnly: false })
return pdf
? createPreviewFileItem({
id: `literature:${pdf.source.sourceVersionId}`,
projectId: item.projectIds[0],
sessionId: LITERATURE_PREVIEW_SESSION_ID,
path: pdf.path,
name: pdf.filename,
mimeType: pdf.mimeType,
size: pdf.size,
source: 'literature'
})
: undefined
}
@@ -0,0 +1,34 @@
import { useState } from 'react'
const STORAGE_KEY = 'open-science-recent-searches'
const readRecentSearches = (): string[] => {
try {
const value: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]')
return Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === 'string' && entry.length <= 200)
.slice(0, 5)
: []
} catch {
return []
}
}
export const useRecentSearches = (): {
recentSearches: string[]
rememberSearch: (query: string) => void
} => {
const [recentSearches, setRecentSearches] = useState(readRecentSearches)
const rememberSearch = (query: string): void => {
const value = query.trim()
if (!value || value.length > 200) return
const next = [value, ...recentSearches.filter((entry) => entry !== value)].slice(0, 5)
setRecentSearches(next)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// Search remains available when browser storage is disabled.
}
}
return { recentSearches, rememberSearch }
}
@@ -0,0 +1,216 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { resultId, type SearchCategory, type SearchResult } from './search-result'
import type { LiteratureCollectionView, LiteratureItemView } from '../../../../shared/literature'
import type { SearchFileFormat, SearchSort } from '../../../../shared/search-text'
import { readLiteratureSelectionPage } from '@/pages/literature/literature-read-pages'
import { useLiteratureChanges } from '@/pages/literature/useLiteratureChanges'
export type SearchPage = {
items: SearchResult[]
totalCount: number
cursor?: string
offset?: number
loading: boolean
error: boolean
incomplete: boolean
}
export const emptySearchPage = (): SearchPage => ({
items: [],
totalCount: 0,
loading: false,
error: false,
incomplete: false
})
const remoteCategories = ['messages', 'uploads', 'generated', 'library'] as const
export type RemoteCategory = (typeof remoteCategories)[number]
const emptyPages = (loading = false): Record<RemoteCategory, SearchPage> =>
Object.fromEntries(
remoteCategories.map((key) => [key, { ...emptySearchPage(), loading }])
) as Record<RemoteCategory, SearchPage>
const isLibraryEntry = (entry: unknown): entry is LiteratureItemView | LiteratureCollectionView =>
typeof entry === 'object' &&
entry !== null &&
('metadataRevision' in entry || ('itemCount' in entry && 'name' in entry))
export const hasMoreSearchResults = (category: SearchCategory, page: SearchPage): boolean =>
isRemoteCategory(category)
? page.cursor !== undefined || page.offset !== undefined
: page.items.length < page.totalCount
export const useSearchResults = (
open: boolean,
ready: boolean,
query: string,
scopeKey: string
): {
pages: Record<RemoteCategory, SearchPage>
load: (category: RemoteCategory, append?: boolean) => Promise<void>
} => {
const identity = JSON.stringify([open, ready, query, scopeKey])
const [clientId] = useState(() => crypto.randomUUID())
const [activeIdentity, setActiveIdentity] = useState(identity)
const [pages, setPages] = useState(() => emptyPages(open && ready))
if (identity !== activeIdentity) {
setActiveIdentity(identity)
setPages(emptyPages(open && ready))
}
const pagesRef = useRef(pages)
useLayoutEffect(() => {
pagesRef.current = pages
}, [pages])
const generation = useRef(0)
const libraryGeneration = useRef(0)
const pending = useRef(new Set<RemoteCategory>())
useLayoutEffect(() => {
const version = generation
version.current++
pending.current = new Set()
return () => {
version.current++
}
}, [open, ready, query, scopeKey])
const load = useCallback(
async (category: RemoteCategory, append = false) => {
if (!open || !ready || pending.current.has(category)) return
const version = generation.current
const libraryVersion = libraryGeneration.current
const isCurrent = (): boolean =>
generation.current === version &&
(category !== 'library' || libraryGeneration.current === libraryVersion)
const previous = append ? pagesRef.current[category] : emptySearchPage()
if (append && !hasMoreSearchResults(category, previous)) return
pending.current.add(category)
setPages((state) => ({ ...state, [category]: { ...previous, loading: true, error: false } }))
try {
const {
projectIds,
excludedSessionIds,
projectId,
updatedAfter,
sort,
role,
format,
entryKind
} = JSON.parse(scopeKey) as {
projectIds: string[]
excludedSessionIds: string[]
projectId?: string
updatedAfter?: number
sort?: SearchSort
role?: 'user' | 'agent'
format?: SearchFileFormat
entryKind?: 'paper' | 'collection' | 'pdf'
}
let page: SearchPage = emptySearchPage()
if (category === 'library') {
const result = await readLiteratureSelectionPage(
{
scope: 'global-search',
query,
projectId,
updatedAfter,
searchSort: sort,
entryKind,
limit: 10,
offset: append ? previous.offset : undefined
},
isCurrent
)
page = {
...page,
items: result.entries.filter(isLibraryEntry).map((item) => ({ kind: 'library', item })),
totalCount: result.totalCount ?? result.entries.length,
offset: result.nextOffset
}
} else if (projectIds.length > 0 && category === 'messages') {
const result = await window.api.sessions.searchMessages({
clientId,
projectIds,
excludedSessionIds,
updatedAfter,
sort,
role,
query,
limit: 10,
cursor: append ? previous.cursor : undefined
})
page = {
...page,
items: result.items.map((item) => ({ kind: 'messages', item })),
totalCount: result.totalCount,
cursor: result.nextCursor,
incomplete: !result.isComplete
}
} else if (projectIds.length > 0 && (category === 'uploads' || category === 'generated')) {
const result = await window.api.projectFiles.searchArtifacts({
primaryProjectIds: projectIds,
otherProjectIds: [],
excludedSessionIds,
filenameContains: query,
searchContent: category === 'uploads',
updatedAfter,
sort,
format,
source: category === 'uploads' ? 'upload' : 'artifact',
primaryLimit: 10,
primaryCursor: append ? previous.cursor : undefined,
otherLimit: 0
})
page = {
...page,
items: result.primary.items.map((item) => ({ kind: category, item })),
totalCount: result.primary.totalCount,
cursor: result.primary.nextCursor,
incomplete: !result.isIndexComplete
}
}
if (!isCurrent()) return
const previousIds = new Set(previous.items.map(resultId))
setPages((state) => ({
...state,
[category]: {
...page,
items: append
? [
...previous.items,
...page.items.filter((item) => !previousIds.has(resultId(item)))
]
: page.items
}
}))
} catch {
if (isCurrent())
setPages((state) => ({
...state,
[category]: { ...previous, loading: false, error: true }
}))
} finally {
if (isCurrent()) pending.current.delete(category)
}
},
[open, ready, query, scopeKey, clientId]
)
useLiteratureChanges(() => {
if (!open || !ready) return
// Supersede an in-flight Library page after a mutation without resetting other categories.
libraryGeneration.current++
pending.current.delete('library')
void load('library')
})
useEffect(() => {
if (!open || !ready) return
const timer = window.setTimeout(
() => {
for (const category of remoteCategories) void load(category)
},
query ? 150 : 0
)
return () => window.clearTimeout(timer)
}, [open, ready, query, scopeKey, load])
return { pages, load }
}
export const isRemoteCategory = (category: SearchCategory): category is RemoteCategory =>
remoteCategories.some((value) => value === category)
@@ -0,0 +1,77 @@
import { useEffect, useState } from 'react'
import { resultId, type SearchResult } from './search-result'
// Only visible project/session rows need totals; keep their metadata requests bounded.
const loadCounts = async (
targets: { key: string; projectId: string; sessionId?: string }[],
active: () => boolean,
onCount: (key: string, count: number | null) => void
): Promise<void> => {
let next = 0
await Promise.all(
Array.from({ length: Math.min(4, targets.length) }, async () => {
while (active() && next < targets.length) {
const target = targets[next++]!
try {
const page = target.sessionId
? await window.api.projectFiles.searchArtifacts({
primaryProjectIds: [target.projectId],
otherProjectIds: [],
source: 'all',
sessionId: target.sessionId,
primaryLimit: 1,
otherLimit: 0
})
: await window.api.projectFiles.getOverview({ projectId: target.projectId })
if (active())
onCount(
target.key,
page.isIndexComplete
? 'primary' in page
? page.primary.totalCount
: page.totalCount
: null
)
} catch {
if (active()) onCount(target.key, null)
}
}
})
)
}
export const useSearchSummaryCounts = (
open: boolean,
results: SearchResult[]
): Record<string, number | null> => {
const [counts, setCounts] = useState<Record<string, number | null>>({})
const key = JSON.stringify(
open
? results.flatMap((result) =>
result.kind === 'projects'
? [{ key: resultId(result), projectId: result.item.id }]
: result.kind === 'sessions'
? [
{
key: resultId(result),
projectId: result.item.projectId,
sessionId: result.item.id
}
]
: []
)
: []
)
useEffect(() => {
let active = true
void loadCounts(
JSON.parse(key),
() => active,
(id, count) => setCounts((state) => (state[id] === count ? state : { ...state, [id]: count }))
)
return () => {
active = false
}
}, [key])
return counts
}
+3 -2
View File
@@ -2076,8 +2076,9 @@ describe('mandatory product glossary', () => {
'Copy command': 'Copier la commande',
'Data location': 'Emplacement des données',
'Edit specialist': 'Modifier le spécialiste',
'Recent artifacts': 'Artefacts récents',
'Search sessions and artifacts…': 'Rechercher des sessions et des artefacts…',
'Recent files': 'Fichiers récents',
'Search messages, projects, files and Library…':
'Rechercher des messages, projets, fichiers et dans Library…',
'Search skills': 'Rechercher des compétences',
'Search skills to add': 'Rechercher des compétences à ajouter',
'Search skills…': 'Rechercher des compétences…',
@@ -15,7 +15,12 @@ export async function readLiteratureDisplayPage(
): Promise<LiteratureCatalogSearchPage> {
const read = recoverOversized ? readSelectionTransportPage : window.api.literature.search
const first = await read(request)
if (request.scope !== 'library' || request.allItemIds || request.countOnly) return first
if (
(request.scope !== 'library' && request.scope !== 'global-search') ||
request.allItemIds ||
request.countOnly
)
return first
const entries = [...first.entries]
const limit = request.limit ?? 50
let nextOffset = first.nextOffset
@@ -50,7 +55,12 @@ async function readSelectionTransportPage(
return await window.api.literature.search(request)
} catch (error) {
const itemId = oversizedLiteratureReference(error)
if (!itemId || request.scope !== 'library' || request.allItemIds || request.countOnly)
if (
!itemId ||
(request.scope !== 'library' && request.scope !== 'global-search') ||
request.allItemIds ||
request.countOnly
)
throw error
const item = literatureItemViewSchema.parse(JSON.parse(await readLiteratureRecord(itemId)))
if (item.id !== itemId) throw new Error('Reference changed while reading search results.')
@@ -19,6 +19,7 @@ type FilePreviewDialogProps = PreviewInteractionPort & {
allowReadingContext?: boolean
onReadWithAgent?: (item: PreviewFileItem) => void
onPdfContextError?: (message: string | null) => void
onViewInContextNavigate?: () => void
onFocusFallback?: () => void
}
@@ -60,6 +61,7 @@ const FilePreviewDialog = ({
allowReadingContext = true,
onReadWithAgent,
onPdfContextError,
onViewInContextNavigate,
onFocusFallback,
...annotationPort
}: FilePreviewDialogProps): React.JSX.Element | null => {
@@ -93,6 +95,15 @@ const FilePreviewDialog = ({
isBackgroundIsolatedRef.current = false
}, [])
// Radix can unmount the portal without animationend when an exit animation is absent or
// interrupted. Release the background lock at that boundary as well as after normal animation.
const setContentRef = useCallback(
(content: HTMLDivElement | null): void => {
if (!content) releaseBackgroundIsolation()
},
[releaseBackgroundIsolation]
)
useEffect(() => {
const observer = new MutationObserver(() => setHasNestedFullscreen(hasStreamdownFullscreen()))
observer.observe(document.body, { childList: true })
@@ -123,6 +134,7 @@ const FilePreviewDialog = ({
className={`${dialogOverlayClassName} z-[60]`}
/>
<Dialog.Content
ref={setContentRef}
data-slot="file-preview-dialog"
aria-describedby={undefined}
aria-modal="true"
@@ -172,7 +184,7 @@ const FilePreviewDialog = ({
provenanceEntry="trailing"
// The modal overlays the conversation panel, so a View in context navigation must
// also close the dialog for the switched session to become visible.
onViewInContextNavigate={onClose}
onViewInContextNavigate={onViewInContextNavigate ?? onClose}
allowReadingContext={allowReadingContext}
onReadWithAgent={onReadWithAgent}
onPdfContextError={onPdfContextError}
@@ -12,6 +12,7 @@ import {
} from '@/stores/preview-workbench-store'
import { previewLeaveGuards } from '@/stores/preview-leave-guard'
import { useNavigationStore } from '@/stores/navigation-store'
import { useSearchMessageFocusStore } from '@/stores/search-message-focus-store'
import { useProjectStore } from '@/stores/project-store'
import { i18next } from '@/i18n'
import { createNotebookInputPreviewKey } from '../../../../shared/notebook'
@@ -4536,6 +4537,36 @@ const seedWorkspaceStores = (): void => {
}
describe('PreviewFileSurface View in context entry', () => {
it('locates the source message of the selected artifact version after navigation', async () => {
seedWorkspaceStores()
useSearchMessageFocusStore.setState({ pending: undefined })
vi.mocked(window.api.artifacts.getLineage).mockResolvedValue({
artifactId: 'artifact-1',
filename: 'sin.png',
originSession: { sessionId: 'session-1', state: 'active' },
versions: [
{ ...descriptor, messageId: 'version-one-message' },
{ ...secondDescriptor, messageId: 'version-two-message' }
]
})
await act(async () => {
root.render(
<PreviewFileSurface
item={{ ...item, selectedVersionId: descriptor.versionId }}
provenanceEntry="trailing"
onClose={vi.fn()}
/>
)
})
await click(container.querySelector('[aria-label="View in context for sin.png"]'))
expect(useSearchMessageFocusStore.getState().pending).toMatchObject({
projectId: 'project-1',
sessionId: 'session-1',
messageId: 'version-one-message',
navigationRevision: useNavigationStore.getState().userNavigationRevision
})
useSearchMessageFocusStore.setState({ pending: undefined })
})
it('opens managed Artifact capabilities from the preview content context menu', async () => {
seedWorkspaceStores()
const onOpenFullScreen = vi.fn()
@@ -39,6 +39,7 @@ import { errorDetail } from '@/lib/error-detail'
import type { PreviewFileItem } from '@/stores/preview-workbench-store'
import { usePreviewWorkbenchStore } from '@/stores/preview-workbench-store'
import { useNavigationStore } from '@/stores/navigation-store'
import { useSearchMessageFocusStore } from '@/stores/search-message-focus-store'
import { useSessionStore } from '@/stores/session-store'
import { previewLeaveGuards } from '@/stores/preview-leave-guard'
import type { ArtifactLineageProvenance } from '../../../../shared/artifact-provenance'
@@ -1215,9 +1216,24 @@ const PreviewFileSurface = forwardRef<PreviewFileSurfaceHandle, PreviewFileSurfa
!originSessionUnavailable
const viewInContext = (): void => {
if (!projectId) return
const sourceMessageId = lineage
? resolveArtifactVersionDescriptor(lineage, annotationVersionId)?.messageId
: undefined
// Resolve the visible version's origin only after guarded navigation succeeds.
const afterNavigate = sourceMessageId
? (): void => {
useSearchMessageFocusStore.getState().request({
projectId,
sessionId: previewItem.sessionId,
messageId: sourceMessageId,
navigationRevision: useNavigationStore.getState().userNavigationRevision
})
onViewInContextNavigate?.()
}
: onViewInContextNavigate
useNavigationStore
.getState()
.openSession(projectId, previewItem.sessionId, 'user', onViewInContextNavigate)
.openSession(projectId, previewItem.sessionId, 'user', afterNavigate)
}
const openProvenance =
previewItem.source !== 'upload' && previewItem.artifactId && projectId
@@ -21,6 +21,12 @@ import {
useReviewStore
} from '@/stores/review-store'
import { useSettingsStore } from '@/stores/settings-store'
import { useNavigationStore } from '@/stores/navigation-store'
import {
useSearchMessageFocusStore,
type SearchMessageFocus
} from '@/stores/search-message-focus-store'
import { findMessageTarget } from './workspace-run-marks'
import { useSessionStore, type ChatMessage, type ChatSession } from '@/stores/session-store'
import {
Fragment,
@@ -233,6 +239,32 @@ const VisibleMessageSnapshotCommit = ({
return null
}
const SearchMessageReveal = ({
target,
viewport
}: {
target?: SearchMessageFocus
viewport: HTMLDivElement | null
}): null => {
const { scrollToMessage } = useMessageScroller()
useEffect(() => {
if (!target || !viewport || !findMessageTarget(viewport, target.messageId)) return
const frame = requestAnimationFrame(() => {
if (!scrollToMessage(target.messageId, { align: 'center', behavior: 'instant' })) return
const element = findMessageTarget(viewport, target.messageId)
if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
element?.animate?.(
[{ backgroundColor: 'var(--bg-200)' }, { backgroundColor: 'transparent' }],
{ duration: 1800 }
)
}
useSearchMessageFocusStore.getState().consume(target)
})
return () => cancelAnimationFrame(frame)
}, [target, viewport, scrollToMessage])
return null
}
type MessageUploadAttachment = NonNullable<ChatSession['messages'][number]['uploads']>[number]
type ConversationMessageNode = NonNullable<ChatSession['conversationGraph']>['messages'][number]
const SCROLL_TO_FIRST_MESSAGE_MIN_USER_TURNS = 2
@@ -726,6 +758,30 @@ const WorkspaceMessageScrollerImpl = ({
messageScrollerViewportRef
)
const revealTranscriptItem = transcriptWindow.revealMessage
const searchFocus = useSearchMessageFocusStore((state) => state.pending)
const navigationRevision = useNavigationStore((state) => state.userNavigationRevision)
useEffect(() => {
if (!searchFocus) return
if (searchFocus.navigationRevision !== navigationRevision) {
useSearchMessageFocusStore.getState().consume(searchFocus)
return
}
if (searchFocus.projectId !== currentProjectId || searchFocus.sessionId !== currentSessionId)
return
const index = conversationItems.findIndex(
(item) => item.type === 'message' && item.message.id === searchFocus.messageId
)
if (index < 0 || (presentationBarrierIndex >= 0 && index > presentationBarrierIndex)) return
revealTranscriptItem(searchFocus.messageId)
}, [
searchFocus,
navigationRevision,
currentProjectId,
currentSessionId,
conversationItems,
presentationBarrierIndex,
revealTranscriptItem
])
useEffect(
() =>
subscribeAnnotationRevealPreparation((annotation) => {
@@ -1365,6 +1421,19 @@ const WorkspaceMessageScrollerImpl = ({
scrollPreviousItemPeek={64}
>
<MessageScroller className="relative min-h-0 flex-1 bg-bg-10">
<SearchMessageReveal
target={
searchFocus?.navigationRevision === navigationRevision &&
searchFocus.projectId === currentProjectId &&
searchFocus.sessionId === currentSessionId &&
transcriptWindow.entries.some(
({ item }) => item.type === 'message' && item.message.id === searchFocus.messageId
)
? searchFocus
: undefined
}
viewport={messageScrollerViewport}
/>
<WorkspaceRunMarks
items={presentedConversationItems}
viewport={messageScrollerViewport}
@@ -23,6 +23,7 @@ const PREVIEW_SUPPORTED_EXTENSIONS: Record<string, PreviewFileFormat> = {
htm: 'html',
html: 'html',
json: 'json',
ipynb: 'json',
markdown: 'markdown',
md: 'markdown',
pdb: 'pdb',
@@ -6,6 +6,7 @@ import type { PreviewFileRendererProps } from './preview-types'
export const PreviewFileContent = ({
item,
presentation,
downloadVersionContext,
onRetry,
annotationVersionId,
@@ -25,6 +26,7 @@ export const PreviewFileContent = ({
}): React.JSX.Element => {
const content = renderPreviewFile({
item,
presentation,
annotationVersionId,
annotationBlockedByHistoricalVersion,
annotationVersionPending,
@@ -0,0 +1,10 @@
import { createContext } from 'react'
// Search opens a matched text page; ordinary previews keep the default beginning-of-file position.
export const PreviewInitialPosition = createContext<
| {
offset: number
startingLineNumber: number
}
| undefined
>(undefined)
@@ -0,0 +1,16 @@
/* global WorkerGlobalScope: readonly */
// PDF.js 5.4 can reject setupDoc after Terminate; only this expected worker cancellation is handled.
if (typeof WorkerGlobalScope !== 'undefined' && globalThis instanceof WorkerGlobalScope) {
let terminating = false
globalThis.addEventListener('message', (event) => {
if (event.data?.action === 'Terminate') terminating = true
})
globalThis.addEventListener('unhandledrejection', (event) => {
if (
terminating &&
event.reason instanceof Error &&
event.reason.message === 'Worker was terminated'
)
event.preventDefault()
})
}
@@ -0,0 +1,34 @@
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import { describe, expect, it, vi } from 'vitest'
const source = readFileSync(new URL('./pdf-worker-cancellation.js', import.meta.url), 'utf8')
describe('PDF worker cancellation', () => {
it('handles only the expected rejection after its worker receives Terminate', () => {
const listeners = new Map<string, (event: unknown) => void>()
class WorkerScope {
addEventListener = (name: string, handler: (event: unknown) => void): void => {
listeners.set(name, handler)
}
}
runInNewContext(source, {
globalThis: new WorkerScope(),
WorkerGlobalScope: WorkerScope,
Error
})
const cancellation = { reason: new Error('Worker was terminated'), preventDefault: vi.fn() }
listeners.get('unhandledrejection')!(cancellation)
expect(cancellation.preventDefault).not.toHaveBeenCalled()
listeners.get('message')!({ data: { action: 'Terminate' } })
listeners.get('unhandledrejection')!(cancellation)
expect(cancellation.preventDefault).toHaveBeenCalledOnce()
const failure = { reason: new Error('Invalid PDF structure'), preventDefault: vi.fn() }
listeners.get('unhandledrejection')!(failure)
expect(failure.preventDefault).not.toHaveBeenCalled()
})
it('never installs cancellation handling on the renderer during a fake-worker fallback', () => {
const addEventListener = vi.fn()
runInNewContext(source, { globalThis: { addEventListener }, Error })
expect(addEventListener).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,6 @@
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
import pdfWorkerUrl from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs?url'
import cancellationSource from './pdf-worker-cancellation.js?raw'
// pdf_viewer.mjs expects the core library on this global. Its import is deferred by PdfPreview
// until after this module has initialized, which also keeps test collection from evaluating the
@@ -8,6 +9,17 @@ import pdfWorkerUrl from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs?url'
// Vite rewrites this to the bundled worker URL, so pdfjs runs off the main thread in dev and prod.
// Configured once here and shared by the full preview and the thumbnail renderer.
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
pdfjsLib.GlobalWorkerOptions.workerSrc =
typeof window !== 'undefined' && typeof Worker !== 'undefined'
? URL.createObjectURL(
new Blob(
[
`export { WorkerMessageHandler } from ${JSON.stringify(new URL(pdfWorkerUrl, window.location.href).href)};\n`,
cancellationSource
],
{ type: 'text/javascript' }
)
)
: pdfWorkerUrl
export { pdfjsLib }
@@ -12,10 +12,13 @@ import { PlanJsonPreview } from './renderers/PlanJsonPreview'
import { PdfPreviewRenderer } from './renderers/PdfPreview'
import { TextPreviewRenderer } from './renderers/TextPreview'
import { TiffPreviewRenderer } from './renderers/TiffPreview'
import { NotebookFilePreview } from './renderers/NotebookFilePreview'
import { getFileExtension } from '../preview-support'
// Keeps the registry as the single routing point while avoiding dynamic component creation in render.
export const renderPreviewFile = ({
item,
presentation,
annotationVersionId,
annotationBlockedByHistoricalVersion,
annotationVersionPending,
@@ -28,6 +31,7 @@ export const renderPreviewFile = ({
}: PreviewFileRendererProps): React.JSX.Element | undefined => {
const props = {
item,
presentation,
annotationVersionId,
annotationBlockedByHistoricalVersion,
annotationVersionPending,
@@ -37,11 +41,16 @@ export const renderPreviewFile = ({
onRemoveAnnotation,
onAnnotationError
}
if (
getFileExtension(item.name) === 'ipynb' ||
item.mimeType?.split(';')[0] === 'application/x-ipynb+json'
)
return <NotebookFilePreview {...props} />
switch (item.format) {
case 'code':
return <CodePreviewRenderer {...props} />
case 'csv':
return <CsvPreviewRenderer item={item} />
return <CsvPreviewRenderer {...props} />
case 'fasta':
return <FastaPreviewRenderer {...props} />
case 'html':
@@ -55,7 +64,7 @@ export const renderPreviewFile = ({
case 'pdb':
return <PdbPreviewRenderer item={item} />
case 'molecule':
return <MoleculePreviewRenderer item={item} />
return <MoleculePreviewRenderer {...props} />
case 'text':
return <TextPreviewRenderer {...props} />
case 'tiff':
@@ -7,6 +7,7 @@ import type {
export type PreviewFileRendererProps = {
item: PreviewFileItem
presentation?: 'reader' | 'search'
annotationVersionId?: string
annotationBlockedByHistoricalVersion?: boolean
annotationVersionPending?: boolean
@@ -56,7 +56,10 @@ const parseCsvRows = (
}
}
export const CsvPreviewRenderer = ({ item }: PreviewFileRendererProps): React.JSX.Element => {
export const CsvPreviewRenderer = ({
item,
presentation
}: PreviewFileRendererProps): React.JSX.Element => {
const { t } = useTranslation()
const state = usePreviewFileContent({ ...item, encoding: 'base64' })
@@ -112,7 +115,13 @@ export const CsvPreviewRenderer = ({ item }: PreviewFileRendererProps): React.JS
const totalKnown = !state.preview.truncated && !rowTruncated
return (
<div className="flex size-full flex-col overflow-hidden bg-bg-10">
<div
className={
presentation === 'search'
? 'search-csv-preview'
: 'flex size-full flex-col overflow-hidden bg-bg-10'
}
>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border-300 bg-bg-000 px-3 py-2 text-[12px] text-text-300">
{totalKnown ? (
<span>
@@ -35,7 +35,11 @@ export const MarkdownPreviewRenderer = (props: PreviewFileRendererProps): React.
return (
<PreviewTextAnnotationSurface {...props}>
<div className="size-full overflow-auto bg-bg-10 p-4">
<div
className={
props.presentation === 'search' ? 'w-full' : 'size-full overflow-auto bg-bg-10 p-4'
}
>
<AgentMarkdown content={state.preview.content} />
</div>
</PreviewTextAnnotationSurface>
@@ -16,11 +16,13 @@ type OclModule = typeof import('openchemlib')
const MoleculePreviewCanvas = ({
content,
format,
name
name,
presentation
}: {
content: string
format: ReturnType<typeof getMoleculeFormat>
name: string
presentation?: PreviewFileRendererProps['presentation']
}): React.JSX.Element => {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement | null>(null)
@@ -103,12 +105,14 @@ const MoleculePreviewCanvas = ({
return (
<div className="flex size-full flex-col overflow-hidden bg-bg-10">
<div className="flex shrink-0 items-center gap-2 border-b border-border-300 bg-bg-000 px-3 py-2 text-[12px] text-text-300">
<FlaskConical className="size-3.5 shrink-0 text-text-300" aria-hidden="true" />
<span className="truncate" title={name}>
{t('Using OpenChemLib viewer')}
</span>
</div>
{presentation !== 'search' && (
<div className="flex shrink-0 items-center gap-2 border-b border-border-300 bg-bg-000 px-3 py-2 text-[12px] text-text-300">
<FlaskConical className="size-3.5 shrink-0 text-text-300" aria-hidden="true" />
<span className="truncate" title={name}>
{t('Using OpenChemLib viewer')}
</span>
</div>
)}
<div className="relative min-h-0 flex-1 overflow-hidden bg-bg-000">
{error ? (
<div className="absolute inset-0 flex items-center justify-center px-6 text-center text-[12px] text-danger-000">
@@ -128,7 +132,10 @@ const MoleculePreviewCanvas = ({
)
}
export const MoleculePreviewRenderer = ({ item }: PreviewFileRendererProps): React.JSX.Element => {
export const MoleculePreviewRenderer = ({
item,
presentation
}: PreviewFileRendererProps): React.JSX.Element => {
const { t } = useTranslation()
const state = usePreviewFileContent(item)
const [showSource, setShowSource] = useState(false)
@@ -153,24 +160,31 @@ export const MoleculePreviewRenderer = ({ item }: PreviewFileRendererProps): Rea
const format = getMoleculeFormat(getFileExtension(item.name), item.mimeType)
return (
<div className="flex size-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border-300 px-3 py-2 text-[12px] text-text-300">
<span>
{!showSource && format !== 'rxn' ? t('Only the first record is previewed.') : null}
</span>
<button
type="button"
className="rounded px-2 py-1 hover:bg-bg-200"
aria-pressed={showSource}
onClick={() => setShowSource(!showSource)}
>
{showSource ? t('Preview') : t('Source')}
</button>
</div>
{presentation !== 'search' && (
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border-300 px-3 py-2 text-[12px] text-text-300">
<span>
{!showSource && format !== 'rxn' ? t('Only the first record is previewed.') : null}
</span>
<button
type="button"
className="rounded px-2 py-1 hover:bg-bg-200"
aria-pressed={showSource}
onClick={() => setShowSource(!showSource)}
>
{showSource ? t('Preview') : t('Source')}
</button>
</div>
)}
<div className="min-h-0 flex-1">
{showSource ? (
<SourcePreviewContent content={state.preview.content} pagination={state.pagination} />
) : (
<MoleculePreviewCanvas content={state.preview.content} format={format} name={item.name} />
<MoleculePreviewCanvas
content={state.preview.content}
format={format}
name={item.name}
presentation={presentation}
/>
)}
</div>
</div>
@@ -0,0 +1,85 @@
import { useTranslation } from 'react-i18next'
import { FileWarning } from 'lucide-react'
import { AgentMarkdown } from '@/components/streamdown/AgentMarkdown'
import { NotebookCodeBlock } from '../../notebook-code'
import { PreviewErrorCard, PreviewFallbackCard, PreviewLoadingContent } from '../PreviewFallback'
import { usePreviewFileContent } from '../usePreviewFileContent'
import type { PreviewFileRendererProps } from '../preview-types'
import { notebookOutputText, parseNotebookDocument } from './notebook-document'
export const NotebookFilePreview = ({ item }: PreviewFileRendererProps): React.JSX.Element => {
const { t } = useTranslation()
const state = usePreviewFileContent({ ...item, maxBytes: 10 * 1024 * 1024 })
if (state.status === 'loading') return <PreviewLoadingContent />
if (state.status === 'ready' && state.preview.truncated)
return (
<PreviewFallbackCard
icon={FileWarning}
name={item.name}
message={t('This Notebook exceeds the 10 MB preview limit.')}
/>
)
let notebook: ReturnType<typeof parseNotebookDocument> | undefined
if (state.status === 'ready' && state.preview.encoding === 'utf8' && !state.preview.truncated) {
try {
notebook = parseNotebookDocument(state.preview.content)
} catch {
/* The standard preview error preserves access to the original file. */
}
}
if (!notebook)
return (
<PreviewErrorCard
name={item.name}
error={state.status === 'error' ? state.error : undefined}
fallbackMessage={t("Notebook couldn't be read for preview")}
/>
)
return (
<div className="saved-notebook-preview space-y-4" data-testid="saved-notebook-preview">
{notebook.cells.slice(0, 100).map((cell, index) => (
<section key={index} className="min-w-0">
{cell.cell_type === 'markdown' ? (
<AgentMarkdown content={cell.source} />
) : (
<>
<div className="mb-1 text-[10px] text-muted-foreground">
{t('Cell {{number}}', { number: cell.execution_count ?? index + 1 })}
</div>
<NotebookCodeBlock
code={cell.source}
language={notebook.metadata?.language_info?.name}
/>
</>
)}
{cell.outputs?.map((output, outputIndex) => {
const image = notebookOutputText(output.data?.['image/png'])
const text =
output.text ??
(notebookOutputText(output.data?.['text/plain']) ||
output.traceback?.join('\n') ||
[output.ename, output.evalue].filter(Boolean).join(': '))
return (
<div key={outputIndex} className="mt-2">
{image && /^[\sA-Za-z0-9+/=]+$/.test(image) ? (
<img
src={`data:image/png;base64,${image.replace(/\s/g, '')}`}
alt={t('Output')}
className="h-auto max-w-full"
/>
) : text ? (
<pre className="whitespace-pre-wrap break-words text-xs">{text}</pre>
) : null}
</div>
)
})}
</section>
))}
{notebook.cells.length > 100 && (
<p className="text-xs text-muted-foreground">
{t('Showing {{shown}} of {{total}} cells', { shown: 100, total: notebook.cells.length })}
</p>
)}
</div>
)
}
@@ -2842,6 +2842,32 @@ describe('PdfPreviewContent', () => {
consoleError.mockRestore()
})
it('releases a canceled PDF load without reporting worker destruction as a failure', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
let rejectLoadingTask!: (error: Error) => void
const promise = new Promise((_, reject) => {
rejectLoadingTask = reject
})
const destroyLoadingTask = vi.fn(async () => {
rejectLoadingTask(new Error('Worker was destroyed'))
})
vi.mocked(createManagedPdfLoadingTask).mockReturnValue({
promise,
destroy: destroyLoadingTask
} as never)
await act(async () => {
root.render(
<PdfPreviewContent path="/workspace/report.pdf" name="report.pdf" source="local" />
)
})
await act(async () => root.render(null))
expect(destroyLoadingTask).toHaveBeenCalledTimes(1)
expect(window.api.previewResources.release).toHaveBeenCalledExactlyOnceWith({
resourceId: 'resource-1'
})
expect(consoleError).not.toHaveBeenCalled()
})
it('destroys the loading task when PDF parsing fails', async () => {
const destroyLoadingTask = vi.fn().mockResolvedValue(undefined)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
@@ -1150,7 +1150,8 @@ export const PdfPreviewContent = ({
onReadingPositionChange,
annotationProps,
pdfEvidenceSource,
pdfRevealSource
pdfRevealSource,
presentation = 'reader'
}: {
path: string
name: string
@@ -1166,6 +1167,7 @@ export const PdfPreviewContent = ({
annotationProps?: PreviewFileRendererProps
pdfEvidenceSource?: PdfAnnotation['source']
pdfRevealSource?: PdfAnnotation['source']
presentation?: PreviewFileRendererProps['presentation']
}): React.JSX.Element => {
const { t } = useTranslation()
const requestKey = createPreviewResourceKey({
@@ -1418,8 +1420,11 @@ export const PdfPreviewContent = ({
setDocumentState({ requestKey: resourceRequestKey, status: 'ready', document })
} catch (error: unknown) {
if (!isUnavailableFileError(error)) console.error('Failed to load PDF preview', error)
if (!canceled) setDocumentState({ requestKey: resourceRequestKey, status: 'error', error })
// Closing or switching a preview can reject the PDF.js task while cleanup destroys it.
if (!canceled) {
if (!isUnavailableFileError(error)) console.error('Failed to load PDF preview', error)
setDocumentState({ requestKey: resourceRequestKey, status: 'error', error })
}
await dispose()
}
})()
@@ -1736,9 +1741,14 @@ export const PdfPreviewContent = ({
return (
<div
className="flex size-full overflow-hidden bg-bg-20"
className={
presentation === 'search'
? 'flex min-h-64 w-full'
: 'flex size-full overflow-hidden bg-bg-20'
}
data-pdf-preview-root
onKeyDownCapture={(event) => {
if (presentation === 'search') return
const primaryModifier = event.metaKey || event.ctrlKey
if (primaryModifier && event.key.toLowerCase() === 'f') {
event.preventDefault()
@@ -1788,14 +1798,17 @@ export const PdfPreviewContent = ({
onNavigate={navigateToPage}
/>
) : null}
<div className="relative min-w-0 flex-1 overflow-hidden">
<div
className={cn('relative min-w-0 flex-1', presentation !== 'search' && 'overflow-hidden')}
>
{/* The inner element is the real scroller (the outer div holds fixed controls), so it must
be keyboard-focusable or PageUp/Down, Space, and arrows never reach the PDF. */}
<div
ref={scrollRef}
data-pdf-cursor-mode={cursorMode}
className={cn(
'size-full overflow-auto p-4 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50',
'outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50',
presentation === 'search' ? 'w-full' : 'size-full overflow-auto p-4',
cursorMode === 'hand' &&
`touch-none select-none [&_*]:cursor-inherit [&_*]:select-none ${panning ? 'cursor-grabbing' : 'cursor-grab'}`
)}
@@ -1852,7 +1865,8 @@ export const PdfPreviewContent = ({
// so a page still fitting a wide/full-screen pane stays centered.
<div
className={cn(
'flex min-w-full flex-col gap-3',
'flex min-w-full flex-col',
presentation === 'search' ? 'gap-[17px]' : 'gap-3',
viewportWidth > 0 && pageWidth > viewportWidth ? 'items-start' : 'items-center'
)}
>
@@ -1883,7 +1897,7 @@ export const PdfPreviewContent = ({
</div>
) : null}
</div>
{document ? (
{document && presentation !== 'search' ? (
<>
<PdfInteractionControls
mode={cursorMode}
@@ -1976,6 +1990,7 @@ export const PdfPreviewRenderer = (props: PreviewFileRendererProps): React.JSX.E
return (
<PdfPreviewContent
presentation={props.presentation}
path={props.item.path}
name={props.item.name}
source={props.item.source ?? 'artifact'}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { notebookOutputText, parseNotebookDocument } from './notebook-document'
describe('saved Notebook preview', () => {
it('reads multiline cells and saved outputs without executing code or interpreting output HTML', () => {
const result = parseNotebookDocument(
JSON.stringify({
nbformat: 4,
cells: [
{ cell_type: 'markdown', source: ['# Notes\n', 'Saved analysis'] },
{
cell_type: 'code',
source: ['print(1)'],
execution_count: 3,
outputs: [
{
output_type: 'display_data',
data: { 'text/html': '<script>execute()</script>', 'text/plain': ['1\n', '2'] }
}
]
}
]
})
)
expect(result.cells[0].source).toBe('# Notes\nSaved analysis')
expect(notebookOutputText(result.cells[1].outputs![0].data!['text/plain'])).toBe('1\n2')
expect(notebookOutputText({ malicious: true })).toBe('')
})
it('rejects malformed or truncated documents instead of presenting missing cells as empty', () => {
expect(() => parseNotebookDocument('{"nbformat":4')).toThrow()
expect(() =>
parseNotebookDocument('{"nbformat":4,"cells":[{"cell_type":"code","source":42}]}')
).toThrow()
})
})
@@ -0,0 +1,36 @@
import { z } from 'zod'
const multiline = z
.union([z.string(), z.array(z.string())])
.transform((value) => (Array.isArray(value) ? value.join('') : value))
const output = z.object({
output_type: z.string(),
text: multiline.optional(),
data: z.record(z.string(), z.unknown()).optional(),
traceback: z.array(z.string()).optional(),
ename: z.string().optional(),
evalue: z.string().optional()
})
const notebookDocumentSchema = z.object({
nbformat: z.literal(4),
metadata: z
.object({ language_info: z.object({ name: z.string().optional() }).optional() })
.optional(),
cells: z.array(
z.object({
cell_type: z.enum(['code', 'markdown', 'raw']),
source: multiline,
execution_count: z.number().nullable().optional(),
outputs: z.array(output).optional()
})
)
})
export const parseNotebookDocument = (content: string): z.infer<typeof notebookDocumentSchema> =>
notebookDocumentSchema.parse(JSON.parse(content))
export const notebookOutputText = (value: unknown): string =>
typeof value === 'string'
? value
: Array.isArray(value) && value.every((part) => typeof part === 'string')
? value.join('')
: ''
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePreviewFileContent } from './usePreviewFileContent'
import { PreviewInitialPosition } from './PreviewInitialPosition'
const Probe = (): React.JSX.Element => {
const state = usePreviewFileContent({
@@ -74,6 +75,26 @@ describe('usePreviewFileContent', () => {
vi.unstubAllGlobals()
})
it('opens a search match at its byte position and leaves ordinary previews at the beginning', async () => {
const bytes = new TextEncoder().encode('group,count\nA,2')
vi.mocked(fetch).mockImplementation(async (_url, options) => {
const range = new Headers(options?.headers).get('Range')!
const start = Number(range.match(/bytes=(\d+)/)![1])
return new Response(bytes.slice(start), { status: 206 })
})
root = createRoot(container)
await act(async () =>
root.render(
<PreviewInitialPosition value={{ offset: 6, startingLineNumber: 1 }}>
<Probe />
</PreviewInitialPosition>
)
)
expect(container.textContent).toBe('count\nA,2')
await act(async () => root.render(<Probe />))
expect(container.textContent).toBe('group,count\nA,2')
})
it.each([
['artifact', '11112222', undefined],
['upload', '11112222', undefined],
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useContext, useEffect, useRef, useState } from 'react'
import { PreviewInitialPosition } from './PreviewInitialPosition'
import { usePreviewResourceGeneration } from './usePreviewResourceGeneration'
import type { ArtifactPreviewResult } from '../../../../../shared/artifacts'
@@ -182,6 +183,7 @@ export const usePreviewFileContent = ({
encoding = 'utf8'
}: UsePreviewFileContentRequest): PreviewFileContentLoadState => {
const generation = usePreviewResourceGeneration()
const initialPosition = useContext(PreviewInitialPosition)
const fileKey = JSON.stringify([
generation,
projectId ?? null,
@@ -191,11 +193,16 @@ export const usePreviewFileContent = ({
selectedVersionId ?? null,
encoding,
maxBytes,
initialPosition?.offset,
maxFileBytes,
path
])
// Retain locations, not previous page contents, for the pinned resource sequence.
const firstPage = { offset: 0, startingLineNumber: 1, startsMidLine: false }
const firstPage = {
offset: initialPosition?.offset ?? 0,
startingLineNumber: initialPosition?.startingLineNumber ?? 1,
startsMidLine: Boolean(initialPosition?.offset)
}
const [pageState, setPageState] = useState<{
fileKey: string
pages: (typeof firstPage)[]
@@ -290,6 +297,14 @@ export const usePreviewFileContent = ({
const previousPage = (): void => {
setPageState((current) => {
const active = current.fileKey === fileKey ? current : activePageState
if (active.index === 0 && page.offset > 0) {
return {
fileKey,
pages: [{ offset: 0, startingLineNumber: 1, startsMidLine: false }],
index: 0,
showLastLines: false
}
}
return { ...active, index: Math.max(0, active.index - 1), showLastLines: true }
})
}
@@ -314,7 +329,7 @@ export const usePreviewFileContent = ({
return {
...state,
pagination: {
pageNumber: activePageState.index + 1,
pageNumber: activePageState.index + (activePageState.pages[0]?.offset ? 2 : 1),
pageKey: requestKey,
startingLineNumber: page.startingLineNumber,
startsMidLine: page.startsMidLine,
@@ -323,7 +338,7 @@ export const usePreviewFileContent = ({
byteStart: offset,
byteEnd: state.preview.nextOffset ?? state.preview.size,
showLastLines: activePageState.showLastLines,
hasPrevious: activePageState.index > 0,
hasPrevious: activePageState.index > 0 || offset > 0,
hasNext: state.preview.nextOffset !== undefined,
previousPage,
nextPage
@@ -0,0 +1,12 @@
import { expect, it } from 'vitest'
import { useSearchMessageFocusStore } from './search-message-focus-store'
it('keeps a newer message navigation request when an older reveal completes', () => {
const first = { projectId: 'p', sessionId: 's', messageId: 'm1', navigationRevision: 1 }
useSearchMessageFocusStore.getState().request(first)
const old = useSearchMessageFocusStore.getState().pending!
useSearchMessageFocusStore.getState().request({ ...first, messageId: 'm2' })
useSearchMessageFocusStore.getState().consume(old)
expect(useSearchMessageFocusStore.getState().pending?.messageId).toBe('m2')
useSearchMessageFocusStore.getState().consume(useSearchMessageFocusStore.getState().pending!)
expect(useSearchMessageFocusStore.getState().pending).toBeUndefined()
})
@@ -0,0 +1,17 @@
import { create } from 'zustand'
export type SearchMessageFocus = {
projectId: string
sessionId: string
messageId: string
navigationRevision: number
}
export const useSearchMessageFocusStore = create<{
pending?: SearchMessageFocus
request: (target: SearchMessageFocus) => void
consume: (target: SearchMessageFocus) => void
}>((set) => ({
request: (target) => set({ pending: target }),
consume: (target) => set((state) => (state.pending === target ? { pending: undefined } : state))
}))
@@ -6260,7 +6260,7 @@ describe('session store public contract', () => {
visit(path)
} else if (
/\.[cm]?tsx?$/.test(entry.name) &&
!/\.(?:test|spec)\.[cm]?tsx?$/.test(entry.name)
!/\.(?:test|spec|test-support)\.[cm]?tsx?$/.test(entry.name)
) {
paths.push(path)
}
+55 -18
View File
@@ -104,6 +104,9 @@
"Your unsaved changes will be lost.": "Ihre ungespeicherten Änderungen gehen verloren."
},
"renderer": {
"The source message is no longer available.": "Die ursprüngliche Nachricht ist nicht mehr verfügbar.",
"This Notebook exceeds the 10 MB preview limit.": "Dieses Notebook überschreitet die Vorschaugrenze von 10 MB.",
"Recent searches": "Letzte Suchen",
"Cancellation request failed": "Abbruchanforderung fehlgeschlagen",
"Skills are loading. Paste again shortly.": "Fähigkeiten werden geladen. Fügen Sie den Inhalt gleich erneut ein.",
"Could not load Skills. Try pasting again.": "Fähigkeiten konnten nicht geladen werden. Versuchen Sie erneut, den Inhalt einzufügen.",
@@ -417,8 +420,6 @@
"+{{count}} more_other": "+{{count}} mehr",
"+{{count}} more_files_one": "+{{count}} weitere Datei",
"+{{count}} more_files_other": "+{{count}} weitere Dateien",
"+{{count}} more matches — show more_one": "+{{count}} weitere Übereinstimmungen – mehr anzeigen",
"+{{count}} more matches — show more_other": "+{{count}} weitere Übereinstimmungen – mehr anzeigen",
", ": ", ",
".ipynb": ".ipynb",
".md files need a name and description in YAML frontmatter. .zip or .skill bundles must contain a SKILL.md. You'll confirm before anything is added.": ".md-Dateien benötigen im YAML-Frontmatter einen Namen und eine Beschreibung. .zip- und .skill-Pakete müssen eine SKILL.md enthalten. Bevor etwas hinzugefügt wird, müssen Sie den Import bestätigen.",
@@ -791,8 +792,6 @@
"Command": "Befehl",
"Command line tool": "Befehlszeilentool",
"Command line tool path": "Pfad des Befehlszeilentools",
"Command palette": "Befehlspalette",
"Commands": "Befehle",
"Common causes: an xychart is missing the <kw1>title</kw1> keyword, axis labels are not quoted, or <kw2>y-axis</kw2> or <kw3>bar/line</kw3> data rows are missing.": "Häufige Ursachen: In einem xychart fehlt das Schlüsselwort <kw1>title</kw1>, Achsenbeschriftungen stehen nicht in Anführungszeichen oder Datenzeilen für <kw2>y-axis</kw2> beziehungsweise <kw3>bar/line</kw3> fehlen.",
"Community": "Community",
"Compact": "Kompakt",
@@ -964,11 +963,8 @@
"Could not export this Skill.": "Diese Fähigkeit konnte nicht exportiert werden.",
"Could not import the selected skills.": "Die ausgewählten Fähigkeiten konnten nicht importiert werden.",
"Could not list packages.": "Pakete konnten nicht aufgelistet werden.",
"Could not load artifacts — retry": "Artefakte konnten nicht geladen werden – versuchen Sie es erneut",
"Could not load artifacts.": "Artefakte konnten nicht geladen werden.",
"Could not load local network details.": "Die Details des lokalen Netzwerks konnten nicht geladen werden.",
"Could not load this session for export.": "Diese Sitzung konnte nicht für den Export geladen werden.",
"Could not load more — retry": "Es konnte nicht mehr geladen werden – versuchen Sie es erneut",
"Could not load project artifacts.": "Projektartefakte konnten nicht geladen werden.",
"Could not load project files": "Projektdateien konnten nicht geladen werden",
"Could not load review history.": "Der Review-Verlauf konnte nicht geladen werden.",
@@ -1021,7 +1017,6 @@
"Could not update the command-line tool.": "Das Befehlszeilentool konnte nicht aktualisiert werden.",
"Couldn't fetch models: {{reason}}. Using the bundled list.": "Modelle konnten nicht abgerufen werden: {{reason}}. Stattdessen wird die integrierte Liste verwendet.",
"Couldn't load hosts.": "Hosts konnten nicht geladen werden.",
"Create a project to search sessions and artifacts.": "Erstellen Sie ein Projekt zum Durchsuchen von Sitzungen und Artefakten.",
"Create a skill to teach Claude a workflow you use.": "Erstellen Sie eine Fähigkeit, um Claude einen von Ihnen verwendeten Arbeitsablauf beizubringen.",
"Create agent": "Agent erstellen",
"Create new…": "Neu erstellen…",
@@ -1029,7 +1024,6 @@
"Created by you.": "Von Ihnen erstellt.",
"Created execution Plan": "Ausführungsplan erstellt",
"Creating execution Plan": "Ausführungsplan erstellen",
"Creation time unavailable": "Erstellungszeit nicht verfügbar",
"Credentials": "Anmeldeinformationen",
"Current (hidden)": "Aktuell (versteckt)",
"Current data location": "Aktueller Datenspeicherort",
@@ -1557,7 +1551,6 @@
"Markdown shown to the agent when the skill is invoked.": "Markdown, das dem Agenten beim Aufruf der Fähigkeit angezeigt wird.",
"Max tokens": "Max. Token",
"Maximize": "Maximieren",
"Mention is unavailable while the composer cannot accept another artifact.": "Eine Erwähnung ist nicht verfügbar, solange das Eingabefeld kein weiteres Artefakt aufnehmen kann.",
"Mention {{name}}": "Erwähnen Sie {{name}}",
"Mermaid syntax could not be rendered": "Die Mermaid-Syntax konnte nicht gerendert werden",
"Message center": "Nachrichtencenter",
@@ -1809,7 +1802,6 @@
"Or type your own answer…": "Oder geben Sie Ihre eigene Antwort ein ...",
"Other": "Sonstiges",
"Other artifacts": "Andere Artefakte",
"Other projects": "Andere Projekte",
"Other…": "Sonstiges…",
"Output": "Ausgabe",
"Output truncated": "Ausgabe abgeschnitten",
@@ -1966,7 +1958,6 @@
"Reasoning effort": "Reasoning-Aufwand",
"Reasoning effort levels": "Reasoning-Stufen",
"Reasoning effort request format": "Anfrageformat für den Reasoning-Aufwand",
"Recent artifacts": "Aktuelle Artefakte",
"Recent sessions": "Letzte Sitzungen",
"Recheck": "Erneut prüfen",
"Recheck message snapshot": "Nachrichten-Snapshot erneut prüfen",
@@ -2210,13 +2201,11 @@
"Search projects": "Projekte durchsuchen",
"Search projects…": "Projekte durchsuchen…",
"Search repositories by keyword, or scan a GitHub repository for Skill folders to import.": "Durchsuchen Sie Repositorys nach Schlüsselwörtern oder ein GitHub-Repository nach importierbaren Ordnern mit Fähigkeiten.",
"Search sessions and artifacts…": "Sitzungen und Artefakte durchsuchen…",
"Search skills": "Fähigkeiten suchen",
"Search skills to add": "Hinzuzufügende Fähigkeiten suchen",
"Search skills…": "Fähigkeiten suchen…",
"Search specialists": "Spezialisten suchen",
"Search specialists…": "Spezialisten suchen…",
"Search this project…": "Dieses Projekt durchsuchen…",
"Searching…": "Suche...",
"Secure credential storage": "Sichere Speicherung von Anmeldeinformationen",
"Secure key storage is unavailable. API keys cannot be saved until the system keychain is unlocked or authorized.": "Eine sichere Schlüsselspeicherung ist nicht verfügbar. API-Schlüssel können erst gespeichert werden, wenn der System-Schlüsselbund entsperrt oder autorisiert wurde.",
@@ -2348,7 +2337,6 @@
"Skipped": "Übersprungen",
"Skipped {{count}} skills_one": "Fähigkeit {{count}} übersprungen",
"Skipped {{count}} skills_other": "{{count}} Fähigkeiten wurden übersprungen",
"Some artifact results may be missing.": "Möglicherweise fehlen einige Artefaktergebnisse.",
"Some files could not be indexed yet.": "Einige Dateien konnten noch nicht indiziert werden.",
"Some permission details are unavailable": "Einige Berechtigungsdetails sind nicht verfügbar",
"Someone else saved a newer version": "Jemand anderes hat eine neuere Version gespeichert",
@@ -2778,7 +2766,6 @@
"e.g. changelog-style": "z. B. Changelog-Stil",
"error": "Fehler",
"error (line {{line}})": "Fehler (Zeile {{line}})",
"esc": "esc",
"exit {{code}}": "Exit-Code {{code}}",
"fail": "fehlgeschlagen",
"failed": "fehlgeschlagen",
@@ -2797,7 +2784,6 @@
"low confidence": "geringe Sicherheit",
"medium": "mittel",
"medium confidence": "mittlere Sicherheit",
"mention": "erwähnen",
"model": "Modell",
"navigate": "navigieren",
"network": "Netzwerk",
@@ -4511,7 +4497,6 @@
"No collections": "Keine Sammlungen",
"No missing metadata was found.": "Es wurden keine fehlenden Metadaten gefunden.",
"No references found": "Keine Literaturangaben gefunden",
"No results match “{{query}}”.": "Keine Ergebnisse für „{{query}}“.",
"No valid references found.": "Keine gültigen Literaturangaben gefunden.",
"Note for {{title}}": "Notiz zu {{title}}",
"Only the first 1,000 references will be imported.": "Nur die ersten 1.000 Literaturangaben werden importiert.",
@@ -4773,6 +4758,57 @@
"Tag assignments are preserved.": "Die Tag-Zuordnungen bleiben erhalten.",
"{{count}} tagged resources are currently unavailable._other": "{{count}} getaggte Ressourcen sind derzeit nicht verfügbar.",
"{{count}} tagged resources are currently unavailable._one": "{{count}} getaggte Ressource ist derzeit nicht verfügbar.",
"Uploaded files": "Hochgeladene Dateien",
"Global search": "Globale Suche",
"Search messages, projects, files and Library…": "Nach Nachrichten, Projekten, Dateien und Library suchen…",
"Search categories": "Suchkategorien",
"Could not load search results.": "Suchergebnisse konnten nicht geladen werden.",
"Some results are unavailable.": "Einige Ergebnisse sind nicht verfügbar.",
"No results found": "Keine Ergebnisse gefunden",
"Recent files": "Letzte Dateien",
"Recent literature": "Letzte Literatur",
"Content preview": "Inhaltsvorschau",
"File information": "Dateiinformationen",
"Jump to message": "Zur Nachricht springen",
"Open session": "Sitzung öffnen",
"Open collection": "Sammlung öffnen",
"Open literature": "Literatur öffnen",
"File name": "Dateiname",
"File size": "Dateigröße",
"Matched message": "Gefundene Nachricht",
"You": "Sie",
"Collapse details": "Details einklappen",
"Back to results": "Zurück zu den Ergebnissen",
"Search scope": "Suchbereich",
"All projects and Library": "Alle Projekte und Library",
"Current project": "Aktuelles Projekt",
"Collection": "Sammlung",
"Try another keyword or broaden the search scope.": "Versuchen Sie einen anderen Suchbegriff oder erweitern Sie den Suchbereich.",
"PDF available": "PDF gespeichert",
"No PDF": "Kein PDF",
"Only the 10 most recent items are shown": "Es werden nur die 10 neuesten Einträge angezeigt",
"All senders": "Alle Absender",
"Sent by me": "Von mir gesendet",
"Sent by agents": "Von Agenten gesendet",
"All formats": "Alle Formate",
"Spreadsheets / CSV": "Tabellen / CSV",
"Images": "Bilder",
"All entries": "Alle Einträge",
"Result order": "Ergebnissortierung",
"Relevance within categories": "Relevanz je Kategorie",
"Any time": "Beliebiger Zeitraum",
"Last 7 days": "Letzte 7 Tage",
"Refine category": "Kategorie eingrenzen",
"Notebook couldn't be read for preview": "Notebook konnte nicht für die Vorschau gelesen werden",
"Cell {{number}}": "Zelle {{number}}",
"Showing {{shown}} of {{total}} cells": "{{shown}} von {{total}} Zellen angezeigt",
"No abstract available": "Keine Zusammenfassung verfügbar",
"Could not load recent content.": "Letzte Inhalte konnten nicht geladen werden.",
"No recent content": "Keine letzten Inhalte",
"{{count}} items_one": "{{count}} Eintrag",
"{{count}} items_other": "{{count}} Einträge",
"{{count}} messages_one": "{{count}} Nachricht",
"{{count}} messages_other": "{{count}} Nachrichten",
"Access authorization has expired. Reopen the Web link from Open Science on the host computer, or return to the remote access entry page to pair again.": "Die Zugriffsberechtigung ist abgelaufen. Öffnen Sie den Web-Link erneut in Open Science auf dem Hostcomputer oder kehren Sie zur Einstiegsseite für den Fernzugriff zurück, um die Kopplung zu wiederholen.",
"The operation result could not be confirmed. It may still be running. Reconnect and check its status before trying again.": "Das Ergebnis des Vorgangs konnte nicht bestätigt werden. Er wird möglicherweise noch ausgeführt. Stellen Sie die Verbindung wieder her und prüfen Sie seinen Status, bevor Sie es erneut versuchen.",
"Any call": "Beliebiger Aufruf",
@@ -4897,6 +4933,7 @@
"Compare selected references": "Ausgewählte Literaturangaben vergleichen",
"PDF added. Refreshing attachment details…": "PDF hinzugefügt. Anhangdetails werden aktualisiert…",
"You can close this dialog. The download continues in the background; reopen it to see progress.": "Sie können diesen Dialog schließen. Der Download läuft im Hintergrund weiter. Öffnen Sie ihn erneut, um den Fortschritt zu sehen.",
"Local computer": "Lokaler Computer",
"Retry file verification": "Datei erneut prüfen",
"Remove attachment": "Anhang entfernen",
"This PDF requires a password. Add an unlocked copy.": "Diese PDF benötigt ein Passwort. Fügen Sie eine entsperrte Kopie hinzu.",
+57 -19
View File
@@ -105,6 +105,9 @@
"Your unsaved changes will be lost.": "Se perderán los cambios sin guardar."
},
"renderer": {
"The source message is no longer available.": "El mensaje de origen ya no está disponible.",
"This Notebook exceeds the 10 MB preview limit.": "Este Notebook supera el límite de vista previa de 10 MB.",
"Recent searches": "Búsquedas recientes",
"Cancellation request failed": "Error al solicitar la cancelación",
"Skills are loading. Paste again shortly.": "Se están cargando las Habilidades. Vuelva a pegar el contenido en breve.",
"Could not load Skills. Try pasting again.": "No se pudieron cargar las Habilidades. Intente pegar el contenido de nuevo.",
@@ -417,9 +420,6 @@
"+{{count}} more_files_one": "y {{count}} más",
"+{{count}} more_files_other": "y {{count}} más",
"+{{count}} more_files_many": "y {{count}} más",
"+{{count}} more matches — show more_one": "+{{count}} coincidencia más — mostrar más",
"+{{count}} more matches — show more_other": "+{{count}} coincidencias más — mostrar más",
"+{{count}} more matches — show more_many": "+{{count}} coincidencias más — mostrar más",
", ": ",",
".ipynb": ".ipynb",
".md files need a name and description in YAML frontmatter. .zip or .skill bundles must contain a SKILL.md. You'll confirm before anything is added.": "Los archivos .md necesitan un nombre y una descripción en el frontmatter de YAML. Los paquetes .zip o .skill deben contener un SKILL.md. Lo confirmará antes de agregar nada.",
@@ -849,8 +849,6 @@
"Command": "Comando",
"Command line tool": "Herramienta de línea de comandos",
"Command line tool path": "Ruta de la herramienta de línea de comandos",
"Command palette": "Paleta de comandos",
"Commands": "Comandos",
"Compute cache": "Caché de cálculo",
"Common causes: an xychart is missing the <kw1>title</kw1> keyword, axis labels are not quoted, or <kw2>y-axis</kw2> or <kw3>bar/line</kw3> data rows are missing.": "Causas habituales: falta la palabra clave <kw1>title</kw1> en un xychart, las etiquetas de los ejes no están entre comillas o faltan filas de datos de <kw2>y-axis</kw2> o <kw3>bar/line</kw3>.",
"Community": "Comunidad",
@@ -1036,8 +1034,6 @@
"Could not export this Skill.": "No se pudo exportar esta habilidad.",
"Could not import the selected skills.": "No se pudieron importar las habilidades seleccionadas.",
"Could not list packages.": "No se pudieron enumerar los paquetes.",
"Could not load artifacts — retry": "No se pudieron cargar los artefactos; vuelva a intentarlo",
"Could not load artifacts.": "No se pudieron cargar artefactos.",
"Could not load local network details.": "No se pudieron cargar los detalles de la red local.",
"Could not load this source": "No se pudo cargar esta fuente",
"This source does not allow embedded previews.": "Esta fuente no permite vistas previas incrustadas.",
@@ -1045,7 +1041,6 @@
"The source returned an HTTP error.": "La fuente devolvió un error HTTP.",
"The source could not be reached.": "No se pudo acceder a esta fuente.",
"Could not load this session for export.": "No se pudo cargar esta sesión para exportar.",
"Could not load more — retry": "No se pudo cargar más; vuelva a intentarlo",
"Could not load project artifacts.": "No se pudieron cargar los artefactos del proyecto.",
"Could not load project files": "No se pudieron cargar los archivos del proyecto",
"Could not load review history.": "No se pudo cargar el historial de revisión.",
@@ -1102,7 +1097,6 @@
"Could not check the command-line tool.": "No se pudo comprobar la herramienta de línea de comandos.",
"Couldn't fetch models: {{reason}}. Using the bundled list.": "No se pudieron obtener los modelos: {{reason}}. Se usará la lista incluida.",
"Couldn't load hosts.": "No se pudieron cargar los hosts.",
"Create a project to search sessions and artifacts.": "Cree un proyecto para buscar sesiones y artefactos.",
"Create a skill to teach Claude a workflow you use.": "Cree una habilidad para enseñarle a Claude un flujo de trabajo que utiliza.",
"Create agent": "Crear agente",
"Create new…": "Crear nuevo…",
@@ -1110,7 +1104,6 @@
"Created by you.": "Creado por usted.",
"Created execution Plan": "Plan de ejecución creado",
"Creating execution Plan": "Creando plan de ejecución",
"Creation time unavailable": "Hora de creación no disponible",
"Credentials": "Credenciales",
"Current (hidden)": "Actual (oculta)",
"Current data location": "Ubicación de datos actual",
@@ -1747,7 +1740,6 @@
"Markdown shown to the agent when the skill is invoked.": "Markdown se muestra al agente cuando se invoca la habilidad.",
"Max tokens": "Máximo de tokens",
"Maximize": "Maximizar",
"Mention is unavailable while the composer cannot accept another artifact.": "No se puede mencionar mientras el editor de mensajes no admita otro artefacto.",
"Mention {{name}}": "Mencionar a {{name}}",
"Mermaid syntax could not be rendered": "No se pudo representar la sintaxis de Mermaid",
"Message center": "Centro de mensajes",
@@ -1874,7 +1866,6 @@
"No review available for this session.": "No hay revisión disponible para esta sesión.",
"No review for this version": "No hay ninguna revisión para esta versión",
"No run history yet": "Aún no hay historial de ejecución",
"No results match “{{query}}”.": "No hay resultados que coincidan con «{{query}}».",
"No skills added yet.": "Aún no se han agregado habilidades.",
"No skills found": "No se encontraron habilidades",
"No skills found in that repo.": "No se encontraron habilidades en ese repositorio.",
@@ -2003,7 +1994,6 @@
"Or type your own answer…": "O escriba su propia respuesta…",
"Other": "Otro",
"Other artifacts": "Otros artefactos",
"Other projects": "Otros proyectos",
"Other…": "Otro…",
"Output": "Salida",
"Output truncated": "Salida truncada",
@@ -2190,7 +2180,6 @@
"Reasoning effort": "Esfuerzo de razonamiento",
"Reasoning effort levels": "Niveles de esfuerzo de razonamiento",
"Reasoning effort request format": "Formato de solicitud de esfuerzo de razonamiento",
"Recent artifacts": "Artefactos recientes",
"Recent sessions": "Sesiones recientes",
"Recheck": "Volver a comprobar",
"Recheck message snapshot": "Volver a comprobar la instantánea de mensajes",
@@ -2488,13 +2477,11 @@
"Search projects": "Buscar proyectos",
"Search projects…": "Buscar proyectos…",
"Search repositories by keyword, or scan a GitHub repository for Skill folders to import.": "Busque repositorios por palabra clave o escanee un repositorio GitHub en busca de carpetas de habilidades para importar.",
"Search sessions and artifacts…": "Buscar sesiones y artefactos…",
"Search skills": "Buscar habilidades",
"Search skills to add": "Buscar habilidades para agregar",
"Search skills…": "Buscar habilidades…",
"Search specialists": "Buscar especialistas",
"Search specialists…": "Buscar especialistas…",
"Search this project…": "Buscar este proyecto…",
"Searching…": "Buscando…",
"Secure credential storage": "Almacenamiento seguro de credenciales",
"Secure key storage is unavailable. API keys cannot be saved until the system keychain is unlocked or authorized.": "El almacenamiento seguro de claves no está disponible. Las claves API no se pueden guardar hasta que el llavero del sistema esté desbloqueado o autorizado.",
@@ -2656,7 +2643,6 @@
"Skipped {{count}} skills_one": "Se omitió {{count}} habilidad",
"Skipped {{count}} skills_other": "Se omitió {{count}} habilidades",
"Skipped {{count}} skills_many": "Se omitió {{count}} habilidades",
"Some artifact results may be missing.": "Es posible que falten algunos resultados de artefactos.",
"Some files could not be indexed yet.": "Algunos archivos aún no se pudieron indexar.",
"Some permission details are unavailable": "Algunos detalles de permisos no están disponibles",
"Someone else saved a newer version": "Alguien más guardó una versión más reciente",
@@ -3110,7 +3096,6 @@
"e.g. changelog-style": "p. ej., changelog-style",
"error": "error",
"error (line {{line}})": "error (línea {{line}})",
"esc": "esc",
"exit {{code}}": "salir {{code}}",
"fail": "falla",
"failed": "falló",
@@ -3129,7 +3114,6 @@
"low confidence": "confianza baja",
"medium": "medio",
"medium confidence": "confianza media",
"mention": "mención",
"model": "modelo",
"navigate": "navegar",
"network": "red",
@@ -4931,6 +4915,59 @@
"{{count}} tagged resources are currently unavailable._many": "{{count}} recursos etiquetados no están disponibles actualmente.",
"{{count}} tagged resources are currently unavailable._other": "{{count}} recursos etiquetados no están disponibles actualmente.",
"{{count}} tagged resources are currently unavailable._one": "{{count}} recurso etiquetado no está disponible actualmente.",
"Uploaded files": "Archivos subidos",
"Global search": "Búsqueda global",
"Search messages, projects, files and Library…": "Buscar mensajes, proyectos, archivos y en Library…",
"Search categories": "Categorías de búsqueda",
"Could not load search results.": "No se pudieron cargar los resultados de búsqueda.",
"Some results are unavailable.": "Algunos resultados no están disponibles.",
"No results found": "No se encontraron resultados",
"Recent files": "Archivos recientes",
"Recent literature": "Publicaciones recientes",
"Content preview": "Vista previa del contenido",
"File information": "Información del archivo",
"Jump to message": "Ir al mensaje",
"Open session": "Abrir sesión",
"Open collection": "Abrir colección",
"Open literature": "Abrir publicación",
"File name": "Nombre del archivo",
"File size": "Tamaño del archivo",
"Matched message": "Mensaje coincidente",
"You": "Usted",
"Collapse details": "Contraer detalles",
"Back to results": "Volver a los resultados",
"Search scope": "Ámbito de búsqueda",
"All projects and Library": "Todos los proyectos y Library",
"Current project": "Proyecto actual",
"Collection": "Colección",
"Try another keyword or broaden the search scope.": "Pruebe otra palabra clave o amplíe el ámbito de búsqueda.",
"PDF available": "PDF guardado",
"No PDF": "Sin PDF",
"Only the 10 most recent items are shown": "Solo se muestran los 10 elementos más recientes",
"All senders": "Todos los remitentes",
"Sent by me": "Enviados por usted",
"Sent by agents": "Enviados por agentes",
"All formats": "Todos los formatos",
"Spreadsheets / CSV": "Hojas de cálculo / CSV",
"Images": "Imágenes",
"All entries": "Todas las entradas",
"Result order": "Orden de resultados",
"Relevance within categories": "Relevancia por categoría",
"Any time": "Cualquier fecha",
"Last 7 days": "Últimos 7 días",
"Refine category": "Filtrar la categoría",
"Notebook couldn't be read for preview": "No se pudo leer el Notebook para la vista previa",
"Cell {{number}}": "Celda {{number}}",
"Showing {{shown}} of {{total}} cells": "Se muestran {{shown}} de {{total}} celdas",
"No abstract available": "No hay resumen disponible",
"Could not load recent content.": "No se pudo cargar el contenido reciente.",
"No recent content": "No hay contenido reciente",
"{{count}} items_one": "{{count}} elemento",
"{{count}} items_many": "{{count}} elementos",
"{{count}} items_other": "{{count}} elementos",
"{{count}} messages_one": "{{count}} mensaje",
"{{count}} messages_many": "{{count}} mensajes",
"{{count}} messages_other": "{{count}} mensajes",
"Access authorization has expired. Reopen the Web link from Open Science on the host computer, or return to the remote access entry page to pair again.": "La autorización de acceso ha caducado. Vuelva a abrir el enlace Web desde Open Science en el equipo anfitrión o regrese a la página de acceso remoto para volver a emparejar.",
"The operation result could not be confirmed. It may still be running. Reconnect and check its status before trying again.": "No se ha podido confirmar el resultado de la operación. Es posible que siga en curso. Vuelva a conectarse y compruebe su estado antes de intentarlo de nuevo.",
"Any call": "Cualquier llamada",
@@ -5058,6 +5095,7 @@
"Compare selected references": "Comparar referencias seleccionadas",
"PDF added. Refreshing attachment details…": "PDF añadido. Actualizando los detalles del archivo adjunto…",
"You can close this dialog. The download continues in the background; reopen it to see progress.": "Puede cerrar este diálogo. La descarga continúa en segundo plano; vuelva a abrirlo para ver el progreso.",
"Local computer": "Equipo local",
"Retry file verification": "Volver a verificar el archivo",
"Remove attachment": "Eliminar adjunto",
"This PDF requires a password. Add an unlocked copy.": "Este PDF requiere una contraseña. Añada una copia desbloqueada.",
+57 -19
View File
@@ -105,6 +105,9 @@
"Your unsaved changes will be lost.": "Vos modifications non enregistrées seront perdues."
},
"renderer": {
"The source message is no longer available.": "Le message d’origine n’est plus disponible.",
"This Notebook exceeds the 10 MB preview limit.": "Ce Notebook dépasse la limite de prévisualisation de 10 Mo.",
"Recent searches": "Recherches récentes",
"Cancellation request failed": "La demande d’annulation a échoué",
"Skills are loading. Paste again shortly.": "Chargement des Compétences. Collez à nouveau dans un instant.",
"Could not load Skills. Try pasting again.": "Impossible de charger les Compétences. Essayez de coller à nouveau.",
@@ -458,9 +461,6 @@
"+{{count}} more_files_one": "+{{count}} fichier",
"+{{count}} more_files_other": "+{{count}} fichiers",
"+{{count}} more_files_many": "+{{count}} fichiers",
"+{{count}} more matches — show more_one": "+{{count}} correspondance supplémentaire — afficher plus",
"+{{count}} more matches — show more_other": "+{{count}} correspondances supplémentaires — afficher plus",
"+{{count}} more matches — show more_many": "+{{count}} correspondances supplémentaires — afficher plus",
", ": ",",
".ipynb": ".ipynb",
".md files need a name and description in YAML frontmatter. .zip or .skill bundles must contain a SKILL.md. You'll confirm before anything is added.": "Les fichiers .md doivent comporter un nom et une description dans le frontmatter YAML. Les archives .zip ou .skill doivent contenir un fichier SKILL.md. Vous confirmerez avant tout ajout.",
@@ -849,8 +849,6 @@
"Command": "Commande",
"Command line tool": "Outil en ligne de commande",
"Command line tool path": "Chemin d'accès à l'outil de ligne de commande",
"Command palette": "Palette de commandes",
"Commands": "Commandes",
"Compute cache": "Cache de calcul",
"Common causes: an xychart is missing the <kw1>title</kw1> keyword, axis labels are not quoted, or <kw2>y-axis</kw2> or <kw3>bar/line</kw3> data rows are missing.": "Causes courantes : un graphique xy ne contient pas le mot-clé <kw1>title</kw1>, les étiquettes des axes ne sont pas citées ou les lignes de données <kw2>y-axis</kw2> ou <kw3>bar/line</kw3> sont manquantes.",
"Community": "Communauté",
@@ -1036,8 +1034,6 @@
"Could not export this Skill.": "Impossible d'exporter cette compétence.",
"Could not import the selected skills.": "Impossible d'importer les compétences sélectionnées.",
"Could not list packages.": "Impossible de répertorier les paquets.",
"Could not load artifacts — retry": "Impossible de charger les artefacts : réessayez",
"Could not load artifacts.": "Impossible de charger les artefacts.",
"Could not load local network details.": "Impossible de charger les détails du réseau local.",
"Could not load this source": "Impossible de charger cette source",
"This source does not allow embedded previews.": "Cette source n’autorise pas les aperçus intégrés.",
@@ -1045,7 +1041,6 @@
"The source returned an HTTP error.": "La source a renvoyé une erreur HTTP.",
"The source could not be reached.": "Cette source est inaccessible.",
"Could not load this session for export.": "Impossible de charger cette session pour l’exportation.",
"Could not load more — retry": "Impossible de charger davantage : réessayez",
"Could not load project artifacts.": "Impossible de charger les artefacts du projet.",
"Could not load project files": "Impossible de charger les fichiers du projet",
"Could not load review history.": "Impossible de charger l'historique des avis.",
@@ -1102,7 +1097,6 @@
"Could not check the command-line tool.": "Impossible de vérifier l'outil de ligne de commande.",
"Couldn't fetch models: {{reason}}. Using the bundled list.": "Impossible de récupérer les modèles : {{reason}}. Utilisation de la liste groupée.",
"Couldn't load hosts.": "Impossible de charger les hôtes.",
"Create a project to search sessions and artifacts.": "Créez un projet pour rechercher des sessions et des artefacts.",
"Create a skill to teach Claude a workflow you use.": "Créez une compétence pour enseigner à Claude un flux de travail que vous utilisez.",
"Create agent": "Créer un agent",
"Create new…": "Créer un nouveau…",
@@ -1110,7 +1104,6 @@
"Created by you.": "Créé par vous.",
"Created execution Plan": "Plan d'exécution créé",
"Creating execution Plan": "Création d'un plan d'exécution",
"Creation time unavailable": "Heure de création indisponible",
"Credentials": "Informations d'identification",
"Current (hidden)": "Actuel (caché)",
"Current data location": "Emplacement actuel des données",
@@ -1746,7 +1739,6 @@
"Markdown shown to the agent when the skill is invoked.": "Markdown affiché à l'agent lorsque la compétence est invoquée.",
"Max tokens": "Nombre maximum de jetons",
"Maximize": "Maximiser",
"Mention is unavailable while the composer cannot accept another artifact.": "La mention n'est pas disponible tant que la zone de saisie ne peut pas accepter un autre artefact.",
"Mention {{name}}": "Mentionner {{name}}",
"Mermaid syntax could not be rendered": "La syntaxe de la sirène n'a pas pu être rendue",
"Message center": "Centre de messages",
@@ -1873,7 +1865,6 @@
"No review available for this session.": "Aucune critique disponible pour cette session.",
"No review for this version": "Aucun avis pour cette version",
"No run history yet": "Pas encore d'historique d'exécution",
"No results match “{{query}}”.": "Aucun résultat ne correspond à « {{query}} ».",
"No skills added yet.": "Aucune compétence ajoutée pour l'instant.",
"No skills found": "Aucune compétence trouvée",
"No skills found in that repo.": "Aucune compétence trouvée dans ce dépôt.",
@@ -2003,7 +1994,6 @@
"Or type your own answer…": "Ou tapez votre propre réponse…",
"Other": "Autre",
"Other artifacts": "Autres artefacts",
"Other projects": "Autres projets",
"Other…": "Autre…",
"Output": "Sortie",
"Output truncated": "Sortie tronquée",
@@ -2168,7 +2158,6 @@
"Reasoning effort": "Effort de raisonnement",
"Reasoning effort levels": "Niveaux d’effort de raisonnement",
"Reasoning effort request format": "Format de demande d'effort de raisonnement",
"Recent artifacts": "Artefacts récents",
"Recent sessions": "Sessions récentes",
"Recheck": "Revérifier",
"Recheck message snapshot": "Revérifier l’instantané des messages",
@@ -2488,13 +2477,11 @@
"Search projects": "Rechercher des projets",
"Search projects…": "Rechercher des projets…",
"Search repositories by keyword, or scan a GitHub repository for Skill folders to import.": "Recherchez les référentiels par mot-clé ou analysez un référentiel GitHub pour rechercher les dossiers de compétences à importer.",
"Search sessions and artifacts…": "Rechercher des sessions et des artefacts…",
"Search skills": "Rechercher des compétences",
"Search skills to add": "Rechercher des compétences à ajouter",
"Search skills…": "Rechercher des compétences…",
"Search specialists": "Rechercher des spécialistes",
"Search specialists…": "Rechercher des spécialistes…",
"Search this project…": "Rechercher ce projet…",
"Searching…": "Recherche…",
"Secure credential storage": "Stockage sécurisé des identifiants",
"Secure key storage is unavailable. API keys cannot be saved until the system keychain is unlocked or authorized.": "Le stockage sécurisé des clés n'est pas disponible. Les clés API ne peuvent pas être enregistrées tant que le trousseau du système n'est pas déverrouillé ou autorisé.",
@@ -2656,7 +2643,6 @@
"Skipped {{count}} skills_one": "{{count}} compétence ignorée",
"Skipped {{count}} skills_other": "{{count}} compétences ignorées",
"Skipped {{count}} skills_many": "{{count}} compétences ignorées",
"Some artifact results may be missing.": "Certains résultats d'artefacts peuvent être manquants.",
"Some files could not be indexed yet.": "Certains fichiers n'ont pas encore pu être indexés.",
"Some permission details are unavailable": "Certains détails d'autorisation ne sont pas disponibles",
"Someone else saved a newer version": "Quelqu'un d'autre a enregistré une version plus récente",
@@ -3110,7 +3096,6 @@
"e.g. changelog-style": "par ex. style journal des modifications",
"error": "erreur",
"error (line {{line}})": "erreur (ligne {{line}})",
"esc": "échap",
"exit {{code}}": "quitter {{code}}",
"fail": "échouer",
"failed": "échec",
@@ -3129,7 +3114,6 @@
"low confidence": "faible confiance",
"medium": "moyen",
"medium confidence": "confiance moyenne",
"mention": "mentionner",
"model": "modèle",
"navigate": "naviguer",
"network": "réseau",
@@ -4931,6 +4915,59 @@
"{{count}} tagged resources are currently unavailable._many": "{{count}} ressources associées à ce tag sont actuellement indisponibles.",
"{{count}} tagged resources are currently unavailable._other": "{{count}} ressources associées à ce tag sont actuellement indisponibles.",
"{{count}} tagged resources are currently unavailable._one": "{{count}} ressource associée à ce tag est actuellement indisponible.",
"Uploaded files": "Fichiers téléversés",
"Global search": "Recherche globale",
"Search messages, projects, files and Library…": "Rechercher des messages, projets, fichiers et dans Library…",
"Search categories": "Catégories de recherche",
"Could not load search results.": "Impossible de charger les résultats de recherche.",
"Some results are unavailable.": "Certains résultats sont indisponibles.",
"No results found": "Aucun résultat trouvé",
"Recent files": "Fichiers récents",
"Recent literature": "Publications récentes",
"Content preview": "Aperçu du contenu",
"File information": "Informations du fichier",
"Jump to message": "Aller au message",
"Open session": "Ouvrir la session",
"Open collection": "Ouvrir la collection",
"Open literature": "Ouvrir la publication",
"File name": "Nom du fichier",
"File size": "Taille du fichier",
"Matched message": "Message correspondant",
"You": "Vous",
"Collapse details": "Réduire les détails",
"Back to results": "Retour aux résultats",
"Search scope": "Périmètre de recherche",
"All projects and Library": "Tous les projets et Library",
"Current project": "Projet actuel",
"Collection": "Collection",
"Try another keyword or broaden the search scope.": "Essayez un autre mot-clé ou élargissez le périmètre de recherche.",
"PDF available": "PDF enregistré",
"No PDF": "Aucun PDF",
"Only the 10 most recent items are shown": "Seuls les 10 éléments les plus récents sont affichés",
"All senders": "Tous les expéditeurs",
"Sent by me": "Envoyés par moi",
"Sent by agents": "Envoyés par les agents",
"All formats": "Tous les formats",
"Spreadsheets / CSV": "Tableurs / CSV",
"Images": "Images",
"All entries": "Toutes les entrées",
"Result order": "Tri des résultats",
"Relevance within categories": "Pertinence par catégorie",
"Any time": "Toute période",
"Last 7 days": "7 derniers jours",
"Refine category": "Affiner la catégorie",
"Notebook couldn't be read for preview": "Impossible de lire le Notebook pour l’aperçu",
"Cell {{number}}": "Cellule {{number}}",
"Showing {{shown}} of {{total}} cells": "{{shown}} cellules affichées sur {{total}}",
"No abstract available": "Aucun résumé disponible",
"Could not load recent content.": "Impossible de charger le contenu récent.",
"No recent content": "Aucun contenu récent",
"{{count}} items_one": "{{count}} élément",
"{{count}} items_many": "{{count}} éléments",
"{{count}} items_other": "{{count}} éléments",
"{{count}} messages_one": "{{count}} message",
"{{count}} messages_many": "{{count}} messages",
"{{count}} messages_other": "{{count}} messages",
"Access authorization has expired. Reopen the Web link from Open Science on the host computer, or return to the remote access entry page to pair again.": "L’autorisation d’accès a expiré. Rouvrez le lien Web depuis Open Science sur l’ordinateur hôte, ou revenez à la page d’accès distant pour effectuer un nouvel appairage.",
"The operation result could not be confirmed. It may still be running. Reconnect and check its status before trying again.": "Le résultat de l’opération n’a pas pu être confirmé. Elle est peut-être encore en cours. Reconnectez-vous et vérifiez son état avant de réessayer.",
"Any call": "Tout appel",
@@ -5058,6 +5095,7 @@
"Compare selected references": "Comparer les références sélectionnées",
"PDF added. Refreshing attachment details…": "PDF ajouté. Actualisation des détails de la pièce jointe…",
"You can close this dialog. The download continues in the background; reopen it to see progress.": "Vous pouvez fermer cette fenêtre. Le téléchargement continue en arrière-plan; rouvrez-la pour voir sa progression.",
"Local computer": "Ordinateur local",
"Retry file verification": "Revérifier le fichier",
"Remove attachment": "Supprimer la pièce jointe",
"This PDF requires a password. Add an unlocked copy.": "Ce PDF nécessite un mot de passe. Ajoutez une copie déverrouillée.",
+53 -17
View File
@@ -103,6 +103,9 @@
"Your unsaved changes will be lost.": "保存されていない変更は失われます。"
},
"renderer": {
"The source message is no longer available.": "元のメッセージは利用できなくなりました。",
"This Notebook exceeds the 10 MB preview limit.": "この Notebook はプレビューの上限 10 MB を超えています。",
"Recent searches": "最近の検索",
"Cancellation request failed": "キャンセル要求に失敗しました",
"Skills are loading. Paste again shortly.": "スキルを読み込んでいます。少し待ってから再度貼り付けてください。",
"Could not load Skills. Try pasting again.": "スキルを読み込めませんでした。もう一度貼り付けてください。",
@@ -442,7 +445,6 @@
"+ {{count}} more steps_other": "+ {{count}} ステップ",
"+{{count}} more_other": "+{{count}} 件",
"+{{count}} more_files_other": "+{{count}} ファイル",
"+{{count}} more matches — show more_other": "さらに {{count}} 件一致 — もっと表示",
", ": "、",
".ipynb": ".ipynb",
".md files need a name and description in YAML frontmatter. .zip or .skill bundles must contain a SKILL.md. You'll confirm before anything is added.": ".md ファイルには YAML フロントマターに名前と説明が必要です。.zip または .skill バンドルには SKILL.md が含まれている必要があります。追加される前に確認が求められます。",
@@ -827,8 +829,6 @@
"Command": "コマンド",
"Command line tool": "コマンドラインツール",
"Command line tool path": "コマンドラインツールパス",
"Command palette": "コマンドパレット",
"Commands": "コマンド",
"Compute cache": "計算キャッシュ",
"Common causes: an xychart is missing the <kw1>title</kw1> keyword, axis labels are not quoted, or <kw2>y-axis</kw2> or <kw3>bar/line</kw3> data rows are missing.": "一般的な原因:xychart に <kw1>title</kw1> キーワードが欠落している、軸ラベルが引用符で囲まれていない、または <kw2>y-axis</kw2> または <kw3>bar/line</kw3> データ行が欠落しています。",
"Community": "コミュニティ",
@@ -987,8 +987,6 @@
"Could not export this Skill.": "このスキルをエクスポートできませんでした。",
"Could not import the selected skills.": "選択したスキルをインポートできませんでした。",
"Could not list packages.": "パッケージをリストできませんでした。",
"Could not load artifacts — retry": "アーティファクトをロードできませんでした — 再試行してください",
"Could not load artifacts.": "アーティファクトをロードできませんでした。",
"Could not load local network details.": "ローカルネットワーク情報を読み込めませんでした。",
"Could not load this source": "この出典を読み込めませんでした",
"This source does not allow embedded previews.": "この出典では埋め込みプレビューが許可されていません。",
@@ -996,7 +994,6 @@
"The source returned an HTTP error.": "出典から HTTP エラーが返されました。",
"The source could not be reached.": "この出典に接続できませんでした。",
"Could not load this session for export.": "エクスポート用にこのセッションを読み込めませんでした。",
"Could not load more — retry": "これ以上ロードできませんでした — 再試行してください",
"Could not load project artifacts.": "プロジェクトのアーティファクトをロードできませんでした。",
"Could not load project files": "プロジェクトファイルをロードできませんでした",
"Could not load review history.": "レビュー履歴を読み込めませんでした。",
@@ -1051,7 +1048,6 @@
"Could not check the command-line tool.": "コマンドラインツールを確認できませんでした。",
"Couldn't fetch models: {{reason}}. Using the bundled list.": "モデルを取得できませんでした:{{reason}}。バンドルリストを使用します。",
"Couldn't load hosts.": "ホストを読み込めませんでした。",
"Create a project to search sessions and artifacts.": "セッションとアーティファクトを検索するには、プロジェクトを作成してください。",
"Create a skill to teach Claude a workflow you use.": "スキルを作成して、よく使うワークフローを Claude に教えます。",
"Create agent": "エージェントを作成",
"Create new…": "新規作成…",
@@ -1059,7 +1055,6 @@
"Created by you.": "あなたが作成しました。",
"Created execution Plan": "作成された実行プラン",
"Creating execution Plan": "実行プランの作成",
"Creation time unavailable": "作成日時は不明です",
"Credentials": "資格情報",
"Current (hidden)": "現在 (非表示)",
"Current data location": "現在のデータの場所",
@@ -1677,7 +1672,6 @@
"Markdown shown to the agent when the skill is invoked.": "スキルが呼び出されたときにエージェントに表示されるマークダウン。",
"Max tokens": "最大トークン数",
"Maximize": "最大化する",
"Mention is unavailable while the composer cannot accept another artifact.": "コンポーザーが別のアーティファクトを受け付けられない間は、メンションを利用できません。",
"Mention {{name}}": "{{name}}をメンション",
"Mermaid syntax could not be rendered": "Mermaid 構文をレンダリングできませんでした",
"Message center": "メッセージセンター",
@@ -1800,7 +1794,6 @@
"No review available for this session.": "このセッションにはレビューがありません。",
"No review for this version": "このバージョンのレビューはありません",
"No run history yet": "まだ実行履歴がありません",
"No results match “{{query}}”.": "「{{query}}」に一致する結果はありません。",
"No skills added yet.": "スキルはまだ追加されていません。",
"No skills found": "スキルが見つかりませんでした",
"No skills found in that repo.": "そのリポジトリにはスキルが見つかりません。",
@@ -1934,7 +1927,6 @@
"Or type your own answer…": "または、自分で回答を入力してください…",
"Other": "その他",
"Other artifacts": "その他のアーティファクト",
"Other projects": "その他のプロジェクト",
"Other…": "その他…",
"Output": "出力",
"Output truncated": "出力は省略されています",
@@ -2100,7 +2092,6 @@
"Reasoning effort": "推論強度",
"Reasoning effort levels": "推論強度レベル",
"Reasoning effort request format": "推論強度要求フォーマット",
"Recent artifacts": "最近のアーティファクト",
"Recent sessions": "最近のセッション",
"Recheck": "再確認",
"Recheck message snapshot": "メッセージスナップショットを再確認",
@@ -2395,13 +2386,11 @@
"Search projects": "プロジェクトを検索",
"Search projects…": "プロジェクトを検索…",
"Search repositories by keyword, or scan a GitHub repository for Skill folders to import.": "キーワードでリポジトリを検索するか、GitHub リポジトリをスキャンしてスキルフォルダーを探してインポートします。",
"Search sessions and artifacts…": "セッションとアーティファクトを検索…",
"Search skills": "スキルを検索",
"Search skills to add": "追加するスキルを検索",
"Search skills…": "スキルを検索…",
"Search specialists": "スペシャリストを検索",
"Search specialists…": "スペシャリストを検索…",
"Search this project…": "このプロジェクトを検索…",
"Searching…": "検索中…",
"Secure credential storage": "安全な認証情報のストレージ",
"Secure key storage is unavailable. API keys cannot be saved until the system keychain is unlocked or authorized.": "安全なキー保管は利用できません。API キーは、システムキーチェーンのロックが解除されるか承認されるまで保存できません。",
@@ -2546,7 +2535,6 @@
"Skip approvals for {{name}}": "{{name}} の承認をスキップ",
"Skipped": "スキップ済み",
"Skipped {{count}} skills_other": "{{count}} スキルをスキップしました",
"Some artifact results may be missing.": "一部のアーティファクト結果が欠落している可能性があります。",
"Some files could not be indexed yet.": "一部のファイルはまだインデックスを作成できませんでした。",
"Some permission details are unavailable": "一部の権限の詳細は利用できません",
"Someone else saved a newer version": "他の人が新しいバージョンを保存しました",
@@ -2990,7 +2978,6 @@
"e.g. changelog-style": "例:変更履歴スタイル",
"error": "エラー",
"error (line {{line}})": "エラー (行 {{line}})",
"esc": "Esc",
"exit {{code}}": "終了コード {{code}}",
"fail": "失敗",
"failed": "失敗しました",
@@ -3009,7 +2996,6 @@
"low confidence": "低い確信度",
"medium": "中程度",
"medium confidence": "中程度の確信度",
"mention": "メンション",
"model": "モデル",
"navigate": "ナビゲートする",
"network": "ネットワーク",
@@ -4623,6 +4609,55 @@
"Not saved yet. Saving later will replace the latest version.": "まだ保存されていません。後で保存すると最新バージョンを上書きします。",
"Tag assignments are preserved.": "タグの関連付けは保持されています。",
"{{count}} tagged resources are currently unavailable._other": "タグ付けされたリソース {{count}} 件は現在利用できません。",
"Uploaded files": "アップロードファイル",
"Global search": "全体検索",
"Search messages, projects, files and Library…": "メッセージ、プロジェクト、ファイル、Library を検索…",
"Search categories": "検索カテゴリ",
"Could not load search results.": "検索結果を読み込めませんでした。",
"Some results are unavailable.": "一部の結果を表示できません。",
"No results found": "結果が見つかりません",
"Recent files": "最近のファイル",
"Recent literature": "最近の文献",
"Content preview": "内容プレビュー",
"File information": "ファイル情報",
"Jump to message": "メッセージに移動",
"Open session": "セッションを開く",
"Open collection": "コレクションを開く",
"Open literature": "文献を開く",
"File name": "ファイル名",
"File size": "ファイルサイズ",
"Matched message": "一致したメッセージ",
"You": "あなた",
"Collapse details": "詳細を折りたたむ",
"Back to results": "結果に戻る",
"Search scope": "検索範囲",
"All projects and Library": "すべてのプロジェクトと Library",
"Current project": "現在のプロジェクト",
"Collection": "コレクション",
"Try another keyword or broaden the search scope.": "別のキーワードを試すか、検索範囲を広げてください。",
"PDF available": "PDF 保存済み",
"No PDF": "PDF なし",
"Only the 10 most recent items are shown": "最新の10件のみ表示しています",
"All senders": "すべての送信者",
"Sent by me": "自分が送信",
"Sent by agents": "エージェントが送信",
"All formats": "すべての形式",
"Spreadsheets / CSV": "表計算 / CSV",
"Images": "画像",
"All entries": "すべての項目",
"Result order": "結果の並び順",
"Relevance within categories": "カテゴリ内の関連度",
"Any time": "期間指定なし",
"Last 7 days": "過去 7 日間",
"Refine category": "カテゴリの絞り込み",
"Notebook couldn't be read for preview": "プレビュー用の Notebook を読み込めませんでした",
"Cell {{number}}": "セル {{number}}",
"Showing {{shown}} of {{total}} cells": "{{total}} 個中 {{shown}} 個のセルを表示",
"No abstract available": "要旨がありません",
"Could not load recent content.": "最近の内容を読み込めませんでした。",
"No recent content": "最近の内容はありません",
"{{count}} items_other": "{{count}}件",
"{{count}} messages_other": "{{count}}件のメッセージ",
"Access authorization has expired. Reopen the Web link from Open Science on the host computer, or return to the remote access entry page to pair again.": "アクセス認証の有効期限が切れました。ホスト側の Open Science から Web リンクを開き直すか、リモートアクセスの入口ページに戻って再度ペアリングしてください。",
"The operation result could not be confirmed. It may still be running. Reconnect and check its status before trying again.": "操作結果を確認できませんでした。操作はまだ実行中の可能性があります。再接続して状態を確認してから、再試行してください。",
"Any call": "すべての呼び出し",
@@ -4744,6 +4779,7 @@
"Compare selected references": "選択した文献を比較",
"PDF added. Refreshing attachment details…": "PDF を追加しました。添付ファイルの詳細を更新しています…",
"You can close this dialog. The download continues in the background; reopen it to see progress.": "このダイアログを閉じても、ダウンロードはバックグラウンドで続行されます。進捗を確認するには、もう一度開いてください。",
"Local computer": "ローカルコンピューター",
"Retry file verification": "ファイルを再検証",
"Remove attachment": "添付ファイルを削除",
"This PDF requires a password. Add an unlocked copy.": "この PDF にはパスワードが必要です。ロックを解除したコピーを追加してください。",
+53 -17
View File
@@ -103,6 +103,9 @@
"Your unsaved changes will be lost.": "저장하지 않은 변경 사항이 사라집니다."
},
"renderer": {
"The source message is no longer available.": "원본 메시지를 더 이상 사용할 수 없습니다.",
"This Notebook exceeds the 10 MB preview limit.": "이 Notebook은 10 MB 미리보기 제한을 초과합니다.",
"Recent searches": "최근 검색",
"Cancellation request failed": "취소 요청에 실패했습니다",
"Skills are loading. Paste again shortly.": "스킬을 불러오는 중입니다. 잠시 후 다시 붙여넣으세요.",
"Could not load Skills. Try pasting again.": "스킬을 불러오지 못했습니다. 다시 붙여넣으세요.",
@@ -442,7 +445,6 @@
"+ {{count}} more steps_other": "+ {{count}}단계",
"+{{count}} more_other": "+{{count}} 더보기",
"+{{count}} more_files_other": "+{{count}} 더보기",
"+{{count}} more matches — show more_other": "+{{count}} 더 많은 일치 항목 — 더 보기",
", ": ",",
".ipynb": ".ipynb",
".md files need a name and description in YAML frontmatter. .zip or .skill bundles must contain a SKILL.md. You'll confirm before anything is added.": ".md 파일의 YAML 프런트매터에는 이름과 설명이 있어야 합니다. .zip 또는 .skill 번들에는 SKILL.md가 포함되어야 합니다. 추가하기 전에 내용을 확인할 수 있습니다.",
@@ -827,8 +829,6 @@
"Command": "명령",
"Command line tool": "명령줄 도구",
"Command line tool path": "명령줄 도구 경로",
"Command palette": "명령 팔레트",
"Commands": "명령",
"Compute cache": "컴퓨팅 캐시",
"Common causes: an xychart is missing the <kw1>title</kw1> keyword, axis labels are not quoted, or <kw2>y-axis</kw2> or <kw3>bar/line</kw3> data rows are missing.": "일반적인 원인: xychart에 <kw1>title</kw1> 키워드가 없거나, 축 레이블이 인용되지 않았거나, <kw2>y-axis</kw2> 또는 <kw3>bar/line</kw3> 데이터 행이 누락되었습니다.",
"Community": "커뮤니티",
@@ -1006,8 +1006,6 @@
"Could not export this Skill.": "스킬을 내보낼 수 없습니다.",
"Could not import the selected skills.": "선택한 스킬을 가져올 수 없습니다.",
"Could not list packages.": "패키지를 나열할 수 없습니다.",
"Could not load artifacts — retry": "아티팩트를 로드할 수 없습니다. 다시 시도하세요.",
"Could not load artifacts.": "아티팩트를 로드할 수 없습니다.",
"Could not load local network details.": "로컬 네트워크 정보를 불러올 수 없습니다.",
"Could not load this source": "이 출처를 불러올 수 없습니다",
"This source does not allow embedded previews.": "이 출처는 임베드 미리보기를 허용하지 않습니다.",
@@ -1015,7 +1013,6 @@
"The source returned an HTTP error.": "출처에서 HTTP 오류를 반환했습니다.",
"The source could not be reached.": "이 출처에 연결할 수 없습니다.",
"Could not load this session for export.": "내보내기 위해 이 세션을 불러올 수 없습니다.",
"Could not load more — retry": "더 이상 로드할 수 없습니다. 다시 시도하세요.",
"Could not load project artifacts.": "프로젝트 아티팩트를 로드할 수 없습니다.",
"Could not load project files": "프로젝트 파일을 로드할 수 없습니다.",
"Could not load review history.": "리뷰 기록을 로드할 수 없습니다.",
@@ -1070,7 +1067,6 @@
"Could not check the command-line tool.": "명령줄 도구를 확인할 수 없습니다.",
"Couldn't fetch models: {{reason}}. Using the bundled list.": "모델을 가져올 수 없습니다: {{reason}}. 번들 목록을 사용합니다.",
"Couldn't load hosts.": "호스트를 로드할 수 없습니다.",
"Create a project to search sessions and artifacts.": "프로젝트를 만들어 세션 및 아티팩트를 검색하세요.",
"Create a skill to teach Claude a workflow you use.": "스킬을 만들어 사용하는 작업 흐름을 Claude에 가르치세요.",
"Create agent": "에이전트 만들기",
"Create new…": "새로 만들기…",
@@ -1078,7 +1074,6 @@
"Created by you.": "직접 만들었습니다.",
"Created execution Plan": "실행 계획 작성",
"Creating execution Plan": "실행 계획 생성",
"Creation time unavailable": "생성 시간을 알 수 없음",
"Credentials": "자격 증명",
"Current (hidden)": "현재(숨김)",
"Current data location": "현재 데이터 위치",
@@ -1692,7 +1687,6 @@
"Markdown shown to the agent when the skill is invoked.": "스킬을 호출하면 에이전트에게 표시되는 Markdown입니다.",
"Max tokens": "최대 토큰",
"Maximize": "최대화",
"Mention is unavailable while the composer cannot accept another artifact.": "작성창이 다른 아티팩트를 받을 수 없는 동안에는 멘션을 사용할 수 없습니다.",
"Mention {{name}}": "멘션 추가: {{name}}",
"Mermaid syntax could not be rendered": "Mermaid 구문을 렌더링할 수 없습니다.",
"Message center": "메시지 센터",
@@ -1815,7 +1809,6 @@
"No review available for this session.": "세션에 대한 리뷰가 없습니다.",
"No review for this version": "이 버전에 대한 리뷰가 없습니다.",
"No run history yet": "아직 실행 기록이 없습니다.",
"No results match “{{query}}”.": "“{{query}}”와 일치하는 결과가 없습니다.",
"No skills added yet.": "아직 스킬이 추가되지 않았습니다.",
"No skills found": "스킬을 찾을 수 없습니다.",
"No skills found in that repo.": "해당 저장소에 스킬이 없습니다.",
@@ -1945,7 +1938,6 @@
"Or type your own answer…": "아니면 자신만의 답변을 입력하세요...",
"Other": "기타",
"Other artifacts": "기타 아티팩트",
"Other projects": "기타 프로젝트",
"Other…": "기타…",
"Output": "출력",
"Output truncated": "잘린 출력",
@@ -2110,7 +2102,6 @@
"Reasoning effort": "추론 강도",
"Reasoning effort levels": "추론 강도 레벨",
"Reasoning effort request format": "추론 강도 요청 형식",
"Recent artifacts": "최근 아티팩트",
"Recent sessions": "최근 세션",
"Recheck": "재확인",
"Recheck message snapshot": "메시지 스냅샷 다시 확인",
@@ -2402,13 +2393,11 @@
"Search projects": "프로젝트 검색",
"Search projects…": "프로젝트 검색…",
"Search repositories by keyword, or scan a GitHub repository for Skill folders to import.": "키워드로 저장소를 검색하거나 GitHub 저장소에서 스킬 폴더를 스캔하여 가져올 수 있습니다.",
"Search sessions and artifacts…": "세션 및 아티팩트를 검색하세요…",
"Search skills": "스킬 검색",
"Search skills to add": "추가하려면 스킬을 검색하세요.",
"Search skills…": "스킬 검색…",
"Search specialists": "스페셜리스트 검색",
"Search specialists…": "스페셜리스트 검색…",
"Search this project…": "이 프로젝트 검색…",
"Searching…": "검색 중…",
"Secure credential storage": "보안 자격 증명 저장소",
"Secure key storage is unavailable. API keys cannot be saved until the system keychain is unlocked or authorized.": "보안 키 저장소를 사용할 수 없습니다. 시스템 키체인의 잠금을 해제하거나 접근을 허용해야 API 키를 저장할 수 있습니다.",
@@ -2557,7 +2546,6 @@
"Skip approvals for {{name}}": "{{name}}에 대한 승인 건너뛰기",
"Skipped": "건너뛰었습니다.",
"Skipped {{count}} skills_other": "스킬 {{count}}개를 건너뜀",
"Some artifact results may be missing.": "일부 아티팩트 결과가 누락될 수 있습니다.",
"Some files could not be indexed yet.": "일부 파일의 색인을 아직 생성할 수 없습니다.",
"Some permission details are unavailable": "일부 권한 세부정보를 사용할 수 없습니다.",
"Someone else saved a newer version": "다른 사람이 최신 버전을 저장했습니다.",
@@ -3001,7 +2989,6 @@
"e.g. changelog-style": "예: 변경 기록 형식",
"error": "오류",
"error (line {{line}})": "오류(라인 {{line}})",
"esc": "에스",
"exit {{code}}": "종료 {{code}}",
"fail": "실패하다",
"failed": "실패했다",
@@ -3020,7 +3007,6 @@
"low confidence": "낮은 자신감",
"medium": "중간",
"medium confidence": "중간 신뢰도",
"mention": "언급하다",
"model": "모델",
"navigate": "항해하다",
"network": "네트워크",
@@ -4621,6 +4607,55 @@
"Not saved yet. Saving later will replace the latest version.": "아직 저장되지 않았습니다. 나중에 저장하면 최신 버전을 덮어씁니다.",
"Tag assignments are preserved.": "태그 연결은 유지됩니다.",
"{{count}} tagged resources are currently unavailable._other": "태그가 지정된 리소스 {{count}}개를 현재 사용할 수 없습니다.",
"Uploaded files": "업로드한 파일",
"Global search": "전체 검색",
"Search messages, projects, files and Library…": "메시지, 프로젝트, 파일 및 Library 검색…",
"Search categories": "검색 카테고리",
"Could not load search results.": "검색 결과를 불러오지 못했습니다.",
"Some results are unavailable.": "일부 결과를 사용할 수 없습니다.",
"No results found": "검색 결과가 없습니다",
"Recent files": "최근 파일",
"Recent literature": "최근 문헌",
"Content preview": "내용 미리보기",
"File information": "파일 정보",
"Jump to message": "메시지로 이동",
"Open session": "세션 열기",
"Open collection": "컬렉션 열기",
"Open literature": "문헌 열기",
"File name": "파일 이름",
"File size": "파일 크기",
"Matched message": "일치하는 메시지",
"You": "사용자",
"Collapse details": "상세 정보 접기",
"Back to results": "결과로 돌아가기",
"Search scope": "검색 범위",
"All projects and Library": "모든 프로젝트 및 Library",
"Current project": "현재 프로젝트",
"Collection": "컬렉션",
"Try another keyword or broaden the search scope.": "다른 검색어를 사용하거나 검색 범위를 넓히세요.",
"PDF available": "PDF 저장됨",
"No PDF": "PDF 없음",
"Only the 10 most recent items are shown": "최근 항목 10개만 표시됩니다",
"All senders": "모든 발신자",
"Sent by me": "내가 보낸 메시지",
"Sent by agents": "에이전트가 보낸 메시지",
"All formats": "모든 형식",
"Spreadsheets / CSV": "스프레드시트 / CSV",
"Images": "이미지",
"All entries": "모든 항목",
"Result order": "결과 정렬",
"Relevance within categories": "분류 내 관련도",
"Any time": "전체 기간",
"Last 7 days": "최근 7일",
"Refine category": "분류 세부 필터",
"Notebook couldn't be read for preview": "미리보기를 위해 Notebook을 읽을 수 없습니다",
"Cell {{number}}": "셀 {{number}}",
"Showing {{shown}} of {{total}} cells": "셀 {{total}}개 중 {{shown}}개 표시",
"No abstract available": "초록이 없습니다",
"Could not load recent content.": "최근 내용을 불러오지 못했습니다.",
"No recent content": "최근 내용이 없습니다",
"{{count}} items_other": "항목 {{count}}개",
"{{count}} messages_other": "메시지 {{count}}개",
"Access authorization has expired. Reopen the Web link from Open Science on the host computer, or return to the remote access entry page to pair again.": "접근 인증이 만료되었습니다. 호스트 컴퓨터의 Open Science에서 Web 링크를 다시 열거나 원격 접근 시작 페이지로 돌아가 다시 페어링하세요.",
"The operation result could not be confirmed. It may still be running. Reconnect and check its status before trying again.": "작업 결과를 확인할 수 없습니다. 작업이 아직 실행 중일 수 있습니다. 다시 연결하여 상태를 확인한 후 재시도하세요.",
"Any call": "모든 호출",
@@ -4742,6 +4777,7 @@
"Compare selected references": "선택한 문헌 비교",
"PDF added. Refreshing attachment details…": "PDF가 추가되었습니다. 첨부 파일 세부 정보를 새로고침하는 중…",
"You can close this dialog. The download continues in the background; reopen it to see progress.": "이 대화 상자를 닫아도 다운로드는 백그라운드에서 계속됩니다. 진행 상황을 확인하려면 다시 여세요.",
"Local computer": "로컬 컴퓨터",
"Retry file verification": "파일 다시 검증",
"Remove attachment": "첨부 파일 제거",
"This PDF requires a password. Add an unlocked copy.": "이 PDF에는 비밀번호가 필요합니다. 잠금 해제된 사본을 추가하세요.",
+59 -20
View File
@@ -106,6 +106,9 @@
"Your unsaved changes will be lost.": "Несохранённые изменения будут потеряны."
},
"renderer": {
"The source message is no longer available.": "Исходное сообщение больше недоступно.",
"This Notebook exceeds the 10 MB preview limit.": "Этот Notebook превышает ограничение предпросмотра в 10 МБ.",
"Recent searches": "Недавние поиски",
"Cancellation request failed": "Не удалось запросить отмену",
"Skills are loading. Paste again shortly.": "Навыки загружаются. Повторите вставку через некоторое время.",
"Could not load Skills. Try pasting again.": "Не удалось загрузить Навыки. Повторите вставку.",
@@ -466,10 +469,6 @@
"+{{count}} more_files_few": "+{{count}} файла",
"+{{count}} more_files_many": "+{{count}} файлов",
"+{{count}} more_files_other": "+{{count}} файла",
"+{{count}} more matches — show more_one": "+ещё {{count}} совпадение — показать больше",
"+{{count}} more matches — show more_few": "+ещё {{count}} совпадения — показать больше",
"+{{count}} more matches — show more_many": "+ещё {{count}} совпадений — показать больше",
"+{{count}} more matches — show more_other": "+ещё {{count}} совпадения — показать больше",
", ": ",",
".ipynb": ".ipynb",
".md files need a name and description in YAML frontmatter. .zip or .skill bundles must contain a SKILL.md. You'll confirm before anything is added.": "Файлы .md должны содержать имя и описание в метаданных YAML. Архивы .zip и пакеты .skill должны содержать SKILL.md. Перед добавлением потребуется подтверждение.",
@@ -858,8 +857,6 @@
"Command": "Команда",
"Command line tool": "Инструмент командной строки",
"Command line tool path": "Путь к инструменту командной строки",
"Command palette": "Панель команд",
"Commands": "Команды",
"Compute cache": "Кэш вычислений",
"Common causes: an xychart is missing the <kw1>title</kw1> keyword, axis labels are not quoted, or <kw2>y-axis</kw2> or <kw3>bar/line</kw3> data rows are missing.": "Общие причины: в xychart отсутствует ключевое слово <kw1>title</kw1>, метки осей не заключены в кавычки, или отсутствуют строки данных <kw2>y-axis</kw2> или <kw3>bar/line</kw3>.",
"Community": "Сообщество",
@@ -1032,8 +1029,6 @@
"Could not export this Skill.": "Не удалось экспортировать этот навык.",
"Could not import the selected skills.": "Не удалось импортировать выбранные навыки.",
"Could not list packages.": "Не удалось получить список пакетов.",
"Could not load artifacts — retry": "Не удалось загрузить артефакты — повторите попытку",
"Could not load artifacts.": "Не удалось загрузить артефакты.",
"Could not load local network details.": "Не удалось загрузить сведения о локальной сети.",
"Could not load this source": "Не удалось загрузить этот источник",
"This source does not allow embedded previews.": "Этот источник запрещает встроенный предпросмотр.",
@@ -1041,7 +1036,6 @@
"The source returned an HTTP error.": "Источник вернул ошибку HTTP.",
"The source could not be reached.": "Не удалось подключиться к этому источнику.",
"Could not load this session for export.": "Не удалось загрузить эту сессию для экспорта.",
"Could not load more — retry": "Не удалось загрузить больше — повторите попытку",
"Could not load project artifacts.": "Не удалось загрузить артефакты проекта.",
"Could not load project files": "Не удалось загрузить файлы проекта",
"Could not load review history.": "Не удалось загрузить историю обзора.",
@@ -1098,7 +1092,6 @@
"Could not check the command-line tool.": "Не удалось проверить инструмент командной строки.",
"Couldn't fetch models: {{reason}}. Using the bundled list.": "Не удалось загрузить модели: {{reason}}. Используется встроенный список.",
"Couldn't load hosts.": "Не удалось загрузить хосты.",
"Create a project to search sessions and artifacts.": "Создайте проект, чтобы искать сессии и артефакты.",
"Create a skill to teach Claude a workflow you use.": "Создайте навык, чтобы научить Claude работе с рабочим процессом, который вы используете.",
"Create agent": "Создать агента",
"Create new…": "Создать новый…",
@@ -1106,7 +1099,6 @@
"Created by you.": "Создано вами.",
"Created execution Plan": "Создан план выполнения",
"Creating execution Plan": "Создание плана выполнения",
"Creation time unavailable": "Время создания недоступно",
"Credentials": "Учётные данные",
"Current (hidden)": "Текущий (скрытый)",
"Current data location": "Текущее расположение данных",
@@ -1750,7 +1742,6 @@
"Maximum output tokens": "Максимальное количество выходных токенов",
"Maximum output tokens must be a positive whole number of tokens.": "Максимальное количество выходных токенов должно быть положительным целым числом.",
"Maximize": "Развернуть",
"Mention is unavailable while the composer cannot accept another artifact.": "Упоминание недоступно, пока в поле ввода нельзя добавить ещё один артефакт.",
"Mention {{name}}": "Упомянуть {{name}}",
"Mermaid syntax could not be rendered": "Не удалось отобразить диаграмму Mermaid",
"Message center": "Центр сообщений",
@@ -1875,7 +1866,6 @@
"No review available for this session.": "Для этой сессии нет доступной проверки.",
"No review for this version": "Для этой версии нет проверки",
"No run history yet": "Истории запусков пока нет",
"No results match “{{query}}”.": "Нет результатов, соответствующих запросу «{{query}}».",
"No skills added yet.": "Навыки пока не добавлены.",
"No skills found": "Навыки не найдены",
"No skills found in that repo.": "В этом репозитории навыки не найдены.",
@@ -2006,7 +1996,6 @@
"Or type your own answer…": "Или введите свой ответ…",
"Other": "Другое",
"Other artifacts": "Другие артефакты",
"Other projects": "Другие проекты",
"Other…": "Другое…",
"Output": "Вывод",
"Output truncated": "Вывод обрезан",
@@ -2171,7 +2160,6 @@
"Reasoning effort": "Глубина рассуждений",
"Reasoning effort levels": "Глубина рассуждений",
"Reasoning effort request format": "Формат запроса глубины рассуждений",
"Recent artifacts": "Последние артефакты",
"Recent sessions": "Последние сессии",
"Recheck": "Повторить проверку",
"Recheck message snapshot": "Проверить снимок сообщений ещё раз",
@@ -2506,13 +2494,11 @@
"Search projects": "Искать проекты",
"Search projects…": "Поиск проектов…",
"Search repositories by keyword, or scan a GitHub repository for Skill folders to import.": "Искать репозитории по ключевому слову или сканировать репозиторий GitHub для импорта папок навыков.",
"Search sessions and artifacts…": "Искать сессии и артефакты…",
"Search skills": "Искать навыки",
"Search skills to add": "Искать навыки для добавления",
"Search skills…": "Искать навыки…",
"Search specialists": "Искать специалистов",
"Search specialists…": "Искать специалистов…",
"Search this project…": "Искать в этом проекте…",
"Searching…": "Поиск…",
"Secure credential storage": "Безопасное хранение учётных данных",
"Secure key storage is unavailable. API keys cannot be saved until the system keychain is unlocked or authorized.": "Безопасное хранение ключей недоступно. Ключи API нельзя сохранить, пока не будет разблокирована система ключей или не будет получено разрешение.",
@@ -2676,7 +2662,6 @@
"Skipped {{count}} skills_few": "Пропущено {{count}} навыка",
"Skipped {{count}} skills_many": "Пропущено {{count}} навыков",
"Skipped {{count}} skills_other": "Пропущено {{count}} навыка",
"Some artifact results may be missing.": "Некоторые результаты артефактов могут отсутствовать.",
"Some files could not be indexed yet.": "Некоторые файлы ещё не были индексированы.",
"Some permission details are unavailable": "Некоторая информация о разрешениях недоступна",
"Someone else saved a newer version": "Кто-то другой сохранил более новую версию",
@@ -3134,7 +3119,6 @@
"e.g. changelog-style": "например, changelog-style",
"error": "ошибка",
"error (line {{line}})": "ошибка (строка {{line}})",
"esc": "esc",
"exit {{code}}": "выход {{code}}",
"fail": "ошибка",
"failed": "не удалось",
@@ -3153,7 +3137,6 @@
"low confidence": "низкая уверенность",
"medium": "средний",
"medium confidence": "средняя уверенность",
"mention": "упоминание",
"model": "модель",
"navigate": "навигация",
"network": "сеть",
@@ -5060,6 +5043,61 @@
"{{count}} tagged resources are currently unavailable._few": "{{count}} ресурса с тегом сейчас недоступны.",
"{{count}} tagged resources are currently unavailable._other": "{{count}} ресурса с тегом сейчас недоступны.",
"{{count}} tagged resources are currently unavailable._one": "{{count}} ресурс с тегом сейчас недоступен.",
"Uploaded files": "Загруженные файлы",
"Global search": "Глобальный поиск",
"Search messages, projects, files and Library…": "Поиск сообщений, проектов, файлов и в Library…",
"Search categories": "Категории поиска",
"Could not load search results.": "Не удалось загрузить результаты поиска.",
"Some results are unavailable.": "Некоторые результаты недоступны.",
"No results found": "Результаты не найдены",
"Recent files": "Недавние файлы",
"Recent literature": "Недавние публикации",
"Content preview": "Просмотр содержимого",
"File information": "Информация о файле",
"Jump to message": "Перейти к сообщению",
"Open session": "Открыть сеанс",
"Open collection": "Открыть коллекцию",
"Open literature": "Открыть публикацию",
"File name": "Имя файла",
"File size": "Размер файла",
"Matched message": "Найденное сообщение",
"You": "Вы",
"Collapse details": "Свернуть подробности",
"Back to results": "Вернуться к результатам",
"Search scope": "Область поиска",
"All projects and Library": "Все проекты и Library",
"Current project": "Текущий проект",
"Collection": "Коллекция",
"Try another keyword or broaden the search scope.": "Попробуйте другое ключевое слово или расширьте область поиска.",
"PDF available": "PDF сохранён",
"No PDF": "Нет PDF",
"Only the 10 most recent items are shown": "Показаны только 10 последних элементов",
"All senders": "Все отправители",
"Sent by me": "Отправленные мной",
"Sent by agents": "Отправленные агентами",
"All formats": "Все форматы",
"Spreadsheets / CSV": "Таблицы / CSV",
"Images": "Изображения",
"All entries": "Все записи",
"Result order": "Порядок результатов",
"Relevance within categories": "Релевантность в категории",
"Any time": "Любое время",
"Last 7 days": "Последние 7 дней",
"Refine category": "Уточнить категорию",
"Notebook couldn't be read for preview": "Не удалось прочитать Notebook для предпросмотра",
"Cell {{number}}": "Ячейка {{number}}",
"Showing {{shown}} of {{total}} cells": "Показано {{shown}} из {{total}} ячеек",
"No abstract available": "Аннотация недоступна",
"Could not load recent content.": "Не удалось загрузить недавнее содержимое.",
"No recent content": "Нет недавнего содержимого",
"{{count}} items_one": "{{count}} элемент",
"{{count}} items_few": "{{count}} элемента",
"{{count}} items_many": "{{count}} элементов",
"{{count}} items_other": "{{count}} элемента",
"{{count}} messages_one": "{{count}} сообщение",
"{{count}} messages_few": "{{count}} сообщения",
"{{count}} messages_many": "{{count}} сообщений",
"{{count}} messages_other": "{{count}} сообщения",
"Access authorization has expired. Reopen the Web link from Open Science on the host computer, or return to the remote access entry page to pair again.": "Срок действия разрешения на доступ истёк. Снова откройте веб-ссылку из Open Science на главном компьютере или вернитесь на страницу удалённого доступа для повторного сопряжения.",
"The operation result could not be confirmed. It may still be running. Reconnect and check its status before trying again.": "Не удалось подтвердить результат операции. Возможно, она ещё выполняется. Подключитесь снова и проверьте её состояние перед повторной попыткой.",
"Any call": "Любой вызов",
@@ -5190,6 +5228,7 @@
"Compare selected references": "Сравнить выбранные источники",
"PDF added. Refreshing attachment details…": "PDF добавлен. Обновление сведений о вложении…",
"You can close this dialog. The download continues in the background; reopen it to see progress.": "Можно закрыть это окно. Загрузка продолжится в фоновом режиме; откройте окно снова, чтобы увидеть ход загрузки.",
"Local computer": "Локальный компьютер",
"Retry file verification": "Повторно проверить файл",
"Remove attachment": "Удалить вложение",
"This PDF requires a password. Add an unlocked copy.": "Этот PDF защищён паролем. Добавьте разблокированную копию.",
+53 -17
View File
@@ -103,6 +103,9 @@
"Your unsaved changes will be lost.": "未保存的更改将会丢失。"
},
"renderer": {
"The source message is no longer available.": "源消息已不可用。",
"This Notebook exceeds the 10 MB preview limit.": "此 Notebook 超过 10 MB 预览限制。",
"Recent searches": "最近搜索",
"Cancellation request failed": "取消请求失败",
"Skills are loading. Paste again shortly.": "正在加载技能,请稍后再次粘贴。",
"Could not load Skills. Try pasting again.": "无法加载技能,请尝试再次粘贴。",
@@ -507,7 +510,6 @@
"+ {{count}} more steps_other": "+ 还有 {{count}} 个步骤",
"+{{count}} more_other": "+{{count}} 更多",
"+{{count}} more_files_other": "另有 {{count}} 个文件",
"+{{count}} more matches — show more_other": "另有 {{count}} 条匹配 —— 显示更多",
", ": ",",
".ipynb": ".ipynb",
".md files need a name and description in YAML frontmatter. .zip or .skill bundles must contain a SKILL.md. You'll confirm before anything is added.": ".md 文件需在 YAML 头信息中包含名称和描述。.zip 或 .skill 包必须包含 SKILL.md。添加前会请您确认。",
@@ -937,8 +939,6 @@
"Command": "命令",
"Command line tool": "命令行工具",
"Command line tool path": "命令行工具路径",
"Command palette": "命令面板",
"Commands": "命令",
"Compute cache": "计算缓存",
"Common causes: an xychart is missing the <kw1>title</kw1> keyword, axis labels are not quoted, or <kw2>y-axis</kw2> or <kw3>bar/line</kw3> data rows are missing.": "常见原因:xychart 缺少 <kw1>title</kw1> 关键字,轴标签没有加引号,或者 <kw2>y-axis</kw2> 或 <kw3>bar/line</kw3> 的数据行缺失。",
"Community": "社区",
@@ -1073,8 +1073,6 @@
"Could not export this Skill.": "无法导出此技能。",
"Could not import the selected skills.": "无法导入所选技能。",
"Could not list packages.": "无法列出软件包。",
"Could not load artifacts — retry": "无法加载产物 —— 重试",
"Could not load artifacts.": "无法加载产物。",
"Could not load local network details.": "无法加载本地网络信息。",
"Could not load this source": "无法加载此来源",
"This source does not allow embedded previews.": "此来源不允许嵌入式预览。",
@@ -1082,7 +1080,6 @@
"The source returned an HTTP error.": "来源返回了 HTTP 错误。",
"The source could not be reached.": "无法连接到此来源。",
"Could not load this session for export.": "无法加载此会话以供导出。",
"Could not load more — retry": "无法加载更多 —— 重试",
"Could not load project artifacts.": "无法加载项目产物。",
"Could not load project files": "无法加载项目文件",
"Could not load review history.": "无法加载审查历史。",
@@ -1137,7 +1134,6 @@
"Could not check the command-line tool.": "无法检查命令行工具。",
"Couldn't fetch models: {{reason}}. Using the bundled list.": "无法获取模型:{{reason}}。将使用内置列表。",
"Couldn't load hosts.": "无法加载主机。",
"Create a project to search sessions and artifacts.": "新建一个项目,即可搜索会话和产物。",
"Create a skill to teach Claude a workflow you use.": "创建一个技能,把你常用的流程教给 Claude。",
"Create agent": "创建智能体",
"Create new…": "新建……",
@@ -1145,7 +1141,6 @@
"Created by you.": "由你创建。",
"Created execution Plan": "已创建执行计划",
"Creating execution Plan": "正在创建执行计划",
"Creation time unavailable": "创建时间不可用",
"Credentials": "凭据",
"Current (hidden)": "当前位置(隐藏)",
"Current data location": "当前数据位置",
@@ -1764,7 +1759,6 @@
"Markdown shown to the agent when the skill is invoked.": "技能被调用时展示给智能体的 Markdown。",
"Max tokens": "词元上限",
"Maximize": "最大化",
"Mention is unavailable while the composer cannot accept another artifact.": "输入框无法再接收产物,暂时无法提及。",
"Mention {{name}}": "提及 {{name}}",
"Mermaid syntax could not be rendered": "无法渲染 Mermaid 语法",
"Message center": "消息中心",
@@ -1887,7 +1881,6 @@
"No review available for this session.": "此会话暂无可用的审查结果。",
"No review for this version": "此版本没有审查记录",
"No run history yet": "还没有运行记录",
"No results match “{{query}}”.": "没有结果匹配“{{query}}”。",
"No skills added yet.": "尚未添加技能。",
"No skills found": "未找到技能",
"No skills found in that repo.": "那个仓库里没有找到技能。",
@@ -2021,7 +2014,6 @@
"Or type your own answer…": "或输入你自己的回答…",
"Other": "其他",
"Other artifacts": "其他 Artifacts",
"Other projects": "其他项目",
"Other…": "其他…",
"Output": "输出",
"Output truncated": "输出已截断",
@@ -2187,7 +2179,6 @@
"Reasoning effort": "推理强度",
"Reasoning effort levels": "推理强度档位",
"Reasoning effort request format": "推理强度请求格式",
"Recent artifacts": "最近的产物",
"Recent sessions": "最近会话",
"Recheck": "重新检测",
"Recheck message snapshot": "重新检查消息快照",
@@ -2482,13 +2473,11 @@
"Search projects": "搜索项目",
"Search projects…": "搜索项目…",
"Search repositories by keyword, or scan a GitHub repository for Skill folders to import.": "按关键词搜索仓库,或扫描某个 GitHub 仓库以导入技能文件夹。",
"Search sessions and artifacts…": "搜索会话和产物…",
"Search skills": "搜索技能",
"Search skills to add": "搜索要添加的技能",
"Search skills…": "搜索技能…",
"Search specialists": "搜索专家",
"Search specialists…": "搜索专家…",
"Search this project…": "在此项目中搜索…",
"Searching…": "搜索中……",
"Secure credential storage": "凭据安全存储",
"Secure key storage is unavailable. API keys cannot be saved until the system keychain is unlocked or authorized.": "安全密钥存储不可用。在系统钥匙串解锁或授权之前,无法保存 API 密钥。",
@@ -2633,7 +2622,6 @@
"Skip approvals for {{name}}": "为 {{name}} 跳过批准",
"Skipped": "已跳过",
"Skipped {{count}} skills_other": "已跳过 {{count}} 个技能",
"Some artifact results may be missing.": "部分产物结果可能未显示。",
"Some files could not be indexed yet.": "部分文件尚未完成索引。",
"Some permission details are unavailable": "部分权限详情不可用",
"Someone else saved a newer version": "其他人保存了更新的版本",
@@ -3077,7 +3065,6 @@
"e.g. changelog-style": "例如 changelog-style",
"error": "错误",
"error (line {{line}})": "错误(第 {{line}} 行)",
"esc": "esc",
"exit {{code}}": "退出码 {{code}}",
"fail": "失败",
"failed": "失败",
@@ -3096,7 +3083,6 @@
"low confidence": "低置信度",
"medium": "中",
"medium confidence": "中等置信度",
"mention": "提及",
"model": "模型",
"navigate": "切换",
"network": "网络",
@@ -4623,6 +4609,55 @@
"Not saved yet. Saving later will replace the latest version.": "尚未保存。之后保存会覆盖最新版本。",
"Tag assignments are preserved.": "标签关联已保留。",
"{{count}} tagged resources are currently unavailable._other": "{{count}} 个已关联资源暂不可用。",
"Uploaded files": "上传文件",
"Global search": "全局搜索",
"Search messages, projects, files and Library…": "搜索消息、项目、文件和 Library…",
"Search categories": "搜索分类",
"Could not load search results.": "无法加载搜索结果。",
"Some results are unavailable.": "部分结果暂不可用。",
"No results found": "未找到结果",
"Recent files": "最近文件",
"Recent literature": "最近文献",
"Content preview": "内容预览",
"File information": "文件信息",
"Jump to message": "跳转到消息",
"Open session": "打开会话",
"Open collection": "打开文献集",
"Open literature": "打开文献",
"File name": "文件名",
"File size": "文件大小",
"Matched message": "命中的消息",
"You": "你",
"Collapse details": "收起详情",
"Back to results": "返回列表",
"Search scope": "搜索范围",
"All projects and Library": "全部项目与 Library",
"Current project": "当前项目",
"Collection": "集合",
"Try another keyword or broaden the search scope.": "可以换个关键词,或扩大搜索范围。",
"PDF available": "已保存 PDF",
"No PDF": "暂无 PDF",
"Only the 10 most recent items are shown": "仅显示最近前 10 条",
"All senders": "全部发送者",
"Sent by me": "我发送的",
"Sent by agents": "智能体发送的",
"All formats": "全部格式",
"Spreadsheets / CSV": "表格 / CSV",
"Images": "图片",
"All entries": "全部条目",
"Result order": "结果排序",
"Relevance within categories": "组内相关度",
"Any time": "不限时间",
"Last 7 days": "最近 7 天",
"Refine category": "分类细筛",
"Notebook couldn't be read for preview": "无法读取 Notebook 进行预览",
"Cell {{number}}": "单元格 {{number}}",
"Showing {{shown}} of {{total}} cells": "显示 {{shown}} / {{total}} 个单元格",
"No abstract available": "暂无摘要",
"Could not load recent content.": "无法加载最近内容。",
"No recent content": "暂无最近内容",
"{{count}} items_other": "{{count}} 项",
"{{count}} messages_other": "{{count}} 条消息",
"Access authorization has expired. Reopen the Web link from Open Science on the host computer, or return to the remote access entry page to pair again.": "访问授权已失效。请从主机上的 Open Science 重新打开 Web 链接,或返回远程访问入口页面重新配对。",
"The operation result could not be confirmed. It may still be running. Reconnect and check its status before trying again.": "无法确认操作结果。操作可能仍在进行。请重新连接并检查其状态,然后再决定是否重试。",
"Any call": "任何调用",
@@ -4744,6 +4779,7 @@
"Compare selected references": "比较所选文献",
"PDF added. Refreshing attachment details…": "PDF 已添加。正在刷新附件详情…",
"You can close this dialog. The download continues in the background; reopen it to see progress.": "您可以关闭此对话框。下载将在后台继续;重新打开即可查看进度。",
"Local computer": "本地计算机",
"Retry file verification": "重新验证文件",
"Remove attachment": "移除附件",
"This PDF requires a password. Add an unlocked copy.": "此 PDF 需要密码。请添加已解锁的副本。",
+53 -17
View File
@@ -103,6 +103,9 @@
"Your unsaved changes will be lost.": "未儲存的變更將會遺失。"
},
"renderer": {
"The source message is no longer available.": "來源訊息已無法使用。",
"This Notebook exceeds the 10 MB preview limit.": "此 Notebook 超過 10 MB 預覽限制。",
"Recent searches": "最近搜尋",
"Cancellation request failed": "取消請求失敗",
"Skills are loading. Paste again shortly.": "正在載入技能,請稍後再次貼上。",
"Could not load Skills. Try pasting again.": "無法載入技能,請嘗試再次貼上。",
@@ -507,7 +510,6 @@
"+ {{count}} more steps_other": "+ 還有 {{count}} 個步驟",
"+{{count}} more_other": "+{{count}} 更多",
"+{{count}} more_files_other": "另有 {{count}} 個檔案",
"+{{count}} more matches — show more_other": "另有 {{count}} 筆符合 —— 顯示更多",
", ": ",",
".ipynb": ".ipynb",
".md files need a name and description in YAML frontmatter. .zip or .skill bundles must contain a SKILL.md. You'll confirm before anything is added.": ".md 檔案需在 YAML 標頭資訊中包含名稱和描述。.zip 或 .skill 包必須包含 SKILL.md。新增前會請您確認。",
@@ -936,8 +938,6 @@
"Command": "指令",
"Command line tool": "命令列工具",
"Command line tool path": "命令列工具路徑",
"Command palette": "命令面板",
"Commands": "命令",
"Compute cache": "運算快取",
"Common causes: an xychart is missing the <kw1>title</kw1> keyword, axis labels are not quoted, or <kw2>y-axis</kw2> or <kw3>bar/line</kw3> data rows are missing.": "常見原因:xychart 缺少 <kw1>title</kw1> 關鍵字,軸標籤沒有加引號,或是 <kw2>y-axis</kw2> 或 <kw3>bar/line</kw3> 的資料列缺失。",
"Community": "社群",
@@ -1072,8 +1072,6 @@
"Could not export this Skill.": "無法匯出此技能。",
"Could not import the selected skills.": "無法匯入所選技能。",
"Could not list packages.": "無法列出套件。",
"Could not load artifacts — retry": "無法載入產物 —— 重試",
"Could not load artifacts.": "無法載入產物。",
"Could not load local network details.": "無法載入本機網路資訊。",
"Could not load this source": "無法載入此來源",
"This source does not allow embedded previews.": "此來源不允許嵌入式預覽。",
@@ -1081,7 +1079,6 @@
"The source returned an HTTP error.": "來源傳回 HTTP 錯誤。",
"The source could not be reached.": "無法連線至此來源。",
"Could not load this session for export.": "無法載入此會話以供匯出。",
"Could not load more — retry": "無法載入更多 —— 重試",
"Could not load project artifacts.": "無法載入專案產物。",
"Could not load project files": "無法載入專案檔案",
"Could not load review history.": "無法載入審查歷史。",
@@ -1136,7 +1133,6 @@
"Could not check the command-line tool.": "無法檢查命令列工具。",
"Couldn't fetch models: {{reason}}. Using the bundled list.": "無法取得模型:{{reason}}。將使用內建清單。",
"Couldn't load hosts.": "無法載入主機。",
"Create a project to search sessions and artifacts.": "新增一個專案,即可搜尋會話和產物。",
"Create a skill to teach Claude a workflow you use.": "建立一個技能,把你常用的流程教給 Claude。",
"Create agent": "建立智能體",
"Create new…": "新建……",
@@ -1144,7 +1140,6 @@
"Created by you.": "由你建立。",
"Created execution Plan": "已建立執行計劃",
"Creating execution Plan": "正在建立執行計劃",
"Creation time unavailable": "建立時間無法取得",
"Credentials": "憑證",
"Current (hidden)": "目前位置(隱藏)",
"Current data location": "目前資料位置",
@@ -1762,7 +1757,6 @@
"Markdown shown to the agent when the skill is invoked.": "技能被呼叫時展示給智能體的 Markdown。",
"Max tokens": "詞元上限",
"Maximize": "最大化",
"Mention is unavailable while the composer cannot accept another artifact.": "輸入框無法再接收產物,暫時無法提及。",
"Mention {{name}}": "提及 {{name}}",
"Mermaid syntax could not be rendered": "無法渲染 Mermaid 語法",
"Message center": "訊息中心",
@@ -1885,7 +1879,6 @@
"No review available for this session.": "此工作階段目前沒有可用的審查結果。",
"No review for this version": "此版本沒有審查記錄",
"No run history yet": "尚無執行記錄",
"No results match “{{query}}”.": "沒有結果符合「{{query}}」。",
"No skills added yet.": "尚未新增技能。",
"No skills found": "未找到技能",
"No skills found in that repo.": "那個儲存庫裡沒有找到技能。",
@@ -2019,7 +2012,6 @@
"Or type your own answer…": "或輸入你自己的回答…",
"Other": "其他",
"Other artifacts": "其他 Artifacts",
"Other projects": "其他專案",
"Other…": "其他…",
"Output": "輸出",
"Output truncated": "輸出已截斷",
@@ -2184,7 +2176,6 @@
"Reasoning effort": "推理強度",
"Reasoning effort levels": "推理強度層級",
"Reasoning effort request format": "推理強度請求格式",
"Recent artifacts": "最近的產物",
"Recent sessions": "最近會話",
"Recheck": "重新偵測",
"Recheck message snapshot": "重新檢查訊息快照",
@@ -2479,13 +2470,11 @@
"Search projects": "搜尋專案",
"Search projects…": "搜尋專案…",
"Search repositories by keyword, or scan a GitHub repository for Skill folders to import.": "依關鍵字搜尋儲存庫,或掃描某個 GitHub 儲存庫以匯入技能資料夾。",
"Search sessions and artifacts…": "搜尋會話和產物…",
"Search skills": "搜尋技能",
"Search skills to add": "搜尋要新增的技能",
"Search skills…": "搜尋技能…",
"Search specialists": "搜尋專家",
"Search specialists…": "搜尋專家…",
"Search this project…": "在這個專案中搜尋…",
"Searching…": "搜尋中……",
"Secure credential storage": "憑證安全儲存",
"Secure key storage is unavailable. API keys cannot be saved until the system keychain is unlocked or authorized.": "安全金鑰儲存不可用。在系統鑰匙串解鎖或授權之前,無法儲存 API 金鑰。",
@@ -2630,7 +2619,6 @@
"Skip approvals for {{name}}": "為 {{name}} 跳過核准",
"Skipped": "已略過",
"Skipped {{count}} skills_other": "已略過 {{count}} 個技能",
"Some artifact results may be missing.": "部分產物結果可能未顯示。",
"Some files could not be indexed yet.": "部分檔案尚未完成索引。",
"Some permission details are unavailable": "部分權限詳細資料無法取得",
"Someone else saved a newer version": "其他人儲存了更新的版本",
@@ -3074,7 +3062,6 @@
"e.g. changelog-style": "例如 changelog-style",
"error": "錯誤",
"error (line {{line}})": "錯誤(第 {{line}} 行)",
"esc": "esc",
"exit {{code}}": "結束碼 {{code}}",
"fail": "失敗",
"failed": "失敗",
@@ -3093,7 +3080,6 @@
"low confidence": "低信賴度",
"medium": "中",
"medium confidence": "中等信賴度",
"mention": "提及",
"model": "模型",
"navigate": "切換",
"network": "網路",
@@ -4623,6 +4609,55 @@
"Not saved yet. Saving later will replace the latest version.": "尚未儲存。之後儲存會覆寫最新版本。",
"Tag assignments are preserved.": "標籤關聯已保留。",
"{{count}} tagged resources are currently unavailable._other": "{{count}} 個已關聯資源暫時無法使用。",
"Uploaded files": "上傳檔案",
"Global search": "全域搜尋",
"Search messages, projects, files and Library…": "搜尋訊息、專案、檔案和 Library…",
"Search categories": "搜尋分類",
"Could not load search results.": "無法載入搜尋結果。",
"Some results are unavailable.": "部分結果暫時無法使用。",
"No results found": "找不到結果",
"Recent files": "最近檔案",
"Recent literature": "最近文獻",
"Content preview": "內容預覽",
"File information": "檔案資訊",
"Jump to message": "跳至訊息",
"Open session": "開啟會話",
"Open collection": "開啟文獻集",
"Open literature": "開啟文獻",
"File name": "檔案名稱",
"File size": "檔案大小",
"Matched message": "符合的訊息",
"You": "您",
"Collapse details": "收合詳細資訊",
"Back to results": "返回清單",
"Search scope": "搜尋範圍",
"All projects and Library": "全部專案與 Library",
"Current project": "目前專案",
"Collection": "集合",
"Try another keyword or broaden the search scope.": "可以換個關鍵字,或擴大搜尋範圍。",
"PDF available": "已儲存 PDF",
"No PDF": "暫無 PDF",
"Only the 10 most recent items are shown": "僅顯示最近前 10 筆",
"All senders": "全部傳送者",
"Sent by me": "我傳送的",
"Sent by agents": "智能體傳送的",
"All formats": "全部格式",
"Spreadsheets / CSV": "試算表 / CSV",
"Images": "圖片",
"All entries": "全部項目",
"Result order": "結果排序",
"Relevance within categories": "分類內相關度",
"Any time": "不限時間",
"Last 7 days": "最近 7 天",
"Refine category": "分類細篩",
"Notebook couldn't be read for preview": "無法讀取 Notebook 以供預覽",
"Cell {{number}}": "儲存格 {{number}}",
"Showing {{shown}} of {{total}} cells": "顯示 {{shown}} / {{total}} 個儲存格",
"No abstract available": "暫無摘要",
"Could not load recent content.": "無法載入最近內容。",
"No recent content": "暫無最近內容",
"{{count}} items_other": "{{count}} 個項目",
"{{count}} messages_other": "{{count}} 則訊息",
"Access authorization has expired. Reopen the Web link from Open Science on the host computer, or return to the remote access entry page to pair again.": "存取授權已失效。請從主機上的 Open Science 重新開啟 Web 連結,或返回遠端存取入口頁面重新配對。",
"The operation result could not be confirmed. It may still be running. Reconnect and check its status before trying again.": "無法確認操作結果。操作可能仍在進行。請重新連線並檢查其狀態,再決定是否重試。",
"Any call": "任何呼叫",
@@ -4744,6 +4779,7 @@
"Compare selected references": "比較所選文獻",
"PDF added. Refreshing attachment details…": "PDF 已新增。正在重新整理附件詳細資訊…",
"You can close this dialog. The download continues in the background; reopen it to see progress.": "您可以關閉此對話框。下載將在背景繼續;重新開啟即可查看進度。",
"Local computer": "本機電腦",
"Retry file verification": "重新驗證檔案",
"Remove attachment": "移除附件",
"This PDF requires a password. Add an unlocked copy.": "此 PDF 需要密碼。請新增已解鎖的副本。",
+20 -4
View File
@@ -383,8 +383,18 @@ export type LiteratureDuplicateGroup = z.infer<typeof literatureDuplicateGroupSc
const literatureCatalogSearchRequestSchema = z
.object({
scope: z.enum(['library', 'inbox', 'collections', 'project-counts', 'duplicates']),
scope: z.enum([
'library',
'inbox',
'collections',
'project-counts',
'duplicates',
'global-search'
]),
refreshDuplicates: z.boolean().optional(),
updatedAfter: z.number().int().nonnegative().optional(),
searchSort: z.enum(['relevance', 'recent']).optional(),
entryKind: z.enum(['paper', 'collection', 'pdf']).optional(),
allItemIds: z.boolean().optional(),
countOnly: z.boolean().optional(),
itemIds: z.array(nonEmptyTextSchema).max(200).optional(),
@@ -392,6 +402,7 @@ const literatureCatalogSearchRequestSchema = z
projectId: optionalTextSchema,
collectionId: optionalTextSchema,
parentId: optionalTextSchema,
itemId: optionalTextSchema,
inboxState: z.enum(LITERATURE_INBOX_STATES).optional(),
lifecycle: z.enum(LITERATURE_LIFECYCLE_STATES).optional(),
sortBy: z.enum(LITERATURE_SORT_FIELDS).optional(),
@@ -408,9 +419,14 @@ const literatureCatalogSearchRequestSchema = z
.refine((request) => request.itemIds === undefined || request.scope === 'library', {
message: 'Selected item membership is only available for the library.'
})
.refine((request) => !request.countOnly || (request.scope === 'library' && !request.allItemIds), {
message: 'Count-only queries require the library and cannot request item membership.'
})
.refine(
(request) =>
!request.countOnly ||
((request.scope === 'library' || request.scope === 'global-search') && !request.allItemIds),
{
message: 'Count-only queries require the library and cannot request item membership.'
}
)
const literatureCatalogSearchPageSchema = z
.object({
+37
View File
@@ -0,0 +1,37 @@
import { z } from 'zod'
export const messageSearchRequestSchema = z
.object({
clientId: z.string().min(1).max(128).optional(),
projectIds: z.array(z.string().min(1)).max(10000),
excludedSessionIds: z.array(z.string().min(1)).optional(),
query: z.string().max(1000),
updatedAfter: z.number().int().nonnegative().optional(),
role: z.enum(['user', 'agent']).optional(),
sort: z.enum(['relevance', 'recent']).optional(),
limit: z.number().int().min(1).max(100),
cursor: z
.string()
.max(1024 * 1024)
.optional()
})
.strict()
export type MessageSearchRequest = z.infer<typeof messageSearchRequestSchema>
export type MessageSearchItem = {
projectId: string
sessionId: string
sessionTitle: string
sessionNumber: number
messageId: string
role: 'user' | 'agent'
content: string
contentTruncated?: boolean
title?: string
createdAt: number
}
export type MessageSearchPage = {
items: MessageSearchItem[]
totalCount: number
nextCursor?: string
isComplete: boolean
}
+9
View File
@@ -1,3 +1,5 @@
import type { SearchFileFormat, SearchSort } from './search-text'
export type ProjectFileSource = 'artifact' | 'upload'
export type ProjectFileOriginSession = {
@@ -24,6 +26,7 @@ export type ProjectFileItem = {
mtimeMs?: number
sortAtMs: number
originSession?: ProjectFileOriginSession
contentMatch?: { offset: number; startingLineNumber: number }
}
export type ProjectFilesSearch = {
@@ -77,6 +80,12 @@ export type ResolveProjectFileRequest = {
// The primary Project set is one paged collection. Home searches all active Projects; Workspace
// pages its current Project and requests a bounded sample from Other Projects.
export type SearchArtifactsRequest = {
searchContent?: boolean
updatedAfter?: number
format?: SearchFileFormat
sort?: SearchSort
source?: ProjectFileSource | 'all'
sessionId?: string
primaryProjectIds: string[]
otherProjectIds: string[]
filenameContains?: string
+4
View File
@@ -1,3 +1,4 @@
import type { MessageSearchRequest, MessageSearchPage } from './message-search'
import type {
LiteratureExportRecordRequest,
LiteratureExportRecordResult
@@ -1690,6 +1691,9 @@ export const RENDERER_API_CONTRACT = Object.freeze({
'sessions.loadAll': callable<() => Promise<LoadAllSessionsResult>>()('sessions', [
'sessions:load-all'
]),
'sessions.searchMessages': callable<
(request: MessageSearchRequest) => Promise<MessageSearchPage>
>()('sessions', ['sessions:search-messages']),
'sessions.loadOne': callable<
(request: LoadSessionRequest) => Promise<PersistedChatSession | undefined>
>()('sessions', ['sessions:load-one']),
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { findSearchMatches } from './search-text'
describe('search text matching', () => {
it('matches Greek final sigma consistently across letter case', () => {
expect(findSearchMatches('ΟΣ and ΣΟΣ', 'ος')).toEqual([
{ start: 0, end: 2 },
{ start: 8, end: 10 }
])
})
it('preserves original offsets for compatibility and composed text', () => {
expect(findSearchMatches('AI and AI', 'ai')).toEqual([
{ start: 0, end: 2 },
{ start: 7, end: 9 }
])
expect(findSearchMatches('x e\u0301 y', 'é')).toEqual([{ start: 2, end: 4 }])
expect(findSearchMatches('a [x] .*', '[x]')).toEqual([{ start: 2, end: 5 }])
expect(findSearchMatches('İ', 'i')).toEqual([])
expect(findSearchMatches('text', '')).toEqual([])
})
})
+67
View File
@@ -0,0 +1,67 @@
export type SearchMatch = { start: number; end: number }
export type SearchSort = 'relevance' | 'recent'
export type SearchFileFormat = 'pdf' | 'spreadsheet' | 'notebook' | 'image'
export const SEARCH_FILE_EXTENSIONS: Record<SearchFileFormat, readonly string[]> = {
pdf: ['pdf'],
spreadsheet: ['csv', 'tsv', 'xls', 'xlsx', 'ods'],
notebook: ['ipynb'],
image: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'tif', 'tiff', 'bmp', 'avif']
}
export const normalizeSearchText = (value: string): string =>
value
.normalize('NFKC')
.toLowerCase()
.replace(/\u03c2/g, '\u03c3')
export const searchTitleRank = (title: string, query: string): number => {
const needle = normalizeSearchText(query.trim())
if (!needle) return 0
const text = normalizeSearchText(title)
return text === needle ? 3 : text.startsWith(needle) ? 2 : text.includes(needle) ? 1 : 0
}
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
// Map normalized matches back to original graphemes, including composed accents and fullwidth text.
export const findSearchMatches = (text: string, query: string, limit = Infinity): SearchMatch[] => {
const needle = normalizeSearchText(query.trim())
if (!needle) return []
const folded = normalizeSearchText(text)
if (!folded.includes(needle)) return []
const matches: SearchMatch[] = []
// Ordinary normalized text keeps UTF-16 offsets, so large transcripts avoid a grapheme map.
if (text.normalize('NFKC') === text && folded.length === text.length) {
let offset = 0
while (offset < folded.length && matches.length < limit) {
const index = folded.indexOf(needle, offset)
if (index < 0) break
const end = index + needle.length
if (!/\p{M}/u.test(folded.slice(end, end + 1))) matches.push({ start: index, end })
offset = end
}
return matches
}
const starts: number[] = []
const ends: number[] = []
for (const { segment, index } of segmenter.segment(text)) {
const normalized = normalizeSearchText(segment)
for (let i = 0; i < normalized.length; i++) {
starts.push(index)
ends.push(index + segment.length)
}
}
let offset = 0
while (offset < folded.length && matches.length < limit) {
const index = folded.indexOf(needle, offset)
if (index < 0) break
const end = index + needle.length
if (
(index === 0 || starts[index - 1] !== starts[index]) &&
(end === folded.length || ends[end - 1] !== ends[end])
) {
matches.push({ start: starts[index]!, end: ends[end - 1]! })
}
offset = end
}
return matches
}
+1
View File
@@ -199,6 +199,7 @@ export const WEB_INVOKE_CHANNELS = {
'sessions.openRecoveryFolder': 'sessions:open-recovery-folder',
'sessions.saveManifest': 'sessions:save-manifest',
'sessions.saveSession': 'sessions:save-session',
'sessions.searchMessages': 'sessions:search-messages',
'sessions.setDelegationPolicy': 'sessions:set-delegation-policy',
'sessions.unlinkPdfContext': 'sessions:unlink-pdf-context',
'sessions.updateArchive': 'sessions:update-archive',
+39
View File
@@ -0,0 +1,39 @@
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { expect, it } from 'vitest'
const require = createRequire(import.meta.url)
it('builds newly added browser fixture pages without falling back to the default page', () => {
const root = mkdtempSync(join(tmpdir(), 'open-science-browser-entry-'))
const fixtureRoot = join(root, 'e2e/browser/fixture')
mkdirSync(fixtureRoot, { recursive: true })
try {
// Exercise the real build with a new page alongside the original explicit entry list.
for (const name of ['index', 'csv-preview', 'message-clipboard', 'media-privacy']) {
writeFileSync(
join(fixtureRoot, `${name}.html`),
`<!doctype html><html><head><title>${name}</title></head><body>${name}</body></html>`
)
}
const run = spawnSync(
process.execPath,
[
join(dirname(require.resolve('vite/package.json')), 'bin/vite.js'),
'build',
'--config',
resolve('vite.browser-test.config.ts')
],
{ cwd: root, encoding: 'utf8', timeout: 20_000 }
)
expect(run.status, run.stderr).toBe(0)
expect(readFileSync(join(root, 'out/browser-tests/media-privacy.html'), 'utf8')).toContain(
'<title>media-privacy</title>'
)
} finally {
rmSync(root, { recursive: true, force: true })
}
}, 30_000)
+16 -1
View File
@@ -1,13 +1,28 @@
import { readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
const fixtureRoot = resolve('e2e/browser/fixture')
export default defineConfig({
root: resolve('e2e/browser/fixture'),
root: fixtureRoot,
resolve: {
alias: { '@': resolve('src/renderer/src'), '@renderer': resolve('src/renderer/src') }
},
plugins: [react(), tailwindcss()],
build: {
outDir: resolve('out/browser-tests'),
emptyOutDir: true,
rollupOptions: {
// New regression pages must be emitted too, rather than silently serving index.html.
input: readdirSync(fixtureRoot, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.html'))
.map((entry) => resolve(fixtureRoot, entry.name))
.sort()
}
},
preview: { host: '127.0.0.1', port: 4178, strictPort: true },
server: { host: '127.0.0.1', port: 4178, strictPort: true, fs: { allow: [process.cwd()] } }
})