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/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/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/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/useAssistantSessionLauncher.test.tsx b/frontend/src/hooks/useAssistantSessionLauncher.test.tsx index 6cb5c8cf..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,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: [ diff --git a/frontend/src/hooks/useAssistantSessionLauncher.ts b/frontend/src/hooks/useAssistantSessionLauncher.ts index 73cd072a..e6cf8109 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'] @@ -14,14 +15,16 @@ 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 } @@ -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 } @@ -94,6 +116,7 @@ export function useAssistantSessionLauncher({ opcodeUrl, directory, onNavigate, + onMissingCachedSession, }: UseAssistantSessionLauncherOptions) { const openAssistant = useCallback(async () => { if (!directory) { @@ -106,6 +129,11 @@ export function useAssistantSessionLauncher({ return } + if (onMissingCachedSession) { + onMissingCachedSession() + return + } + const client = new OpenCodeClient(opcodeUrl, directory) const newest = await getLatestAssistantSession(client, directory) @@ -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 } } 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/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/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index f74cca10..daac101d 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -2,8 +2,9 @@ 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 { useCreateSession } from "@/hooks/useOpenCode" +import { OpenCodeClient } from "@/api/opencode" +import { getCachedAssistantDirectory, setCachedAssistantSessionId, useAssistantSessionLauncher } from "@/hooks/useAssistantSessionLauncher" +import { useCreateSession, useSessionsAcrossDirectories } from "@/hooks/useOpenCode" import { useDialogParam } from "@/hooks/useDialogParam" import { useSSE } from "@/hooks/useSSE" import { OPENCODE_API_ENDPOINT } from "@/config" @@ -16,8 +17,8 @@ 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 { getSessionListPath, getAssistantPath } from "@/lib/navigation" +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" @@ -35,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({ @@ -43,29 +45,73 @@ export function AssistantRedirect() { }) const handleNavigate = useCallback((sessionId: string) => { + const directory = repo?.fullPath ?? cachedAssistantDirectory + if (directory) { + setCachedAssistantSessionId(repoId, directory, sessionId) + void queryClient.prefetchQuery({ + 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`) - }, [navigate, 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 }) + }, [navigate]) + + 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, opcodeUrl, - directory: repo?.fullPath, + directory: assistantDirectory, onNavigate: handleNavigate, + onMissingCachedSession: handleMissingCachedSession, }) - const assistantDirectory = repo?.fullPath - const assistantFileBasePath = assistantDirectory?.split('/').filter(Boolean).at(-1) - useSSE(opcodeUrl, assistantDirectory) const createSessionMutation = useCreateSession(opcodeUrl, assistantDirectory, (session) => { - navigate(`/repos/${repoId}/sessions/${session.id}?assistant=1`) + if (assistantDirectory) { + setCachedAssistantSessionId(repoId, assistantDirectory, session.id) + } + prefetchAssistantMessages(session.id, assistantDirectory) + navigate(`/repos/${repoId}/sessions/${session.id}?assistant=1`, { state: { directory: assistantDirectory } }) }) + const handleSelectSession = useCallback((sessionId: string, directory?: string) => { + const selectedDirectory = directory ?? assistantDirectory + if (selectedDirectory) { + setCachedAssistantSessionId(repoId, selectedDirectory, sessionId) + } + prefetchAssistantMessages(sessionId, selectedDirectory) + navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`, { state: { directory: selectedDirectory } }) + }, [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 }) } @@ -77,8 +123,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() @@ -94,7 +140,7 @@ export function AssistantRedirect() { return () => { cancelled = true } - }, [repo?.fullPath, repoError, repoLoading, openAssistant, showSessionList]) + }, [assistantDirectory, repoError, repoLoading, openAssistant, showSessionList]) if (showSessionList) { return ( @@ -116,15 +162,15 @@ export function AssistantRedirect() {
- {repoError ? ( + {repoError && !assistantDirectory ? (
Failed to load Assistant sessions
- ) : repoLoading || !repo?.fullPath ? ( + ) : repoLoading && !assistantDirectory ? (
Loading Assistant sessions...
) : ( navigate(`/repos/${repoId}/sessions/${sessionId}?assistant=1`)} + onSelectSession={handleSelectSession} /> )}
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/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) @@ -67,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(); @@ -118,16 +120,23 @@ export function SessionDetail() { const opcodeUrl = OPENCODE_API_ENDPOINT; - const repoDirectory = repo?.fullPath; + const cachedAssistantDirectory = isAssistantSession ? getCachedAssistantDirectory(repoId) : undefined; + const repoDirectory = navigationDirectory ?? repo?.fullPath ?? cachedAssistantDirectory; const sessionRouteSuffix = isAssistantSession ? '?assistant=1' : ''; - const { isConnected, isReconnecting } = useSSE(opcodeUrl, repoDirectory, sessionId); + useEffect(() => { + if (isAssistantSession && repoDirectory && sessionId) { + setCachedAssistantSessionId(repoId, repoDirectory, sessionId); + } + }, [isAssistantSession, repoDirectory, repoId, sessionId]); const { data: rawMessages, isLoading: messagesLoading } = useMessages(opcodeUrl, sessionId, repoDirectory); - const { data: session, isLoading: sessionLoading } = useSession( + const initialMessagesDirectory = repoDirectory && !messagesLoading ? repoDirectory : undefined; + const { isConnected, isReconnecting } = useSSE(opcodeUrl, initialMessagesDirectory, sessionId); + const { data: session } = useSession( opcodeUrl, sessionId, - repoDirectory, + initialMessagesDirectory, ); const messages = useMemo(() => { @@ -153,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(); @@ -209,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, @@ -234,12 +243,15 @@ export function SessionDetail() { try { const newSession = await createSession.mutateAsync({ agent: undefined }); if (newSession?.id) { - navigate(`/repos/${repoId}/sessions/${newSession.id}${sessionRouteSuffix}`); + if (isAssistantSession && repoDirectory) { + setCachedAssistantSessionId(repoId, repoDirectory, newSession.id); + } + navigate(`/repos/${repoId}/sessions/${newSession.id}${sessionRouteSuffix}`, { state: { directory: repoDirectory } }); } } catch { showToast.error('Failed to create new session'); } - }, [createSession, navigate, repoId, sessionRouteSuffix]); + }, [createSession, isAssistantSession, navigate, repoDirectory, repoId, sessionRouteSuffix]); useSidebarAction('new-session', () => { handleNewSession(); @@ -288,7 +300,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) { @@ -365,14 +377,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 @@ -408,7 +420,7 @@ export function SessionDetail() { return ; } - if (!repo && !isAssistantSession) { + if (!repo && !isAssistantSession && !repoDirectory) { return (
@@ -464,13 +476,15 @@ export function SessionDetail() {
- + {initialMessagesDirectory && ( + + )} @@ -482,7 +496,7 @@ export function SessionDetail() {
- {repoLoading || sessionLoading || messagesLoading ? ( + {(!repoDirectory && repoLoading) || !repoDirectory || messagesLoading ? ( ) : opcodeUrl && repoDirectory ? ( ) : null}
- {opcodeUrl && repoDirectory && !isEditingMessage && ( + {opcodeUrl && initialMessagesDirectory && !isEditingMessage && (
{ - 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..ccc6b7bf --- /dev/null +++ b/frontend/src/pages/__tests__/SessionDetail.first-load-directory.test.tsx @@ -0,0 +1,292 @@ +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 { setCachedAssistantSessionId } from '../../hooks/useAssistantSessionLauncher' +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/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), +})) + +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() + localStorage.clear() + + 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() + }) + + 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', + state: { directory: '/abs/assistant' }, + }) + + await waitFor(() => { + expect(getByText('Messages rendered')).toBeTruthy() + }) + + 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() + }) +})