Skip to content
4 changes: 2 additions & 2 deletions frontend/src/api/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe('OpenCodeClient', () => {
})
})

it('sends cursor-only params without directory for cursor-based requests', async () => {
it('preserves directory for cursor-based requests', async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
Expand All @@ -138,7 +138,7 @@ describe('OpenCodeClient', () => {
await new OpenCodeClient('/api/opencode', '/repo').listSessionsPage({ cursor: 'cursor_123' })

expect(fetchMock).toHaveBeenCalledWith(
'http://localhost/api/opencode/api/session?cursor=cursor_123',
'http://localhost/api/opencode/api/session?cursor=cursor_123&directory=%2Frepo',
expect.any(Object),
)
})
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export class OpenCodeClient {
async listSessionsPage(params?: SessionPageParams): Promise<SessionPage> {
const isCursorRequest = params?.cursor !== undefined
const queryParams = isCursorRequest
? { cursor: params.cursor }
? this.getParams({ cursor: params.cursor })
: this.getParams({
...(params?.limit !== undefined && { limit: params.limit }),
...(params?.order !== undefined && { order: params.order }),
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/schedules/RunDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import type { ScheduleRun } from '@opencode-manager/shared/types'

interface RunDetailPanelProps {
repoId: number
directory?: string
activeRun: ScheduleRun | null
selectedRunLoading: boolean
onCancelRun: () => void
cancelRunPending: boolean
}

export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancelRun, cancelRunPending }: RunDetailPanelProps) {
export function RunDetailPanel({ repoId, directory, activeRun, selectedRunLoading, onCancelRun, cancelRunPending }: RunDetailPanelProps) {
const navigate = useNavigate()

if (selectedRunLoading && !activeRun) {
Expand Down Expand Up @@ -41,7 +42,7 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel
<div className="flex items-center justify-between gap-2 px-3 py-2">
<div className="flex items-center gap-2">
{activeRun.sessionId && (
<Button variant="outline" size="sm" onClick={() => navigate(`/repos/${repoId}/sessions/${activeRun.sessionId}`)}>
<Button variant="outline" size="sm" onClick={() => navigate(`/repos/${repoId}/sessions/${activeRun.sessionId}${repoId === 0 ? '?assistant=1' : ''}`, { state: { directory } })}>
Open session
</Button>
)}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/schedules/RunHistoryCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useRepoScheduleRun } from '@/hooks/useSchedules'
interface RunHistoryCardsProps {
runs: ScheduleRun[] | undefined
runsLoading: boolean
directory?: string
onSelectRun: (id: number) => void
onCancelRun: () => void
cancelRunPending: boolean
Expand All @@ -17,6 +18,7 @@ interface RunHistoryCardsProps {
export function RunHistoryCards({
runs,
runsLoading,
directory,
onSelectRun,
onCancelRun,
cancelRunPending,
Expand Down Expand Up @@ -113,6 +115,7 @@ export function RunHistoryCards({
<div className="flex flex-col min-h-0 flex-1 overflow-hidden">
<RunDetailPanel
repoId={run.repoId}
directory={directory}
activeRun={displayRun}
selectedRunLoading={isExpanded && isLoading}
onCancelRun={onCancelRun}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/schedules/RunHistoryTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { RunHistoryCards, RunDetailPanel } from '@/components/schedules'

interface RunHistoryTabProps {
repoId: number
directory?: string
selectedJob: ScheduleJob | undefined
runs: ScheduleRun[] | undefined
runsLoading: boolean
Expand All @@ -17,6 +18,7 @@ interface RunHistoryTabProps {

export function RunHistoryTab({
repoId,
directory,
selectedJob,
runs,
runsLoading,
Expand Down Expand Up @@ -53,6 +55,7 @@ export function RunHistoryTab({
<RunHistoryCards
runs={runs}
runsLoading={runsLoading}
directory={directory}
onSelectRun={onSelectRun}
onCancelRun={onCancelRun}
cancelRunPending={cancelRunPending}
Expand All @@ -61,6 +64,7 @@ export function RunHistoryTab({
<div className="hidden xl:flex min-h-0 flex-col overflow-hidden rounded-xl border border-border/60 bg-background/60 p-4">
<RunDetailPanel
repoId={repoId}
directory={directory}
activeRun={activeRun}
selectedRunLoading={selectedRunLoading}
onCancelRun={onCancelRun}
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/session/SessionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ vi.mock('@/hooks/useOpenCode', () => ({

describe('SessionList', () => {
beforeEach(() => {
const now = Date.now()
sessionsData.splice(0, sessionsData.length,
{ id: 'ses_same', title: 'audit: mic-warmup 1/2 #2', directory: '/w/a', workspaceID: 'wrk_a', time: { updated: Date.now() } },
{ id: 'ses_same', title: 'audit: mic-warmup 1/2 #2', directory: '/w/b', workspaceID: 'wrk_b', time: { updated: Date.now() } },
{ id: 'ses_same', title: 'audit: mic-warmup 1/2 #2', directory: '/w/c', workspaceID: 'wrk_c', time: { updated: Date.now() } },
{ id: 'ses_same', title: 'audit: mic-warmup 1/2 #2', directory: '/w/a', workspaceID: 'wrk_a', time: { updated: now } },
{ id: 'ses_same', title: 'audit: mic-warmup 1/2 #2', directory: '/w/b', workspaceID: 'wrk_b', time: { updated: now - 1 } },
{ id: 'ses_same', title: 'audit: mic-warmup 1/2 #2', directory: '/w/c', workspaceID: 'wrk_c', time: { updated: now - 2 } },
)
createSessionMock.mockReset()
createSessionState.directory = undefined
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/session/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface SessionListProps {
createDirectory?: string;
directoryLabels?: Record<string, string>;
activeSessionID?: string;
onSelectSession: (sessionID: string) => void;
onSelectSession: (sessionID: string, directory?: string) => void;
}

export const SessionList = ({
Expand All @@ -41,7 +41,7 @@ export const SessionList = ({
const { data: sessions, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useSessionsAcrossDirectories(opcodeUrl, directoriesList, { search: searchQuery, limit: 25 });
const deleteSession = useDeleteSession(opcodeUrl, directoriesList);
const createSession = useCreateSession(opcodeUrl, sessionCreateDirectory, (newSession) => {
onSelectSession(newSession.id);
onSelectSession(newSession.id, primaryDirectory);
});
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<DeleteSessionTarget | DeleteSessionTarget[] | null>(null);
Expand Down Expand Up @@ -273,7 +273,7 @@ export const SessionList = ({
isActive={activeSessionID === session.id}
manageMode={manageMode}
workspaceLabel={session.directory ? directoryLabels?.[session.directory] : undefined}
onSelect={onSelectSession}
onSelect={(sessionID) => onSelectSession(sessionID, session.directory ?? primaryDirectory)}
onToggleSelection={(selected) => toggleSessionSelection(session, selected)}
onDelete={(e) => handleDelete(session, e)}
/>
Expand All @@ -292,7 +292,7 @@ export const SessionList = ({
isActive={activeSessionID === session.id}
manageMode={manageMode}
workspaceLabel={session.directory ? directoryLabels?.[session.directory] : undefined}
onSelect={onSelectSession}
onSelect={(sessionID) => onSelectSession(sessionID, session.directory ?? primaryDirectory)}
onToggleSelection={(selected) => toggleSessionSelection(session, selected)}
onDelete={(e) => handleDelete(session, e)}
/>
Expand Down
30 changes: 14 additions & 16 deletions frontend/src/contexts/EventContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,16 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
return repo?.id ?? null
}, [repos, findSessionDirectory])

const navigateToSession = useCallback((sessionID: string) => {
const repoId = getRepoIdForSession(sessionID)
if (!repoId && repoId !== 0) return
const directory = findSessionDirectory(sessionID) ?? undefined
const targetPath = `/repos/${repoId}/sessions/${sessionID}${repoId === 0 ? '?assistant=1' : ''}`
if (`${window.location.pathname}${window.location.search}` !== targetPath) {
navigate(targetPath, { state: { directory } })
}
}, [findSessionDirectory, getRepoIdForSession, navigate])

const getClient = useCallback((sessionID: string): OpenCodeClient | null => {
const result = findSessionInCache(sessionID)
if (!result) return null
Expand Down Expand Up @@ -397,25 +407,13 @@ export function EventProvider({ children }: { children: React.ReactNode }) {

const navigateToCurrentQuestion = useCallback(() => {
if (!currentQuestion) return
const repoId = getRepoIdForSession(currentQuestion.sessionID)
if (repoId) {
const targetPath = `/repos/${repoId}/sessions/${currentQuestion.sessionID}`
if (window.location.pathname !== targetPath) {
navigate(targetPath)
}
}
}, [currentQuestion, getRepoIdForSession, navigate])
navigateToSession(currentQuestion.sessionID)
}, [currentQuestion, navigateToSession])

const navigateToCurrentPermission = useCallback(() => {
if (!currentPermission) return
const repoId = getRepoIdForSession(currentPermission.sessionID)
if (repoId) {
const targetPath = `/repos/${repoId}/sessions/${currentPermission.sessionID}`
if (window.location.pathname !== targetPath) {
navigate(targetPath)
}
}
}, [currentPermission, getRepoIdForSession, navigate])
navigateToSession(currentPermission.sessionID)
}, [currentPermission, navigateToSession])

const fetchInitialPendingData = useCallback(async () => {
const reposToUse = reposRef.current
Expand Down
30 changes: 29 additions & 1 deletion frontend/src/hooks/useAssistantSessionLauncher.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAssistantSessionLauncher } from './useAssistantSessionLauncher'
import { getCachedAssistantDirectory, setCachedAssistantSessionId, useAssistantSessionLauncher } from './useAssistantSessionLauncher'
import { OpenCodeClient } from '@/api/opencode'

const mocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -111,6 +111,34 @@ describe('useAssistantSessionLauncher', () => {
expect(mocks.sendPromptAsync).not.toHaveBeenCalled()
})

it('reads the cached assistant directory from the cached session key', () => {
setCachedAssistantSessionId(123, '/assistant', 'cached')

expect(getCachedAssistantDirectory(123)).toBe('/assistant')
})

it('uses the cache-miss callback without querying OpenCode', async () => {
const onNavigate = vi.fn()
const onMissingCachedSession = vi.fn()
const { result } = renderHook(() => useAssistantSessionLauncher({
repoId: 123,
opcodeUrl: 'http://localhost:5551',
directory: '/assistant',
onNavigate,
onMissingCachedSession,
}))

await act(async () => {
await result.current.openAssistant()
})

expect(onMissingCachedSession).toHaveBeenCalled()
expect(onNavigate).not.toHaveBeenCalled()
expect(OpenCodeClient).not.toHaveBeenCalled()
expect(mocks.listSessionsPage).not.toHaveBeenCalled()
expect(mocks.createSession).not.toHaveBeenCalled()
})

it('creates a session when the assistant directory has no root sessions', async () => {
mocks.listSessionsPage.mockResolvedValue({
items: [
Expand Down
32 changes: 30 additions & 2 deletions frontend/src/hooks/useAssistantSessionLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,24 @@ interface UseAssistantSessionLauncherOptions {
opcodeUrl: string
directory?: string
onNavigate: (sessionId: string) => void
onMissingCachedSession?: () => void
}

type OpenCodeSession = components['schemas']['Session']

const ASSISTANT_SESSION_LOOKUP_PAGE_SIZE = 25

const LAST_ASSISTANT_SESSION_KEY_PREFIX = 'ocm:assistant:last-session'
const LAST_ASSISTANT_DIRECTORY_KEY_PREFIX = 'ocm:assistant:last-directory'

function getLastAssistantSessionKey(repoId: number, directory: string): string {
return `${LAST_ASSISTANT_SESSION_KEY_PREFIX}:${repoId}:${directory}`
}

function setCachedAssistantSessionId(repoId: number, directory: string, sessionId: string): void {
export function setCachedAssistantSessionId(repoId: number, directory: string, sessionId: string): void {
try {
localStorage.setItem(getLastAssistantSessionKey(repoId, directory), sessionId)
localStorage.setItem(`${LAST_ASSISTANT_DIRECTORY_KEY_PREFIX}:${repoId}`, directory)
} catch {
return
}
Expand All @@ -35,6 +38,25 @@ function getCachedAssistantSessionId(repoId: number, directory: string): string
}
}

export function getCachedAssistantDirectory(repoId: number): string | undefined {
try {
const cachedDirectory = localStorage.getItem(`${LAST_ASSISTANT_DIRECTORY_KEY_PREFIX}:${repoId}`)
if (cachedDirectory) return cachedDirectory

const prefix = `${LAST_ASSISTANT_SESSION_KEY_PREFIX}:${repoId}:`
const storageKeys = [
...Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index)),
...Object.keys(localStorage),
]
for (const key of storageKeys) {
if (key?.startsWith(prefix)) return key.slice(prefix.length)
}
return undefined
} catch {
return undefined
}
}

function isAssistantRootSession(session: OpenCodeSession, assistantDirectory: string): boolean {
return !session.parentID && session.directory === assistantDirectory
}
Expand Down Expand Up @@ -94,6 +116,7 @@ export function useAssistantSessionLauncher({
opcodeUrl,
directory,
onNavigate,
onMissingCachedSession,
}: UseAssistantSessionLauncherOptions) {
const openAssistant = useCallback(async () => {
if (!directory) {
Expand All @@ -106,6 +129,11 @@ export function useAssistantSessionLauncher({
return
}

if (onMissingCachedSession) {
onMissingCachedSession()
return
}

const client = new OpenCodeClient(opcodeUrl, directory)

const newest = await getLatestAssistantSession(client, directory)
Expand All @@ -119,7 +147,7 @@ export function useAssistantSessionLauncher({
onNavigate(session.id)
void sendAssistantWelcomePrompt(client, session.id)
}
}, [repoId, opcodeUrl, directory, onNavigate])
}, [repoId, opcodeUrl, directory, onNavigate, onMissingCachedSession])

return { openAssistant }
}
5 changes: 3 additions & 2 deletions frontend/src/hooks/useCommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ export function useCommandHandler({
const repoMatch = currentPath.match(/\/repos\/(\d+)\/sessions\//)
if (repoMatch) {
const repoId = repoMatch[1]
const newPath = `/repos/${repoId}/sessions/${newSession.id}`
navigate(newPath)
const assistantSuffix = new URLSearchParams(window.location.search).get('assistant') === '1' ? '?assistant=1' : ''
const newPath = `/repos/${repoId}/sessions/${newSession.id}${assistantSuffix}`
navigate(newPath, { state: { directory } })
} else {
navigate(`/session/${newSession.id}`)
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/hooks/useOpenCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export const useSession = (opcodeUrl: string | null | undefined, sessionID: stri
return useQuery({
queryKey: ["opencode", "session", opcodeUrl, sessionID, directory],
queryFn: () => client!.getSession(sessionID!),
enabled: !!client && !!sessionID,
enabled: !!client && !!sessionID && !!directory,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
staleTime: 15000,
Expand All @@ -144,7 +144,7 @@ export const useMessages = (opcodeUrl: string | null | undefined, sessionID: str
const response = await client!.listMessages(sessionID!)
return response as MessageWithParts[]
},
enabled: !!client && !!sessionID,
enabled: !!client && !!sessionID && !!directory,
refetchOnMount: 'always',
refetchOnWindowFocus: true,
refetchOnReconnect: true,
Expand Down
Loading