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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions frontend/src/api/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,63 @@ export async function backgroundSession(sessionID: string, serverId?: string): P
await callOpenCode((api) => api.session.background({ sessionID }), serverId)
}

/**
* Stores one instruction entry on a session, which the model is told about at
* the next step boundary.
*
* @param sessionID The session that receives the instruction.
* @param key Stable key of the instruction entry.
* @param text Instruction text stored under the key.
* @param serverId Server that owns the session, when it is not the local one.
*/
export async function putSessionInstruction(
sessionID: string,
key: string,
text: string,
serverId?: string,
): Promise<void> {
await callOpenCode(
(api) => api.session.instructions.entry.put({ sessionID, key, value: text }),
serverId,
)
}

/**
* Removes one instruction entry from a session.
*
* @param sessionID The session that holds the instruction.
* @param key Stable key of the instruction entry.
* @param serverId Server that owns the session, when it is not the local one.
*/
export async function removeSessionInstruction(
sessionID: string,
key: string,
serverId?: string,
): Promise<void> {
await callOpenCode(
(api) => api.session.instructions.entry.remove({ sessionID, key }),
serverId,
)
}

/**
* Lists the keys of the instruction entries a session holds.
*
* @param sessionID The session whose instruction keys are read.
* @param serverId Server that owns the session, when it is not the local one.
* @returns The stored instruction keys, in the order the server reports them.
*/
export async function listSessionInstructionKeys(
sessionID: string,
serverId?: string,
): Promise<string[]> {
const entries = await callOpenCode(
(api) => api.session.instructions.entry.list({ sessionID }),
serverId,
)
return entries.map((entry) => entry.key)
}

export async function listShells(directory: string, serverId?: string): Promise<ShellInfo[]> {
const { data } = await callOpenCode((api) => api.shell.list(openCodeLocation(directory)), serverId)
return data
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/message/PromptInput.command.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PromptInput, type PromptInputHandle } from './PromptInput'
import { BUILTIN_COMMANDS } from '@/lib/builtinCommands'
import { createCommandActionsMock, stubMatchMedia } from '@/test/test-utils'
import { createCommandActionsMock, stubMatchMedia, createVoiceConversationControllerMock } from '@/test/test-utils'

