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
24 changes: 23 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { MobileTabBar } from '@/components/navigation/MobileTabBar'
import { MobileSheetHost } from '@/components/navigation/MobileSheetHost'
import { DesktopSidebar } from '@/components/navigation/DesktopSidebar'
import { useTheme } from './hooks/useTheme'
import { useSwipeBack } from './hooks/useMobile'
import { useRightEdgeSwipe, useSwipeBack } from './hooks/useMobile'
import { useMobileTabBar } from '@/hooks/useMobileTabBar'
import { TTSProvider } from './contexts/TTSContext'
import { AuthProvider } from './contexts/AuthContext'
import { EventProvider, usePermissions, useEventContext } from '@/contexts/EventContext'
Expand Down Expand Up @@ -76,6 +77,7 @@ function AppShell() {
const navigate = useNavigate()
const location = useLocation()
const rootRef = useRef<HTMLDivElement>(null)
const { open: openMobileSheet, openSheet } = useMobileTabBar()
useTheme()

const getSwipeBackTarget = () => {
Expand Down Expand Up @@ -123,13 +125,33 @@ function AppShell() {
}
)

const canOpenMoreWithSwipe = () => {
return /^\/repos\/[^/]+\/sessions\/[^/]+$/.test(location.pathname) && !openSheet
}

const { bind: bindMoreSwipe } = useRightEdgeSwipe(
() => openMobileSheet('more'),
{
enabled: canOpenMoreWithSwipe(),
edgeWidth: 32,
threshold: 72,
}
)

useEffect(() => {
const cleanup = bindRouteSwipe(rootRef.current)
return () => {
cleanup?.()
}
}, [bindRouteSwipe])

useEffect(() => {
const cleanup = bindMoreSwipe(rootRef.current)
return () => {
cleanup?.()
}
}, [bindMoreSwipe])

useEffect(() => {
const channel = new BroadcastChannel('notification-click')
channel.onmessage = (event: MessageEvent) => {
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/file-browser/FileBrowserSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { GPU_ACCELERATED_STYLE, MODAL_TRANSITION_MS } from '@/lib/utils'
import { useSwipeBack } from '@/hooks/useMobile'
import { downloadDirectoryAsZip } from '@/api/files'
import { downloadRepo } from '@/api/repos'
import type { FileInfo } from '@/types/files'
import {
DropdownMenu,
DropdownMenuContent,
Expand All @@ -24,9 +25,10 @@ interface FileBrowserSheetProps {
repoId?: number
initialSelectedFile?: string
allowNavigateAboveBase?: boolean
onFileSelect?: (file: FileInfo) => void
}

export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose, basePath = '', repoName, repoId, initialSelectedFile, allowNavigateAboveBase = false }: FileBrowserSheetProps) {
export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose, basePath = '', repoName, repoId, initialSelectedFile, allowNavigateAboveBase = false, onFileSelect }: FileBrowserSheetProps) {
const normalizedBasePath = basePath || '.'
const [isEditing, setIsEditing] = useState(false)
const [displayPath, setDisplayPath] = useState<string>('/')
Expand Down Expand Up @@ -197,6 +199,7 @@ export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose
basePath={normalizedBasePath}
embedded={true}
initialSelectedFile={initialSelectedFile}
onFileSelect={onFileSelect}
onDirectoryLoad={handleDirectoryLoad}
onPreviewStateChange={setIsPreviewOpen}
allowNavigateAboveBase={allowNavigateAboveBase}
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/components/message/MessagePart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -676,4 +676,39 @@ describe('MessagePart', () => {
expect(screen.getByText('Reasoning')).toBeInTheDocument()
})
})

describe('synthetic text parts', () => {
const createTextPart = (overrides: Partial<MessagePartType>): MessagePartType => ({
type: 'text',
text: 'hello',
sessionID: 'test-session',
messageID: 'm1',
...overrides,
} as MessagePartType)

it('does not render synthetic text parts', () => {
setup()
const part = createTextPart({
text: 'Called the Read tool with the following input: {"filePath":"/x/README.md"}',
synthetic: true,
} as Partial<MessagePartType>)

const { container } = render(
<MessagePart part={part} role="user" allParts={[part]} partIndex={0} />,
)

expect(container.firstChild).toBeNull()
})

it('renders non-synthetic text parts normally', () => {
setup()
const part = createTextPart({ text: 'Just a normal user message' })

render(
<MessagePart part={part} role="user" allParts={[part]} partIndex={0} />,
)

expect(screen.getByText('Just a normal user message')).toBeInTheDocument()
})
})
})
1 change: 1 addition & 0 deletions frontend/src/components/message/MessagePart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const MessagePart = memo(function MessagePart({ part, role, allParts, par

switch (part.type) {
case 'text':
if (part.synthetic) return null
if (role === 'user' && allParts && partIndex !== undefined) {
const nextPart = allParts[partIndex + 1]
if (nextPart && nextPart.type === 'file') {
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/components/message/PromptInput.stt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PromptInput } from './PromptInput'
import { useUIState } from '@/stores/uiStateStore'

const createTestQueryClient = () => new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -189,6 +190,8 @@ describe('PromptInput STT Gesture Tests', () => {
mocks.useAgents.mockReturnValue({ data: [] })
mocks.useUserBash.mockReturnValue({ addUserBashCommand: vi.fn() })
mocks.useSessionAgentStore.mockReturnValue({ setAgent: mockSetAgent })
useUIState.getState().clearPendingPromptCommand()
useUIState.getState().clearPendingPromptFile()
})

const renderComponent = (sttOverrides: Partial<MockSTTReturn> = {}) => {
Expand Down Expand Up @@ -228,6 +231,37 @@ describe('PromptInput STT Gesture Tests', () => {
}

describe('quick tap behavior', () => {
it('inserts a command selected from the mobile drawer', async () => {
renderComponent()

act(() => {
useUIState.getState().selectPromptCommand({
name: 'help',
description: 'Show help',
template: '',
agent: '',
model: '',
hints: [],
})
})

await waitFor(() => {
expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('/help ')
})
})

it('inserts a file selected from the mobile drawer', async () => {
renderComponent()

act(() => {
useUIState.getState().selectPromptFile('src/App.tsx')
})

await waitFor(() => {
expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('@App.tsx ')
})
})

it('quick tap starts recording through click only', async () => {
mockStartRecording.mockResolvedValue(true)

Expand Down
114 changes: 68 additions & 46 deletions frontend/src/components/message/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useSTT } from '@/hooks/useSTT'

import { useUserBash } from '@/stores/userBashStore'
import { useSessionAgentStore } from '@/stores/sessionAgentStore'
import { useUIState } from '@/stores/uiStateStore'
import { useMobile } from '@/hooks/useMobile'

import { usePermissions } from '@/contexts/EventContext'
Expand Down Expand Up @@ -122,6 +123,10 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
const voiceHoldActivatedRef = useRef(false)
const voiceStartRequestRef = useRef(0)
const handleSubmitRef = useRef<() => void>(() => {})
const pendingPromptCommand = useUIState((state) => state.pendingPromptCommand)
const pendingPromptFile = useUIState((state) => state.pendingPromptFile)
const clearPendingPromptCommand = useUIState((state) => state.clearPendingPromptCommand)
const clearPendingPromptFile = useUIState((state) => state.clearPendingPromptFile)

const {
isRecording,
Expand Down Expand Up @@ -323,7 +328,7 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
abortSession.mutate(sessionID)
}

const handleCommandSelect = async (command: CommandType) => {
const handleCommandSelect = useCallback(async (command: CommandType) => {
if (!textareaRef.current) return

setShowSuggestions(false)
Expand All @@ -348,25 +353,68 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
const cursorPosition = textareaRef.current.selectionStart
const commandMatch = prompt.slice(0, cursorPosition).match(/(^|\s)\/([a-zA-Z0-9_-]*)$/)

if (commandMatch) {
const beforeCommand = prompt.slice(0, commandMatch.index)
const afterCommand = prompt.slice(cursorPosition)
const newPrompt = beforeCommand + '/' + command.name + ' ' + afterCommand

setPrompt(newPrompt)

setTimeout(() => {
if (textareaRef.current) {
const newCursorPos = beforeCommand.length + command.name.length + 2
textareaRef.current.focus()
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.current.scrollTop = textareaRef.current.scrollHeight
}
}, 0)
}
const beforeCommand = commandMatch ? prompt.slice(0, commandMatch.index) : ''
const afterCommand = commandMatch ? prompt.slice(cursorPosition) : ''
const newPrompt = beforeCommand + '/' + command.name + ' ' + afterCommand

setPrompt(newPrompt)

setTimeout(() => {
if (textareaRef.current) {
const newCursorPos = beforeCommand.length + command.name.length + 2
textareaRef.current.focus()
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.current.scrollTop = textareaRef.current.scrollHeight
}
}, 0)
}
}

}, [prompt])

useEffect(() => {
if (!pendingPromptCommand) return
handleCommandSelect(pendingPromptCommand.command)
clearPendingPromptCommand()
}, [pendingPromptCommand, handleCommandSelect, clearPendingPromptCommand])

const insertFileMention = useCallback((filePath: string, range: { start: number, end: number } | null = mentionRange) => {
const filename = getFilename(filePath)
const beforeMention = range ? prompt.slice(0, range.start) : `${prompt}${prompt.trim() ? ' ' : ''}`
const afterMention = range ? prompt.slice(range.end) : ''
const newPrompt = beforeMention + '@' + filename + ' ' + afterMention

setPrompt(newPrompt)

const absolutePath = filePath.startsWith('/')
? filePath
: directory
? `${directory}/${filePath}`
: filePath

setAttachedFiles(prev => {
const next = new Map(prev)
next.set(filename.toLowerCase(), {
path: absolutePath,
name: filename
})
return next
})

setTimeout(() => {
if (textareaRef.current) {
const newCursorPos = beforeMention.length + filename.length + 2
textareaRef.current.focus()
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.current.scrollTop = textareaRef.current.scrollHeight
}
}, 0)
}, [directory, mentionRange, prompt])

useEffect(() => {
if (!pendingPromptFile) return
insertFileMention(pendingPromptFile.path, null)
clearPendingPromptFile()
}, [pendingPromptFile, insertFileMention, clearPendingPromptFile])

const handleMentionSelect = (item: MentionItem) => {
if (!mentionRange || !textareaRef.current) return

Expand All @@ -387,33 +435,7 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
}
}, 0)
} else {
const filename = getFilename(item.value)
const newPrompt = beforeMention + '@' + filename + ' ' + afterMention
setPrompt(newPrompt)

const absolutePath = item.value.startsWith('/')
? item.value
: directory
? `${directory}/${item.value}`
: item.value

setAttachedFiles(prev => {
const next = new Map(prev)
next.set(filename.toLowerCase(), {
path: absolutePath,
name: filename
})
return next
})

setTimeout(() => {
if (textareaRef.current) {
const newCursorPos = beforeMention.length + filename.length + 2
textareaRef.current.focus()
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.current.scrollTop = textareaRef.current.scrollHeight
}
}, 0)
insertFileMention(item.value, mentionRange)
}

setShowMentionSuggestions(false)
Expand Down
Loading
Loading