From efbe3ca2b6ff82f25713202335e6a5465b44ef31 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 2 May 2026 22:09:03 -0400 Subject: [PATCH 01/21] fix: enable swipe-back suspension on more drawer and remove unused variable --- frontend/src/components/navigation/MoreDrawer.tsx | 2 +- frontend/src/components/navigation/moreDrawerItems.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 84046b1a..77b3d4e4 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -30,7 +30,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { const [commandsOpen, setCommandsOpen] = useState(false) const [mentionFileBrowserOpen, setMentionFileBrowserOpen] = useState(false) const swipeRef = useRef(null) - const { bind } = useSwipeBack(onClose, { enabled: true, suspendsRouteSwipe: false }) + const { bind } = useSwipeBack(onClose, { enabled: true, suspendsRouteSwipe: true }) const { logout } = useAuth() const { data: health } = useServerHealth() const isSessionDetail = /^\/repos\/\d+\/sessions\/[^/]+$/.test(location.pathname) diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index 0f58509b..72f55eac 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -70,7 +70,6 @@ export function buildNavModel(pathname: string): NavModel { const sessionDetailMatch = /^\/repos\/(\d+)\/sessions\/[^/]+$/.exec(pathname) if (sessionDetailMatch) { - const id = sessionDetailMatch[1] const items: MoreDrawerItem[] = [ { key: 'files', label: 'Files', icon: Folder, dialog: 'files' }, { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, From 93fd219087749d568b8fcbaf6cc124f71e00a1c5 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 3 May 2026 10:43:30 -0400 Subject: [PATCH 02/21] refactor(frontend): improve dialog component and settings navigation --- .../components/settings/SettingsDialog.tsx | 28 +- frontend/src/components/ui/dialog.test.tsx | 290 ++++++++++++++---- frontend/src/components/ui/dialog.tsx | 37 ++- 3 files changed, 268 insertions(+), 87 deletions(-) diff --git a/frontend/src/components/settings/SettingsDialog.tsx b/frontend/src/components/settings/SettingsDialog.tsx index 0243db8f..2a5b23e5 100644 --- a/frontend/src/components/settings/SettingsDialog.tsx +++ b/frontend/src/components/settings/SettingsDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback } from 'react' import { GeneralSettings } from '@/components/settings/GeneralSettings' import { GitSettings } from '@/components/settings/GitSettings' import { KeyboardShortcuts } from '@/components/settings/KeyboardShortcuts' @@ -12,7 +12,6 @@ import { Dialog, DialogContent } from '@/components/ui/dialog' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Settings2, Keyboard, Code, ChevronLeft, Key, GitBranch, User, Volume2, Bell, X } from 'lucide-react' import { Button } from '@/components/ui/button' -import { useSwipeBack } from '@/hooks/useMobile' import { useSettingsDialog } from '@/hooks/useSettingsDialog' type SettingsView = 'menu' | 'general' | 'git' | 'shortcuts' | 'opencode' | 'providers' | 'account' | 'voice' | 'notifications' @@ -21,7 +20,6 @@ export function SettingsDialog() { const { isOpen, close, activeTab, setActiveTab } = useSettingsDialog() const [mobileView, setMobileView] = useState('menu') const [sectionHistory, setSectionHistory] = useState([]) - const contentRef = useRef(null) const pushSectionHistory = useCallback((view: SettingsView) => { if (view === 'menu') return @@ -54,16 +52,6 @@ export function SettingsDialog() { setMobileView('menu') }, [mobileView, sectionHistory, close, setActiveTab]) - const { bind: bindSwipe, swipeStyles } = useSwipeBack(close, { - enabled: isOpen, - canBack: () => mobileView !== 'menu', - onBack: handleSettingsBack, - }) - - useEffect(() => { - return bindSwipe(contentRef.current) - }, [bindSwipe]) - useEffect(() => { if (!isOpen) { setMobileView('menu') @@ -105,13 +93,13 @@ export function SettingsDialog() { } return ( - - + !open && close()}> + mobileView !== 'menu'} + onSwipeBack={handleSettingsBack} + >

diff --git a/frontend/src/components/ui/dialog.test.tsx b/frontend/src/components/ui/dialog.test.tsx index 5133bdd2..473fdd63 100644 --- a/frontend/src/components/ui/dialog.test.tsx +++ b/frontend/src/components/ui/dialog.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { Dialog, @@ -7,7 +7,30 @@ import { DialogTitle, } from "./dialog"; +function withMobileViewport(fn: () => void) { + const originalWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 375, + }) + fn() + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: originalWidth, + }) +} + describe("DialogContent", () => { + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 375, + }) + }) + it("applies safe-area padding when fullscreen prop is true", () => { render( @@ -162,64 +185,225 @@ describe("DialogContent", () => { expect(content).toHaveStyle({ paddingTop: "env(safe-area-inset-top, 0px)" }); }); - it('renders hidden close trigger when mobileSwipeToClose is enabled', () => { - const onOpenChange = vi.fn(); - render( - - - - Swipe Dialog - - - - ); - const closeTrigger = document.querySelector('[data-swipe-close-trigger]'); - expect(closeTrigger).toBeInTheDocument(); + it('renders hidden close trigger by default on mobile', () => { + withMobileViewport(() => { + const onOpenChange = vi.fn(); + render( + + + + Swipe Dialog + + + + ); + const closeTrigger = document.querySelector('[data-swipe-close-trigger]'); + expect(closeTrigger).toBeInTheDocument(); + }); + }); + + it('does not render hidden close trigger when mobileSwipeToClose is false', () => { + withMobileViewport(() => { + render( + + + + Swipe Dialog + + + + ); + const closeTrigger = document.querySelector('[data-swipe-close-trigger]'); + expect(closeTrigger).not.toBeInTheDocument(); + }); }); it('closes dialog when hidden close trigger is activated', () => { - const onOpenChange = vi.fn(); - render( - - - - Swipe Dialog - - - - ); - - const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null; - expect(closeTrigger).toBeInTheDocument(); - - if (closeTrigger) { - closeTrigger.click(); + withMobileViewport(() => { + const onOpenChange = vi.fn(); + render( + + + + Swipe Dialog + + + + ); + + const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null; + expect(closeTrigger).toBeInTheDocument(); + + if (closeTrigger) { + closeTrigger.click(); + expect(onOpenChange).toHaveBeenCalledWith(false); + } + }); + }); + + it('calls onSwipeBack when canSwipeBack is true and swipe completes', () => { + withMobileViewport(() => { + const mockOnSwipeBack = vi.fn(); + const onOpenChange = vi.fn(); + render( + + true} + onSwipeBack={mockOnSwipeBack} + data-testid="swipe-dialog" + > + Content + + + ); + + const content = screen.getByTestId('swipe-dialog'); + + content.dispatchEvent(new TouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchmove', { + touches: [{ clientX: 100, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchend', { + changedTouches: [{ clientX: 100, clientY: 100 }] as any, + })); + + expect(mockOnSwipeBack).toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + }); + + it('attempts close when canSwipeBack is false and swipe completes', () => { + withMobileViewport(() => { + const mockOnSwipeBack = vi.fn(); + const onOpenChange = vi.fn(); + render( + + false} + onSwipeBack={mockOnSwipeBack} + data-testid="swipe-dialog" + > + Content + + + ); + + const content = screen.getByTestId('swipe-dialog'); + content.dispatchEvent(new TouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchmove', { + touches: [{ clientX: 100, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchend', { + changedTouches: [{ clientX: 100, clientY: 100 }] as any, + })); + + expect(mockOnSwipeBack).not.toHaveBeenCalled(); expect(onOpenChange).toHaveBeenCalledWith(false); - } + }); + }); + + it('applies safe-area style and hidden trigger for mobileFullscreen', () => { + withMobileViewport(() => { + render( + + + Content + + + ); + const content = screen.getByTestId("dialog-content"); + expect(content).toHaveStyle({ paddingTop: "env(safe-area-inset-top, 0px)" }); + expect(document.querySelector('[data-swipe-close-trigger]')).toBeInTheDocument(); + }); + }); + + it('does not apply transform styles to non-fullscreen dialogs', () => { + withMobileViewport(() => { + render( + + + Content + + + ); + const content = screen.getByTestId("dialog-content"); + const style = content.getAttribute("style") || ""; + expect(style).not.toMatch(/transform/); + }); + }); + + it('applies swipe transform to fullscreen dialogs during touchmove', () => { + withMobileViewport(() => { + render( + + + Content + + + ); + const content = screen.getByTestId("dialog-content"); + content.dispatchEvent(new TouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchmove', { + touches: [{ clientX: 50, clientY: 100 }] as any, + })); + + const style = content.getAttribute("style") || ""; + expect(style).toMatch(/transform/); + }); + }); + + it('does not apply swipe transform to non-fullscreen dialogs during touchmove', () => { + withMobileViewport(() => { + render( + + + Content + + + ); + const content = screen.getByTestId("dialog-content"); + content.dispatchEvent(new TouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchmove', { + touches: [{ clientX: 50, clientY: 100 }] as any, + })); + + const style = content.getAttribute("style") || ""; + expect(style).not.toMatch(/transform/); + }); }); it('binds swipe handler when mobileSwipeToClose and mobileFullscreen are enabled', () => { - const onOpenChange = vi.fn(); - render( - - - - Swipe Dialog - - - - ); - - const content = screen.getByTestId('swipe-dialog'); - const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null; - - expect(content).toBeInTheDocument(); - expect(closeTrigger).toBeInTheDocument(); - - const clickSpy = vi.spyOn(closeTrigger as HTMLButtonElement, 'click'); - closeTrigger?.click(); - - expect(clickSpy).toHaveBeenCalled(); - expect(onOpenChange).toHaveBeenCalledWith(false); + withMobileViewport(() => { + const onOpenChange = vi.fn(); + render( + + + + Swipe Dialog + + + + ); + + const content = screen.getByTestId('swipe-dialog'); + const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null; + + expect(content).toBeInTheDocument(); + expect(closeTrigger).toBeInTheDocument(); + + const clickSpy = vi.spyOn(closeTrigger as HTMLButtonElement, 'click'); + closeTrigger?.click(); + + expect(clickSpy).toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); }); }); diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 2f0448b8..9c0b7340 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -34,6 +34,8 @@ interface DialogContentProps fullscreen?: boolean mobileFullscreen?: boolean mobileSwipeToClose?: boolean + canSwipeBack?: () => boolean + onSwipeBack?: () => void onOpenChange?: (open: boolean) => void overlayClassName?: string } @@ -41,14 +43,15 @@ interface DialogContentProps const DialogContent = React.forwardRef< React.ElementRef, DialogContentProps ->(({ className, children, hideCloseButton, fullscreen, mobileFullscreen, mobileSwipeToClose, overlayClassName, ...props }, ref) => { +>(({ className, children, hideCloseButton, fullscreen, mobileFullscreen, mobileSwipeToClose, canSwipeBack, onSwipeBack, overlayClassName, style, ...props }, ref) => { const isMobileFullscreenMode = fullscreen || mobileFullscreen + const [isMobile, setIsMobile] = React.useState(() => typeof window !== 'undefined' ? window.innerWidth < 768 : false) + const shouldEnableMobileSwipe = mobileSwipeToClose !== false && isMobile + const shouldAnimateSwipe = shouldEnableMobileSwipe && isMobileFullscreenMode const swipeContainerRef = React.useRef(null) - const contentRef = React.useRef(null) const closeTriggerRef = React.useRef(null) const combinedRef = React.useCallback((node: HTMLDivElement | null) => { - contentRef.current = node swipeContainerRef.current = node if (typeof ref === 'function') { ref(node) @@ -57,25 +60,33 @@ const DialogContent = React.forwardRef< } }, [ref]) - const [isMobile, setIsMobile] = React.useState(() => typeof window !== 'undefined' ? window.innerWidth < 768 : false) - React.useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) window.addEventListener('resize', check) return () => window.removeEventListener('resize', check) }, []) - const { bind: swipeBind } = useSwipeBack( + const { bind: swipeBind, swipeStyles } = useSwipeBack( () => closeTriggerRef.current?.click(), - { enabled: isMobileFullscreenMode && mobileSwipeToClose === true && isMobile } + { enabled: shouldEnableMobileSwipe, canBack: canSwipeBack, onBack: onSwipeBack } ) React.useEffect(() => { - if (isMobileFullscreenMode && mobileSwipeToClose && isMobile) { + if (shouldEnableMobileSwipe) { return swipeBind(swipeContainerRef.current) } return undefined - }, [isMobileFullscreenMode, mobileSwipeToClose, isMobile, swipeBind]) - + }, [shouldEnableMobileSwipe, swipeBind]) + + const baseStyle = isMobileFullscreenMode + ? { paddingTop: 'env(safe-area-inset-top, 0px)' } + : undefined + + const mergedStyle = { + ...baseStyle, + ...style, + ...(shouldAnimateSwipe ? swipeStyles : undefined), + } + return ( {!fullscreen && } @@ -92,13 +103,11 @@ const DialogContent = React.forwardRef< : "left-[50%] top-[50%] w-[90%] max-w-lg translate-x-[-50%] translate-y-[-50%] p-6 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", className )} - style={isMobileFullscreenMode ? { - paddingTop: 'env(safe-area-inset-top, 0px)', - } : undefined} + style={Object.keys(mergedStyle).length > 0 ? mergedStyle : undefined} {...props} > {children} - {mobileSwipeToClose && isMobileFullscreenMode && ( + {shouldEnableMobileSwipe && ( )} {!hideCloseButton && !fullscreen && ( From f50df39aed1f0547d8805c39122f2932a9d60f95 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 3 May 2026 10:49:10 -0400 Subject: [PATCH 03/21] fix: disable route swipe suspension in MoreDrawer to prevent unintended navigation --- frontend/src/components/navigation/MoreDrawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 77b3d4e4..84046b1a 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -30,7 +30,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { const [commandsOpen, setCommandsOpen] = useState(false) const [mentionFileBrowserOpen, setMentionFileBrowserOpen] = useState(false) const swipeRef = useRef(null) - const { bind } = useSwipeBack(onClose, { enabled: true, suspendsRouteSwipe: true }) + const { bind } = useSwipeBack(onClose, { enabled: true, suspendsRouteSwipe: false }) const { logout } = useAuth() const { data: health } = useServerHealth() const isSessionDetail = /^\/repos\/\d+\/sessions\/[^/]+$/.test(location.pathname) From abd54e9754a0203a6516a5305dc9957a2edce7f5 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 3 May 2026 11:02:49 -0400 Subject: [PATCH 04/21] fix(frontend): enable swipe-back suspension on more drawer route --- frontend/src/App.tsx | 8 +++++--- frontend/src/components/navigation/MoreDrawer.tsx | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c91c056..3c8e7351 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,7 +24,7 @@ import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { TTSProvider } from './contexts/TTSContext' import { AuthProvider } from './contexts/AuthContext' import { EventProvider, usePermissions, useEventContext } from '@/contexts/EventContext' -import { SwipeNavigationProvider } from '@/contexts/SwipeNavigationContext' +import { SwipeNavigationProvider, useSwipeNavigation } from '@/contexts/SwipeNavigationContext' import { PermissionRequestDialog } from './components/session/PermissionRequestDialog' import { SSHHostKeyDialog } from './components/ssh/SSHHostKeyDialog' import { loginLoader, setupLoader, registerLoader, protectedLoader } from './lib/auth-loaders' @@ -88,14 +88,16 @@ function AppShell() { const { open: openMobileSheet, openSheet } = useMobileTabBar() useTheme() + const swipeNav = useSwipeNavigation() + const getRouteSwipeBackTarget = useCallback( () => getSwipeBackTarget(location.pathname, location.search), [location.pathname, location.search] ) const canSwipeBack = useCallback( - () => getRouteSwipeBackTarget() !== null, - [getRouteSwipeBackTarget] + () => !swipeNav?.isSuspended() && getRouteSwipeBackTarget() !== null, + [swipeNav, getRouteSwipeBackTarget] ) const handleSwipeBack = useCallback(() => { diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 84046b1a..77b3d4e4 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -30,7 +30,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { const [commandsOpen, setCommandsOpen] = useState(false) const [mentionFileBrowserOpen, setMentionFileBrowserOpen] = useState(false) const swipeRef = useRef(null) - const { bind } = useSwipeBack(onClose, { enabled: true, suspendsRouteSwipe: false }) + const { bind } = useSwipeBack(onClose, { enabled: true, suspendsRouteSwipe: true }) const { logout } = useAuth() const { data: health } = useServerHealth() const isSessionDetail = /^\/repos\/\d+\/sessions\/[^/]+$/.test(location.pathname) From bc82be56ffd0c319315606d8c1b2f6f94174cc3c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 3 May 2026 14:45:40 -0400 Subject: [PATCH 05/21] refactor(frontend): improve mobile navigation drawer and tab bar --- frontend/src/App.tsx | 6 +- .../navigation/MobileSheetHost.test.tsx | 12 +- .../components/navigation/MobileSheetHost.tsx | 5 +- .../components/navigation/MobileTabBar.tsx | 15 +- .../src/components/navigation/MoreDrawer.tsx | 203 ++++++++++-------- .../navigation/SessionMoreButton.tsx | 6 +- frontend/src/hooks/useMobile.test.tsx | 35 +++ frontend/src/hooks/useMobile.ts | 11 +- frontend/src/index.css | 2 + frontend/src/stores/uiStateStore.ts | 4 + 10 files changed, 195 insertions(+), 104 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3c8e7351..22d8894c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,7 @@ import { loginLoader, setupLoader, registerLoader, protectedLoader } from './lib import { getSwipeBackTarget } from '@/lib/navigation' import { useAuth } from '@/hooks/useAuth' import { useServerHealth } from '@/hooks/useServerHealth' +import { useUIState } from '@/stores/uiStateStore' const queryClient = new QueryClient({ defaultOptions: { @@ -85,7 +86,7 @@ function AppShell() { const navigate = useNavigate() const location = useLocation() const rootRef = useRef(null) - const { open: openMobileSheet, openSheet } = useMobileTabBar() + const { openSheet } = useMobileTabBar() useTheme() const swipeNav = useSwipeNavigation() @@ -119,8 +120,9 @@ function AppShell() { return /^\/repos\/[^/]+\/sessions\/[^/]+$/.test(location.pathname) && !openSheet } + const setMoreDrawerOpen = useUIState((state) => state.setMoreDrawerOpen) const { bind: bindMoreSwipe } = useRightEdgeSwipe( - () => openMobileSheet('more'), + () => setMoreDrawerOpen(true), { enabled: canOpenMoreWithSwipe(), edgeWidth: 32, diff --git a/frontend/src/components/navigation/MobileSheetHost.test.tsx b/frontend/src/components/navigation/MobileSheetHost.test.tsx index 1c6dc569..daf03975 100644 --- a/frontend/src/components/navigation/MobileSheetHost.test.tsx +++ b/frontend/src/components/navigation/MobileSheetHost.test.tsx @@ -27,6 +27,7 @@ import { MemoryRouter } from 'react-router-dom' import { MobileSheetHost } from './MobileSheetHost' import { useMobile } from '@/hooks/useMobile' import { useMobileTabBar } from '@/hooks/useMobileTabBar' +import { useUIState } from '@/stores/uiStateStore' describe('MobileSheetHost', () => { beforeEach(() => { @@ -103,18 +104,15 @@ describe('MobileSheetHost', () => { expect(screen.getByTestId('notifications-sheet')).toBeInTheDocument() }) - it('renders MoreDrawer when mobileTab=more', () => { - vi.mocked(useMobileTabBar).mockReturnValue({ - openSheet: 'more', - open: vi.fn(), - close: vi.fn(), - }) + it('renders MoreDrawer when isMoreDrawerOpen is true', () => { + useUIState.setState({ isMoreDrawerOpen: true }) render( - + , ) expect(screen.getByTestId('more-drawer')).toBeInTheDocument() + useUIState.setState({ isMoreDrawerOpen: false }) }) it('closes sheet when onClose is called', () => { diff --git a/frontend/src/components/navigation/MobileSheetHost.tsx b/frontend/src/components/navigation/MobileSheetHost.tsx index 0ad4b48c..e448b40a 100644 --- a/frontend/src/components/navigation/MobileSheetHost.tsx +++ b/frontend/src/components/navigation/MobileSheetHost.tsx @@ -4,10 +4,13 @@ import { FileBrowserSheet } from '@/components/file-browser/FileBrowserSheet' import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' import { NotificationsSheet } from '@/components/navigation/NotificationsSheet' import { MoreDrawer } from '@/components/navigation/MoreDrawer' +import { useUIState } from '@/stores/uiStateStore' export function MobileSheetHost() { const isMobile = useMobile() const { openSheet, close } = useMobileTabBar() + const isMoreDrawerOpen = useUIState((state) => state.isMoreDrawerOpen) + const setMoreDrawerOpen = useUIState((state) => state.setMoreDrawerOpen) if (!isMobile) return null @@ -24,7 +27,7 @@ export function MobileSheetHost() { /> )} {openSheet === 'notifications' && } - {openSheet === 'more' && } + setMoreDrawerOpen(false)} /> ) } diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx index 96e62fde..e45b50c7 100644 --- a/frontend/src/components/navigation/MobileTabBar.tsx +++ b/frontend/src/components/navigation/MobileTabBar.tsx @@ -4,6 +4,7 @@ import { FolderGit2, FolderOpen, CalendarClock, Menu, Info, History, Bot } from import { cn } from '@/lib/utils' import { useMobile } from '@/hooks/useMobile' import { useMobileTabBar, useScheduleTab, type ScheduleTabKey } from '@/hooks/useMobileTabBar' +import { useUIState } from '@/stores/uiStateStore' interface TabDef { key: string @@ -23,6 +24,8 @@ interface GlobalTabsArgs { navigate: ReturnType isInsideRepo: boolean repoId: string | null + isMoreDrawerOpen: boolean + setMoreDrawerOpen: (open: boolean) => void } type TabBarMode = 'hidden' | 'global' | 'schedule' @@ -57,7 +60,7 @@ function getMobileTabRouteState(pathname: string): MobileTabRouteState { } } -function buildGlobalTabs({ pathname, search, openSheet, open, close, navigate, isInsideRepo, repoId }: GlobalTabsArgs): TabDef[] { +function buildGlobalTabs({ pathname, search, openSheet, open, close, navigate, isInsideRepo, repoId, isMoreDrawerOpen, setMoreDrawerOpen }: GlobalTabsArgs): TabDef[] { const navigateWithSearch = (params: URLSearchParams) => { const nextSearch = params.toString() navigate(nextSearch ? `${pathname}?${nextSearch}` : pathname, { replace: true }) @@ -117,8 +120,8 @@ function buildGlobalTabs({ pathname, search, openSheet, open, close, navigate, i key: 'more', label: 'More', icon: Menu, - onClick: () => open('more'), - active: openSheet === 'more', + onClick: () => setMoreDrawerOpen(true), + active: isMoreDrawerOpen, }, ] } @@ -190,6 +193,8 @@ export const MobileTabBar = memo(function MobileTabBar() { const { openSheet, open, close } = useMobileTabBar() const { scheduleTab, setScheduleTab } = useScheduleTab() const isMobile = useMobile() + const isMoreDrawerOpen = useUIState((state) => state.isMoreDrawerOpen) + const setMoreDrawerOpen = useUIState((state) => state.setMoreDrawerOpen) const routeState = useMemo(() => getMobileTabRouteState(pathname), [pathname]) const tabs = useMemo( @@ -204,6 +209,8 @@ export const MobileTabBar = memo(function MobileTabBar() { navigate, isInsideRepo: routeState.isInsideRepo, repoId: routeState.repoId, + isMoreDrawerOpen, + setMoreDrawerOpen, })), [ routeState, @@ -215,6 +222,8 @@ export const MobileTabBar = memo(function MobileTabBar() { open, close, navigate, + isMoreDrawerOpen, + setMoreDrawerOpen, ], ) diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 77b3d4e4..32a7516b 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -30,7 +30,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { const [commandsOpen, setCommandsOpen] = useState(false) const [mentionFileBrowserOpen, setMentionFileBrowserOpen] = useState(false) const swipeRef = useRef(null) - const { bind } = useSwipeBack(onClose, { enabled: true, suspendsRouteSwipe: true }) + const { bind } = useSwipeBack(onClose, { enabled: isOpen, suspendsRouteSwipe: true }) const { logout } = useAuth() const { data: health } = useServerHealth() const isSessionDetail = /^\/repos\/\d+\/sessions\/[^/]+$/.test(location.pathname) @@ -48,6 +48,35 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { } }, [isOpen, bind]) + const onCloseRef = useRef(onClose) + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + + useEffect(() => { + if (!isOpen) return + let sentinelActive = true + const baseState = window.history.state + const baseUrl = window.location.href + window.history.pushState({ ...(baseState ?? {}), moreDrawerSentinel: true }, '', baseUrl) + const onPop = () => { + if (!sentinelActive) return + sentinelActive = false + onCloseRef.current() + } + window.addEventListener('popstate', onPop) + return () => { + window.removeEventListener('popstate', onPop) + if (sentinelActive) { + sentinelActive = false + const top = window.history.state as { moreDrawerSentinel?: boolean } | null + if (top?.moreDrawerSentinel) { + window.history.back() + } + } + } + }, [isOpen]) + const { data: repo } = useQuery({ queryKey: ['repo', repoId], queryFn: () => repoId ? getRepo(repoId) : null, @@ -65,6 +94,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { newParams.set('settings', 'open') newParams.set('tab', 'account') navigate({ search: newParams.toString() }, { replace: true }) + onClose() } const handleLogoutClick = async () => { @@ -84,6 +114,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { newParams.delete('mobileTab') navigate({ search: newParams.toString() }, { replace: true }) } + onClose() } const handleCommandClick = (command: CommandType) => { @@ -121,100 +152,102 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { return ( -
-
- {versionLabel && ( - {versionLabel} - )} - -
- {(repoDisplayName || currentBranch) && ( -
- {repoDisplayName && ( - {repoDisplayName} - )} - - {currentBranch && ( - <> - - {currentBranch} - +
+
+
+ {versionLabel && ( + {versionLabel} )} -
- )} -
- - {isSessionDetail && ( -
- {commandsOpen && ( -
- {commands.map((command) => ( - - ))} -
- )} +
+ {(repoDisplayName || currentBranch) && ( +
+ {repoDisplayName && ( + {repoDisplayName} + )} + + {currentBranch && ( + <> + + {currentBranch} + + )} +
+ )} +
+ + {isSessionDetail && ( +
+ + {commandsOpen && ( +
+ {commands.map((command) => ( + + ))} +
+ )} + +
+ )} + {items.map((item) => ( -
- )} - {items.map((item) => ( - - ))} - + ))} + +
setMentionFileBrowserOpen(false)} diff --git a/frontend/src/components/navigation/SessionMoreButton.tsx b/frontend/src/components/navigation/SessionMoreButton.tsx index 41a56b8f..9264ac41 100644 --- a/frontend/src/components/navigation/SessionMoreButton.tsx +++ b/frontend/src/components/navigation/SessionMoreButton.tsx @@ -1,15 +1,15 @@ import { MoreVertical } from 'lucide-react' import { Button } from '@/components/ui/button' -import { useMobileTabBar } from '@/hooks/useMobileTabBar' +import { useUIState } from '@/stores/uiStateStore' export function SessionMoreButton() { - const { open } = useMobileTabBar() + const setMoreDrawerOpen = useUIState((state) => state.setMoreDrawerOpen) return ( +

+ )), + JobDetailTab: vi.fn(({ onRunNow }) => ( +
+ +
+ )), + RunHistoryTab: vi.fn(() =>
RunHistoryTab
), + ScheduleTabMenu: vi.fn(() =>
ScheduleTabMenu
), +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => + {children} +} + +const renderSchedules = (repoId: string) => { + return render( + + + } /> + + , + { wrapper: createWrapper() } + ) +} + +describe('Schedules', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useScheduleTab.mockReturnValue({ + scheduleTab: 'jobs', + setScheduleTab: vi.fn(), + }) + mocks.useCreateRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useUpdateRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useDeleteRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useRunRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useCancelRepoScheduleRun.mockReturnValue({ mutate: vi.fn(), isPending: false }) + }) + + describe('assistant workspace (repoId=0)', () => { + it('renders assistant title and subtitle', () => { + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 0, + kind: 'assistant', + name: 'Assistant', + subtitle: 'Assistant Workspace', + fullPath: '/abs/assistant', + backHref: '/repos/0/assistant', + }, + isLoading: false, + isError: false, + }) + mocks.useRepoSchedules.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('0') + + expect(screen.getByText('Assistant')).toBeInTheDocument() + expect(screen.getByText('Assistant Workspace')).toBeInTheDocument() + }) + + it('does not render Repository not found', () => { + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 0, + kind: 'assistant', + name: 'Assistant', + subtitle: 'Assistant Workspace', + fullPath: '/abs/assistant', + backHref: '/repos/0/assistant', + }, + isLoading: false, + isError: false, + }) + mocks.useRepoSchedules.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('0') + + expect(screen.queryByText('Repository not found')).not.toBeInTheDocument() + }) + + it('renders back button with correct href', () => { + mockNavigate.mockClear() + + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 0, + kind: 'assistant', + name: 'Assistant', + subtitle: 'Assistant Workspace', + fullPath: '/abs/assistant', + backHref: '/repos/0/assistant', + }, + isLoading: false, + isError: false, + }) + mocks.useRepoSchedules.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('0') + + const backButton = screen.getAllByRole('button')[0] + expect(backButton).toBeInTheDocument() + fireEvent.click(backButton) + expect(mockNavigate).toHaveBeenCalledWith('/repos/0/assistant') + }) + + it('calls runMutation with repoId=0 when Run Now is clicked', () => { + const mutateMock = vi.fn() + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 0, + kind: 'assistant', + name: 'Assistant', + subtitle: 'Assistant Workspace', + fullPath: '/abs/assistant', + backHref: '/repos/0/assistant', + }, + isLoading: false, + isError: false, + }) + const mockJob = { + id: 123, + name: 'Test Job', + repoId: 0, + cronExpression: null, + intervalMinutes: 30, + timezone: 'UTC', + enabled: true, + createdAt: 0, + updatedAt: 0, + scheduleMode: 'interval' as const, + agentSlug: null, + prompt: 'test', + triggerSource: 'manual' as const, + lastRunAt: null, + nextRunAt: null, + skillMetadata: null, + } + mocks.useScheduleTab.mockReturnValue({ + scheduleTab: 'detail', + setScheduleTab: vi.fn(), + }) + mocks.useRepoSchedules.mockReturnValue({ data: [mockJob], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: mockJob, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useRunRepoSchedule.mockReturnValue({ mutate: mutateMock, isPending: false }) + + renderSchedules('0') + + const runNowButton = screen.getByTestId('run-now') + runNowButton.click() + + expect(mutateMock).toHaveBeenCalledWith({ repoId: 0, jobId: 123 }, expect.any(Object)) + }) + }) + + describe('repo workspace (repoId=5)', () => { + it('renders repo name and subtitle', () => { + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 5, + kind: 'repo', + name: 'my-repo', + subtitle: 'repos/my-repo', + fullPath: '/abs/repos/my-repo', + backHref: '/repos/5', + }, + isLoading: false, + isError: false, + }) + mocks.useRepoSchedules.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('5') + + expect(screen.getByText('my-repo')).toBeInTheDocument() + expect(screen.getByText('repos/my-repo')).toBeInTheDocument() + }) + + it('renders back button with correct href', () => { + mockNavigate.mockClear() + + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 5, + kind: 'repo', + name: 'my-repo', + subtitle: 'repos/my-repo', + fullPath: '/abs/repos/my-repo', + backHref: '/repos/5', + }, + isLoading: false, + isError: false, + }) + mocks.useRepoSchedules.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('5') + + const backButton = screen.getAllByRole('button')[0] + expect(backButton).toBeInTheDocument() + fireEvent.click(backButton) + expect(mockNavigate).toHaveBeenCalledWith('/repos/5') + }) + }) + + describe('workspace not found', () => { + it('renders not found fallback for real repo', () => { + mocks.useWorkspace.mockReturnValue({ + workspace: undefined, + isLoading: false, + isError: true, + }) + mocks.useRepoSchedules.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('999') + + expect(screen.getByText('Repository not found')).toBeInTheDocument() + }) + + it('renders not found fallback for assistant', () => { + mocks.useWorkspace.mockReturnValue({ + workspace: undefined, + isLoading: false, + isError: true, + }) + mocks.useRepoSchedules.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('0') + + expect(screen.getByText('Workspace not found')).toBeInTheDocument() + }) + }) +}) From 5178ea6cdc7e55d804e1ba9ee5c4aa66dddde137 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 4 May 2026 01:11:57 -0400 Subject: [PATCH 08/21] fix: prevent MoreDrawer sentinel from undoing navigation --- frontend/src/components/navigation/MoreDrawer.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 32a7516b..894bf31f 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -67,10 +67,12 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { window.addEventListener('popstate', onPop) return () => { window.removeEventListener('popstate', onPop) + // Only go back if sentinel is still active AND we haven't navigated away if (sentinelActive) { sentinelActive = false const top = window.history.state as { moreDrawerSentinel?: boolean } | null - if (top?.moreDrawerSentinel) { + // Only go back if the current URL hasn't changed (i.e., no navigation occurred) + if (top?.moreDrawerSentinel && window.location.href === baseUrl) { window.history.back() } } From 93fe4d6e0767481b4d3a8650f932b597d8c44d46 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 4 May 2026 21:24:28 -0400 Subject: [PATCH 09/21] feat: per-agent model selection and UI refinements (#217) * feat: per-agent model selection and UI refinements - Add agentModels store with setAgentModel/getAgentModel - PromptInput uses session model priority and stores agent model on change - useContextUsage derives model from assistant message instead of global - Migrate sub-agent/subtask badges from purple to blue with refined styling - Fix MoreDrawer sentinel to skip history-back on navigation * refactor: improve per-agent model selection and task tool call status indicators --- .../src/components/message/MessagePart.tsx | 2 +- .../src/components/message/PromptInput.tsx | 10 +++-- .../src/components/message/ToolCallPart.tsx | 42 ++++++++++++++----- .../src/components/navigation/MoreDrawer.tsx | 8 +++- frontend/src/components/ui/side-drawer.tsx | 2 +- frontend/src/hooks/useContextUsage.ts | 18 +++++--- frontend/src/pages/SessionDetail.tsx | 2 +- frontend/src/stores/modelStore.ts | 19 +++++++++ 8 files changed, 79 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/message/MessagePart.tsx b/frontend/src/components/message/MessagePart.tsx index 0534d1fd..223fc0f4 100644 --- a/frontend/src/components/message/MessagePart.tsx +++ b/frontend/src/components/message/MessagePart.tsx @@ -177,7 +177,7 @@ export const MessagePart = memo(function MessagePart({ part, role, allParts, par case 'subtask': { const label = part.description || part.prompt || 'Sub-agent task' return ( -
+
{label} sub-agent diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 3faeac4d..a64d4190 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -429,9 +429,13 @@ export const PromptInput = memo(forwardRef( setMentionRange(null) } - const handleAgentChange = (agent: string) => { - setLocalMode(agent) - setStoredAgent(sessionID, agent) + const handleAgentChange = (agentName: string) => { + setLocalMode(agentName) + setStoredAgent(sessionID, agentName) + const agent = agents.find(a => a.name === agentName) + if (agent?.model) { + setStoredModel({ providerID: agent.model.providerID, modelID: agent.model.modelID }) + } } const startVoiceRecording = async () => { diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index 0b2a7111..77ceae93 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react' import type { components } from '@/api/opencode-types' import { useSettings } from '@/hooks/useSettings' import { useUserBash } from '@/stores/userBashStore' +import { useSessionStatusForSession } from '@/stores/sessionStatusStore' import { usePermissions, useQuestions } from '@/contexts/EventContext' import { detectFileReferences } from '@/lib/fileReferences' import { ExternalLink, Loader2 } from 'lucide-react' @@ -68,6 +69,8 @@ function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (fi export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCallPartProps) { const { preferences } = useSettings() const { userBashCommands } = useUserBash() + const taskSessionId = part.tool === 'task' ? getTaskSessionId(part) : undefined + const taskSessionStatus = useSessionStatusForSession(taskSessionId) const { getForCallID: getPermissionForCallID } = usePermissions() const { getForCallID: getQuestionForCallID } = useQuestions() const outputRef = useRef(null) @@ -152,17 +155,36 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal const isFileTool = ['read', 'write', 'edit'].includes(part.tool) if (part.tool === 'task') { - const sessionId = getTaskSessionId(part) + const sessionId = taskSessionId const description = previewText || 'Sub-agent task' - const isRunning = part.state.status === 'running' || part.state.status === 'pending' + const status = part.state.status + + const isPending = status === 'pending' + const isRunning = status === 'running' && taskSessionStatus.type !== 'idle' + const isCompleted = status === 'completed' || (status === 'running' && !!sessionId && taskSessionStatus.type === 'idle') + const isError = status === 'error' + const content = (
- {isRunning ? ( - - ) : null} + {isPending && ( +
+ + + +
+ )} + {isRunning && ( +
+ + + +
+ )} + {isCompleted && ✓} + {isError && ✗} {description} - sub-agent - {sessionId && } + sub-agent + {sessionId && }
) @@ -170,7 +192,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal return ( - )}
))}
diff --git a/frontend/src/components/ui/bottom-sheet.test.tsx b/frontend/src/components/ui/bottom-sheet.test.tsx index e633daad..0e3ef98e 100644 --- a/frontend/src/components/ui/bottom-sheet.test.tsx +++ b/frontend/src/components/ui/bottom-sheet.test.tsx @@ -109,7 +109,7 @@ describe('BottomSheet', () => { expect(useSwipeDismiss).toHaveBeenCalledWith(expect.any(Function), { enabled: true, - threshold: 80, + threshold: 60, }) }) diff --git a/frontend/src/components/ui/page-header.test.tsx b/frontend/src/components/ui/page-header.test.tsx index fc8e7a2f..2ba0ca64 100644 --- a/frontend/src/components/ui/page-header.test.tsx +++ b/frontend/src/components/ui/page-header.test.tsx @@ -52,16 +52,9 @@ describe("PageHeader", () => { expect(header).toHaveClass("z-10"); }); - it("applies background styling", () => { + it("applies transparent background", () => { render(Content); const header = screen.getByTestId("header"); - expect(header).toHaveClass("bg-gradient-to-b"); - expect(header).toHaveClass("from-background"); - }); - - it("applies backdrop-blur-sm for frosted glass effect", () => { - render(Content); - const header = screen.getByTestId("header"); - expect(header).toHaveClass("backdrop-blur-sm"); + expect(header).toHaveClass("bg-transparent"); }); }); diff --git a/frontend/src/hooks/__tests__/useMessages.fallback.test.tsx b/frontend/src/hooks/__tests__/useMessages.fallback.test.tsx index 097e420b..b2bc2f5b 100644 --- a/frontend/src/hooks/__tests__/useMessages.fallback.test.tsx +++ b/frontend/src/hooks/__tests__/useMessages.fallback.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { renderHook, waitFor } from '@testing-library/react' +import { renderHook } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useMessages } from '../useOpenCode' @@ -43,10 +43,8 @@ describe('useMessages fallback poll', () => { expect(mocks.listMessages).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(5000) - - await waitFor(() => { - expect(mocks.listMessages).toHaveBeenCalledTimes(2) - }) + + expect(mocks.listMessages).toHaveBeenCalledTimes(2) vi.useRealTimers() }) diff --git a/frontend/src/hooks/useAssistantSessionLauncher.test.tsx b/frontend/src/hooks/useAssistantSessionLauncher.test.tsx index f784faf6..5769e2b0 100644 --- a/frontend/src/hooks/useAssistantSessionLauncher.test.tsx +++ b/frontend/src/hooks/useAssistantSessionLauncher.test.tsx @@ -7,7 +7,7 @@ import { initializeAssistantMode } from '@/api/repos' const mocks = vi.hoisted(() => ({ listSessions: vi.fn(), createSession: vi.fn(), - sendPrompt: vi.fn(), + sendPromptAsync: vi.fn(), initializeAssistantMode: vi.fn(), })) @@ -19,10 +19,14 @@ vi.mock('@/api/opencode', () => ({ OpenCodeClient: vi.fn(() => ({ listSessions: mocks.listSessions, createSession: mocks.createSession, - sendPrompt: mocks.sendPrompt, + sendPromptAsync: mocks.sendPromptAsync, })), })) +beforeEach(() => { + mocks.sendPromptAsync.mockResolvedValue(undefined) +}) + describe('useAssistantSessionLauncher', () => { beforeEach(() => { vi.clearAllMocks() @@ -70,7 +74,7 @@ describe('useAssistantSessionLauncher', () => { }) expect(mocks.createSession).toHaveBeenCalledWith({ title: 'Assistant' }) - expect(mocks.sendPrompt).toHaveBeenCalledWith('created', { + expect(mocks.sendPromptAsync).toHaveBeenCalledWith('created', { parts: [ expect.objectContaining({ type: 'text', @@ -79,5 +83,62 @@ describe('useAssistantSessionLauncher', () => { ], }) expect(onNavigate).toHaveBeenCalledWith('created') + + const promptCall = mocks.sendPromptAsync.mock.calls[0] + const promptText = promptCall[1].parts[0].text as string + expect(promptText).toContain('.opencode/agents/assistant.md') + expect(promptText).toContain('AGENTS.md') + expect(promptText).toContain('.opencode/skills/') + expect(promptText).not.toContain('v file') + }) + + it('navigates after creating a session without waiting for the welcome prompt to complete', async () => { + mocks.listSessions.mockResolvedValue([]) + let resolvePrompt: () => void + const promptPromise = new Promise((resolve) => { + resolvePrompt = resolve + }) + mocks.createSession.mockResolvedValue({ id: 'created' }) + mocks.sendPromptAsync.mockImplementation(() => promptPromise) + const onNavigate = vi.fn() + const { result } = renderHook(() => useAssistantSessionLauncher({ + repoId: 123, + opcodeUrl: 'http://localhost:5551', + onNavigate, + })) + + await act(async () => { + await result.current.openAssistant() + }) + + expect(onNavigate).toHaveBeenCalledWith('created') + expect(mocks.sendPromptAsync).toHaveBeenCalledWith('created', { + parts: [ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Welcome to OpenCode Manager!'), + }), + ], + }) + resolvePrompt!() + }) + + it('navigates even when welcome prompt fails', async () => { + mocks.listSessions.mockResolvedValue([]) + mocks.createSession.mockResolvedValue({ id: 'created' }) + mocks.sendPromptAsync.mockRejectedValueOnce(new Error('provider unavailable')) + const onNavigate = vi.fn() + const { result } = renderHook(() => useAssistantSessionLauncher({ + repoId: 123, + opcodeUrl: 'http://localhost:5551', + onNavigate, + })) + + await act(async () => { + await result.current.openAssistant() + }) + + expect(onNavigate).toHaveBeenCalledWith('created') + expect(mocks.sendPromptAsync).toHaveBeenCalled() }) }) diff --git a/frontend/src/hooks/useAssistantSessionLauncher.ts b/frontend/src/hooks/useAssistantSessionLauncher.ts index b3c22869..9808f8fe 100644 --- a/frontend/src/hooks/useAssistantSessionLauncher.ts +++ b/frontend/src/hooks/useAssistantSessionLauncher.ts @@ -8,6 +8,35 @@ interface UseAssistantSessionLauncherOptions { onNavigate: (sessionId: string) => void } +const ASSISTANT_WELCOME_PROMPT = `Welcome to OpenCode Manager! I'm your assistant and I'm here to help you work with your code. + +To get started, let's set up your assistant: + +**1. Name your assistant** +What would you like to call me? This name will help personalize our interactions. + +**2. Review AGENTS.md** +AGENTS.md contains workspace-level instructions, durable preferences, and self-editing rules. + +**3. Review the assistant agent** +.opencode/agents/assistant.md defines the default Assistant Mode agent and can be customized later. + +**4. Use workspace skills** +Skills for repos, schedules, notifications, and settings are available under .opencode/skills/. + +Take your time exploring and customizing these settings. Let me know when you're ready to start coding, or if you have any questions about getting set up!` + +async function sendAssistantWelcomePrompt(client: OpenCodeClient, sessionId: string): Promise { + await client.sendPromptAsync(sessionId, { + parts: [ + { + type: 'text', + text: ASSISTANT_WELCOME_PROMPT, + }, + ], + }).catch(() => undefined) +} + export function useAssistantSessionLauncher({ repoId, opcodeUrl, @@ -36,28 +65,8 @@ export function useAssistantSessionLauncher({ onNavigate(newest.id) } else { const session = await client.createSession({ title: 'Assistant' }) - await client.sendPrompt(session.id, { - parts: [ - { - type: 'text', - text: `Welcome to OpenCode Manager! I'm your assistant and I'm here to help you work with your code. - -To get started, let's set up your assistant: - -**1. Name your assistant** -What would you like to call me? This name will help personalize our interactions. - -**2. Configure AGENTS.md** -This file contains instructions that define my behavior, persona, and preferences. You can customize it to match your workflow. Take a moment to review and edit it - you can always adjust it later. - -**3. Set up your v file (optional)** -The v file stores conversation state and context between sessions. This helps me maintain memory of our work together. - -Take your time exploring and customizing these settings. Let me know when you're ready to start coding, or if you have any questions about getting set up!`, - }, - ], - }) onNavigate(session.id) + void sendAssistantWelcomePrompt(client, session.id) } }, [repoId, opcodeUrl, onNavigate]) diff --git a/frontend/src/hooks/useSessionAgent.test.tsx b/frontend/src/hooks/useSessionAgent.test.tsx index 503f350b..86a0ea68 100644 --- a/frontend/src/hooks/useSessionAgent.test.tsx +++ b/frontend/src/hooks/useSessionAgent.test.tsx @@ -99,6 +99,12 @@ describe('resolveDefaultSessionAgent', () => { const result = resolveDefaultSessionAgent('missing-agent', agents, true) expect(result).toBe('build') }) + + it('returns assistant when assistant workspace config sets default_agent to assistant', () => { + const agents = [{ name: 'assistant', mode: 'primary' }] + const result = resolveDefaultSessionAgent('assistant', agents, true) + expect(result).toBe('assistant') + }) }) describe('useSessionAgent', () => { diff --git a/frontend/src/pages/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index 948338dd..2806a0ce 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -84,7 +84,7 @@ export function AssistantRedirect() { try { if (showSessionList) return setStatus("preparing") - if (!repoId || repoId < 0) { + if (!id || isNaN(repoId) || repoId <= 0) { const repos = await listRepos() const fallbackRepo = repos.sort((a, b) => (b.lastAccessedAt ?? 0) - (a.lastAccessedAt ?? 0))[0] if (!fallbackRepo) throw new Error("No repository available to open Assistant") @@ -108,7 +108,7 @@ export function AssistantRedirect() { return () => { cancelled = true } - }, [repoId, openAssistant, navigate, showSessionList]) + }, [repoId, openAssistant, navigate, showSessionList, id]) if (showSessionList) { return ( diff --git a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx index 5a12599d..e4be6445 100644 --- a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { renderHook, waitFor } from '@testing-library/react' +import { renderHook } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query' @@ -51,10 +51,8 @@ describe('SessionDetail pending-actions polling', () => { expect(mocks.syncPendingActions).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(30000) - - await waitFor(() => { - expect(mocks.syncPendingActions).toHaveBeenCalledTimes(2) - }) + + expect(mocks.syncPendingActions).toHaveBeenCalledTimes(2) vi.useRealTimers() }) From 3cf4bc33851ef82b3ec2d68dc3257abfe35bdf5c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 5 May 2026 13:02:54 -0400 Subject: [PATCH 12/21] fix: update atomic-json test assertions --- backend/src/utils/atomic-json.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/utils/atomic-json.test.ts b/backend/src/utils/atomic-json.test.ts index 50864e2d..5803224b 100644 --- a/backend/src/utils/atomic-json.test.ts +++ b/backend/src/utils/atomic-json.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { readJsonSafe, writeJsonAtomic, withFileLock } from './atomic-json' import { join } from 'node:path' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' describe('atomic-json', () => { @@ -24,7 +24,7 @@ describe('atomic-json', () => { it('returns fallback when file contains invalid JSON and logs a warning', async () => { const filePath = join(tmpDir, 'invalid.json') - await Bun.write(filePath, '{ invalid json }') + await writeFile(filePath, '{ invalid json }', 'utf8') const fallback = { foo: 'bar' } const result = await readJsonSafe(filePath, fallback) expect(result).toEqual(fallback) @@ -33,7 +33,7 @@ describe('atomic-json', () => { it('returns parsed value when file contains valid JSON', async () => { const filePath = join(tmpDir, 'valid.json') const data = { foo: 'bar', nested: { value: 42 } } - await Bun.write(filePath, JSON.stringify(data)) + await writeFile(filePath, JSON.stringify(data), 'utf8') const result = await readJsonSafe(filePath, { fallback: true }) expect(result).toEqual(data) }) @@ -44,7 +44,7 @@ describe('atomic-json', () => { const filePath = join(tmpDir, 'output.json') const data = { test: 'value', number: 123 } await writeJsonAtomic(filePath, data) - const content = await Bun.file(filePath).text() + const content = await readFile(filePath, 'utf8') const parsed = JSON.parse(content) expect(parsed).toEqual(data) }) From 286f9fb1ce929a4c63d9cc610c27bf04f899856e Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 5 May 2026 18:54:41 +0000 Subject: [PATCH 13/21] refactor: split Assistant Mode instructions between AGENTS.md and assistant.md --- backend/src/services/assistant-mode.ts | 175 +++++++++++- backend/test/services/assistant-mode.test.ts | 254 +++++++++++++++++- .../useAssistantSessionLauncher.test.tsx | 43 +++ .../src/hooks/useAssistantSessionLauncher.ts | 45 +++- shared/src/schemas/repo.ts | 5 + 5 files changed, 499 insertions(+), 23 deletions(-) diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index 710743b0..b19d71b4 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -83,7 +83,7 @@ function hasSameContentHash(existingContent: string | undefined, generatedConten return existingContent !== undefined && hashContent(existingContent) === hashContent(generatedContent) } -export function buildAssistantAgentsMd(): string { +function buildLegacyAssistantAgentsMd(): string { return `# Assistant Mode Instructions This folder is the shared Assistant mode workspace for OpenCode Manager. @@ -133,7 +133,7 @@ This workspace includes a skill at \`.opencode/skills/manager-settings/SKILL.md\ ` } -function buildAssistantAgentPrompt(): string { +function buildLegacyAssistantAgentPrompt(): string { return [ 'You are the default Assistant Mode agent for OpenCode Manager.', '', @@ -150,6 +150,95 @@ function buildAssistantAgentPrompt(): string { ].join('\n') } +function buildLegacyAssistantDefaultAgentMd(): string { + const prompt = buildLegacyAssistantAgentPrompt() + const permission = buildAssistantAgentPermission() + + return `--- +description: Default OpenCode Manager assistant workspace agent +mode: primary +permission: + read: ${permission.read} + edit: ${permission.edit} + glob: ${permission.glob} + grep: ${permission.grep} + list: ${permission.list} + bash: ${permission.bash} + external_directory: ${permission.external_directory} +--- + +${prompt} +` +} + +function matchesGeneratedAssistantAgentsMd(content: string): boolean { + const currentHash = hashContent(buildAssistantAgentsMd()) + const legacyHash = hashContent(buildLegacyAssistantAgentsMd()) + const contentHash = hashContent(content) + return contentHash === currentHash || contentHash === legacyHash +} + +function matchesGeneratedAssistantDefaultAgentMd(content: string): boolean { + const currentHash = hashContent(buildAssistantDefaultAgentMd()) + const legacyHash = hashContent(buildLegacyAssistantDefaultAgentMd()) + const contentHash = hashContent(content) + return contentHash === currentHash || contentHash === legacyHash +} + +function matchesGeneratedAssistantAgentPrompt(content: unknown): content is string { + if (typeof content !== 'string') return false + const currentHash = hashContent(buildAssistantAgentPrompt()) + const legacyHash = hashContent(buildLegacyAssistantAgentPrompt()) + const contentHash = hashContent(content) + return contentHash === currentHash || contentHash === legacyHash +} + +function containsLegacyAssistantAgentsGuidance(content: string): boolean { + return content.includes('## Self-Editing Rules') && + content.includes('AGENTS.md') && + content.includes('durable preferences') +} + +export function buildAssistantAgentsMd(): string { + return `# Assistant Mode Workspace + +This directory is the shared Assistant Mode workspace for OpenCode Manager. + +## Directory Contents + +- \`opencode.json\` configures this workspace and selects the default assistant agent. +- \`.opencode/agents/assistant.md\` contains the default assistant agent instructions, behavior, durable preferences, and self-editing rules. +- \`.opencode/skills/\` contains managed workspace skills for repos, schedules, notifications, and settings. +- \`.opencode/internal-token\` is managed by OpenCode Manager for internal API authentication. + +Assistant-specific instructions belong in \`.opencode/agents/assistant.md\`. +` +} + +function buildAssistantAgentPrompt(): string { + return [ + 'You are the default Assistant Mode agent for OpenCode Manager.', + '', + 'This workspace is the shared assistant workspace for OpenCode Manager. Help the user manage repos, schedules, notifications, settings, and assistant behavior safely.', + '', + '## Self-Editing Rules', + '', + 'Durable assistant instructions, behavior, and preferences belong in `.opencode/agents/assistant.md`. Edit that file when the user expresses lasting preferences or when you need to refine your behavior.', + '', + 'The workspace directory explanation belongs in `AGENTS.md`. Keep that file focused on describing the directory contents and pointing to managed files.', + '', + 'Preserve user-customized workspace files unless the user explicitly asks you to change them. Ask before making significant, destructive, or out-of-workspace changes.', + '', + '## Skill Usage', + '', + 'Use the workspace skills when relevant:', + '- Load `repo-management` before `schedule-management` when you need a repo ID.', + '- Load `schedule-management` for schedule jobs and runs.', + '- Load `notifications` when the user should be notified about important events.', + '- Load `manager-settings` when reading or safely updating UI preferences.', + ].join('\n') +} + function buildAssistantAgentPermission(): { read: 'allow'; edit: 'allow'; glob: 'allow'; grep: 'allow'; list: 'allow'; bash: 'allow'; external_directory: 'ask' } { return { read: 'allow', @@ -611,8 +700,24 @@ export async function ensureAssistantMode( const overwriteAgentsMd = options?.overwriteAgentsMd ?? false const agentsMdContent = buildAssistantAgentsMd() const existingAgentsMdContent = agentsMdExists ? await readFileContent(agentsMdPath) : undefined - const agentsMdCreated = !agentsMdExists || (overwriteAgentsMd && !hasSameContentHash(existingAgentsMdContent, agentsMdContent)) - if (agentsMdCreated) { + + const agentsMdShouldMigrate = + existingAgentsMdContent !== undefined && + matchesGeneratedAssistantAgentsMd(existingAgentsMdContent) && + !hasSameContentHash(existingAgentsMdContent, agentsMdContent) + + const agentsMdHasPreservedLegacyGuidance = + existingAgentsMdContent !== undefined && + !overwriteAgentsMd && + !matchesGeneratedAssistantAgentsMd(existingAgentsMdContent) && + containsLegacyAssistantAgentsGuidance(existingAgentsMdContent) + + const agentsMdCreated = + !agentsMdExists || + overwriteAgentsMd || + agentsMdShouldMigrate + + if (agentsMdCreated && !hasSameContentHash(existingAgentsMdContent, agentsMdContent)) { await writeFileContent(agentsMdPath, agentsMdContent) } @@ -625,7 +730,10 @@ export async function ensureAssistantMode( try { const existingContent = await readFileContent(opencodeJsonPath) const existingConfig = JSON.parse(existingContent) as OpenCodeConfigInput - return mergeAssistantOpenCodeConfig(existingConfig) + const mergedConfig = mergeAssistantOpenCodeConfig(existingConfig) + return assistantOpenCodeConfigPromptNeedsMigration(mergedConfig) + ? migrateGeneratedAssistantOpenCodePrompt(mergedConfig) + : mergedConfig } catch { return buildAssistantOpenCodeConfig() } @@ -637,9 +745,15 @@ export async function ensureAssistantMode( try { const existingContent = await readFileContent(opencodeJsonPath) const existingConfig = JSON.parse(existingContent) as OpenCodeConfigInput - if (assistantOpenCodeConfigNeedsRepair(existingConfig)) { - const mergedConfig = mergeAssistantOpenCodeConfig(existingConfig) - await writeFileContent(opencodeJsonPath, JSON.stringify(mergedConfig, null, 2)) + const repairedConfig = assistantOpenCodeConfigNeedsRepair(existingConfig) + ? mergeAssistantOpenCodeConfig(existingConfig) + : existingConfig + const updatedConfig = assistantOpenCodeConfigPromptNeedsMigration(repairedConfig) + ? migrateGeneratedAssistantOpenCodePrompt(repairedConfig) + : repairedConfig + + if (updatedConfig !== existingConfig) { + await writeFileContent(opencodeJsonPath, JSON.stringify(updatedConfig, null, 2)) opencodeJsonUpdated = true } } catch { @@ -696,15 +810,37 @@ export async function ensureAssistantMode( const assistantAgentExists = await fileExists(assistantAgentPath) const assistantAgentContent = buildAssistantDefaultAgentMd() - const assistantAgentCreated = !assistantAgentExists + const existingAssistantAgentContent = assistantAgentExists + ? await readFileContent(assistantAgentPath) + : undefined + + const assistantAgentShouldMigrate = + existingAssistantAgentContent !== undefined && + matchesGeneratedAssistantDefaultAgentMd(existingAssistantAgentContent) && + !hasSameContentHash(existingAssistantAgentContent, assistantAgentContent) + + const assistantAgentCreated = !assistantAgentExists || assistantAgentShouldMigrate + if (assistantAgentCreated) { await writeFileContent(assistantAgentPath, assistantAgentContent) } + const managedUpdatesApplied = agentsMdCreated || opencodeJsonUpdated || assistantAgentCreated + const warnings = managedUpdatesApplied && agentsMdHasPreservedLegacyGuidance + ? [ + { + code: 'assistant-agents-md-preserved', + path: agentsMdPath, + message: 'Some Assistant Mode instruction updates were not applied because AGENTS.md appears to contain customized legacy assistant instructions. To regenerate the default workspace explanation, manually delete AGENTS.md and initialize Assistant Mode again.', + }, + ] + : undefined + return { repoId: repo.id, directory: assistantDir, relativePath: ASSISTANT_MODE_RELATIVE_PATH, + warnings, files: { agentsMd: { path: agentsMdPath, @@ -757,6 +893,27 @@ function assistantOpenCodeConfigNeedsRepair(config: OpenCodeConfigInput): boolea return false } +function assistantOpenCodeConfigPromptNeedsMigration(config: OpenCodeConfigInput): boolean { + const prompt = (config.agent?.[ASSISTANT_DEFAULT_AGENT_NAME] as { prompt?: unknown } | undefined)?.prompt + return matchesGeneratedAssistantAgentPrompt(prompt) && prompt !== buildAssistantAgentPrompt() +} + +function migrateGeneratedAssistantOpenCodePrompt(config: OpenCodeConfigInput): OpenCodeConfigInput { + const existingAssistantAgent = config.agent?.[ASSISTANT_DEFAULT_AGENT_NAME] + if (typeof existingAssistantAgent !== 'object' || existingAssistantAgent === null) return config + + return { + ...config, + agent: { + ...(config.agent ?? {}), + [ASSISTANT_DEFAULT_AGENT_NAME]: { + ...existingAssistantAgent, + prompt: buildAssistantAgentPrompt(), + }, + }, + } +} + function mergeAssistantOpenCodeConfig(existing?: OpenCodeConfigInput): OpenCodeConfigInput { const generated = buildAssistantOpenCodeConfig() const existingAssistantAgent = existing?.agent?.[ASSISTANT_DEFAULT_AGENT_NAME] diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index 6587a102..3b26365f 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -97,8 +97,8 @@ describe('ensureAssistantMode', () => { const repoSkill = await readFile(path.join(ws.assistantDir, '.opencode/skills/repo-management/SKILL.md'), 'utf8') const assistantAgent = await readFile(path.join(ws.assistantDir, '.opencode/agents/assistant.md'), 'utf8') - expect(agentsMd).toContain('schedule-management') - expect(agentsMd).toContain('repo-management') + expect(agentsMd).toContain('.opencode/agents/assistant.md') + expect(agentsMd).not.toContain('Self-Editing Rules') const parsedConfig = JSON.parse(opencodeJson) expect(parsedConfig.default_agent).toBe('assistant') expect(parsedConfig).not.toHaveProperty('mcp') @@ -164,6 +164,11 @@ describe('ensureAssistantMode', () => { expect(opencodeJson.agent?.assistant?.description).toBe('Default OpenCode Manager assistant workspace agent') expect(opencodeJson.agent?.assistant?.mode).toBe('primary') expect(opencodeJson.agent?.assistant?.prompt).toContain('This workspace is the shared assistant workspace') + expect(opencodeJson.agent?.assistant?.prompt).toContain('Self-Editing') + expect(opencodeJson.agent?.assistant?.prompt).toContain('repo-management') + expect(opencodeJson.agent?.assistant?.prompt).toContain('schedule-management') + expect(opencodeJson.agent?.assistant?.prompt).toContain('notifications') + expect(opencodeJson.agent?.assistant?.prompt).toContain('manager-settings') expect(opencodeJson.agent?.assistant?.permission).toEqual({ read: 'allow', edit: 'allow', @@ -175,12 +180,13 @@ describe('ensureAssistantMode', () => { }) const agentsMdContent = await readFile(agentsMdPath, 'utf8') - expect(agentsMdContent).toContain('Assistant Mode Instructions') - expect(agentsMdContent).toContain('Self-Editing Rules') - expect(agentsMdContent).toContain('Schedule Management') - expect(agentsMdContent).toContain('Notifications') - expect(agentsMdContent).toContain('Settings Management') - expect(agentsMdContent).toContain('Repo Management') + expect(agentsMdContent).toContain('Assistant Mode Workspace') + expect(agentsMdContent).toContain('.opencode/agents/assistant.md') + expect(agentsMdContent).not.toContain('Self-Editing Rules') + expect(agentsMdContent).not.toContain('Schedule Management') + expect(agentsMdContent).not.toContain('Notifications') + expect(agentsMdContent).not.toContain('Settings Management') + expect(agentsMdContent).not.toContain('Repo Management') const schedulesSkillContent = await readFile(schedulesSkillPath, 'utf8') expect(schedulesSkillContent).toContain('name: schedule-management') @@ -200,6 +206,11 @@ describe('ensureAssistantMode', () => { const assistantAgentContent = await readFile(assistantAgentPath, 'utf8') expect(assistantAgentContent).toContain('mode: primary') + expect(assistantAgentContent).toContain('Self-Editing') + expect(assistantAgentContent).toContain('repo-management') + expect(assistantAgentContent).toContain('schedule-management') + expect(assistantAgentContent).toContain('notifications') + expect(assistantAgentContent).toContain('manager-settings') expect(result.files.opencodeJson?.exists).toBe(true) expect(result.files.agentsMd?.exists).toBe(true) @@ -284,6 +295,233 @@ describe('ensureAssistantMode', () => { expect(repaired.agent.assistant.disable).toBe(false) expect(result.files.opencodeJson?.created).toBe(true) }) + + it('migrates generated legacy AGENTS.md and assistant.md to the new split', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + + const legacyAgentsMd = `# Assistant Mode Instructions + +This folder is the shared Assistant mode workspace for OpenCode Manager. + +## Purpose + +Assistant mode provides an isolated space for: +- Self-editing agent instructions and preferences +- Customized workflows specific to this assistant workspace +- Iterative improvement of assistant behavior + +## Self-Editing Rules + +The agent MAY self-edit the following files within this workspace: +- \`AGENTS.md\` - Assistant instructions, persona, and durable preferences +- \`opencode.json\` - OpenCode configuration for this workspace + +## Constraints + +- Changes outside this workspace require explicit user direction +- Self-edits should be concise and auditable +- Preserve user-customized content when modifying files +- Always ask for confirmation before making significant changes + +## Guidelines + +1. Keep instructions clear and actionable +2. Update AGENTS.md when learning durable preferences +3. Maintain version control awareness +4. Document significant changes in commit messages + +## Repo Management + +This workspace includes a skill at \`.opencode/skills/repo-management/SKILL.md\` for listing repos available to OpenCode Manager via the internal HTTP API. Load it before the schedule-management skill when you don't know the repo ID. + +## Schedule Management + +This workspace ships with a workspace-scoped skill at \`.opencode/skills/schedule-management/SKILL.md\` that documents how to list, create, update, delete, run, inspect, and cancel schedule jobs and runs across any repo via the internal HTTP API. Load it whenever the user asks about schedules. + +## Notifications + +This workspace includes a skill at \`.opencode/skills/notifications/SKILL.md\` for sending push notifications to the user's registered devices via the internal HTTP API. Load it when you need to notify the user about important events. + +## Settings Management + +This workspace includes a skill at \`.opencode/skills/manager-settings/SKILL.md\` for reading and safely modifying user preferences via the internal HTTP API. Load it when you need to inspect or update UI settings. +` + + const legacyAssistantAgent = `--- +description: Default OpenCode Manager assistant workspace agent +mode: primary +permission: + read: allow + edit: allow + glob: allow + grep: allow + list: allow + bash: allow + external_directory: ask +--- + +You are the default Assistant Mode agent for OpenCode Manager. + +This workspace is the shared assistant workspace. Help the user manage repos, schedules, notifications, settings, and assistant behavior safely. + +Use the workspace skills when relevant: +- Load repo-management before schedule-management when you need a repo ID. +- Load schedule-management for schedule jobs and runs. +- Load notifications when the user should be notified about important events. +- Load manager-settings when reading or safely updating UI preferences. + +Preserve user-customized workspace files unless the user explicitly asks you to change them. +Ask before destructive operations or changes outside this assistant workspace. +` + + const agentsMdPath = path.join(ws.assistantDir, 'AGENTS.md') + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + const assistantAgentPath = path.join(ws.assistantDir, '.opencode/agents/assistant.md') + const legacyAssistantPrompt = legacyAssistantAgent.split('---\n\n')[1]?.trimEnd() + + if (legacyAssistantPrompt === undefined) throw new Error('Legacy assistant prompt fixture is invalid') + + await writeFile(agentsMdPath, legacyAgentsMd) + await writeFile(assistantAgentPath, legacyAssistantAgent) + await writeFile(opencodeJsonPath, JSON.stringify({ + default_agent: 'assistant', + instructions: ['AGENTS.md'], + permission: { + read: 'allow', + edit: 'allow', + glob: 'allow', + grep: 'allow', + list: 'allow', + bash: 'allow', + external_directory: 'ask', + }, + agent: { + assistant: { + description: 'Default OpenCode Manager assistant workspace agent', + mode: 'primary', + prompt: legacyAssistantPrompt, + permission: { + read: 'allow', + edit: 'allow', + glob: 'allow', + grep: 'allow', + list: 'allow', + bash: 'allow', + external_directory: 'ask', + }, + }, + }, + }, null, 2)) + + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + + const updatedAgentsMd = await readFile(agentsMdPath, 'utf8') + const updatedAssistantAgent = await readFile(assistantAgentPath, 'utf8') + const updatedOpenCodeJson = JSON.parse(await readFile(opencodeJsonPath, 'utf8')) + + expect(updatedAgentsMd).toContain('Assistant Mode Workspace') + expect(updatedAgentsMd).toContain('.opencode/agents/assistant.md') + expect(updatedAgentsMd).not.toContain('Self-Editing Rules') + + expect(updatedAssistantAgent).toContain('Self-Editing') + expect(updatedAssistantAgent).toContain('repo-management') + expect(updatedAssistantAgent).toContain('schedule-management') + expect(updatedAssistantAgent).toContain('notifications') + expect(updatedAssistantAgent).toContain('manager-settings') + + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('Self-Editing') + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('.opencode/agents/assistant.md') + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('repo-management') + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('schedule-management') + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('notifications') + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('manager-settings') + expect(updatedOpenCodeJson.agent.assistant.prompt).not.toContain('Update AGENTS.md') + + expect(result.files.agentsMd?.created).toBe(true) + expect(result.files.opencodeJson?.created).toBe(true) + expect(result.defaultAgent?.created).toBe(true) + }) + + it('preserves custom AGENTS.md content on subsequent ensureAssistantMode calls', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const agentsMdPath = path.join(ws.assistantDir, 'AGENTS.md') + + const customContent = '# Custom Assistant Workspace\n\nThis is my custom AGENTS.md content.' + await writeFile(agentsMdPath, customContent) + + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + + const preservedContent = await readFile(agentsMdPath, 'utf8') + expect(preservedContent).toBe(customContent) + expect(result.files.agentsMd?.created).toBe(false) + }) + + it('warns when managed updates apply but customized legacy AGENTS.md is preserved', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const agentsMdPath = path.join(ws.assistantDir, 'AGENTS.md') + const assistantAgentPath = path.join(ws.assistantDir, '.opencode/agents/assistant.md') + + await writeFile(agentsMdPath, `# Assistant Mode Instructions + +This folder is the shared Assistant mode workspace for OpenCode Manager. + +## Self-Editing Rules + +The agent MAY self-edit the following files within this workspace: +- \`AGENTS.md\` - Assistant instructions, persona, and durable preferences +`) + await writeFile(assistantAgentPath, `--- +description: Default OpenCode Manager assistant workspace agent +mode: primary +permission: + read: allow + edit: allow + glob: allow + grep: allow + list: allow + bash: allow + external_directory: ask +--- + +You are the default Assistant Mode agent for OpenCode Manager. + +This workspace is the shared assistant workspace. Help the user manage repos, schedules, notifications, settings, and assistant behavior safely. + +Use the workspace skills when relevant: +- Load repo-management before schedule-management when you need a repo ID. +- Load schedule-management for schedule jobs and runs. +- Load notifications when the user should be notified about important events. +- Load manager-settings when reading or safely updating UI preferences. + +Preserve user-customized workspace files unless the user explicitly asks you to change them. +Ask before destructive operations or changes outside this assistant workspace. +`) + + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + + const preservedAgentsMd = await readFile(agentsMdPath, 'utf8') + expect(preservedAgentsMd).toContain('Self-Editing Rules') + expect(result.files.agentsMd?.created).toBe(false) + expect(result.defaultAgent?.created).toBe(true) + expect(result.warnings?.[0]?.code).toBe('assistant-agents-md-preserved') + expect(result.warnings?.[0]?.message).toContain('manually delete AGENTS.md') + }) + + it('overwrites custom AGENTS.md when overwriteAgentsMd is true', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const agentsMdPath = path.join(ws.assistantDir, 'AGENTS.md') + + const customContent = '# Custom Assistant Workspace\n\nThis is my custom AGENTS.md content.' + await writeFile(agentsMdPath, customContent) + + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }, { overwriteAgentsMd: true }) + + const updatedContent = await readFile(agentsMdPath, 'utf8') + expect(updatedContent).toContain('Assistant Mode Workspace') + expect(updatedContent).toContain('.opencode/agents/assistant.md') + expect(updatedContent).not.toBe(customContent) + expect(result.files.agentsMd?.created).toBe(true) + }) }) describe('assistant-mode end-to-end', () => { diff --git a/frontend/src/hooks/useAssistantSessionLauncher.test.tsx b/frontend/src/hooks/useAssistantSessionLauncher.test.tsx index 5769e2b0..fb4360b1 100644 --- a/frontend/src/hooks/useAssistantSessionLauncher.test.tsx +++ b/frontend/src/hooks/useAssistantSessionLauncher.test.tsx @@ -55,6 +55,45 @@ describe('useAssistantSessionLauncher', () => { expect(OpenCodeClient).toHaveBeenCalledWith('http://localhost:5551', '/assistant') expect(onNavigate).toHaveBeenCalledWith('newest') expect(mocks.createSession).not.toHaveBeenCalled() + expect(mocks.sendPromptAsync).not.toHaveBeenCalled() + }) + + it('notifies an existing assistant session when some generated updates were preserved', async () => { + mocks.initializeAssistantMode.mockResolvedValue({ + directory: '/assistant', + warnings: [ + { + code: 'assistant-agents-md-preserved', + path: '/assistant/AGENTS.md', + message: 'Some Assistant Mode instruction updates were not applied because AGENTS.md appears to contain customized legacy assistant instructions. To regenerate the default workspace explanation, manually delete AGENTS.md and initialize Assistant Mode again.', + }, + ], + }) + mocks.listSessions.mockResolvedValue([ + { id: 'existing', directory: '/assistant', time: { updated: 10 } }, + ]) + const onNavigate = vi.fn() + const { result } = renderHook(() => useAssistantSessionLauncher({ + repoId: 123, + opcodeUrl: 'http://localhost:5551', + onNavigate, + })) + + await act(async () => { + await result.current.openAssistant() + }) + + expect(onNavigate).toHaveBeenCalledWith('existing') + expect(mocks.sendPromptAsync).toHaveBeenCalledWith('existing', { + parts: [ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('some generated instruction changes were not applied'), + }), + ], + }) + const promptText = mocks.sendPromptAsync.mock.calls[0][1].parts[0].text as string + expect(promptText).toContain('manually delete AGENTS.md') }) it('creates a session when the assistant directory has no root sessions', async () => { @@ -89,7 +128,11 @@ describe('useAssistantSessionLauncher', () => { expect(promptText).toContain('.opencode/agents/assistant.md') expect(promptText).toContain('AGENTS.md') expect(promptText).toContain('.opencode/skills/') + expect(promptText).toContain('directory') + expect(promptText).toContain('durable preferences') + expect(promptText).toContain('self-editing rules') expect(promptText).not.toContain('v file') + expect(promptText).not.toMatch(/AGENTS\.md contains workspace-level instructions, durable preferences, and self-editing rules/) }) it('navigates after creating a session without waiting for the welcome prompt to complete', async () => { diff --git a/frontend/src/hooks/useAssistantSessionLauncher.ts b/frontend/src/hooks/useAssistantSessionLauncher.ts index 9808f8fe..5ebc2619 100644 --- a/frontend/src/hooks/useAssistantSessionLauncher.ts +++ b/frontend/src/hooks/useAssistantSessionLauncher.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { initializeAssistantMode } from '@/api/repos' import { OpenCodeClient } from '@/api/opencode' +import type { AssistantModeStatus } from '@opencode-manager/shared/types' interface UseAssistantSessionLauncherOptions { repoId: number @@ -16,22 +17,53 @@ To get started, let's set up your assistant: What would you like to call me? This name will help personalize our interactions. **2. Review AGENTS.md** -AGENTS.md contains workspace-level instructions, durable preferences, and self-editing rules. +AGENTS.md explains the assistant workspace directory and points to the files OpenCode Manager manages. **3. Review the assistant agent** -.opencode/agents/assistant.md defines the default Assistant Mode agent and can be customized later. +.opencode/agents/assistant.md contains the default Assistant Mode agent instructions, durable preferences, self-editing rules, and skill guidance. **4. Use workspace skills** -Skills for repos, schedules, notifications, and settings are available under .opencode/skills/. +.opencode/skills/ contains managed workspace skills for repos, schedules, notifications, and settings. Take your time exploring and customizing these settings. Let me know when you're ready to start coding, or if you have any questions about getting set up!` -async function sendAssistantWelcomePrompt(client: OpenCodeClient, sessionId: string): Promise { +function buildAssistantModeWarningsPrompt(assistant: AssistantModeStatus): string | undefined { + if (!assistant.warnings?.length) return undefined + + return [ + 'Assistant Mode was updated, but some generated instruction changes were not applied.', + '', + ...assistant.warnings.map((warning) => `- ${warning.message}`), + ].join('\n') +} + +function buildAssistantWelcomePrompt(assistant: AssistantModeStatus): string { + const warningsPrompt = buildAssistantModeWarningsPrompt(assistant) + return warningsPrompt + ? `${ASSISTANT_WELCOME_PROMPT}\n\n${warningsPrompt}` + : ASSISTANT_WELCOME_PROMPT +} + +async function sendAssistantWelcomePrompt(client: OpenCodeClient, sessionId: string, assistant: AssistantModeStatus): Promise { + await client.sendPromptAsync(sessionId, { + parts: [ + { + type: 'text', + text: buildAssistantWelcomePrompt(assistant), + }, + ], + }).catch(() => undefined) +} + +async function sendAssistantModeWarningsPrompt(client: OpenCodeClient, sessionId: string, assistant: AssistantModeStatus): Promise { + const warningsPrompt = buildAssistantModeWarningsPrompt(assistant) + if (!warningsPrompt) return + await client.sendPromptAsync(sessionId, { parts: [ { type: 'text', - text: ASSISTANT_WELCOME_PROMPT, + text: warningsPrompt, }, ], }).catch(() => undefined) @@ -63,10 +95,11 @@ export function useAssistantSessionLauncher({ if (newest) { onNavigate(newest.id) + void sendAssistantModeWarningsPrompt(client, newest.id, assistant) } else { const session = await client.createSession({ title: 'Assistant' }) onNavigate(session.id) - void sendAssistantWelcomePrompt(client, session.id) + void sendAssistantWelcomePrompt(client, session.id, assistant) } }, [repoId, opcodeUrl, onNavigate]) diff --git a/shared/src/schemas/repo.ts b/shared/src/schemas/repo.ts index b9f9e938..6144ead4 100644 --- a/shared/src/schemas/repo.ts +++ b/shared/src/schemas/repo.ts @@ -65,6 +65,11 @@ export const AssistantModeStatusSchema = z.object({ repoId: z.number(), directory: z.string(), relativePath: z.literal('repos/assistant'), + warnings: z.array(z.object({ + code: z.string(), + path: z.string(), + message: z.string(), + })).optional(), files: z.object({ agentsMd: AssistantModeFileSchema, opencodeJson: AssistantModeFileSchema, From 84d5b1886cef682a5ff44dd90b5d2c699360f4db Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 5 May 2026 15:33:13 -0400 Subject: [PATCH 14/21] loop: todo-header-mobile completed after 3 iterations (#218) --- .../message/SessionTodoDisplay.test.tsx | 103 ++++++ .../components/message/SessionTodoDisplay.tsx | 4 +- frontend/src/pages/SessionDetail.tsx | 3 +- .../SessionDetail.todo-header.test.tsx | 330 ++++++++++++++++++ 4 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/message/SessionTodoDisplay.test.tsx create mode 100644 frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx diff --git a/frontend/src/components/message/SessionTodoDisplay.test.tsx b/frontend/src/components/message/SessionTodoDisplay.test.tsx new file mode 100644 index 00000000..94960211 --- /dev/null +++ b/frontend/src/components/message/SessionTodoDisplay.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SessionTodoDisplay } from './SessionTodoDisplay' +import { useSessionTodos } from '@/stores/sessionTodosStore' +import type { Todo } from './SessionTodoDisplay' + +const activeTodos: Todo[] = [ + { id: '1', content: 'Implement mobile header fix', status: 'in_progress', priority: 'high' }, + { id: '2', content: 'Add regression tests', status: 'pending', priority: 'medium' }, + { id: '3', content: 'Verify completed item grouping', status: 'completed', priority: 'low' }, +] + +const allCompletedTodos: Todo[] = [ + { id: '1', content: 'Task one', status: 'completed', priority: 'high' }, + { id: '2', content: 'Task two', status: 'completed', priority: 'medium' }, +] + +describe('SessionTodoDisplay', () => { + beforeEach(() => { + useSessionTodos.setState({ todos: new Map() }) + }) + + it('renders collapsed by default', () => { + useSessionTodos.getState().setTodos('session-1', activeTodos) + + render() + + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + + expect(screen.queryByText('Implement mobile header fix')).not.toBeInTheDocument() + expect(screen.queryByText('Add regression tests')).not.toBeInTheDocument() + }) + + it('expands to show a small scrollable task preview when clicked', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + render() + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByText('Implement mobile header fix')).toBeInTheDocument() + expect(screen.getByText('Add regression tests')).toBeInTheDocument() + expect(screen.getByText('Verify completed item grouping')).toBeInTheDocument() + + const expandedContainer = screen.getByTestId('todo-expanded-list') + expect(expandedContainer).toHaveClass('max-h-[80px]') + expect(expandedContainer).toHaveClass('sm:max-h-[160px]') + expect(expandedContainer).toHaveClass('overflow-y-auto') + }) + + it('collapses again when expanded header is clicked', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + render() + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByTestId('todo-expanded-list')).toBeInTheDocument() + + const expandedHeader = screen.getByText('Tasks: 1/3 complete') + await user.click(expandedHeader) + + expect(screen.queryByTestId('todo-expanded-list')).not.toBeInTheDocument() + }) + + it('does not render when all tasks are completed', () => { + useSessionTodos.getState().setTodos('session-1', allCompletedTodos) + + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('dismisses current todo signature and reappears when todo status changes', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + const { rerender } = render() + + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + + const dismissButton = screen.getByLabelText('Dismiss tasks') + await user.click(dismissButton) + + expect(screen.queryByText('Tasks: 1/3 complete')).not.toBeInTheDocument() + + const updatedTodos: Todo[] = [ + { id: '1', content: 'Implement mobile header fix', status: 'completed', priority: 'high' }, + { id: '2', content: 'Add regression tests', status: 'pending', priority: 'medium' }, + { id: '3', content: 'Verify completed item grouping', status: 'completed', priority: 'low' }, + ] + useSessionTodos.getState().setTodos('session-1', updatedTodos) + + rerender() + + expect(screen.getByText('Tasks: 2/3 complete')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/message/SessionTodoDisplay.tsx b/frontend/src/components/message/SessionTodoDisplay.tsx index af320298..f8894133 100644 --- a/frontend/src/components/message/SessionTodoDisplay.tsx +++ b/frontend/src/components/message/SessionTodoDisplay.tsx @@ -15,7 +15,7 @@ const todoSignature = (todos: Todo[]) => export function SessionTodoDisplay({ sessionID }: SessionTodoDisplayProps) { const todos = useSessionTodosForSession(sessionID) - const [isCollapsed, setIsCollapsed] = useState(false) + const [isCollapsed, setIsCollapsed] = useState(true) const [isDismissed, setIsDismissed] = useState(false) const dismissedSignatureRef = useRef('') @@ -129,7 +129,7 @@ export function SessionTodoDisplay({ sessionID }: SessionTodoDisplayProps) {
-
+
{renderGroup('In Progress', inProgress)} {renderGroup('Pending', pending)} {renderGroup('Completed', completedTodos)} diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 135b149f..7ade79e8 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -457,9 +457,10 @@ export function SessionDetail() { className="h-dvh max-h-dvh overflow-hidden bg-gradient-to-br from-background via-background to-background flex flex-col" >
diff --git a/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx b/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx new file mode 100644 index 00000000..73fcc4d3 --- /dev/null +++ b/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx @@ -0,0 +1,330 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { useSessionTodos } from '@/stores/sessionTodosStore' +import type { Todo } from '@/components/message/SessionTodoDisplay' +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(), +})) + +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: vi.fn(() => ({ + preferences: { expandToolCalls: false }, + updateSettings: vi.fn(), + })), +})) + +vi.mock('@/hooks/useSettingsDialog', () => ({ + useSettingsDialog: vi.fn(() => ({ open: vi.fn() })), +})) + +vi.mock('@/hooks/useMobile', () => ({ + useMobile: vi.fn(() => false), + useSwipeBack: vi.fn(() => ({ ref: vi.fn() })), +})) + +vi.mock('@/hooks/useVisualViewport', () => ({ + useVisualViewport: vi.fn(() => ({ keyboardHeight: 0 })), +})) + +vi.mock('@/hooks/useKeyboardShortcuts', () => ({ + useKeyboardShortcuts: vi.fn(() => ({ leaderActive: false })), +})) + +vi.mock('@/hooks/useAutoScroll', () => ({ + useAutoScroll: vi.fn(() => ({ scrollToBottom: vi.fn() })), +})) + +vi.mock('@/hooks/useDialogParam', () => ({ + useDialogParam: vi.fn(() => [false, vi.fn()]), +})) + +vi.mock('@/hooks/useSidebarAction', () => ({ + useSidebarAction: vi.fn(() => {}), +})) + +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(() => Promise.resolve({ + id: 1, + repoUrl: 'https://github.com/test/repo', + localPath: '/test/repo', + sourcePath: null, + fullPath: '/test/repo', + branch: 'main', + currentBranch: 'main', + fullSlug: 'test/repo', + repoType: 'github' as const, + })), + initializeAssistantMode: vi.fn(() => Promise.resolve({ directory: '/test/repo' })), +})) + +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: vi.fn(() => null), +})) + +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), +})) + +const activeTodos: Todo[] = [ + { id: '1', content: 'Implement mobile header fix', status: 'in_progress', priority: 'high' }, + { id: '2', content: 'Add regression tests', status: 'pending', priority: 'medium' }, + { id: '3', content: 'Verify completed item grouping', status: 'completed', priority: 'low' }, +] + +describe('SessionDetail todo-header integration', () => { + beforeEach(() => { + vi.clearAllMocks() + useSessionTodos.setState({ todos: new Map() }) + + 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 renderSessionDetail = (sessionId: string, repoId: number) => { + return render( + + + + } /> + + + + ) + } + + it('renders SessionTodoDisplay collapsed by default inside SessionDetail header', async () => { + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + }) + + expect(screen.queryByText('Implement mobile header fix')).not.toBeInTheDocument() + }) + + it('expands todo list when clicked inside SessionDetail header', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + }) + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByText('Implement mobile header fix')).toBeInTheDocument() + expect(screen.getByText('Add regression tests')).toBeInTheDocument() + + const expandedContainer = screen.getByTestId('todo-expanded-list') + expect(expandedContainer).toHaveClass('max-h-[80px]') + expect(expandedContainer).toHaveClass('sm:max-h-[160px]') + expect(expandedContainer).toHaveClass('overflow-y-auto') + }) + + it('header wrapper uses max-h-72 sm:max-h-80 and overflow-hidden for proper containment', async () => { + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByTestId('session-header-region')).toBeInTheDocument() + }) + + const headerRegion = screen.getByTestId('session-header-region') + + expect(headerRegion.className).toContain('max-h-72') + expect(headerRegion.className).toContain('sm:max-h-80') + expect(headerRegion.className).toContain('overflow-hidden') + expect(headerRegion.className).not.toContain('max-h-40') + }) + + it('collapses todo list when expanded header is clicked again', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + }) + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByTestId('todo-expanded-list')).toBeInTheDocument() + + const expandedHeader = screen.getByText('Tasks: 1/3 complete') + await user.click(expandedHeader) + + expect(screen.queryByTestId('todo-expanded-list')).not.toBeInTheDocument() + }) + + it('does not render SessionTodoDisplay when all tasks are completed', async () => { + const allCompletedTodos: Todo[] = [ + { id: '1', content: 'Task one', status: 'completed', priority: 'high' }, + { id: '2', content: 'Task two', status: 'completed', priority: 'medium' }, + ] + useSessionTodos.getState().setTodos('session-1', allCompletedTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + const headerRegion = screen.getByTestId('session-header-region') + expect(headerRegion).toBeInTheDocument() + }) + + expect(screen.queryByText(/Tasks:/)).not.toBeInTheDocument() + }) +}) From 92da06ef662f3b2203b8f1579b47d45c706c3b3f Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 5 May 2026 19:34:50 -0400 Subject: [PATCH 15/21] loop: stt-perf-1 completed after 5 iterations --- frontend/public/audio-worklet-processor.js | 60 +++++++- frontend/src/api/stt.test.ts | 125 +++++++++++++++++ frontend/src/api/stt.ts | 9 +- frontend/src/hooks/useSTT.ts | 25 +++- frontend/src/lib/audioRecorder.test.ts | 152 +++++++++++++++++++++ frontend/src/lib/audioRecorder.ts | 150 +++++++++----------- 6 files changed, 421 insertions(+), 100 deletions(-) create mode 100644 frontend/src/api/stt.test.ts create mode 100644 frontend/src/lib/audioRecorder.test.ts diff --git a/frontend/public/audio-worklet-processor.js b/frontend/public/audio-worklet-processor.js index 7a3c9631..0a0e584d 100644 --- a/frontend/public/audio-worklet-processor.js +++ b/frontend/public/audio-worklet-processor.js @@ -1,7 +1,13 @@ class RecorderProcessor extends AudioWorkletProcessor { - constructor() { + constructor(options) { super() + this._targetSampleRate = options?.processorOptions?.targetSampleRate ?? 16000 + this._inputSampleRate = sampleRate + this._ratio = this._inputSampleRate / this._targetSampleRate + this._fraction = 0 + this._lastSample = 0 this._active = true + this._buffer = [] this.port.onmessage = (e) => { if (e.data === 'stop') { this._active = false @@ -10,13 +16,57 @@ class RecorderProcessor extends AudioWorkletProcessor { } process(inputs) { - if (!this._active) return false - const input = inputs[0] - if (input && input[0] && input[0].length > 0) { - this.port.postMessage(new Float32Array(input[0])) + if (!this._active) { + if (this._buffer.length > 0) { + this._flushBuffer() + } + return false + } + + const input = inputs[0]?.[0] + if (!input || input.length === 0) { + return true + } + + const outputLength = Math.floor((input.length - this._fraction) / this._ratio) + for (let i = 0; i < outputLength; i++) { + const index = i * this._ratio + this._fraction + const sample = this._interpolate(input, index) + this._buffer.push(sample) + } + this._fraction = (outputLength * this._ratio) + this._fraction - input.length + this._lastSample = input[input.length - 1] + + if (this._buffer.length >= 1024) { + this._flushBuffer() } + return true } + + _interpolate(input, index) { + if (index < 0) { + const t = (index % 1) + 1 + return this._lastSample * (1 - t) + input[0] * t + } + const prevIndex = Math.floor(index) + const nextIndex = prevIndex + 1 + if (nextIndex >= input.length) { + return input[prevIndex] + } + const t = index - prevIndex + return input[prevIndex] * (1 - t) + input[nextIndex] * t + } + + _flushBuffer() { + const int16 = new Int16Array(this._buffer.length) + for (let i = 0; i < this._buffer.length; i++) { + const sample = Math.max(-1, Math.min(1, this._buffer[i])) + int16[i] = sample < 0 ? sample * 32768 : sample * 32767 + } + this.port.postMessage(int16, [int16.buffer]) + this._buffer = [] + } } registerProcessor('recorder-processor', RecorderProcessor) diff --git a/frontend/src/api/stt.test.ts b/frontend/src/api/stt.test.ts new file mode 100644 index 00000000..deb67b37 --- /dev/null +++ b/frontend/src/api/stt.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { sttApi } from './stt' + +describe('WAV extension selection logic', () => { + const originalFetch = global.fetch + const mockFetch = vi.fn() + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.restoreAllMocks() + global.fetch = originalFetch + }) + + const createMockResponse = (ok = true, data = {}) => { + return new Response(JSON.stringify(data), { + status: ok ? 200 : 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + it('should return wav for audio/wav blob', async () => { + const blob = new Blob([], { type: 'audio/wav' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.wav') + }) + + it('should return webm for audio/webm blob', async () => { + const blob = new Blob([], { type: 'audio/webm' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.webm') + }) + + it('should return ogg for audio/ogg blob', async () => { + const blob = new Blob([], { type: 'audio/ogg' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.ogg') + }) + + it('should return m4a for audio/mp4 blob', async () => { + const blob = new Blob([], { type: 'audio/mp4' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.m4a') + }) + + it('should default to wav for unknown types', async () => { + const blob = new Blob([], { type: 'audio/unknown' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.wav') + }) + + it('should prioritize wav over webm when both present', async () => { + const blob = new Blob([], { type: 'audio/wav;codecs=pcm' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.wav') + }) + + it('should include userId in request URL', async () => { + const blob = new Blob([], { type: 'audio/wav' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'custom-user') + + const callArgs = mockFetch.mock.calls[0] + const url = callArgs[0] as string + expect(url).toContain('userId=custom-user') + }) + + it('should send FormData with audio file', async () => { + const blob = new Blob(['audio data'], { type: 'audio/wav' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'transcribed text' })) + + await sttApi.transcribe(blob, 'test-user') + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/stt/transcribe'), + expect.objectContaining({ + method: 'POST', + body: expect.any(FormData), + }) + ) + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + expect(formData.get('audio')).toBeInstanceOf(File) + }) +}) diff --git a/frontend/src/api/stt.ts b/frontend/src/api/stt.ts index 6725daaf..b068c4e1 100644 --- a/frontend/src/api/stt.ts +++ b/frontend/src/api/stt.ts @@ -42,9 +42,12 @@ export const sttApi = { ): Promise => { const formData = new FormData() - const extension = audioBlob.type.includes('webm') ? 'webm' : - audioBlob.type.includes('ogg') ? 'ogg' : - audioBlob.type.includes('mp4') ? 'm4a' : 'webm' + const type = audioBlob.type + const extension = + type.includes('wav') ? 'wav' : + type.includes('webm') ? 'webm' : + type.includes('ogg') ? 'ogg' : + type.includes('mp4') ? 'm4a' : 'wav' formData.append('audio', audioBlob, `recording.${extension}`) const urlObj = new URL(`${API_BASE_URL}/api/stt/transcribe`, window.location.origin) diff --git a/frontend/src/hooks/useSTT.ts b/frontend/src/hooks/useSTT.ts index f4dc5e5c..384cdf1b 100644 --- a/frontend/src/hooks/useSTT.ts +++ b/frontend/src/hooks/useSTT.ts @@ -25,6 +25,9 @@ export function useSTT(userId = 'default') { const lastProcessedBlobRef = useRef(null) const startupTimeoutRef = useRef | null>(null) const startOpIdRef = useRef(0) + const recorderConfiguredRef = useRef(false) + const interimRafRef = useRef(null) + const pendingInterimRef = useRef('') useEffect(() => { userIdRef.current = userId @@ -51,12 +54,21 @@ export function useSTT(userId = 'default') { rec.onResult((result: SpeechRecognitionResult) => { setIsProcessing(false) - setTranscript((prev) => prev + ' ' + result.transcript) + setTranscript((prev) => { + const prevTrimmed = prev.trim() + const next = result.transcript.trim() + return prevTrimmed ? `${prevTrimmed} ${next}` : next + }) setIsRecording(false) }) rec.onInterimResult((interim: string) => { - setInterimTranscript(interim.trim()) + pendingInterimRef.current = interim.trim() + if (interimRafRef.current != null) return + interimRafRef.current = requestAnimationFrame(() => { + interimRafRef.current = null + setInterimTranscript(pendingInterimRef.current) + }) }) rec.onError((errorMessage: string) => { @@ -88,6 +100,10 @@ export function useSTT(userId = 'default') { return () => { rec.clearCallbacks() + if (interimRafRef.current != null) { + cancelAnimationFrame(interimRafRef.current) + interimRafRef.current = null + } } }, [isEnabled, isExternalProvider]) @@ -178,7 +194,10 @@ export function useSTT(userId = 'default') { audioRecorder.current = new AudioRecorder() } - setupAudioRecorder(audioRecorder.current) + if (!recorderConfiguredRef.current) { + setupAudioRecorder(audioRecorder.current) + recorderConfiguredRef.current = true + } return () => { if (audioRecorder.current) { diff --git a/frontend/src/lib/audioRecorder.test.ts b/frontend/src/lib/audioRecorder.test.ts new file mode 100644 index 00000000..d8dc0eaa --- /dev/null +++ b/frontend/src/lib/audioRecorder.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest' +import { AudioRecorder, downsampleAndConvert, encodeWavFromInt16 } from './audioRecorder' + +describe('downsampleAndConvert', () => { + it('should produce correct output length for 48kHz to 16kHz', () => { + const inputLength = 4800 + const input = new Float32Array(inputLength) + const output = downsampleAndConvert(input, 48000, 16000) + + const expectedLength = Math.floor(inputLength * 16000 / 48000) + expect(output.length).toBe(expectedLength) + expect(output.length).toBe(inputLength / 3) + }) + + it('should produce correct output length for 44.1kHz to 16kHz', () => { + const inputLength = 4410 + const input = new Float32Array(inputLength) + const output = downsampleAndConvert(input, 44100, 16000) + + const expectedLength = Math.floor(inputLength * 16000 / 44100) + expect(output.length).toBeCloseTo(expectedLength, 0) + }) + + it('should clamp values in range [-1, 1] to Int16 range', () => { + const input = new Float32Array([1.0, -1.0, 0.5, -0.5, 0.0]) + const output = downsampleAndConvert(input, 16000, 16000) + + expect(output[0]).toBe(32767) + expect(output[1]).toBe(-32768) + expect(output[2]).toBeCloseTo(16383, 0) + expect(output[3]).toBeCloseTo(-16384, 0) + expect(output[4]).toBe(0) + }) + + it('should clamp values outside [-1, 1] range', () => { + const input = new Float32Array([1.5, -1.5, 2.0, -2.0]) + const output = downsampleAndConvert(input, 16000, 16000) + + expect(output[0]).toBe(32767) + expect(output[1]).toBe(-32768) + expect(output[2]).toBe(32767) + expect(output[3]).toBe(-32768) + }) + + it('should return Int16Array type', () => { + const input = new Float32Array(100) + const output = downsampleAndConvert(input, 48000, 16000) + + expect(output instanceof Int16Array).toBe(true) + }) +}) + +describe('encodeWavFromInt16', () => { + it('should create a Blob with audio/wav type', () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + + expect(blob.type).toBe('audio/wav') + }) + + it('should have RIFF header at offset 0', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const riff = String.fromCharCode( + view.getUint8(0), + view.getUint8(1), + view.getUint8(2), + view.getUint8(3) + ) + expect(riff).toBe('RIFF') + }) + + it('should have WAVE identifier at offset 8', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const wave = String.fromCharCode( + view.getUint8(8), + view.getUint8(9), + view.getUint8(10), + view.getUint8(11) + ) + expect(wave).toBe('WAVE') + }) + + it('should have sample rate at offset 24', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const sampleRate = view.getUint32(24, true) + expect(sampleRate).toBe(16000) + }) + + it('should have data identifier at offset 36', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const data = String.fromCharCode( + view.getUint8(36), + view.getUint8(37), + view.getUint8(38), + view.getUint8(39) + ) + expect(data).toBe('data') + }) + + it('should have correct file size for 1000 samples', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + const arrayBuffer = await blob.arrayBuffer() + + expect(arrayBuffer.byteLength).toBe(44 + 1000 * 2) + }) + + it('should handle different sample rates', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 44100, 1) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const sampleRate = view.getUint32(24, true) + expect(sampleRate).toBe(44100) + }) + + it('should handle stereo channels', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 2) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const channels = view.getUint16(22, true) + expect(channels).toBe(2) + }) +}) + +describe('AudioRecorder.isSupported', () => { + it('should return boolean without throwing', () => { + expect(() => { + const result = AudioRecorder.isSupported() + expect(typeof result).toBe('boolean') + }).not.toThrow() + }) +}) diff --git a/frontend/src/lib/audioRecorder.ts b/frontend/src/lib/audioRecorder.ts index 0f80b61b..131a7752 100644 --- a/frontend/src/lib/audioRecorder.ts +++ b/frontend/src/lib/audioRecorder.ts @@ -10,54 +10,63 @@ const DEFAULT_OPTIONS: AudioRecorderOptions = { channelCount: 1, } -function encodeWAV(audioBuffer: AudioBuffer): Blob { - const numberOfChannels = audioBuffer.numberOfChannels - const sampleRate = audioBuffer.sampleRate - const format = 1 - const bitDepth = 16 +let workletModulePromise: Promise | null = null - const channelData: Float32Array[] = [] +function ensureWorkletLoaded(ctx: AudioContext): Promise { + if (!workletModulePromise) { + workletModulePromise = ctx.audioWorklet.addModule('/audio-worklet-processor.js') + } + return workletModulePromise +} - for (let i = 0; i < numberOfChannels; i++) { - channelData.push(audioBuffer.getChannelData(i)) +export function downsampleAndConvert(input: Float32Array, inputRate: number, targetRate: number): Int16Array { + const ratio = inputRate / targetRate + const outputLength = Math.floor(input.length / ratio) + const output = new Int16Array(outputLength) + + for (let i = 0; i < outputLength; i++) { + const index = i * ratio + const prevIndex = Math.floor(index) + const nextIndex = prevIndex + 1 + const t = index - prevIndex + + let sample: number + if (nextIndex >= input.length) { + sample = input[prevIndex] + } else { + sample = input[prevIndex] * (1 - t) + input[nextIndex] * t + } + + const clamped = Math.max(-1, Math.min(1, sample)) + output[i] = clamped < 0 ? clamped * 32768 : clamped * 32767 } + + return output +} - const interleaved = interleave(channelData) - const dataLength = interleaved.length * (bitDepth / 8) - const buffer = new ArrayBuffer(44 + dataLength) +export function encodeWavFromInt16(samples: Int16Array, sampleRate: number, channels: number): Blob { + const dataLength = samples.length * 2 + const bufferSize = 44 + dataLength + const buffer = new ArrayBuffer(bufferSize) const view = new DataView(buffer) - + writeString(view, 0, 'RIFF') view.setUint32(4, 36 + dataLength, true) writeString(view, 8, 'WAVE') writeString(view, 12, 'fmt ') view.setUint32(16, 16, true) - view.setUint16(20, format, true) - view.setUint16(22, numberOfChannels, true) + view.setUint16(20, 1, true) + view.setUint16(22, channels, true) view.setUint32(24, sampleRate, true) - view.setUint32(28, sampleRate * numberOfChannels * (bitDepth / 8), true) - view.setUint16(32, numberOfChannels * (bitDepth / 8), true) - view.setUint16(34, bitDepth, true) + view.setUint32(28, sampleRate * channels * 2, true) + view.setUint16(32, channels * 2, true) + view.setUint16(34, 16, true) writeString(view, 36, 'data') view.setUint32(40, dataLength, true) - - floatTo16BitPCM(view, 44, interleaved) - - return new Blob([view], { type: 'audio/wav' }) -} - -function interleave(channelData: Float32Array[]): Float32Array { - const length = channelData[0].length * channelData.length - const result = new Float32Array(length) - let offset = 0 - - for (let i = 0; i < channelData[0].length; i++) { - for (let channel = 0; channel < channelData.length; channel++) { - result[offset++] = channelData[channel][i] - } - } - - return result + + new Int16Array(buffer, 44).set(samples) + + return new Blob([buffer], { type: 'audio/wav' }) } function writeString(view: DataView, offset: number, string: string): void { @@ -66,20 +75,13 @@ function writeString(view: DataView, offset: number, string: string): void { } } -function floatTo16BitPCM(view: DataView, offset: number, input: Float32Array): void { - for (let i = 0; i < input.length; i++, offset += 2) { - const s = Math.max(-1, Math.min(1, input[i])) - view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true) - } -} - export class AudioRecorder { private audioContext: AudioContext | null = null private mediaStream: MediaStream | null = null private source: MediaStreamAudioSourceNode | null = null private processor: ScriptProcessorNode | null = null private workletNode: AudioWorkletNode | null = null - private chunks: Float32Array[] = [] + private chunks: Int16Array[] = [] private totalSamples: number = 0 private state: AudioRecorderState = 'idle' private options: AudioRecorderOptions @@ -152,29 +154,33 @@ export class AudioRecorder { if (this.audioContext.audioWorklet) { try { - await this.audioContext.audioWorklet.addModule('/audio-worklet-processor.js') - this.workletNode = new AudioWorkletNode(this.audioContext, 'recorder-processor') - this.workletNode.port.onmessage = (e: MessageEvent) => { + await ensureWorkletLoaded(this.audioContext) + this.workletNode = new AudioWorkletNode(this.audioContext, 'recorder-processor', { + processorOptions: { targetSampleRate: this.options.sampleRate }, + }) + this.workletNode.port.onmessage = (e: MessageEvent) => { this.chunks.push(e.data) this.totalSamples += e.data.length } this.source.connect(this.workletNode) - this.workletNode.connect(this.audioContext.destination) - } catch { + } catch (error) { + workletModulePromise = null this.audioContext.close() this.audioContext = null - throw new Error('Failed to load audio worklet processor') + throw new Error('Failed to load audio worklet processor', { cause: error }) } } else if (this.audioContext) { const bufferSize = 4096 this.processor = this.audioContext.createScriptProcessor(bufferSize, 1, 1) + const targetRate = this.options.sampleRate ?? 16000 + const inputRate = this.audioContext.sampleRate this.processor.onaudioprocess = (e) => { - const inputData = new Float32Array(e.inputBuffer.getChannelData(0)) - this.chunks.push(inputData) - this.totalSamples += inputData.length + const inputData = e.inputBuffer.getChannelData(0) + const int16Chunk = downsampleAndConvert(inputData, inputRate, targetRate) + this.chunks.push(int16Chunk) + this.totalSamples += int16Chunk.length } this.source.connect(this.processor) - this.processor.connect(this.audioContext.destination) } this.setState('recording') @@ -220,21 +226,13 @@ export class AudioRecorder { } try { - const merged = new Float32Array(this.totalSamples) + const merged = new Int16Array(this.totalSamples) let offset = 0 for (const chunk of this.chunks) { merged.set(chunk, offset) offset += chunk.length } - - const audioBuffer = this.audioContext!.createBuffer( - 1, - this.totalSamples, - this.audioContext!.sampleRate - ) - - audioBuffer.copyToChannel(merged, 0) - const wavBlob = encodeWAV(audioBuffer) + const wavBlob = encodeWavFromInt16(merged, this.options.sampleRate ?? 16000, 1) this.onDataAvailable?.(wavBlob) } catch { this.onError?.('Failed to process recording') @@ -276,30 +274,4 @@ export class AudioRecorder { this.chunks = [] this.totalSamples = 0 } - - getRecordingBlob(): Blob | null { - if (!this.audioContext || this.chunks.length === 0 || this.totalSamples === 0) { - return null - } - - try { - const merged = new Float32Array(this.totalSamples) - let offset = 0 - for (const chunk of this.chunks) { - merged.set(chunk, offset) - offset += chunk.length - } - - const audioBuffer = this.audioContext.createBuffer( - 1, - this.totalSamples, - this.audioContext.sampleRate - ) - - audioBuffer.copyToChannel(merged, 0) - return encodeWAV(audioBuffer) - } catch { - return null - } - } } From a0b010d8d28fd377b0818d95ae119eb41d69b70c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 5 May 2026 19:59:22 -0400 Subject: [PATCH 16/21] fix: load audio worklet per recording context --- frontend/src/lib/audioRecorder.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/audioRecorder.ts b/frontend/src/lib/audioRecorder.ts index 131a7752..526eacd6 100644 --- a/frontend/src/lib/audioRecorder.ts +++ b/frontend/src/lib/audioRecorder.ts @@ -10,13 +10,18 @@ const DEFAULT_OPTIONS: AudioRecorderOptions = { channelCount: 1, } -let workletModulePromise: Promise | null = null +const workletModulePromises = new WeakMap>() function ensureWorkletLoaded(ctx: AudioContext): Promise { - if (!workletModulePromise) { - workletModulePromise = ctx.audioWorklet.addModule('/audio-worklet-processor.js') + const existingPromise = workletModulePromises.get(ctx) + + if (existingPromise) { + return existingPromise } - return workletModulePromise + + const promise = ctx.audioWorklet.addModule('/audio-worklet-processor.js') + workletModulePromises.set(ctx, promise) + return promise } export function downsampleAndConvert(input: Float32Array, inputRate: number, targetRate: number): Int16Array { @@ -164,7 +169,6 @@ export class AudioRecorder { } this.source.connect(this.workletNode) } catch (error) { - workletModulePromise = null this.audioContext.close() this.audioContext = null throw new Error('Failed to load audio worklet processor', { cause: error }) From c0e9ac845c7133c4f31900613ea74513a4b17b48 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 6 May 2026 11:23:13 -0400 Subject: [PATCH 17/21] fix: canonicalize assistant navigation routes --- .../navigation/MobileTabBar.test.tsx | 6 +- .../components/navigation/MobileTabBar.tsx | 18 +-- .../components/navigation/MoreDrawer.test.tsx | 10 ++ .../src/components/navigation/MoreDrawer.tsx | 3 +- .../navigation/RepoQuickSwitchSheet.test.tsx | 148 +++++++++++++++++- .../navigation/RepoQuickSwitchSheet.tsx | 20 ++- .../navigation/moreDrawerItems.test.ts | 19 ++- .../components/navigation/moreDrawerItems.ts | 13 +- .../src/hooks/__tests__/useWorkspace.test.tsx | 1 + frontend/src/lib/navigation.test.ts | 68 ++++++-- frontend/src/lib/navigation.ts | 26 ++- frontend/src/lib/schedules/workspace.test.ts | 2 +- frontend/src/lib/schedules/workspace.ts | 3 +- frontend/src/pages/AssistantRedirect.tsx | 17 +- .../src/pages/__tests__/Schedules.test.tsx | 10 +- 15 files changed, 292 insertions(+), 72 deletions(-) diff --git a/frontend/src/components/navigation/MobileTabBar.test.tsx b/frontend/src/components/navigation/MobileTabBar.test.tsx index dc788c26..79403a57 100644 --- a/frontend/src/components/navigation/MobileTabBar.test.tsx +++ b/frontend/src/components/navigation/MobileTabBar.test.tsx @@ -67,7 +67,7 @@ describe('MobileTabBar', () => { const queryClient = new QueryClient() render( - + , @@ -77,7 +77,7 @@ describe('MobileTabBar', () => { expect(screen.getByText('Schedules')).toBeInTheDocument() }) - it('navigates to assistant route when repo id is present', async () => { + it('navigates to /assistant when assistant is clicked from repo context', async () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() const user = userEvent.setup() @@ -96,7 +96,7 @@ describe('MobileTabBar', () => { ) await user.click(screen.getByRole('button', { name: 'Assistant' })) - expect(screen.getByTestId('location')).toHaveTextContent('/repos/123/assistant') + expect(screen.getByTestId('location')).toHaveTextContent('/assistant') }) it('navigates to assistant route when assistant is clicked without repo id', async () => { diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx index e45b50c7..56645754 100644 --- a/frontend/src/components/navigation/MobileTabBar.tsx +++ b/frontend/src/components/navigation/MobileTabBar.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils' import { useMobile } from '@/hooks/useMobile' import { useMobileTabBar, useScheduleTab, type ScheduleTabKey } from '@/hooks/useMobileTabBar' import { useUIState } from '@/stores/uiStateStore' +import { getAssistantPath, isAssistantPath } from '@/lib/navigation' interface TabDef { key: string @@ -37,11 +38,15 @@ interface MobileTabRouteState { } function getMobileTabRouteState(pathname: string): MobileTabRouteState { + if (isAssistantPath(pathname)) { + return { mode: 'global', isInsideRepo: false, repoId: null } + } + const repoMatch = pathname.match(/^\/repos\/(\d+)(?:\/([^/]+))?/) const repoId = repoMatch?.[1] ?? null const repoSection = repoMatch?.[2] - if (pathname === '/' || pathname === '/schedules' || pathname === '/assistant') { + if (pathname === '/' || pathname === '/schedules') { return { mode: 'global', isInsideRepo: false, repoId: null } } @@ -51,7 +56,6 @@ function getMobileTabRouteState(pathname: string): MobileTabRouteState { switch (repoSection) { case undefined: - case 'assistant': return { mode: 'global', isInsideRepo: true, repoId } case 'schedules': return { mode: 'schedule', isInsideRepo: true, repoId } @@ -77,14 +81,8 @@ function buildGlobalTabs({ pathname, search, openSheet, open, close, navigate, i } const handleAssistantClick = () => { - if (repoId) { - close() - navigate(`/repos/${repoId}/assistant`) - return - } - close() - navigate('/assistant') + navigate(getAssistantPath()) } return [ @@ -107,7 +105,7 @@ function buildGlobalTabs({ pathname, search, openSheet, open, close, navigate, i label: 'Assistant', icon: Bot, onClick: handleAssistantClick, - active: isInsideRepo && pathname === `/repos/${repoId}/assistant` && !openSheet, + active: isAssistantPath(pathname) && !openSheet, }, { key: 'schedules', diff --git a/frontend/src/components/navigation/MoreDrawer.test.tsx b/frontend/src/components/navigation/MoreDrawer.test.tsx index e9c6e3c5..76810cae 100644 --- a/frontend/src/components/navigation/MoreDrawer.test.tsx +++ b/frontend/src/components/navigation/MoreDrawer.test.tsx @@ -240,4 +240,14 @@ describe('MoreDrawer', () => { expect(screen.queryByText('wrong-repo')).not.toBeInTheDocument() }) + it('shows Assistant instead of the source repo on canonical /assistant route', () => { + mockAuth() + mockServerHealth() + const handleClose = vi.fn() + renderMoreDrawer({ initialEntry: '/assistant', routePath: '/assistant', onClose: handleClose }) + + expect(screen.getByText('Assistant')).toBeInTheDocument() + expect(screen.queryByText('wrong-repo')).not.toBeInTheDocument() + }) + }) diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 306f1173..74798e5d 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -13,6 +13,7 @@ import { FileBrowserSheet } from '@/components/file-browser/FileBrowserSheet' import { buildMoreItems } from './moreDrawerItems' import { useSwipeBack } from '@/hooks/useMobile' import { getRepoDisplayName } from '@/lib/utils' +import { isAssistantPath } from '@/lib/navigation' import type { components } from '@/api/opencode-types' type CommandType = components['schemas']['Command'] @@ -35,7 +36,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { const { logout } = useAuth() const { data: health } = useServerHealth() const isSessionDetail = /^\/repos\/\d+\/sessions\/[^/]+$/.test(location.pathname) - const isAssistantRoute = /^\/repos\/\d+\/assistant$/.test(location.pathname) + const isAssistantRoute = isAssistantPath(location.pathname) const isAssistantSession = isSessionDetail && new URLSearchParams(location.search).get('assistant') === '1' const { filterCommands } = useCommands(isSessionDetail ? OPENCODE_API_ENDPOINT : null) const activePromptFileBasePath = useUIState((state) => state.activePromptFileBasePath) diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx index 9e567877..4f07a35f 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx @@ -134,7 +134,7 @@ describe('RepoQuickSwitchSheet', () => { expect(handleClose).toHaveBeenCalled() }) - it('navigates directly to assistant when mobileTabAction is assistant', async () => { + it('navigates to assistant when mobileTabAction is assistant', async () => { vi.mocked(listRepos).mockResolvedValue([ { id: 1, @@ -172,11 +172,53 @@ describe('RepoQuickSwitchSheet', () => { fireEvent.click(screen.getByText('repo1')) - expect(screen.getByTestId('location')).toHaveTextContent('/repos/1/assistant') + expect(screen.getByTestId('location')).toHaveTextContent('/assistant') expect(handleClose).not.toHaveBeenCalled() }) - it('navigates from assistant to repo detail when clicking active repo', async () => { + it('navigates from assistant to repo detail when clicking repo on canonical assistant route', async () => { + vi.mocked(listRepos).mockResolvedValue([ + { + id: 1, + repoUrl: 'https://github.com/test/repo1.git', + localPath: '/path/to/repo1', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + const handleClose = vi.fn() + render( + + + + + + + + } + /> + + + , + ) + + await waitFor(() => { + expect(screen.getByText('repo1')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('repo1')) + + expect(screen.getByTestId('location')).toHaveTextContent('/repos/1') + expect(handleClose).not.toHaveBeenCalled() + }) + + it('navigates from legacy assistant route to repo detail when clicking repo', async () => { vi.mocked(listRepos).mockResolvedValue([ { id: 1, @@ -218,6 +260,106 @@ describe('RepoQuickSwitchSheet', () => { expect(handleClose).not.toHaveBeenCalled() }) + it('does not mark any repo active on /assistant', async () => { + vi.mocked(listRepos).mockResolvedValue([ + { + id: 1, + repoUrl: 'https://github.com/test/repo1.git', + localPath: '/path/to/repo1', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 2, + repoUrl: 'https://github.com/test/repo2.git', + localPath: '/path/to/repo2', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + const handleClose = vi.fn() + render( + + + + + + + + } + /> + + + , + ) + + await waitFor(() => { + expect(screen.getByText('repo1')).toBeInTheDocument() + expect(screen.getByText('repo2')).toBeInTheDocument() + }) + + expect(screen.queryByRole('button', { current: 'page' })).toBeNull() + }) + + it('does not mark any repo active on legacy /repos/1/assistant', async () => { + vi.mocked(listRepos).mockResolvedValue([ + { + id: 1, + repoUrl: 'https://github.com/test/repo1.git', + localPath: '/path/to/repo1', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 2, + repoUrl: 'https://github.com/test/repo2.git', + localPath: '/path/to/repo2', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + const handleClose = vi.fn() + render( + + + + + + + + } + /> + + + , + ) + + await waitFor(() => { + expect(screen.getByText('repo1')).toBeInTheDocument() + expect(screen.getByText('repo2')).toBeInTheDocument() + }) + + expect(screen.queryByRole('button', { current: 'page' })).toBeNull() + }) + it('switches repos from the mobile repos sheet without navigating back to the previous repo', async () => { vi.mocked(listRepos).mockResolvedValue([ { diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx index d069fa0e..f12c7499 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx @@ -8,6 +8,7 @@ import { cn, getRepoDisplayName } from '@/lib/utils' import { listRepos } from '@/api/repos' import { AddRepoDialog } from '@/components/repo/AddRepoDialog' import { FolderGit2, Check, Plus } from 'lucide-react' +import { isAssistantPath, getAssistantPath } from '@/lib/navigation' interface RepoQuickSwitchSheetProps { isOpen: boolean @@ -20,10 +21,13 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr const [searchQuery, setSearchQuery] = useState('') const [addRepoOpen, setAddRepoOpen] = useState(false) + const isAssistantRoute = useMemo(() => isAssistantPath(location.pathname), [location.pathname]) + const activeRepoId = useMemo(() => { + if (isAssistantRoute) return null const match = location.pathname.match(/^\/repos\/(\d+)/) return match ? Number(match[1]) : null - }, [location.pathname]) + }, [location.pathname, isAssistantRoute]) const { data: repos, isLoading } = useQuery({ queryKey: ['repos'], @@ -52,18 +56,18 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr const handleClick = (id: number) => { const pendingAction = new URLSearchParams(location.search).get('mobileTabAction') + + if (isAssistantRoute) { + navigateAndClose(`/repos/${id}`, { replace: true }) + return + } + if (pendingAction === 'assistant') { - navigateAndClose(`/repos/${id}/assistant`) + navigateAndClose(getAssistantPath()) return } - const isAssistantRoute = location.pathname === `/repos/${id}/assistant` if (id === activeRepoId) { - if (isAssistantRoute) { - navigateAndClose(`/repos/${id}`, { replace: true }) - return - } - onClose() return } diff --git a/frontend/src/components/navigation/moreDrawerItems.test.ts b/frontend/src/components/navigation/moreDrawerItems.test.ts index b52cfcdc..b2b42bad 100644 --- a/frontend/src/components/navigation/moreDrawerItems.test.ts +++ b/frontend/src/components/navigation/moreDrawerItems.test.ts @@ -97,7 +97,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('/repos/5/assistant') + expect(model.primary[1].to).toBe('/assistant') }) it('returns new-session and assistant primary CTAs for session detail', () => { @@ -107,7 +107,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('/repos/5/assistant') + expect(model.primary[1].to).toBe('/assistant') expect(model.primary[1].variant).toBe('secondary') }) @@ -118,7 +118,18 @@ 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('/repos/5/assistant') + expect(model.primary[1].to).toBe('/assistant') + expect(model.primary[1].variant).toBe('secondary') + }) + + it('returns new-session and assistant primary CTAs for canonical /assistant', () => { + const model = buildNavModel('/assistant') + expect(model.primary).toHaveLength(2) + expect(model.primary[0].key).toBe('new-session') + 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].variant).toBe('secondary') }) @@ -135,7 +146,7 @@ describe('buildNavModel', () => { 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('/repos/5/assistant') + expect(model2.primary[1].to).toBe('/assistant') }) it('returns assistant primary for unknown routes', () => { diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index 72f55eac..cdd81554 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -1,5 +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' export interface MoreDrawerItem { key: string @@ -24,14 +25,12 @@ export interface NavModel { items: MoreDrawerItem[] } -function getAssistantNavItem(pathname: string, variant: NavPrimaryCta['variant'] = 'secondary'): NavPrimaryCta { - const repoMatch = /^\/repos\/(\d+)/.exec(pathname) - +function getAssistantNavItem(_pathname: string, variant: NavPrimaryCta['variant'] = 'secondary'): NavPrimaryCta { return { key: 'assistant', label: 'Assistant', icon: Bot, - to: repoMatch ? `/repos/${repoMatch[1]}/assistant` : '/assistant', + to: getAssistantPath(), variant, } } @@ -89,15 +88,13 @@ export function buildNavModel(pathname: string): NavModel { } } - const assistantMatch = /^\/repos\/(\d+)\/assistant$/.exec(pathname) - if (assistantMatch) { - const id = assistantMatch[1] + if (isAssistantPath(pathname)) { const items: MoreDrawerItem[] = [ { key: 'files', label: 'Files', icon: Folder, dialog: 'files' }, { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, { key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' }, { key: 'reset-permissions', label: 'Reset Permissions', icon: ShieldOff, dialog: 'resetPermissions', danger: true }, - { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: `/repos/${id}/schedules` }, + { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: '/repos/0/schedules' }, { key: 'source-control', label: 'Source Control', icon: GitCommitHorizontal, dialog: 'sourceControl' }, ...baseItems, ] diff --git a/frontend/src/hooks/__tests__/useWorkspace.test.tsx b/frontend/src/hooks/__tests__/useWorkspace.test.tsx index f4b0f1cf..702a85d7 100644 --- a/frontend/src/hooks/__tests__/useWorkspace.test.tsx +++ b/frontend/src/hooks/__tests__/useWorkspace.test.tsx @@ -54,6 +54,7 @@ describe('useWorkspace', () => { expect(result.current.workspace?.kind).toBe('assistant') expect(result.current.workspace?.fullPath).toBe('/abs/assistant') expect(result.current.workspace?.repoId).toBe(0) + expect(result.current.workspace?.backHref).toBe('/assistant') expect(result.current.isLoading).toBe(false) expect(result.current.isError).toBe(false) }) diff --git a/frontend/src/lib/navigation.test.ts b/frontend/src/lib/navigation.test.ts index 115d5f61..d5bc4f42 100644 --- a/frontend/src/lib/navigation.test.ts +++ b/frontend/src/lib/navigation.test.ts @@ -1,5 +1,39 @@ import { describe, it, expect } from 'vitest'; -import { getSessionListPath, getSwipeBackTarget } from './navigation'; +import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath } from './navigation'; + +describe('getAssistantPath', () => { + it('returns /assistant', () => { + expect(getAssistantPath()).toBe('/assistant'); + }); +}); + +describe('getAssistantSessionListPath', () => { + it('returns /assistant?view=sessions', () => { + expect(getAssistantSessionListPath()).toBe('/assistant?view=sessions'); + }); +}); + +describe('isAssistantPath', () => { + it('returns true for /assistant', () => { + expect(isAssistantPath('/assistant')).toBe(true); + }); + + it('returns true for legacy /repos/0/assistant', () => { + expect(isAssistantPath('/repos/0/assistant')).toBe(true); + }); + + it('returns true for legacy /repos/5/assistant', () => { + expect(isAssistantPath('/repos/5/assistant')).toBe(true); + }); + + it('returns false for /repos/5', () => { + expect(isAssistantPath('/repos/5')).toBe(false); + }); + + it('returns false for /schedules', () => { + expect(isAssistantPath('/schedules')).toBe(false); + }); +}); describe('getSessionListPath', () => { it('returns repo path for non-assistant sessions', () => { @@ -7,9 +41,9 @@ describe('getSessionListPath', () => { expect(getSessionListPath('123', false)).toBe('/repos/123'); }); - it('returns assistant path with view=sessions for assistant sessions', () => { - expect(getSessionListPath(42, true)).toBe('/repos/42/assistant?view=sessions'); - expect(getSessionListPath('123', true)).toBe('/repos/123/assistant?view=sessions'); + it('returns assistant session list path for assistant sessions', () => { + expect(getSessionListPath(42, true)).toBe('/assistant?view=sessions'); + expect(getSessionListPath('123', true)).toBe('/assistant?view=sessions'); }); }); @@ -20,12 +54,12 @@ describe('getSwipeBackTarget', () => { expect(getSwipeBackTarget('/repos/123/sessions/xyz-789', '')).toBe('/repos/123'); }); - it('returns assistant path for assistant session detail with assistant=1', () => { + it('returns assistant session list path for assistant session detail with assistant=1', () => { expect(getSwipeBackTarget('/repos/42/sessions/abc', '?assistant=1')).toBe( - '/repos/42/assistant?view=sessions' + '/assistant?view=sessions' ); expect(getSwipeBackTarget('/repos/123/sessions/xyz', '?assistant=1')).toBe( - '/repos/123/assistant?view=sessions' + '/assistant?view=sessions' ); }); @@ -36,12 +70,20 @@ describe('getSwipeBackTarget', () => { }); describe('assistant route', () => { - it('returns assistant sessions path for assistant route', () => { - expect(getSwipeBackTarget('/repos/123/assistant', '')).toBe('/repos/123/assistant?view=sessions'); + it('returns assistant session list path for canonical assistant route', () => { + expect(getSwipeBackTarget('/assistant', '')).toBe('/assistant?view=sessions'); }); - it('returns repo path for assistant session list route', () => { - expect(getSwipeBackTarget('/repos/42/assistant', '?view=sessions')).toBe('/repos/42'); + it('returns root for assistant session list route', () => { + expect(getSwipeBackTarget('/assistant', '?view=sessions')).toBe('/'); + }); + + it('returns assistant session list path for legacy assistant route', () => { + expect(getSwipeBackTarget('/repos/123/assistant', '')).toBe('/assistant?view=sessions'); + }); + + it('returns root for legacy assistant session list route', () => { + expect(getSwipeBackTarget('/repos/42/assistant', '?view=sessions')).toBe('/'); }); }); @@ -53,6 +95,10 @@ describe('getSwipeBackTarget', () => { }); describe('schedules routes', () => { + it('returns /assistant for assistant schedules', () => { + expect(getSwipeBackTarget('/repos/0/schedules', '')).toBe('/assistant'); + }); + it('returns repo path for repo schedules', () => { expect(getSwipeBackTarget('/repos/42/schedules', '')).toBe('/repos/42'); }); diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 5351febb..a4192746 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,9 +1,20 @@ +export function getAssistantPath(): string { + return '/assistant'; +} + +export function getAssistantSessionListPath(): string { + return '/assistant?view=sessions'; +} + +export function isAssistantPath(pathname: string): boolean { + return pathname === '/assistant' || /^\/repos\/[^/]+\/assistant$/.test(pathname); +} + export function getSessionListPath(repoId: string | number, isAssistantSession: boolean): string { - const id = String(repoId); if (isAssistantSession) { - return `/repos/${id}/assistant?view=sessions`; + return getAssistantSessionListPath(); } - return `/repos/${id}`; + return `/repos/${String(repoId)}`; } export function getSwipeBackTarget(pathname: string, search = ''): string | null { @@ -17,13 +28,12 @@ export function getSwipeBackTarget(pathname: string, search = ''): string | null return getSessionListPath(repoId, isAssistant); } - if (pathname === '/repos/:id/assistant' || /^\/repos\/[^/]+\/assistant$/.test(pathname)) { - const repoId = pathname.split('/')[2]; + if (isAssistantPath(pathname)) { const params = new URLSearchParams(search); if (params.get('view') !== 'sessions') { - return getSessionListPath(repoId, true); + return getAssistantSessionListPath(); } - return `/repos/${repoId}`; + return '/'; } if (/^\/repos\/[^/]+$/.test(pathname)) { @@ -33,7 +43,7 @@ export function getSwipeBackTarget(pathname: string, search = ''): string | null if (/^\/repos\/[^/]+\/schedules$/.test(pathname)) { const repoId = pathname.split('/')[2]; if (repoId === '0') { - return `/repos/${repoId}/assistant`; + return getAssistantPath(); } return `/repos/${repoId}`; } diff --git a/frontend/src/lib/schedules/workspace.test.ts b/frontend/src/lib/schedules/workspace.test.ts index d6cf515f..06461d21 100644 --- a/frontend/src/lib/schedules/workspace.test.ts +++ b/frontend/src/lib/schedules/workspace.test.ts @@ -67,7 +67,7 @@ describe('workspaceFromAssistant', () => { name: 'Assistant', subtitle: 'Assistant Workspace', fullPath: '/abs/assistant', - backHref: '/repos/0/assistant', + backHref: '/assistant', }) }) }) diff --git a/frontend/src/lib/schedules/workspace.ts b/frontend/src/lib/schedules/workspace.ts index c0eb8128..3952db99 100644 --- a/frontend/src/lib/schedules/workspace.ts +++ b/frontend/src/lib/schedules/workspace.ts @@ -1,6 +1,7 @@ import type { Repo } from '@/api/types' import type { AssistantModeStatus } from '@opencode-manager/shared/types' import { getRepoDisplayName } from '@/lib/utils' +import { getAssistantPath } from '@/lib/navigation' export interface Workspace { repoId: number @@ -35,6 +36,6 @@ export function workspaceFromAssistant(status: AssistantModeStatus): Workspace { name: 'Assistant', subtitle: 'Assistant Workspace', fullPath: status.directory, - backHref: `/repos/${ASSISTANT_REPO_ID}/assistant`, + backHref: getAssistantPath(), } } diff --git a/frontend/src/pages/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index 2806a0ce..067e1422 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react" import { useParams, useNavigate, useLocation } from "react-router-dom" import { useQuery, useQueryClient } from "@tanstack/react-query" -import { getRepo, initializeAssistantMode, listRepos } from "@/api/repos" +import { getRepo, initializeAssistantMode } from "@/api/repos" import { useAssistantSessionLauncher } from "@/hooks/useAssistantSessionLauncher" import { useCreateSession } from "@/hooks/useOpenCode" import { useDialogParam } from "@/hooks/useDialogParam" @@ -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 } from "@/lib/navigation" +import { getSessionListPath, getAssistantPath } from "@/lib/navigation" import { SwitchConfigDialog } from "@/components/repo/SwitchConfigDialog" import { Loader2, Plus } from "lucide-react" @@ -84,11 +84,10 @@ export function AssistantRedirect() { try { if (showSessionList) return setStatus("preparing") - if (!id || isNaN(repoId) || repoId <= 0) { - const repos = await listRepos() - const fallbackRepo = repos.sort((a, b) => (b.lastAccessedAt ?? 0) - (a.lastAccessedAt ?? 0))[0] - if (!fallbackRepo) throw new Error("No repository available to open Assistant") - navigate(`/repos/${fallbackRepo.id}/assistant`, { replace: true }) + if (repoId <= 0) { + if (cancelled) return + setStatus("creating") + await openAssistant() return } @@ -114,7 +113,7 @@ export function AssistantRedirect() { return (
- + Assistant
@@ -174,7 +173,7 @@ export function AssistantRedirect() { <>

{errorMessage}