From f99d75251ff7cae68bb39e661008aca141975559 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:52:39 -0400 Subject: [PATCH 1/6] refactor: fix directory param in cursor pagination, add cache-miss callback --- frontend/src/api/opencode.test.ts | 4 +-- frontend/src/api/opencode.ts | 2 +- .../useAssistantSessionLauncher.test.tsx | 22 ++++++++++++++++ .../src/hooks/useAssistantSessionLauncher.ts | 11 ++++++-- frontend/src/pages/AssistantRedirect.tsx | 26 ++++++++++++++++--- frontend/src/pages/SessionDetail.tsx | 12 ++++++++- 6 files changed, 67 insertions(+), 10 deletions(-) diff --git a/frontend/src/api/opencode.test.ts b/frontend/src/api/opencode.test.ts index ad8cde86..f04caf67 100644 --- a/frontend/src/api/opencode.test.ts +++ b/frontend/src/api/opencode.test.ts @@ -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({ @@ -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), ) }) diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index 8e7bbe1e..6cb6ae1e 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -120,7 +120,7 @@ export class OpenCodeClient { async listSessionsPage(params?: SessionPageParams): Promise { 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 }), diff --git a/frontend/src/hooks/useAssistantSessionLauncher.test.tsx b/frontend/src/hooks/useAssistantSessionLauncher.test.tsx index 6cb5c8cf..4a2ec38c 100644 --- a/frontend/src/hooks/useAssistantSessionLauncher.test.tsx +++ b/frontend/src/hooks/useAssistantSessionLauncher.test.tsx @@ -111,6 +111,28 @@ describe('useAssistantSessionLauncher', () => { expect(mocks.sendPromptAsync).not.toHaveBeenCalled() }) + 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: [ diff --git a/frontend/src/hooks/useAssistantSessionLauncher.ts b/frontend/src/hooks/useAssistantSessionLauncher.ts index 73cd072a..cf84bc62 100644 --- a/frontend/src/hooks/useAssistantSessionLauncher.ts +++ b/frontend/src/hooks/useAssistantSessionLauncher.ts @@ -7,6 +7,7 @@ interface UseAssistantSessionLauncherOptions { opcodeUrl: string directory?: string onNavigate: (sessionId: string) => void + onMissingCachedSession?: () => void } type OpenCodeSession = components['schemas']['Session'] @@ -19,7 +20,7 @@ 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) } catch { @@ -94,6 +95,7 @@ export function useAssistantSessionLauncher({ opcodeUrl, directory, onNavigate, + onMissingCachedSession, }: UseAssistantSessionLauncherOptions) { const openAssistant = useCallback(async () => { if (!directory) { @@ -106,6 +108,11 @@ export function useAssistantSessionLauncher({ return } + if (onMissingCachedSession) { + onMissingCachedSession() + return + } + const client = new OpenCodeClient(opcodeUrl, directory) const newest = await getLatestAssistantSession(client, directory) @@ -119,7 +126,7 @@ export function useAssistantSessionLauncher({ onNavigate(session.id) void sendAssistantWelcomePrompt(client, session.id) } - }, [repoId, opcodeUrl, directory, onNavigate]) + }, [repoId, opcodeUrl, directory, onNavigate, onMissingCachedSession]) return { openAssistant } } diff --git a/frontend/src/pages/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index f74cca10..f95c101e 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react" import { useNavigate, useLocation } from "react-router-dom" import { useQuery, useQueryClient } from "@tanstack/react-query" import { getRepo } from "@/api/repos" -import { useAssistantSessionLauncher } from "@/hooks/useAssistantSessionLauncher" +import { setCachedAssistantSessionId, useAssistantSessionLauncher } from "@/hooks/useAssistantSessionLauncher" import { useCreateSession } from "@/hooks/useOpenCode" import { useDialogParam } from "@/hooks/useDialogParam" import { useSSE } from "@/hooks/useSSE" @@ -17,7 +17,7 @@ import { SourceControlPanel } from "@/components/source-control" import { ResetPermissionsDialog } from "@/components/repo/ResetPermissionsDialog" import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup" import { invalidateConfigCaches } from "@/lib/queryInvalidation" -import { getSessionListPath, getAssistantPath } from "@/lib/navigation" +import { getSessionListPath, getAssistantPath, getAssistantSessionListPath } from "@/lib/navigation" import { SwitchConfigDialog } from "@/components/repo/SwitchConfigDialog" import { Loader2, Plus } from "lucide-react" @@ -43,18 +43,26 @@ export function AssistantRedirect() { }) const handleNavigate = useCallback((sessionId: string) => { + if (repo?.fullPath) { + setCachedAssistantSessionId(repoId, repo.fullPath, sessionId) + } setStatus("opening") if (!showSessionList) { window.history.replaceState(window.history.state, "", getSessionListPath(repoId, true)) } navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`) - }, [navigate, repoId, showSessionList]) + }, [navigate, repo?.fullPath, repoId, showSessionList]) + + const handleMissingCachedSession = useCallback(() => { + navigate(getAssistantSessionListPath(), { replace: true }) + }, [navigate]) const { openAssistant } = useAssistantSessionLauncher({ repoId, opcodeUrl, directory: repo?.fullPath, onNavigate: handleNavigate, + onMissingCachedSession: handleMissingCachedSession, }) const assistantDirectory = repo?.fullPath @@ -63,9 +71,19 @@ export function AssistantRedirect() { useSSE(opcodeUrl, assistantDirectory) const createSessionMutation = useCreateSession(opcodeUrl, assistantDirectory, (session) => { + if (assistantDirectory) { + setCachedAssistantSessionId(repoId, assistantDirectory, session.id) + } navigate(`/repos/${repoId}/sessions/${session.id}?assistant=1`) }) + const handleSelectSession = useCallback((sessionId: string) => { + if (assistantDirectory) { + setCachedAssistantSessionId(repoId, assistantDirectory, sessionId) + } + navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`) + }, [assistantDirectory, navigate, repoId]) + const handleCreateSession = async () => { await createSessionMutation.mutateAsync({ agent: undefined }) } @@ -124,7 +142,7 @@ export function AssistantRedirect() { navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`)} + onSelectSession={handleSelectSession} /> )} diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index d4bd73c6..532adfd5 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -52,6 +52,7 @@ import { SessionTodoDisplay } from "@/components/message/SessionTodoDisplay"; import { useDialogParam } from "@/hooks/useDialogParam"; import { useSidebarAction } from "@/hooks/useSidebarAction"; import { SessionMoreButton } from "@/components/navigation/SessionMoreButton"; +import { setCachedAssistantSessionId } from "@/hooks/useAssistantSessionLauncher"; const compareMessageIds = (id1: string, id2: string): number => { const num1 = parseInt(id1, 10) @@ -121,6 +122,12 @@ export function SessionDetail() { const repoDirectory = repo?.fullPath; const sessionRouteSuffix = isAssistantSession ? '?assistant=1' : ''; + useEffect(() => { + if (isAssistantSession && repoDirectory && sessionId) { + setCachedAssistantSessionId(repoId, repoDirectory, sessionId); + } + }, [isAssistantSession, repoDirectory, repoId, sessionId]); + const { isConnected, isReconnecting } = useSSE(opcodeUrl, repoDirectory, sessionId); const { data: rawMessages, isLoading: messagesLoading } = useMessages(opcodeUrl, sessionId, repoDirectory); @@ -234,12 +241,15 @@ export function SessionDetail() { try { const newSession = await createSession.mutateAsync({ agent: undefined }); if (newSession?.id) { + if (isAssistantSession && repoDirectory) { + setCachedAssistantSessionId(repoId, repoDirectory, newSession.id); + } navigate(`/repos/${repoId}/sessions/${newSession.id}${sessionRouteSuffix}`); } } catch { showToast.error('Failed to create new session'); } - }, [createSession, navigate, repoId, sessionRouteSuffix]); + }, [createSession, isAssistantSession, navigate, repoDirectory, repoId, sessionRouteSuffix]); useSidebarAction('new-session', () => { handleNewSession(); From 0c5603239c9f0f6d6aa974035b4939575e3d8b10 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:14:52 -0400 Subject: [PATCH 2/6] fix: add directory check to session query enabled conditions --- frontend/src/hooks/useOpenCode.ts | 4 ++-- frontend/src/pages/SessionDetail.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index e0122c32..a00a92ac 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -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, @@ -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, diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 532adfd5..06120916 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -492,7 +492,7 @@ export function SessionDetail() {
- {repoLoading || sessionLoading || messagesLoading ? ( + {repoLoading || !repoDirectory || sessionLoading || messagesLoading ? ( ) : opcodeUrl && repoDirectory ? ( Date: Sun, 7 Jun 2026 20:20:36 -0400 Subject: [PATCH 3/6] fix: preserve directory context through navigate state across session navigations --- .../src/components/session/SessionList.tsx | 8 +- frontend/src/pages/AssistantRedirect.tsx | 22 +- frontend/src/pages/RepoDetail.tsx | 6 +- frontend/src/pages/SessionDetail.tsx | 19 +- ...essionDetail.first-load-directory.test.tsx | 240 ++++++++++++++++++ 5 files changed, 271 insertions(+), 24 deletions(-) create mode 100644 frontend/src/pages/__tests__/SessionDetail.first-load-directory.test.tsx diff --git a/frontend/src/components/session/SessionList.tsx b/frontend/src/components/session/SessionList.tsx index 29f6216b..419c355c 100644 --- a/frontend/src/components/session/SessionList.tsx +++ b/frontend/src/components/session/SessionList.tsx @@ -15,7 +15,7 @@ interface SessionListProps { createDirectory?: string; directoryLabels?: Record; activeSessionID?: string; - onSelectSession: (sessionID: string) => void; + onSelectSession: (sessionID: string, directory?: string) => void; } export const SessionList = ({ @@ -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(null); @@ -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)} /> @@ -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)} /> diff --git a/frontend/src/pages/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index f95c101e..c5717148 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react" import { useNavigate, useLocation } from "react-router-dom" import { useQuery, useQueryClient } from "@tanstack/react-query" import { getRepo } from "@/api/repos" +import { OpenCodeClient } from "@/api/opencode" import { setCachedAssistantSessionId, useAssistantSessionLauncher } from "@/hooks/useAssistantSessionLauncher" import { useCreateSession } from "@/hooks/useOpenCode" import { useDialogParam } from "@/hooks/useDialogParam" @@ -16,7 +17,7 @@ import { RepoSkillsDialog } from "@/components/repo/RepoSkillsDialog" import { SourceControlPanel } from "@/components/source-control" import { ResetPermissionsDialog } from "@/components/repo/ResetPermissionsDialog" import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup" -import { invalidateConfigCaches } from "@/lib/queryInvalidation" +import { invalidateConfigCaches, messagesQueryKey } from "@/lib/queryInvalidation" import { getSessionListPath, getAssistantPath, getAssistantSessionListPath } from "@/lib/navigation" import { SwitchConfigDialog } from "@/components/repo/SwitchConfigDialog" import { Loader2, Plus } from "lucide-react" @@ -45,13 +46,17 @@ export function AssistantRedirect() { const handleNavigate = useCallback((sessionId: string) => { if (repo?.fullPath) { setCachedAssistantSessionId(repoId, repo.fullPath, sessionId) + void queryClient.prefetchQuery({ + queryKey: messagesQueryKey(opcodeUrl, sessionId, repo.fullPath), + queryFn: () => new OpenCodeClient(opcodeUrl, repo.fullPath).listMessages(sessionId), + }) } setStatus("opening") if (!showSessionList) { window.history.replaceState(window.history.state, "", getSessionListPath(repoId, true)) } - navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`) - }, [navigate, repo?.fullPath, repoId, showSessionList]) + navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`, { state: { directory: repo?.fullPath } }) + }, [navigate, opcodeUrl, queryClient, repo?.fullPath, repoId, showSessionList]) const handleMissingCachedSession = useCallback(() => { navigate(getAssistantSessionListPath(), { replace: true }) @@ -74,14 +79,15 @@ export function AssistantRedirect() { if (assistantDirectory) { setCachedAssistantSessionId(repoId, assistantDirectory, session.id) } - navigate(`/repos/${repoId}/sessions/${session.id}?assistant=1`) + navigate(`/repos/${repoId}/sessions/${session.id}?assistant=1`, { state: { directory: assistantDirectory } }) }) - const handleSelectSession = useCallback((sessionId: string) => { - if (assistantDirectory) { - setCachedAssistantSessionId(repoId, assistantDirectory, sessionId) + const handleSelectSession = useCallback((sessionId: string, directory?: string) => { + const selectedDirectory = directory ?? assistantDirectory + if (selectedDirectory) { + setCachedAssistantSessionId(repoId, selectedDirectory, sessionId) } - navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`) + navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`, { state: { directory: selectedDirectory } }) }, [assistantDirectory, navigate, repoId]) const handleCreateSession = async () => { diff --git a/frontend/src/pages/RepoDetail.tsx b/frontend/src/pages/RepoDetail.tsx index a9de77b0..71603d94 100644 --- a/frontend/src/pages/RepoDetail.tsx +++ b/frontend/src/pages/RepoDetail.tsx @@ -114,7 +114,7 @@ export function RepoDetail() { ); const createSessionMutation = useCreateSession(opcodeUrl, composerDirectory, (session) => { - navigate(sessionUrl(session.id)); + navigate(sessionUrl(session.id), { state: { directory: composerDirectory } }); }); const handleCreateSession = async (options?: { @@ -149,8 +149,8 @@ export function RepoDetail() { setWorkspaceSelectorOpen(true); }; - const handleSelectSession = (sessionId: string) => { - navigate(sessionUrl(sessionId)); + const handleSelectSession = (sessionId: string, directory?: string) => { + navigate(sessionUrl(sessionId), { state: { directory: directory ?? composerDirectory } }); }; useSidebarAction('new-session', () => { diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 06120916..f9a7b729 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -68,6 +68,7 @@ export function SessionDetail() { const { id, sessionId } = useParams<{ id: string; sessionId: string }>(); const navigate = useNavigate(); const location = useLocation(); + const navigationDirectory = (location.state as { directory?: string } | null)?.directory; const repoId = Number(id) || 0; const isAssistantSession = new URLSearchParams(location.search).get('assistant') === '1'; const { preferences, updateSettings } = useSettings(); @@ -119,7 +120,7 @@ export function SessionDetail() { const opcodeUrl = OPENCODE_API_ENDPOINT; - const repoDirectory = repo?.fullPath; + const repoDirectory = navigationDirectory ?? repo?.fullPath; const sessionRouteSuffix = isAssistantSession ? '?assistant=1' : ''; useEffect(() => { @@ -244,7 +245,7 @@ export function SessionDetail() { if (isAssistantSession && repoDirectory) { setCachedAssistantSessionId(repoId, repoDirectory, newSession.id); } - navigate(`/repos/${repoId}/sessions/${newSession.id}${sessionRouteSuffix}`); + navigate(`/repos/${repoId}/sessions/${newSession.id}${sessionRouteSuffix}`, { state: { directory: repoDirectory } }); } } catch { showToast.error('Failed to create new session'); @@ -298,7 +299,7 @@ export function SessionDetail() { const client = createOpenCodeClient(opcodeUrl, repoDirectory); const forkedSession = await client.forkSession(sessionId); if (forkedSession?.id) { - navigate(`/repos/${repoId}/sessions/${forkedSession.id}${sessionRouteSuffix}`); + navigate(`/repos/${repoId}/sessions/${forkedSession.id}${sessionRouteSuffix}`, { state: { directory: repoDirectory } }); showToast.success('Session forked'); } } catch (error) { @@ -375,14 +376,14 @@ export function SessionDetail() { }, [setFileBrowserOpen]); const handleChildSessionClick = useCallback((childSessionId: string) => { - navigate(`/repos/${repoId}/sessions/${childSessionId}${sessionRouteSuffix}`) - }, [navigate, repoId, sessionRouteSuffix]); + navigate(`/repos/${repoId}/sessions/${childSessionId}${sessionRouteSuffix}`, { state: { directory: repoDirectory } }) + }, [navigate, repoDirectory, repoId, sessionRouteSuffix]); const handleParentSessionClick = useCallback(() => { if (session?.parentID) { - navigate(`/repos/${repoId}/sessions/${session.parentID}${sessionRouteSuffix}`) + navigate(`/repos/${repoId}/sessions/${session.parentID}${sessionRouteSuffix}`, { state: { directory: repoDirectory } }) } - }, [navigate, repoId, session?.parentID, sessionRouteSuffix]); + }, [navigate, repoDirectory, repoId, session?.parentID, sessionRouteSuffix]); const handleToggleDetails = useCallback(() => { const newValue = !preferences?.expandToolCalls @@ -590,8 +591,8 @@ export function SessionDetail() { opcodeUrl={opcodeUrl} directory={repoDirectory} activeSessionID={sessionId || undefined} - onSelectSession={(sessionID) => { - navigate(`/repos/${repoId}/sessions/${sessionID}${sessionRouteSuffix}`) + onSelectSession={(sessionID, directory) => { + navigate(`/repos/${repoId}/sessions/${sessionID}${sessionRouteSuffix}`, { state: { directory: directory ?? repoDirectory } }) setSessionsDialogOpen(false) }} /> diff --git a/frontend/src/pages/__tests__/SessionDetail.first-load-directory.test.tsx b/frontend/src/pages/__tests__/SessionDetail.first-load-directory.test.tsx new file mode 100644 index 00000000..e21bf8c9 --- /dev/null +++ b/frontend/src/pages/__tests__/SessionDetail.first-load-directory.test.tsx @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { SessionDetail } from '../SessionDetail' + +const mocks = vi.hoisted(() => ({ + useSession: vi.fn(), + useMessages: vi.fn(), + useSSE: vi.fn(), + useRepoActivity: vi.fn(), + usePermissions: vi.fn(), + useQuestions: vi.fn(), + useSSEHealth: vi.fn(), + useConfig: vi.fn(), + useOpenCodeClient: vi.fn(), + useSettings: vi.fn(), + useSettingsDialog: vi.fn(), + useMobile: vi.fn(), + useVisualViewport: vi.fn(), + useKeyboardShortcuts: vi.fn(), + useAutoScroll: vi.fn(), + useDialogParam: vi.fn(), + useSidebarAction: vi.fn(), + RepoSkillsDialog: vi.fn(() => null), +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useSession: mocks.useSession, + useAbortSession: vi.fn(() => ({ mutate: vi.fn() })), + useUpdateSession: vi.fn(() => ({ mutate: vi.fn() })), + useCreateSession: vi.fn(() => ({ mutateAsync: vi.fn() })), + useMessages: mocks.useMessages, + useConfig: mocks.useConfig, +})) + +vi.mock('@/hooks/useModelSelection', () => ({ + useModelSelection: vi.fn(() => ({ model: null, modelString: null })), +})) + +vi.mock('@/hooks/useOpenCodeClient', () => ({ + useOpenCodeClient: mocks.useOpenCodeClient, +})) + +vi.mock('@/hooks/useTTS', () => ({ + useTTS: vi.fn(() => ({ isEnabled: false })), +})) + +vi.mock('@/hooks/useSettings', () => ({ + useSettings: mocks.useSettings, +})) + +vi.mock('@/hooks/useSettingsDialog', () => ({ + useSettingsDialog: mocks.useSettingsDialog, +})) + +vi.mock('@/hooks/useMobile', () => ({ + useMobile: mocks.useMobile, + useSwipeBack: vi.fn(() => ({ ref: vi.fn() })), +})) + +vi.mock('@/hooks/useVisualViewport', () => ({ + useVisualViewport: mocks.useVisualViewport, +})) + +vi.mock('@/hooks/useKeyboardShortcuts', () => ({ + useKeyboardShortcuts: mocks.useKeyboardShortcuts, +})) + +vi.mock('@/hooks/useAutoScroll', () => ({ + useAutoScroll: mocks.useAutoScroll, +})) + +vi.mock('@/hooks/useDialogParam', () => ({ + useDialogParam: mocks.useDialogParam, +})) + +vi.mock('@/hooks/useSidebarAction', () => ({ + useSidebarAction: mocks.useSidebarAction, +})) + +vi.mock('@/hooks/useAutoPlayLastResponse', () => ({ + getAssistantText: vi.fn(() => ''), + getLatestPlayableAssistantMessage: vi.fn(() => null), + useAutoPlayLastResponse: vi.fn(() => {}), +})) + +vi.mock('@/stores/uiStateStore', () => ({ + useUIState: vi.fn(() => vi.fn()), +})) + +vi.mock('@/stores/sessionStatusStore', () => ({ + useSessionStatus: vi.fn(() => ({ setStatus: vi.fn() })), + useSessionStatusForSession: vi.fn(() => ({ type: 'idle' })), +})) + +vi.mock('@/hooks/useSSE', () => ({ + useSSE: mocks.useSSE, +})) + +vi.mock('@/hooks/useRepoActivity', () => ({ + useRepoActivity: mocks.useRepoActivity, +})) + +vi.mock('@/contexts/EventContext', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as object), + usePermissions: mocks.usePermissions, + useQuestions: mocks.useQuestions, + useSSEHealth: mocks.useSSEHealth, + } +}) + +vi.mock('@/api/repos', () => ({ + getRepo: vi.fn(() => new Promise(() => {})), +})) + +vi.mock('@/components/model/ModelSelectDialog', () => ({ + ModelSelectDialog: vi.fn(() => null), +})) + +vi.mock('@/components/session/SessionList', () => ({ + SessionList: vi.fn(() => null), +})) + +vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ + FileBrowserSheet: vi.fn(() => null), +})) + +vi.mock('@/components/repo/RepoMcpDialog', () => ({ + RepoMcpDialog: vi.fn(() => null), +})) + +vi.mock('@/components/repo/ResetPermissionsDialog', () => ({ + ResetPermissionsDialog: vi.fn(() => null), +})) + +vi.mock('@/components/repo/RepoLspDialog', () => ({ + RepoLspDialog: vi.fn(() => null), +})) + +vi.mock('@/components/repo/RepoSkillsDialog', () => ({ + RepoSkillsDialog: mocks.RepoSkillsDialog, +})) + +vi.mock('@/components/source-control', () => ({ + SourceControlPanel: vi.fn(() => null), +})) + +vi.mock('@/components/session/QuestionPrompt', () => ({ + QuestionPrompt: vi.fn(() => null), +})) + +vi.mock('@/components/session/MinimizedQuestionIndicator', () => ({ + MinimizedQuestionIndicator: vi.fn(() => null), +})) + +vi.mock('@/components/notifications/PendingActionsGroup', () => ({ + PendingActionsGroup: vi.fn(() => null), +})) + +describe('SessionDetail first-load navigation directory', () => { + beforeEach(() => { + vi.clearAllMocks() + + mocks.useSession.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useMessages.mockReturnValue({ data: [], isLoading: false }) + mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false }) + mocks.useRepoActivity.mockReturnValue(undefined) + mocks.usePermissions.mockReturnValue({ + pendingCount: 0, + hasPermissionsForSession: vi.fn(() => false), + syncForSession: vi.fn(), + }) + mocks.useQuestions.mockReturnValue({ + current: null, + pendingCount: 0, + hasQuestionsForSession: vi.fn(() => false), + reply: vi.fn(), + reject: vi.fn(), + syncForSession: vi.fn(), + }) + mocks.useSSEHealth.mockReturnValue({ isHealthy: true }) + mocks.useConfig.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useOpenCodeClient.mockReturnValue({}) + mocks.useSettings.mockReturnValue({ + preferences: { expandToolCalls: false }, + updateSettings: vi.fn(), + }) + mocks.useSettingsDialog.mockReturnValue({ open: vi.fn() }) + mocks.useMobile.mockReturnValue(false) + mocks.useVisualViewport.mockReturnValue({ keyboardHeight: 0 }) + mocks.useKeyboardShortcuts.mockReturnValue({ leaderActive: false }) + mocks.useAutoScroll.mockReturnValue({ scrollToBottom: vi.fn() }) + mocks.useDialogParam.mockReturnValue([false, vi.fn()]) + mocks.useSidebarAction.mockReturnValue(undefined) + }) + + const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + + const renderSession = (initialEntry: string | { pathname: string; state?: unknown }) => render( + + + + } /> + + + + ) + + it('uses the router-state directory on first render while the repo is loading', async () => { + renderSession({ pathname: '/repos/7/sessions/sess-x', state: { directory: '/abs/other-repo' } }) + + await waitFor(() => { + const call = mocks.useMessages.mock.calls[mocks.useMessages.mock.calls.length - 1] + expect(call?.[2]).toBe('/abs/other-repo') + }) + + const sessionCall = mocks.useSession.mock.calls[mocks.useSession.mock.calls.length - 1] + expect(sessionCall?.[2]).toBe('/abs/other-repo') + }) + + it('leaves directory undefined on first render without router state while the repo is loading', async () => { + renderSession('/repos/7/sessions/sess-x') + + await waitFor(() => { + const call = mocks.useMessages.mock.calls[mocks.useMessages.mock.calls.length - 1] + expect(call?.[2]).toBeUndefined() + }) + + const sessionCall = mocks.useSession.mock.calls[mocks.useSession.mock.calls.length - 1] + expect(sessionCall?.[2]).toBeUndefined() + }) +}) From 0469ab86d48ceefb3bc5654579c3e7c147c5be63 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:32:19 -0400 Subject: [PATCH 4/6] fix: cache assistant directory separately to handle repo-not-loaded edge case --- .../useAssistantSessionLauncher.test.tsx | 8 +++++- .../src/hooks/useAssistantSessionLauncher.ts | 21 ++++++++++++++ frontend/src/pages/AssistantRedirect.tsx | 28 ++++++++++--------- frontend/src/pages/SessionDetail.tsx | 4 +-- ...essionDetail.first-load-directory.test.tsx | 21 ++++++++++++++ 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/frontend/src/hooks/useAssistantSessionLauncher.test.tsx b/frontend/src/hooks/useAssistantSessionLauncher.test.tsx index 4a2ec38c..9c04327c 100644 --- a/frontend/src/hooks/useAssistantSessionLauncher.test.tsx +++ b/frontend/src/hooks/useAssistantSessionLauncher.test.tsx @@ -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(() => ({ @@ -111,6 +111,12 @@ 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() diff --git a/frontend/src/hooks/useAssistantSessionLauncher.ts b/frontend/src/hooks/useAssistantSessionLauncher.ts index cf84bc62..e6cf8109 100644 --- a/frontend/src/hooks/useAssistantSessionLauncher.ts +++ b/frontend/src/hooks/useAssistantSessionLauncher.ts @@ -15,6 +15,7 @@ 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}` @@ -23,6 +24,7 @@ function getLastAssistantSessionKey(repoId: number, directory: string): string { 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 } @@ -36,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 } diff --git a/frontend/src/pages/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index c5717148..bbcdb51f 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -3,7 +3,7 @@ import { useNavigate, useLocation } from "react-router-dom" import { useQuery, useQueryClient } from "@tanstack/react-query" import { getRepo } from "@/api/repos" import { OpenCodeClient } from "@/api/opencode" -import { setCachedAssistantSessionId, useAssistantSessionLauncher } from "@/hooks/useAssistantSessionLauncher" +import { getCachedAssistantDirectory, setCachedAssistantSessionId, useAssistantSessionLauncher } from "@/hooks/useAssistantSessionLauncher" import { useCreateSession } from "@/hooks/useOpenCode" import { useDialogParam } from "@/hooks/useDialogParam" import { useSSE } from "@/hooks/useSSE" @@ -36,6 +36,7 @@ export function AssistantRedirect() { const [switchConfigOpen, setSwitchConfigOpen] = useState(false) const [status, setStatus] = useState<"preparing" | "opening" | "creating" | "error">("preparing") const [errorMessage, setErrorMessage] = useState(null) + const cachedAssistantDirectory = getCachedAssistantDirectory(repoId) const opcodeUrl = OPENCODE_API_ENDPOINT const { data: repo, isLoading: repoLoading, error: repoError } = useQuery({ @@ -44,19 +45,20 @@ export function AssistantRedirect() { }) const handleNavigate = useCallback((sessionId: string) => { - if (repo?.fullPath) { - setCachedAssistantSessionId(repoId, repo.fullPath, sessionId) + const directory = repo?.fullPath ?? cachedAssistantDirectory + if (directory) { + setCachedAssistantSessionId(repoId, directory, sessionId) void queryClient.prefetchQuery({ - queryKey: messagesQueryKey(opcodeUrl, sessionId, repo.fullPath), - queryFn: () => new OpenCodeClient(opcodeUrl, repo.fullPath).listMessages(sessionId), + queryKey: messagesQueryKey(opcodeUrl, sessionId, directory), + queryFn: () => new OpenCodeClient(opcodeUrl, directory).listMessages(sessionId), }) } setStatus("opening") if (!showSessionList) { window.history.replaceState(window.history.state, "", getSessionListPath(repoId, true)) } - navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`, { state: { directory: repo?.fullPath } }) - }, [navigate, opcodeUrl, queryClient, repo?.fullPath, repoId, showSessionList]) + navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`, { state: { directory } }) + }, [cachedAssistantDirectory, navigate, opcodeUrl, queryClient, repo?.fullPath, repoId, showSessionList]) const handleMissingCachedSession = useCallback(() => { navigate(getAssistantSessionListPath(), { replace: true }) @@ -70,7 +72,7 @@ export function AssistantRedirect() { onMissingCachedSession: handleMissingCachedSession, }) - const assistantDirectory = repo?.fullPath + const assistantDirectory = repo?.fullPath ?? cachedAssistantDirectory const assistantFileBasePath = assistantDirectory?.split('/').filter(Boolean).at(-1) useSSE(opcodeUrl, assistantDirectory) @@ -101,8 +103,8 @@ export function AssistantRedirect() { try { if (showSessionList) return setStatus("preparing") - if (repoLoading) return - if (repoError || !repo?.fullPath) throw new Error("Failed to load Assistant workspace") + if (repoLoading && !assistantDirectory) return + if ((repoError && !assistantDirectory) || !assistantDirectory) throw new Error("Failed to load Assistant workspace") if (cancelled) return setStatus("creating") await openAssistant() @@ -118,7 +120,7 @@ export function AssistantRedirect() { return () => { cancelled = true } - }, [repo?.fullPath, repoError, repoLoading, openAssistant, showSessionList]) + }, [assistantDirectory, repoError, repoLoading, openAssistant, showSessionList]) if (showSessionList) { return ( @@ -140,9 +142,9 @@ export function AssistantRedirect() {
- {repoError ? ( + {repoError && !assistantDirectory ? (
Failed to load Assistant sessions
- ) : repoLoading || !repo?.fullPath ? ( + ) : repoLoading && !assistantDirectory ? (
Loading Assistant sessions...
) : ( ; } - if (!repo && !isAssistantSession) { + if (!repo && !isAssistantSession && !repoDirectory) { return (
@@ -493,7 +493,7 @@ export function SessionDetail() {
- {repoLoading || !repoDirectory || sessionLoading || messagesLoading ? ( + {(!repoDirectory && repoLoading) || !repoDirectory || sessionLoading || messagesLoading ? ( ) : opcodeUrl && repoDirectory ? ( ({ FileBrowserSheet: vi.fn(() => null), })) +vi.mock('@/components/message/MessageSkeleton', () => ({ + MessageSkeleton: vi.fn(() =>
Messages loading skeleton
), +})) + +vi.mock('@/components/message/MessageThread', () => ({ + MessageThread: vi.fn(() =>
Messages rendered
), +})) + vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpDialog: vi.fn(() => null), })) @@ -237,4 +245,17 @@ describe('SessionDetail first-load navigation directory', () => { const sessionCall = mocks.useSession.mock.calls[mocks.useSession.mock.calls.length - 1] expect(sessionCall?.[2]).toBeUndefined() }) + + it('renders messages instead of the skeleton when assistant navigation provides directory while the repo is loading', async () => { + const { queryByText, getByText } = renderSession({ + pathname: '/repos/0/sessions/sess-assistant', + state: { directory: '/abs/assistant' }, + }) + + await waitFor(() => { + expect(getByText('Messages rendered')).toBeTruthy() + }) + + expect(queryByText('Messages loading skeleton')).toBeNull() + }) }) From 8b08444bb56621bb447cd5dc9ab7bab51aa817bf Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:42:03 -0400 Subject: [PATCH 5/6] fix: hoist assistantDirectory before useAssistantSessionLauncher, drop sessionLoading from skeleton guard --- frontend/src/pages/AssistantRedirect.tsx | 8 ++++---- frontend/src/pages/SessionDetail.tsx | 4 ++-- .../SessionDetail.first-load-directory.test.tsx | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index bbcdb51f..55c64c5a 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -64,17 +64,17 @@ export function AssistantRedirect() { navigate(getAssistantSessionListPath(), { replace: true }) }, [navigate]) + const assistantDirectory = repo?.fullPath ?? cachedAssistantDirectory + const assistantFileBasePath = assistantDirectory?.split('/').filter(Boolean).at(-1) + const { openAssistant } = useAssistantSessionLauncher({ repoId, opcodeUrl, - directory: repo?.fullPath, + directory: assistantDirectory, onNavigate: handleNavigate, onMissingCachedSession: handleMissingCachedSession, }) - const assistantDirectory = repo?.fullPath ?? cachedAssistantDirectory - const assistantFileBasePath = assistantDirectory?.split('/').filter(Boolean).at(-1) - useSSE(opcodeUrl, assistantDirectory) const createSessionMutation = useCreateSession(opcodeUrl, assistantDirectory, (session) => { diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 09d2d666..b0a57202 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -132,7 +132,7 @@ export function SessionDetail() { const { isConnected, isReconnecting } = useSSE(opcodeUrl, repoDirectory, sessionId); const { data: rawMessages, isLoading: messagesLoading } = useMessages(opcodeUrl, sessionId, repoDirectory); - const { data: session, isLoading: sessionLoading } = useSession( + const { data: session } = useSession( opcodeUrl, sessionId, repoDirectory, @@ -493,7 +493,7 @@ export function SessionDetail() {
- {(!repoDirectory && repoLoading) || !repoDirectory || sessionLoading || messagesLoading ? ( + {(!repoDirectory && repoLoading) || !repoDirectory || messagesLoading ? ( ) : opcodeUrl && repoDirectory ? ( { expect(queryByText('Messages loading skeleton')).toBeNull() }) + + it('renders assistant messages while session metadata is still loading', async () => { + mocks.useSession.mockReturnValue({ data: undefined, isLoading: true }) + + const { queryByText, getByText } = renderSession({ + pathname: '/repos/0/sessions/sess-assistant', + state: { directory: '/abs/assistant' }, + }) + + await waitFor(() => { + expect(getByText('Messages rendered')).toBeTruthy() + }) + + expect(queryByText('Messages loading skeleton')).toBeNull() + }) }) From 0007635cbb36ad26b833d9fc436ab789663f1ba3 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:46:44 -0400 Subject: [PATCH 6/6] fix: pass directory context through schedules navigation, hoist SSE after directory resolve --- .../components/schedules/RunDetailPanel.tsx | 5 ++- .../components/schedules/RunHistoryCards.tsx | 3 ++ .../components/schedules/RunHistoryTab.tsx | 4 ++ .../components/session/SessionList.test.tsx | 7 +-- frontend/src/contexts/EventContext.tsx | 30 ++++++------- frontend/src/hooks/useCommandHandler.ts | 5 ++- frontend/src/pages/AssistantRedirect.tsx | 24 +++++++++- frontend/src/pages/Schedules.tsx | 1 + frontend/src/pages/SessionDetail.tsx | 45 ++++++++++--------- ...essionDetail.first-load-directory.test.tsx | 16 +++++++ 10 files changed, 94 insertions(+), 46 deletions(-) diff --git a/frontend/src/components/schedules/RunDetailPanel.tsx b/frontend/src/components/schedules/RunDetailPanel.tsx index 1a0f4206..c5018a8a 100644 --- a/frontend/src/components/schedules/RunDetailPanel.tsx +++ b/frontend/src/components/schedules/RunDetailPanel.tsx @@ -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) { @@ -41,7 +42,7 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel
{activeRun.sessionId && ( - )} diff --git a/frontend/src/components/schedules/RunHistoryCards.tsx b/frontend/src/components/schedules/RunHistoryCards.tsx index a9f1cee9..a6a582dc 100644 --- a/frontend/src/components/schedules/RunHistoryCards.tsx +++ b/frontend/src/components/schedules/RunHistoryCards.tsx @@ -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 @@ -17,6 +18,7 @@ interface RunHistoryCardsProps { export function RunHistoryCards({ runs, runsLoading, + directory, onSelectRun, onCancelRun, cancelRunPending, @@ -113,6 +115,7 @@ export function RunHistoryCards({
({ 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 diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index f82d8106..d246b444 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -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 @@ -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 diff --git a/frontend/src/hooks/useCommandHandler.ts b/frontend/src/hooks/useCommandHandler.ts index 9af49c2a..ee52adc3 100644 --- a/frontend/src/hooks/useCommandHandler.ts +++ b/frontend/src/hooks/useCommandHandler.ts @@ -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}`) } diff --git a/frontend/src/pages/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index 55c64c5a..daac101d 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query" import { getRepo } from "@/api/repos" import { OpenCodeClient } from "@/api/opencode" import { getCachedAssistantDirectory, setCachedAssistantSessionId, useAssistantSessionLauncher } from "@/hooks/useAssistantSessionLauncher" -import { useCreateSession } from "@/hooks/useOpenCode" +import { useCreateSession, useSessionsAcrossDirectories } from "@/hooks/useOpenCode" import { useDialogParam } from "@/hooks/useDialogParam" import { useSSE } from "@/hooks/useSSE" import { OPENCODE_API_ENDPOINT } from "@/config" @@ -66,6 +66,16 @@ export function AssistantRedirect() { const assistantDirectory = repo?.fullPath ?? cachedAssistantDirectory const assistantFileBasePath = assistantDirectory?.split('/').filter(Boolean).at(-1) + const assistantSessionDirectories = showSessionList && assistantDirectory ? [assistantDirectory] : [] + const { data: assistantSessionsForWarmup } = useSessionsAcrossDirectories(opcodeUrl, assistantSessionDirectories, { limit: 25 }) + + const prefetchAssistantMessages = useCallback((sessionId: string, directory?: string) => { + if (!directory) return + void queryClient.prefetchQuery({ + queryKey: messagesQueryKey(opcodeUrl, sessionId, directory), + queryFn: () => new OpenCodeClient(opcodeUrl, directory).listMessages(sessionId), + }) + }, [opcodeUrl, queryClient]) const { openAssistant } = useAssistantSessionLauncher({ repoId, @@ -81,6 +91,7 @@ export function AssistantRedirect() { if (assistantDirectory) { setCachedAssistantSessionId(repoId, assistantDirectory, session.id) } + prefetchAssistantMessages(session.id, assistantDirectory) navigate(`/repos/${repoId}/sessions/${session.id}?assistant=1`, { state: { directory: assistantDirectory } }) }) @@ -89,8 +100,17 @@ export function AssistantRedirect() { if (selectedDirectory) { setCachedAssistantSessionId(repoId, selectedDirectory, sessionId) } + prefetchAssistantMessages(sessionId, selectedDirectory) navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`, { state: { directory: selectedDirectory } }) - }, [assistantDirectory, navigate, repoId]) + }, [assistantDirectory, navigate, prefetchAssistantMessages, repoId]) + + useEffect(() => { + if (!showSessionList || !assistantDirectory || assistantSessionsForWarmup.length === 0) return + const session = assistantSessionsForWarmup.find((item) => !item.parentID) ?? assistantSessionsForWarmup[0] + if (session?.id) { + prefetchAssistantMessages(session.id, session.directory ?? assistantDirectory) + } + }, [assistantDirectory, assistantSessionsForWarmup, prefetchAssistantMessages, showSessionList]) const handleCreateSession = async () => { await createSessionMutation.mutateAsync({ agent: undefined }) diff --git a/frontend/src/pages/Schedules.tsx b/frontend/src/pages/Schedules.tsx index b5c32e88..f89dcab3 100644 --- a/frontend/src/pages/Schedules.tsx +++ b/frontend/src/pages/Schedules.tsx @@ -284,6 +284,7 @@ export function Schedules() { {repoScheduleTab === 'runs' && ( { const num1 = parseInt(id1, 10) @@ -120,7 +120,8 @@ export function SessionDetail() { const opcodeUrl = OPENCODE_API_ENDPOINT; - const repoDirectory = navigationDirectory ?? repo?.fullPath; + const cachedAssistantDirectory = isAssistantSession ? getCachedAssistantDirectory(repoId) : undefined; + const repoDirectory = navigationDirectory ?? repo?.fullPath ?? cachedAssistantDirectory; const sessionRouteSuffix = isAssistantSession ? '?assistant=1' : ''; useEffect(() => { @@ -129,13 +130,13 @@ export function SessionDetail() { } }, [isAssistantSession, repoDirectory, repoId, sessionId]); - const { isConnected, isReconnecting } = useSSE(opcodeUrl, repoDirectory, sessionId); - const { data: rawMessages, isLoading: messagesLoading } = useMessages(opcodeUrl, sessionId, repoDirectory); + const initialMessagesDirectory = repoDirectory && !messagesLoading ? repoDirectory : undefined; + const { isConnected, isReconnecting } = useSSE(opcodeUrl, initialMessagesDirectory, sessionId); const { data: session } = useSession( opcodeUrl, sessionId, - repoDirectory, + initialMessagesDirectory, ); const messages = useMemo(() => { @@ -161,7 +162,7 @@ export function SessionDetail() { const abortSession = useAbortSession(opcodeUrl, repoDirectory, sessionId); const updateSession = useUpdateSession(opcodeUrl, repoDirectory); const createSession = useCreateSession(opcodeUrl, repoDirectory); - const { model, modelString } = useModelSelection(opcodeUrl, repoDirectory); + const { model, modelString } = useModelSelection(opcodeUrl, initialMessagesDirectory); const isEditingMessage = useUIState((state) => state.isEditingMessage); const setActivePromptFileBasePath = useUIState((state) => state.setActivePromptFileBasePath); const { isEnabled: ttsEnabled } = useTTS(); @@ -217,20 +218,20 @@ export function SessionDetail() { }, [sessionId, minimizedQuestion]) const syncPendingActionsForSession = useCallback(async () => { - if (!repoDirectory || !sessionId) return + if (!initialMessagesDirectory || !sessionId) return await Promise.all([ - syncPermissionsForSession(repoDirectory, sessionId), - syncQuestionsForSession(repoDirectory, sessionId), + syncPermissionsForSession(initialMessagesDirectory, sessionId), + syncQuestionsForSession(initialMessagesDirectory, sessionId), ]) - }, [repoDirectory, sessionId, syncPermissionsForSession, syncQuestionsForSession]) + }, [initialMessagesDirectory, sessionId, syncPermissionsForSession, syncQuestionsForSession]) useQuery({ - queryKey: ['opencode', 'pending-actions', opcodeUrl, sessionId, repoDirectory], + queryKey: ['opencode', 'pending-actions', opcodeUrl, sessionId, initialMessagesDirectory], queryFn: async () => { await syncPendingActionsForSession() return null }, - enabled: !!repoDirectory && !!sessionId, + enabled: !!initialMessagesDirectory && !!sessionId, refetchOnMount: 'always', refetchOnReconnect: true, refetchOnWindowFocus: true, @@ -475,13 +476,15 @@ export function SessionDetail() {
- + {initialMessagesDirectory && ( + + )} @@ -508,7 +511,7 @@ export function SessionDetail() { /> ) : null}
- {opcodeUrl && repoDirectory && !isEditingMessage && ( + {opcodeUrl && initialMessagesDirectory && !isEditingMessage && (
({ @@ -171,6 +172,7 @@ vi.mock('@/components/notifications/PendingActionsGroup', () => ({ describe('SessionDetail first-load navigation directory', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.clear() mocks.useSession.mockReturnValue({ data: undefined, isLoading: false }) mocks.useMessages.mockReturnValue({ data: [], isLoading: false }) @@ -246,6 +248,20 @@ describe('SessionDetail first-load navigation directory', () => { expect(sessionCall?.[2]).toBeUndefined() }) + it('uses the cached assistant directory on direct assistant session load while the repo is loading', async () => { + setCachedAssistantSessionId(0, '/abs/assistant', 'sess-assistant') + + renderSession('/repos/0/sessions/sess-assistant?assistant=1') + + await waitFor(() => { + const call = mocks.useMessages.mock.calls[mocks.useMessages.mock.calls.length - 1] + expect(call?.[2]).toBe('/abs/assistant') + }) + + const sessionCall = mocks.useSession.mock.calls[mocks.useSession.mock.calls.length - 1] + expect(sessionCall?.[2]).toBe('/abs/assistant') + }) + it('renders messages instead of the skeleton when assistant navigation provides directory while the repo is loading', async () => { const { queryByText, getByText } = renderSession({ pathname: '/repos/0/sessions/sess-assistant',