const mocks = vi.hoisted(() => ({
runCommand: vi.fn(),
Expand All @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
dismiss: vi.fn(),
},
useSTT: vi.fn(),
useVoiceConversation: vi.fn(),
useMobile: vi.fn(),
useCommands: vi.fn(),
useFileSearch: vi.fn(),
Expand Down Expand Up @@ -57,7 +58,11 @@ vi.mock('@/hooks/useOpenCode', async () => {
}
})

vi.mock('@/hooks/useSTT', () => ({ useSTT: mocks.useSTT }))
vi.mock('@/hooks/useSTT', () => ({
useSTT: mocks.useSTT,
useSttAvailability: () => ({ isEnabled: true, isSupported: true }),
}))
vi.mock('@/hooks/useVoiceConversation', () => ({ useVoiceConversation: mocks.useVoiceConversation }))
vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile }))
vi.mock('@/hooks/useCommands', () => ({ useCommands: mocks.useCommands }))
vi.mock('@/hooks/useFileSearch', () => ({ useFileSearch: mocks.useFileSearch }))
Expand Down Expand Up @@ -169,6 +174,7 @@ describe('PromptInput command submission', () => {

beforeEach(() => {
vi.clearAllMocks()
mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock())
mocks.runCommand.mockResolvedValue(undefined)
mocks.switchSessionModel.mockResolvedValue(undefined)
mocks.switchSessionAgent.mockResolvedValue(undefined)
Expand Down
26 changes: 16 additions & 10 deletions frontend/src/components/message/PromptInput.composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { render, screen, fireEvent, waitFor, within, act } from '@testing-librar
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PromptInput } from './PromptInput'
import { useSessionStatus } from '@/stores/sessionStatusStore'
import { createCommandActionsMock } from '@/test/test-utils'
import { createCommandActionsMock, createVoiceConversationControllerMock } from '@/test/test-utils'

const mocks = vi.hoisted(() => ({
useSTT: vi.fn(),
useVoiceConversation: vi.fn(),
useMobile: vi.fn(),
useCommands: vi.fn(),
useCommandHandler: vi.fn(),
Expand All @@ -23,7 +24,11 @@ const mocks = vi.hoisted(() => ({
setShowDialog: vi.fn(),
}))

vi.mock('@/hooks/useSTT', () => ({ useSTT: mocks.useSTT }))
vi.mock('@/hooks/useSTT', () => ({
useSTT: mocks.useSTT,
useSttAvailability: () => ({ isEnabled: true, isSupported: true }),
}))
vi.mock('@/hooks/useVoiceConversation', () => ({ useVoiceConversation: mocks.useVoiceConversation }))
vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile }))
vi.mock('@/hooks/useCommands', () => ({ useCommands: mocks.useCommands }))
vi.mock('@/hooks/useCommandHandler', () => ({ useCommandHandler: mocks.useCommandHandler }))
Expand Down Expand Up @@ -88,7 +93,7 @@ const createTestQueryClient = () => new QueryClient({
const defaultProps = {
directory: '/test',
sessionID: 'test-session',
showScrollButton: false,
showScrollButton: false,
isSessionActive: false,
isStreamingResponse: false,
onScrollToBottom: vi.fn(),
Expand All @@ -106,11 +111,12 @@ describe('PromptInput composer layout', () => {
const promptInput = () => screen.getByPlaceholderText('Send a message...')
const submitButton = () => document.querySelector('[data-submit-prompt]')
const controlsRow = () => screen.getByTestId('composer-controls-row')
const mobileVoiceButton = () => document.querySelector('[data-voice-variant="mobile"]')
const voiceMicButton = () => screen.queryByRole('button', { name: 'Start voice conversation' })
const keyButtons = () => screen.queryAllByRole('toolbar', { name: 'Insert' })

beforeEach(() => {
vi.clearAllMocks()
mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock())
mocks.hasPendingPermission.mockReturnValue(false)
mocks.useMobile.mockReturnValue(false)
mocks.useSTT.mockReturnValue({
Expand Down Expand Up @@ -328,7 +334,7 @@ describe('PromptInput composer layout', () => {
fireEvent.focus(promptInput())

const actions = controlsRow().lastElementChild as HTMLElement
expect(actions).toContainElement(mobileVoiceButton() as HTMLElement)
expect(actions).toContainElement(voiceMicButton() as HTMLElement)
expect(actions).toContainElement(screen.getByTestId('composer-tools-menu'))
})
})
Expand Down Expand Up @@ -362,15 +368,15 @@ describe('PromptInput composer layout', () => {
fireEvent.change(promptInput(), { target: { value: 'hello' } })

expect(submitButton()).not.toBeNull()
expect(mobileVoiceButton()).not.toBeNull()
expect(controlsRow().lastElementChild).toContainElement(mobileVoiceButton() as HTMLElement)
expect(voiceMicButton()).not.toBeNull()
expect(controlsRow().lastElementChild).toContainElement(voiceMicButton() as HTMLElement)
})

it('hides the mic while a permission is pending', () => {
mocks.hasPendingPermission.mockReturnValue(true)
renderComponent()

expect(mobileVoiceButton()).toBeNull()
expect(voiceMicButton()).toBeNull()
expect(screen.getByTitle('View pending permission')).toBeInTheDocument()
})
})
Expand All @@ -380,13 +386,13 @@ describe('PromptInput composer layout', () => {
mocks.useMobile.mockReturnValue(true)
renderComponent()

const micBeforeFocus = mobileVoiceButton() as HTMLElement
const micBeforeFocus = voiceMicButton() as HTMLElement
expect(micBeforeFocus).toHaveClass('min-h-11', 'min-w-11')
expect(micBeforeFocus).not.toHaveClass('min-h-[44px]')

fireEvent.focus(promptInput())

const micAfterFocus = mobileVoiceButton() as HTMLElement
const micAfterFocus = voiceMicButton() as HTMLElement
expect(micAfterFocus).toHaveClass('min-h-11', 'min-w-11')
expect(micAfterFocus).not.toHaveClass('min-h-[44px]')
})
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/message/PromptInput.goal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PromptInput } from './PromptInput'
import { createCommandActionsMock, stubMatchMedia } from '@/test/test-utils'
import { createCommandActionsMock, stubMatchMedia, createVoiceConversationControllerMock } from '@/test/test-utils'

const mocks = vi.hoisted(() => ({
sendPrompt: vi.fn(),
Expand All @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({
dismiss: vi.fn(),
},
useSTT: vi.fn(),
useVoiceConversation: vi.fn(),
useMobile: vi.fn(),
useCommands: vi.fn(),
useFileSearch: vi.fn(),
Expand Down Expand Up @@ -73,7 +74,11 @@ vi.mock('@/hooks/useSessionPermissionMode', () => ({
useSessionPermissionMode: mocks.useSessionPermissionMode,
}))

vi.mock('@/hooks/useSTT', () => ({ useSTT: mocks.useSTT }))
vi.mock('@/hooks/useSTT', () => ({
useSTT: mocks.useSTT,
useSttAvailability: () => ({ isEnabled: true, isSupported: true }),
}))
vi.mock('@/hooks/useVoiceConversation', () => ({ useVoiceConversation: mocks.useVoiceConversation }))
vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile }))
vi.mock('@/hooks/useCommands', () => ({ useCommands: mocks.useCommands }))
vi.mock('@/hooks/useFileSearch', () => ({ useFileSearch: mocks.useFileSearch }))
Expand Down Expand Up @@ -159,6 +164,7 @@ describe('PromptInput goal mode', () => {

beforeEach(() => {
vi.clearAllMocks()
mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock())
mocks.useSessionGoal.mockReturnValue({ data: null })
mocks.useSessionPermissionMode.mockReturnValue({ data: undefined })
mocks.startGoal.mockResolvedValue({ id: 1 })
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/message/PromptInput.mention.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PromptInput } from './PromptInput'
import { createCommandActionsMock } from '@/test/test-utils'
import { createCommandActionsMock, createVoiceConversationControllerMock } from '@/test/test-utils'

const mocks = vi.hoisted(() => ({
sendPrompt: vi.fn(),
Expand All @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
agents: [] as Array<{ name: string; description?: string }>,
skills: [] as Array<{ id: string; name: string; description?: string }>,
useSTT: vi.fn(),
useVoiceConversation: vi.fn(),
useMobile: vi.fn(),
useCommands: vi.fn(),
useFileSearch: vi.fn(),
Expand Down Expand Up @@ -45,7 +46,11 @@ vi.mock('@/hooks/useOpenCode', async () => {
}
})

vi.mock('@/hooks/useSTT', () => ({ useSTT: mocks.useSTT }))
vi.mock('@/hooks/useSTT', () => ({
useSTT: mocks.useSTT,
useSttAvailability: () => ({ isEnabled: true, isSupported: true }),
}))
vi.mock('@/hooks/useVoiceConversation', () => ({ useVoiceConversation: mocks.useVoiceConversation }))
vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile }))
vi.mock('@/hooks/useCommands', () => ({ useCommands: mocks.useCommands }))
vi.mock('@/hooks/useFileSearch', () => ({ useFileSearch: mocks.useFileSearch }))
Expand Down Expand Up @@ -148,6 +153,7 @@ describe('PromptInput agent mention submission', () => {

beforeEach(() => {
vi.clearAllMocks()
mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock())
mocks.sendPrompt.mockResolvedValue({
id: 'inbox_1',
sessionID: 'test-session',
Expand Down
Loading