Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/app/e2e/fast-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,63 @@ test('codex search results expose resume-related actions', async () => {
const resumeInCli = row.getByRole('button', { name: 'Resume in CLI' })
await expect(resumeInCli).toBeVisible()
})

test('session page can submit a new search without returning home first', async () => {
const { window } = ctx

await search(window, 'XYLOPHONE_CANARY_42')
await window.locator('[data-testid="fragment-row"]').first().click()
await expect(window.locator('[data-testid="session-detail"]')).toBeVisible()

const input = window.locator('[data-testid="search-input"]')
await input.fill('TROMBONE_CANARY_99')
await input.press('Enter')

await expect(window.locator('[data-testid="session-detail"]')).toHaveCount(0)
await expect(window.locator('[data-testid="fragment-row"]').first()).toContainText('TROMBONE_CANARY_99')
})

test('session page supports cmd or ctrl + f find-in-page', async () => {
const { window } = ctx

await search(window, 'XYLOPHONE_CANARY_42')
await window.locator('[data-testid="fragment-row"]').first().click()
await expect(window.locator('[data-testid="session-detail"]')).toBeVisible()

await window.keyboard.press(process.platform === 'darwin' ? 'Meta+f' : 'Control+f')

const findInput = window.locator('[data-testid="session-find-input"]')
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()

await findInput.fill('XYLOPHONE')
await window.locator('[data-testid="session-detail"]').click()
await window.keyboard.press(process.platform === 'darwin' ? 'Meta+f' : 'Control+f')
await expect(findInput).toBeFocused()
await findInput.type('_CANARY_42')
await expect(findInput).toHaveValue('XYLOPHONE_CANARY_42')
await expect(window.locator('[data-testid="session-find-status"]')).toContainText(/\d+\s*\/\s*\d+/, { timeout: 5000 })
await expect(window.locator('[data-testid="session-find-active-match"]').first()).toContainText('XYLOPHONE_CANARY_42')
})

test('session find supports cmd or ctrl + arrow navigation', async () => {
const { window } = ctx

await search(window, 'XYLOPHONE_CANARY_42')
await window.locator('[data-testid="fragment-row"]').first().click()
await expect(window.locator('[data-testid="session-detail"]')).toBeVisible()

await window.keyboard.press(process.platform === 'darwin' ? 'Meta+f' : 'Control+f')

const findInput = window.locator('[data-testid="session-find-input"]')
const status = window.locator('[data-testid="session-find-status"]')

await findInput.fill('the')
await expect(status).toContainText(/1\s*\/\s*[2-9]\d*/, { timeout: 5000 })

await window.keyboard.press(process.platform === 'darwin' ? 'Meta+ArrowRight' : 'Control+ArrowRight')
await expect(status).toContainText(/2\s*\/\s*[2-9]\d*/, { timeout: 5000 })

await window.keyboard.press(process.platform === 'darwin' ? 'Meta+ArrowLeft' : 'Control+ArrowLeft')
await expect(status).toContainText(/1\s*\/\s*[2-9]\d*/, { timeout: 5000 })
})
14 changes: 12 additions & 2 deletions packages/app/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,11 @@ export default function App() {
}, [doSearch, searchMode, aiAnswer, aiError])

const handleSubmit = useCallback(() => {
if (query.trim()) setHomeMode(false)
if (!query.trim()) return
setHomeMode(false)
setSelectedSession(null)
setTargetMessageId(null)
setView('search')
if (searchMode === 'ai') {
doAiSearch()
} else {
Expand All @@ -196,7 +200,13 @@ export default function App() {
setAiStreaming(false)
setAiToolCalls(new Map())
aiAnswerRef.current = ''
if (query.trim()) doSearch(query)
if (query.trim()) {
setHomeMode(false)
setSelectedSession(null)
setTargetMessageId(null)
setView('search')
doSearch(query)
}
} else {
setResults([])
setIsSearching(false)
Expand Down
68 changes: 65 additions & 3 deletions packages/app/src/renderer/components/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
import type { ReactNode } from 'react'
import type { Message } from '@spool/core'

export type FindRange = {
start: number
end: number
}

interface Props {
message: Message
findRanges?: FindRange[]
matchIndexOffset?: number
activeMatchIndex?: number
onActiveMatchRef?: (node: HTMLElement | null) => void
}

export default function MessageBubble({ message }: Props) {
export default function MessageBubble({
message,
findRanges = [],
matchIndexOffset = 0,
activeMatchIndex = -1,
onActiveMatchRef,
}: Props) {
const isUser = message.role === 'user'
const isSystem = message.role === 'system'
const contentText = message.contentText || (isSystem ? '(summary)' : '')

if (isSystem) {
return (
<div className="px-4 py-2">
<div className="bg-neutral-100 dark:bg-neutral-800/60 rounded px-3 py-2 text-xs text-neutral-500 dark:text-neutral-400 italic">
{message.contentText || '(summary)'}
{renderHighlightedText(contentText, findRanges, matchIndexOffset, activeMatchIndex, onActiveMatchRef)}
</div>
</div>
)
Expand All @@ -39,7 +56,9 @@ export default function MessageBubble({ message }: Props) {
</div>
)}
<p className="text-sm text-neutral-800 dark:text-neutral-200 leading-relaxed whitespace-pre-wrap break-words select-text cursor-text">
{message.contentText || <span className="text-neutral-400 italic">(tool use)</span>}
{message.contentText
? renderHighlightedText(contentText, findRanges, matchIndexOffset, activeMatchIndex, onActiveMatchRef)
: <span className="text-neutral-400 italic">(tool use)</span>}
</p>
<p className="text-[10px] text-neutral-400 mt-1">{formatTime(message.timestamp)}</p>
</div>
Expand All @@ -48,6 +67,49 @@ export default function MessageBubble({ message }: Props) {
)
}

function renderHighlightedText(
text: string,
ranges: FindRange[],
matchIndexOffset: number,
activeMatchIndex: number,
onActiveMatchRef?: (node: HTMLElement | null) => void,
): ReactNode {
if (!ranges.length) return text

const parts: ReactNode[] = []
let cursor = 0

ranges.forEach((range, localIndex) => {
if (range.start > cursor) {
parts.push(text.slice(cursor, range.start))
}

const matchText = text.slice(range.start, range.end)
const globalIndex = matchIndexOffset + localIndex
const isActive = globalIndex === activeMatchIndex

parts.push(
<mark
key={`${globalIndex}-${range.start}-${range.end}`}
ref={isActive ? onActiveMatchRef ?? null : null}
data-testid={isActive ? 'session-find-active-match' : undefined}
className="font-semibold transition-colors"
style={{ color: 'var(--color-accent)', background: 'transparent' }}
>
{matchText}
</mark>,
)

cursor = range.end
})

if (cursor < text.length) {
parts.push(text.slice(cursor))
}

return parts
}

function formatTime(iso: string): string {
try { return new Date(iso).toLocaleTimeString() } catch { return '' }
}
Loading
Loading