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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions frontend/src/components/navigation/MobileTabBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('MobileTabBar', () => {
expect(screen.getByText('Schedules')).toBeInTheDocument()
})

it('navigates to /assistant when assistant is clicked from repo context', async () => {
it('navigates to assistant session list when assistant is clicked from repo context', async () => {
vi.mocked(useMobile).mockReturnValue(true)
const queryClient = new QueryClient()
const user = userEvent.setup()
Expand All @@ -96,10 +96,10 @@ describe('MobileTabBar', () => {
)

await user.click(screen.getByRole('button', { name: 'Assistant' }))
expect(screen.getByTestId('location')).toHaveTextContent('/assistant')
expect(screen.getByTestId('location')).toHaveTextContent('/assistant?view=sessions')
})

it('navigates to assistant route when assistant is clicked without repo id', async () => {
it('navigates to assistant session list when assistant is clicked without repo id', async () => {
vi.mocked(useMobile).mockReturnValue(true)
const queryClient = new QueryClient()
const user = userEvent.setup()
Expand All @@ -118,7 +118,7 @@ describe('MobileTabBar', () => {
)

await user.click(screen.getByRole('button', { name: 'Assistant' }))
expect(screen.getByTestId('location')).toHaveTextContent('/assistant')
expect(screen.getByTestId('location')).toHaveTextContent('/assistant?view=sessions')
})

it('renders schedule tabs on /repos/:id/schedules path', () => {
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/navigation/MobileTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useMobile } from '@/hooks/useMobile'
import { useMobileTabBar } from '@/hooks/useMobileTabBar'
import { useUrlParams } from '@/hooks/useUrlParams'
import { useScheduleUrlState, type ScheduleTab } from '@/hooks/useScheduleUrlState'
import { getAssistantPath, isAssistantPath } from '@/lib/navigation'
import { getAssistantPath, getAssistantSessionListPath, isAssistantPath } from '@/lib/navigation'

interface TabDef {
key: string
Expand Down Expand Up @@ -74,7 +74,7 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR

const handleAssistantClick = () => {
close()
navigate(getAssistantPath())
navigate(getAssistantSessionListPath())
}

return [
Expand All @@ -97,7 +97,7 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR
label: 'Assistant',
icon: Bot,
onClick: handleAssistantClick,
active: isAssistantPath(pathname) && !openSheet,
active: (pathname === getAssistantPath() || isAssistantPath(pathname)) && !openSheet,
},
{
key: 'schedules',
Expand Down
16 changes: 8 additions & 8 deletions frontend/src/components/navigation/moreDrawerItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('buildNavModel', () => {
expect(model.primary[0].key).toBe('new-repo')
expect(model.primary[0].onSelect).toBe('new-repo')
expect(model.primary[1].key).toBe('assistant')
expect(model.primary[1].to).toBe('/assistant')
expect(model.primary[1].to).toBe('/assistant?view=sessions')
})

it('returns new-session and assistant primary CTAs for repo detail', () => {
Expand All @@ -99,7 +99,7 @@ describe('buildNavModel', () => {
expect(model.primary[0].key).toBe('new-session')
expect(model.primary[0].onSelect).toBe('new-session')
expect(model.primary[1].key).toBe('assistant')
expect(model.primary[1].to).toBe('/assistant')
expect(model.primary[1].to).toBe('/assistant?view=sessions')
})

it('returns new-session and assistant primary CTAs for session detail', () => {
Expand All @@ -109,7 +109,7 @@ describe('buildNavModel', () => {
expect(model.primary[0].onSelect).toBe('new-session')
expect(model.primary[0].variant).toBe('primary')
expect(model.primary[1].key).toBe('assistant')
expect(model.primary[1].to).toBe('/assistant')
expect(model.primary[1].to).toBe('/assistant?view=sessions')
expect(model.primary[1].variant).toBe('secondary')
})

Expand All @@ -120,7 +120,7 @@ describe('buildNavModel', () => {
expect(model.primary[0].onSelect).toBe('new-session')
expect(model.primary[0].variant).toBe('primary')
expect(model.primary[1].key).toBe('assistant')
expect(model.primary[1].to).toBe('/assistant')
expect(model.primary[1].to).toBe('/assistant?view=sessions')
expect(model.primary[1].variant).toBe('secondary')
})

Expand All @@ -131,7 +131,7 @@ describe('buildNavModel', () => {
expect(model.primary[0].onSelect).toBe('new-session')
expect(model.primary[0].variant).toBe('primary')
expect(model.primary[1].key).toBe('assistant')
expect(model.primary[1].to).toBe('/assistant')
expect(model.primary[1].to).toBe('/assistant?view=sessions')
expect(model.primary[1].variant).toBe('secondary')
})

Expand All @@ -141,21 +141,21 @@ describe('buildNavModel', () => {
expect(model1.primary[0].key).toBe('new-schedule')
expect(model1.primary[0].onSelect).toBe('new-schedule')
expect(model1.primary[1].key).toBe('assistant')
expect(model1.primary[1].to).toBe('/assistant')
expect(model1.primary[1].to).toBe('/assistant?view=sessions')

const model2 = buildNavModel('/repos/5/schedules')
expect(model2.primary).toHaveLength(2)
expect(model2.primary[0].key).toBe('new-schedule')
expect(model2.primary[0].onSelect).toBe('new-schedule')
expect(model2.primary[1].key).toBe('assistant')
expect(model2.primary[1].to).toBe('/assistant')
expect(model2.primary[1].to).toBe('/assistant?view=sessions')
})

it('returns assistant primary for unknown routes', () => {
const model = buildNavModel('/unknown/path')
expect(model.primary).toHaveLength(1)
expect(model.primary[0].key).toBe('assistant')
expect(model.primary[0].to).toBe('/assistant')
expect(model.primary[0].to).toBe('/assistant?view=sessions')
})

it('preserves backwards compatibility with buildMoreItems', () => {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/navigation/moreDrawerItems.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { LucideIcon } from 'lucide-react'
import { Plug, Sparkles, ShieldOff, CalendarClock, GitCommitHorizontal, Code2, Settings, LogOut, Plus, Bot, Folder, Clock, SquarePlus } from 'lucide-react'
import { getAssistantPath, isAssistantPath } from '@/lib/navigation'
import { getAssistantSessionListPath, isAssistantPath } from '@/lib/navigation'

export interface MoreDrawerItem {
key: string
Expand Down Expand Up @@ -30,7 +30,7 @@ function getAssistantNavItem(_pathname: string, variant: NavPrimaryCta['variant'
key: 'assistant',
label: 'Assistant',
icon: Bot,
to: getAssistantPath(),
to: getAssistantSessionListPath(),
variant,
}
}
Expand Down
47 changes: 46 additions & 1 deletion frontend/src/hooks/__tests__/useLoadSkill.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useLoadSkill } from '../useOpenCode'
import type { MessageWithParts } from '../../api/types'

import { showToast } from '../../lib/toast'
import { useSessionStatus } from '../../stores/sessionStatusStore'

const mocks = vi.hoisted(() => ({
sendCommand: vi.fn(),
Expand Down Expand Up @@ -38,6 +39,7 @@ const createWrapper = () => {
describe('useLoadSkill', () => {
beforeEach(() => {
vi.clearAllMocks()
useSessionStatus.getState().replaceStatuses({})
})

it('calls sendCommand with correct parameters when mutate is called', async () => {
Expand Down Expand Up @@ -161,13 +163,29 @@ describe('useLoadSkill', () => {
})
})

it('sets session status to busy on mutate and idle on error', async () => {
it('sets session status to busy on mutate', async () => {
mocks.sendCommand.mockReturnValue(new Promise(() => {}))

const { result } = renderHook(
() => useLoadSkill('http://localhost:5551', 'test-session-id', '/test/dir'),
{ wrapper: createWrapper() },
)

result.current.mutate({ skillName: 'my-skill' })

await waitFor(() => {
expect(useSessionStatus.getState().getStatus('test-session-id').type).toBe('busy')
})
})

it('rolls back to idle on mutation error', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})

const testError = new Error('Command failed')
mocks.sendCommand.mockRejectedValue(testError)

Expand All @@ -182,6 +200,33 @@ describe('useLoadSkill', () => {
expect(result.current.isError).toBe(true)
})

expect(useSessionStatus.getState().getStatus('test-session-id').type).toBe('idle')
})

it('does not set session status when sessionID is undefined', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})

mocks.sendCommand.mockReturnValue(new Promise(() => {}))

const { result } = renderHook(
() => useLoadSkill('http://localhost:5551', undefined, '/test/dir'),
{ wrapper: ({ children }) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> },
)

result.current.mutate({ skillName: 'my-skill' })

await waitFor(() => {
expect(result.current.isError).toBe(true)
})

expect(result.current.error).toBeInstanceOf(Error)
expect((result.current.error as Error).message).toBe('No active session')
expect(useSessionStatus.getState().getStatus('test-session-id').type).toBe('idle')
})

it('removes optimistic message from cache on error', async () => {
Expand Down
29 changes: 25 additions & 4 deletions frontend/src/hooks/useOpenCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type { paths, components } from "../api/opencode-types";
import { parseNetworkError } from "../lib/opencode-errors";
import { showToast } from "../lib/toast";
import { useSendErrorStore } from "../stores/sendErrorStore";
import { beginOptimisticBusy, rollbackOptimisticBusy } from "../stores/sessionStatusStore";
import type { SessionStatusType } from "../stores/sessionStatusStore";
import { invalidateSessionListCaches, messagesQueryKey } from "../lib/queryInvalidation";

type AssistantMessage = components["schemas"]["AssistantMessage"];
Expand All @@ -22,6 +24,8 @@ type SendPromptRequest = NonNullable<

type SendCommandResponse = paths["/session/{sessionID}/command"]["post"]["responses"]["200"]["content"]["application/json"];

type OptimisticBusyContext = { previousBusyStatus: SessionStatusType };

const parseModelString = (model: string) => {
const [providerID, ...rest] = model.split("/");
const modelID = rest.join("/");
Expand Down Expand Up @@ -477,8 +481,13 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?:

return { optimisticUserID, response, queued: false };
},
onError: (error, variables) => {
onMutate: ({ sessionID }): OptimisticBusyContext => ({
previousBusyStatus: beginOptimisticBusy(sessionID),
}),
onError: (error, variables, context: OptimisticBusyContext | undefined) => {
const { sessionID } = variables;
rollbackOptimisticBusy(sessionID, context?.previousBusyStatus ?? { type: 'idle' });

const queryKey = messagesQueryKey(opcodeUrl, sessionID, directory);

queryClient.setQueryData<MessageWithParts[]>(
Expand Down Expand Up @@ -726,8 +735,12 @@ export const useSendShell = (opcodeUrl: string | null | undefined, directory?: s

return { optimisticUserID, response };
},
onError: (_, variables) => {
onMutate: ({ sessionID }): OptimisticBusyContext => ({
previousBusyStatus: beginOptimisticBusy(sessionID),
}),
onError: (_error, variables, context: OptimisticBusyContext | undefined) => {
const { sessionID } = variables;
rollbackOptimisticBusy(sessionID, context?.previousBusyStatus ?? { type: 'idle' });
queryClient.setQueryData<MessageWithParts[]>(
messagesQueryKey(opcodeUrl, sessionID, directory),
(old) => {
Expand Down Expand Up @@ -788,7 +801,8 @@ export const useLoadSkill = (
return useMutation<
{ optimisticUserID: string; response: SendCommandResponse },
Error,
{ skillName: string }
{ skillName: string },
OptimisticBusyContext | undefined
>({
mutationFn: async ({ skillName }: { skillName: string }) => {
if (!client) throw new Error("No OpenCode client available");
Expand Down Expand Up @@ -817,7 +831,14 @@ export const useLoadSkill = (
const response = await client.sendCommand(sessionID, { command: skillName, arguments: "" });
return { optimisticUserID, response };
},
onError: (error) => {
onMutate: () => {
if (!sessionID) return undefined;
return { previousBusyStatus: beginOptimisticBusy(sessionID) };
},
onError: (error, _variables, context: OptimisticBusyContext | undefined) => {
if (sessionID && context?.previousBusyStatus) {
rollbackOptimisticBusy(sessionID, context.previousBusyStatus);
}
if (sessionID) {
const queryKey = messagesQueryKey(opcodeUrl, sessionID, directory);
queryClient.setQueryData<MessageWithParts[]>(
Expand Down
29 changes: 24 additions & 5 deletions frontend/src/hooks/useSendPrompt.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook } from '@testing-library/react'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createElement } from 'react'
import { useSendPrompt } from './useOpenCode'
import { FetchError } from '../api/fetchWrapper'
import { useSessionStatus } from '../stores/sessionStatusStore'

const mockSendPrompt = vi.fn()
const mockSendPromptAsync = vi.fn()
Expand All @@ -19,10 +20,6 @@ vi.mock('../api/opencode', async () => {
}
})

vi.mock('@/stores/sessionStatusStore', () => ({
useSessionStatus: vi.fn(() => vi.fn()),
}))

vi.mock('../lib/toast', () => ({
showToast: { error: vi.fn() },
}))
Expand Down Expand Up @@ -62,6 +59,7 @@ describe('useSendPrompt', () => {

beforeEach(() => {
vi.clearAllMocks()
useSessionStatus.getState().replaceStatuses({})
queryClient = createTestQueryClient()
mockSendPrompt.mockResolvedValue({
info: { id: 'test-response' },
Expand Down Expand Up @@ -194,4 +192,25 @@ describe('useSendPrompt', () => {

expect(mockClearError).toHaveBeenCalledWith('session-2')
})

it('sets session status to busy immediately on send', async () => {
mockSendPrompt.mockReturnValue(new Promise(() => {}))

const { result } = renderHookWithProviders()
result.current.mutate({ sessionID: 'busy-session', prompt: 'Hi' })

await waitFor(() => {
expect(useSessionStatus.getState().getStatus('busy-session').type).toBe('busy')
})
})

it('rolls back to idle when send fails', async () => {
mockSendPrompt.mockRejectedValue(new Error('boom'))

const { result } = renderHookWithProviders()
result.current.mutate({ sessionID: 'busy-session', prompt: 'Hi' })

await waitFor(() => expect(result.current.isError).toBe(true))
expect(useSessionStatus.getState().getStatus('busy-session').type).toBe('idle')
})
})
Loading