mirror of
https://github.com/aipoch/open-science.git
synced 2026-10-02 02:14:40 +08:00
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:
@@ -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()
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,6 +520,7 @@ describe('preload bridge — public surface inventory', () => {
|
||||
'sessions.openRecoveryFolder',
|
||||
'sessions.saveManifest',
|
||||
'sessions.saveSession',
|
||||
'sessions.searchMessages',
|
||||
'sessions.sendFlushResponse',
|
||||
'sessions.setDelegationPolicy',
|
||||
'sessions.unlinkPdfContext',
|
||||
|
||||
+46
-267
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 にはパスワードが必要です。ロックを解除したコピーを追加してください。",
|
||||
|
||||
@@ -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에는 비밀번호가 필요합니다. 잠금 해제된 사본을 추가하세요.",
|
||||
|
||||
@@ -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 защищён паролем. Добавьте разблокированную копию.",
|
||||
|
||||
@@ -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 需要密码。请添加已解锁的副本。",
|
||||
|
||||
@@ -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 需要密碼。請新增已解鎖的副本。",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
@@ -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()] } }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user