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
50 changes: 44 additions & 6 deletions frontend/src/api/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,32 @@ describe('OpenCode facade', () => {
expect(page.nextCursor).toBeUndefined()
})

it('omits the directory when listing sessions across all directories', async () => {
fetchMock.mockResolvedValue(
jsonResponse({ data: [sessionInfo('ses_1', '/repo')], cursor: { next: 'cursor_next' } }),
)

const page = await listSessionPage({ limit: 25, order: 'desc' })

expect(lastRequest().url).toBe(
'http://localhost/api/opencode/api/session?limit=25&order=desc&parentID=null',
)
expect(lastRequest().init.method).toBe('GET')
expect(page.items).toEqual([sessionInfo('ses_1', '/repo')])
expect(page.nextCursor).toBe('cursor_next')
})

it('omits the directory on an all-directories continuation page', async () => {
fetchMock.mockResolvedValue(jsonResponse({ data: [], cursor: {} }))

await listSessionPage({ cursor: 'cursor_1' })

expect(lastRequest().url).toBe(
'http://localhost/api/opencode/api/session?parentID=null&cursor=cursor_1',
)
expect(lastRequest().init.method).toBe('GET')
})

it('reads a single session from the V2 session route', async () => {
fetchMock.mockResolvedValue(jsonResponse({ data: sessionInfo('ses_1', '/repo') }))

Expand Down Expand Up @@ -529,15 +555,27 @@ describe('OpenCode facade', () => {
})

it('stages, commits, and clears a session revert', async () => {
fetchMock.mockResolvedValue(jsonResponse({ data: { messageID: 'msg_1' } }))
fetchMock.mockImplementation((input: RequestInfo | URL) => {
const url = String(input)
if (url.endsWith('/interrupt')) return Promise.resolve(jsonResponse({ data: { interrupted: true } }))
if (url.endsWith('/wait')) return Promise.resolve(new Response(null, { status: 204 }))
if (url.endsWith('/revert/stage')) return Promise.resolve(jsonResponse({ data: { messageID: 'msg_1' } }))
return Promise.resolve(new Response(null, { status: 204 }))
})

await stageRevert('ses_1', 'msg_1')

expect(lastRequest().url).toBe('http://localhost/api/opencode/api/session/ses_1/revert/stage')
expect(lastRequest().init.method).toBe('POST')
expect(lastRequest().init.body).toBe(JSON.stringify({ messageID: 'msg_1' }))

fetchMock.mockResolvedValue(new Response(null, { status: 204 }))
const stageRequests = fetchMock.mock.calls.map(([input, init]) => ({
url: String(input),
init: init as RequestInit,
}))
expect(stageRequests.map((request) => request.url)).toEqual([
'http://localhost/api/opencode/api/session/ses_1/interrupt',
'http://localhost/api/opencode/api/experimental/session/ses_1/wait',
'http://localhost/api/opencode/api/session/ses_1/revert/stage',
])
expect(stageRequests.map((request) => request.init.method)).toEqual(['POST', 'POST', 'POST'])
expect(stageRequests[2].init.body).toBe(JSON.stringify({ messageID: 'msg_1' }))

await commitRevert('ses_1')
expect(lastRequest().url).toBe('http://localhost/api/opencode/api/session/ses_1/revert/commit')
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/api/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export interface SessionPage {
}

export interface SessionPageInput {
directory: string
directory?: string
limit?: number
order?: 'asc' | 'desc'
search?: string
Expand Down Expand Up @@ -93,7 +93,7 @@ function buildPromptFields(input: PromptFields) {
export async function listSessionPage(input: SessionPageInput): Promise<SessionPage> {
const { data, cursor } = await callOpenCode((api) =>
api.session.list({
directory: input.directory,
...(input.directory === undefined ? {} : { directory: input.directory }),
parentID: 'null',
limit: input.limit,
order: input.order,
Expand Down Expand Up @@ -241,6 +241,8 @@ export async function removeShell(id: string, directory: string): Promise<void>
}

export async function stageRevert(sessionID: string, messageID: string): Promise<SessionRevert> {
await interruptSession(sessionID)
await callOpenCode((api) => api.session.wait({ sessionID }))
return callOpenCode((api) => api.session.revert.stage({ sessionID, messageID }))
}

Expand Down
40 changes: 38 additions & 2 deletions frontend/src/components/navigation/DesktopSessionTree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const {
getSessionMock,
sessionPinsData,
sessionsHasNextPage,
searchError,
} = vi.hoisted(() => ({
listReposMock: vi.fn(),
sessionsData: [] as SessionFixture[],
Expand All @@ -45,6 +46,7 @@ const {
getSessionMock: vi.fn(),
sessionPinsData: { current: [] as SessionPinFixture[] },
sessionsHasNextPage: { current: false },
searchError: { current: false },
}))

vi.mock('@/api/repos', async (importOriginal) => {
Expand All @@ -62,12 +64,17 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => {
return {
...actual,
useSessionsAcrossDirectories: (directories: string[], options?: { search?: string; limit?: number }) => {
sessionsHookCalls.push({ directories, options })
if (directories.length > 0) sessionsHookCalls.push({ directories, options })
const data =
options?.search && emptyOnSearch.current
? []
: sessionsData.filter((session) => directories.includes(session.location.directory))
return { data, isLoading: false, isError: false, hasNextPage: sessionsHasNextPage.current }
return {
data,
isLoading: false,
isError: Boolean(options?.search) && searchError.current,
hasNextPage: sessionsHasNextPage.current,
}
},
useCreateSession: (directory?: string) => ({
mutate: (vars?: unknown) => {
Expand Down Expand Up @@ -161,6 +168,7 @@ describe('DesktopSessionTree', () => {
getSessionMock.mockResolvedValue(undefined)
sessionPinsData.current = []
sessionsHasNextPage.current = false
searchError.current = false
})

it('lists every repo by last access, even inside a repo, with no Recent section', async () => {
Expand Down Expand Up @@ -302,6 +310,9 @@ describe('DesktopSessionTree', () => {
await user.type(input, 'Session')

expect(await screen.findByRole('button', { name: /^Session a1/ })).toBeTruthy()
await waitFor(() =>
expect(sessionsHookCalls.some((call) => call.options?.search === 'Session')).toBe(true),
)
const searchCall = sessionsHookCalls.find((call) => call.options?.search === 'Session')
expect(searchCall?.directories).toEqual(['/repos/a', '/repos/b'])
expect(sessionsHookCalls.some((call) => call.options?.search === 'Sess')).toBe(false)
Expand All @@ -315,6 +326,19 @@ describe('DesktopSessionTree', () => {
expect(toggleFor('Alpha')).toBeTruthy()
})

it('filters loaded sessions by title immediately', async () => {
const user = userEvent.setup()
render(<DesktopSessionTree />, { wrapper: createWrapper(['/']) })

const input = await screen.findByLabelText('Search sessions')
await screen.findByText('Alpha')
await user.type(input, 'a1')

expect(await screen.findByRole('button', { name: /^Session a1/ })).toBeTruthy()
expect(screen.queryByRole('button', { name: /^Session b1/ })).toBeNull()
expect(screen.queryByRole('button', { name: /^Session c1/ })).toBeNull()
})

it('clears an active search with the clear button', async () => {
const user = userEvent.setup()
render(<DesktopSessionTree />, { wrapper: createWrapper(['/']) })
Expand All @@ -340,6 +364,7 @@ describe('DesktopSessionTree', () => {

expect(await screen.findByRole('button', { name: /^Session b1/ })).toBeTruthy()
expect(screen.queryByRole('button', { name: /^Session a1/ })).toBeNull()
await waitFor(() => expect(sessionsHookCalls.some((call) => call.options?.search === 'bet')).toBe(true))
const searchCall = sessionsHookCalls.find((call) => call.options?.search === 'bet')
expect(searchCall?.directories).toEqual(['/repos/c', '/repos/a'])
})
Expand Down Expand Up @@ -369,6 +394,17 @@ describe('DesktopSessionTree', () => {
expect(await screen.findByText('No sessions found')).toBeTruthy()
})

it('shows the error state when the session search fails', async () => {
const user = userEvent.setup()
searchError.current = true
render(<DesktopSessionTree />, { wrapper: createWrapper(['/']) })

const input = await screen.findByLabelText('Search sessions')
await user.type(input, 'Session')

expect(await screen.findByText('Failed to load sessions')).toBeTruthy()
})

it('creates a session in the repo full path', async () => {
const user = userEvent.setup()
render(<DesktopSessionTree />, { wrapper: createWrapper(['/']) })
Expand Down
98 changes: 71 additions & 27 deletions frontend/src/components/navigation/DesktopSessionTree.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { Search } from 'lucide-react'
import type { Repo } from '@/api/types'
import type { Repo, Session } from '@/api/types'
import { Input } from '@/components/ui/input'
import { useNavigableRepos, useSidebarRepoGroups } from '@/hooks/useSidebarRepoGroups'
import { useSessionSearch } from '@/hooks/useSessionSearch'
import { useSessionPins } from '@/hooks/useSessionPins'
import {
NewSessionButton,
RepoNavGroup,
Expand All @@ -12,12 +14,16 @@ import {
SessionNavRow,
SessionNavStatus,
} from '@/components/navigation/RepoSessionNav'
import { getActiveRepoId, isCurrentSessionItem, isRepoReady } from '@/components/navigation/sidebar-session-tree'
import {
SIDEBAR_SESSIONS_PER_REPO,
buildSidebarRepoGroups,
getActiveRepoId,
isCurrentSessionItem,
isRepoReady,
} from '@/components/navigation/sidebar-session-tree'
import { getRepoPath } from '@/lib/navigation'
import { getRepoDisplayName } from '@/lib/utils'
import { useDebouncedValue } from '@/hooks/useDebouncedValue'

const SESSION_SEARCH_DEBOUNCE_MS = 300
import { buildPinnedSessionKeys } from '@/lib/sessionKey'

function SessionSearchInput({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const clear = () => onChange('')
Expand Down Expand Up @@ -46,29 +52,37 @@ function SessionSearchInput({ value, onChange }: { value: string; onChange: (val
)
}

function SessionSearchResults({ repos, search }: { repos: Repo[]; search: string }) {
interface SessionSearchResultsProps {
nameMatchedRepos: Repo[]
otherRepos: Repo[]
sessions: Session[]
isSearchLoading: boolean
isSearchError: boolean
}

function SessionSearchResults({
nameMatchedRepos,
otherRepos,
sessions,
isSearchLoading,
isSearchError,
}: SessionSearchResultsProps) {
const navigate = useNavigate()
const location = useLocation()
const { nameMatchedRepos, otherRepos } = useMemo(() => {
const query = search.toLowerCase()
const readyRepos = repos.filter(isRepoReady)
const isNameMatch = (repo: Repo) => getRepoDisplayName(repo).toLowerCase().includes(query)
return {
nameMatchedRepos: readyRepos.filter(isNameMatch),
otherRepos: readyRepos.filter((repo) => !isNameMatch(repo)),
}
}, [repos, search])
const nameMatches = useSidebarRepoGroups({ repos: nameMatchedRepos })
const sessionMatches = useSidebarRepoGroups({ repos: otherRepos, search })
const isLoading = nameMatches.isLoading || sessionMatches.isLoading
const isError = nameMatches.isError || sessionMatches.isError
const { data: sessionPins } = useSessionPins()
const pinnedKeys = useMemo(() => buildPinnedSessionKeys(sessionPins ?? []), [sessionPins])
const sessionGroups = useMemo(
() => buildSidebarRepoGroups({ repos: otherRepos, sessions, pinnedKeys, now: Date.now() }),
[otherRepos, sessions, pinnedKeys],
)
const matchingGroups = [
...nameMatches.groups,
...sessionMatches.groups.filter((group) => group.items.length > 0),
...sessionGroups.filter((group) => group.items.length > 0),
]

if (isLoading) return <SessionNavStatus>Loading sessions...</SessionNavStatus>
if (isError) return <SessionNavStatus>Failed to load sessions</SessionNavStatus>
if (nameMatches.isLoading || isSearchLoading) return <SessionNavStatus>Loading sessions...</SessionNavStatus>
if (nameMatches.isError || isSearchError) return <SessionNavStatus>Failed to load sessions</SessionNavStatus>
if (matchingGroups.length === 0) return <SessionNavStatus>No sessions found</SessionNavStatus>

return (
Expand Down Expand Up @@ -101,23 +115,53 @@ function SessionSearchResults({ repos, search }: { repos: Repo[]; search: string
export function DesktopSessionTree() {
const navigate = useNavigate()
const location = useLocation()
const { repos, isLoading } = useNavigableRepos()
const readyRepos = useMemo(() => repos.filter(isRepoReady), [repos])

const [draft, setDraft] = useState('')
const trimmedDraft = draft.trim()
const debouncedSearch = useDebouncedValue(trimmedDraft, SESSION_SEARCH_DEBOUNCE_MS)
const search = trimmedDraft ? debouncedSearch : ''
const { repos, isLoading } = useNavigableRepos()

const { nameMatchedRepos, otherRepos } = useMemo(() => {
const query = trimmedDraft.toLowerCase()
const isNameMatch = (repo: Repo) => getRepoDisplayName(repo).toLowerCase().includes(query)
return {
nameMatchedRepos: readyRepos.filter(isNameMatch),
otherRepos: readyRepos.filter((repo) => !isNameMatch(repo)),
}
}, [readyRepos, trimmedDraft])

const otherDirectories = useMemo(() => otherRepos.map((repo) => repo.fullPath), [otherRepos])
const searchDirectories = useMemo(
() => (trimmedDraft ? otherDirectories : []),
[trimmedDraft, otherDirectories],
)
const { setQuery, filteredSessions, isLoading: isSearchLoading, isError: isSearchError } = useSessionSearch(
searchDirectories,
{ limit: SIDEBAR_SESSIONS_PER_REPO },
)

const handleSearchChange = (value: string) => {
setDraft(value)
setQuery(value)
}

return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="px-2 pb-2">
<SessionSearchInput value={draft} onChange={setDraft} />
<SessionSearchInput value={draft} onChange={handleSearchChange} />
</div>

<div role="region" aria-label="Session navigator" className="min-h-0 flex-1 overflow-y-auto pb-2">
{isLoading ? (
<SessionNavStatus>Loading repos...</SessionNavStatus>
) : search ? (
<SessionSearchResults repos={repos} search={search} />
) : trimmedDraft ? (
<SessionSearchResults
nameMatchedRepos={nameMatchedRepos}
otherRepos={otherRepos}
sessions={filteredSessions}
isSearchLoading={isSearchLoading}
isSearchError={isSearchError}
/>
) : (
<RepoSessionNavList
repos={repos}
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/components/navigation/sidebar-session-tree.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import type { Repo, Session } from '@/api/types'
import {
buildRepoByDirectory,
buildSidebarRepoGroups,
getActiveRepoId,
isCurrentSessionItem,
Expand Down Expand Up @@ -112,6 +113,19 @@ describe('selectNavigableRepos', () => {
})
})

describe('buildRepoByDirectory', () => {
it('indexes each repo by full path', () => {
const repoA = createRepo({ id: 1, fullPath: '/repos/a' })
const repoB = createRepo({ id: 2, fullPath: '/repos/b' })

const byDirectory = buildRepoByDirectory([repoA, repoB])

expect(byDirectory.get('/repos/a')).toBe(repoA)
expect(byDirectory.get('/repos/b')).toBe(repoB)
expect(byDirectory.size).toBe(2)
})
})

describe('buildSidebarRepoGroups', () => {
const repoA = createRepo({ id: 1, fullPath: '/repos/a', name: 'Alpha' })
const repoB = createRepo({ id: 2, fullPath: '/repos/b', name: 'Beta' })
Expand Down
Loading
Loading