From 578a2211baf15b8ede6f9adeca18b4cbc8231b9f Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:56:10 -0400 Subject: [PATCH 01/14] feat(frontend): replace the repos sheet with a sessions picker sheet The mobile tab bar's Repos sheet becomes a Sessions sheet with Activity, Sessions and Projects tabs, matching the mobile app. The picker body is shared with the desktop session dialog, the scope toggles by tapping the selected Sessions tab, and the header adds new-session, add-repo and server controls. The collapsed desktop sidebar opens the session dialog instead of the old sheet. --- .../navigation/DesktopSidebar.test.tsx | 31 +- .../components/navigation/DesktopSidebar.tsx | 56 +- .../navigation/LinkedServersNav.test.tsx | 16 +- .../navigation/LinkedServersNav.tsx | 6 +- .../navigation/MobileSheetHost.test.tsx | 20 +- .../components/navigation/MobileSheetHost.tsx | 4 +- .../navigation/MobileTabBar.test.tsx | 46 +- .../components/navigation/MobileTabBar.tsx | 12 +- .../components/navigation/MoreDrawer.test.tsx | 12 +- .../src/components/navigation/MoreDrawer.tsx | 4 +- .../navigation/RepoQuickSwitchSheet.test.tsx | 1128 ----------------- .../navigation/RepoQuickSwitchSheet.tsx | 159 --- .../session/ProjectPickerContent.tsx | 18 + .../session/SessionPickerDialog.test.tsx | 134 +- .../session/SessionPickerDialog.tsx | 412 ++++-- .../session/SessionPickerSheet.test.tsx | 332 +++++ .../components/session/SessionPickerSheet.tsx | 94 ++ frontend/src/hooks/useMobileTabBar.test.tsx | 8 +- frontend/src/hooks/useMobileTabBar.ts | 4 +- frontend/src/hooks/useOpenCode.test.tsx | 21 + frontend/src/hooks/useOpenCode.ts | 2 +- .../src/hooks/useSessionPickerRouteScope.ts | 52 + frontend/src/hooks/useSettingsDialog.test.tsx | 4 +- frontend/src/pages/SessionDetail.tsx | 14 +- .../__tests__/SessionDetail.commands.test.tsx | 31 + 25 files changed, 1129 insertions(+), 1491 deletions(-) delete mode 100644 frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx delete mode 100644 frontend/src/components/navigation/RepoQuickSwitchSheet.tsx create mode 100644 frontend/src/components/session/SessionPickerSheet.test.tsx create mode 100644 frontend/src/components/session/SessionPickerSheet.tsx create mode 100644 frontend/src/hooks/useSessionPickerRouteScope.ts diff --git a/frontend/src/components/navigation/DesktopSidebar.test.tsx b/frontend/src/components/navigation/DesktopSidebar.test.tsx index a52b6d721..e1c29c285 100644 --- a/frontend/src/components/navigation/DesktopSidebar.test.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.test.tsx @@ -11,6 +11,9 @@ import * as useAuthModule from '@/hooks/useAuth' vi.mock('@/hooks/useDesktop') vi.mock('@/hooks/useSidebarCollapsed') vi.mock('@/hooks/useAuth') +vi.mock('@/api/repos', () => ({ + listRepos: vi.fn().mockResolvedValue([]), +})) vi.mock('@/hooks/useOpenCodeServers', () => ({ useOpenCodeServers: () => ({ servers: [], isLoading: false }), useOpenCodeServerStatuses: () => ({ statuses: [], statusById: {}, isLoading: false }), @@ -18,6 +21,10 @@ vi.mock('@/hooks/useOpenCodeServers', () => ({ vi.mock('@/components/navigation/DesktopSessionTree', () => ({ DesktopSessionTree: () =>
, })) +vi.mock('@/components/session/SessionPickerDialog', () => ({ + SessionPickerDialog: ({ open }: { open: boolean }) => + open ?
: null, +})) function LocationDisplay() { const location = useLocation() @@ -166,7 +173,7 @@ describe('DesktopSidebar', () => { for (const label of ['Home', 'Settings', 'Logout']) { expect(screen.getByText(label)).toBeInTheDocument() } - for (const label of ['Menu', 'Repos', 'Files', 'MCP', 'Skills', 'Schedules', 'All Schedules', 'Source Control', 'Terminal', 'Walkthrough', 'Preview']) { + for (const label of ['Menu', 'Files', 'MCP', 'Skills', 'Schedules', 'All Schedules', 'Source Control', 'Terminal', 'Walkthrough', 'Preview']) { expect(screen.queryByText(label)).toBeNull() } }) @@ -263,7 +270,7 @@ describe('DesktopSidebar', () => { expect(screen.queryByRole('button', { name: /Switch server/ })).toBeNull() }) - it('renders home, repos and account items and no session tree when collapsed', () => { + it('renders home, sessions and account items and no session tree when collapsed', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([true, vi.fn()]) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ @@ -276,8 +283,26 @@ describe('DesktopSidebar', () => { expect(screen.queryByTestId('session-tree')).toBeNull() expect(screen.getByText('Home')).toBeInTheDocument() - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.queryByText('Files')).toBeNull() }) + + it('opens the sessions picker dialog from the collapsed Sessions item', () => { + vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) + vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([true, vi.fn()]) + vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ + isAuthenticated: true, + isLoading: false, + logout: vi.fn(), + } as any) + + render(, { wrapper: createWrapper(['/repos/5']) }) + + expect(screen.queryByTestId('sessions-picker-dialog')).toBeNull() + + fireEvent.click(screen.getByText('Sessions')) + + expect(screen.getByTestId('sessions-picker-dialog')).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index 2303c5e34..5b19e28bb 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -6,8 +6,9 @@ import { useSidebarWidth } from '@/hooks/useSidebarWidth' import { useAuth } from '@/hooks/useAuth' import { useUrlParams } from '@/hooks/useUrlParams' import { useOpenNavItem } from '@/hooks/useOpenNavItem' +import { useSessionPickerRouteScope } from '@/hooks/useSessionPickerRouteScope' import { buildNavModel, type MoreDrawerItem, type NavPrimaryCta } from '@/components/navigation/moreDrawerItems' -import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' +import { SessionPickerDialog } from '@/components/session/SessionPickerDialog' import { DesktopSessionTree } from '@/components/navigation/DesktopSessionTree' import { LinkedServersNav } from '@/components/navigation/LinkedServersNav' import { @@ -15,10 +16,48 @@ import { SidebarSection, SidebarItem, } from '@/components/ui/sidebar' -import { FolderGit2 } from 'lucide-react' +import { getRepoPath, getServerSessionPath, getSessionPath } from '@/lib/navigation' +import { List } from 'lucide-react' const FOOTER_ITEM_KEYS = new Set(['home', 'settings', 'logout']) +function DesktopSessionsPicker({ onDismiss }: { onDismiss: () => void }) { + const navigate = useNavigate() + const { currentRepo, activeSessionID, remote } = useSessionPickerRouteScope(true) + + return ( + { + if (!nextOpen) onDismiss() + }} + currentRepo={currentRepo} + activeSessionID={activeSessionID} + remote={remote} + onSelectSession={(session, sessionRepo) => { + navigate(getSessionPath(sessionRepo.id, session.id)) + onDismiss() + }} + onSelectRemoteSession={(session, serverId) => { + navigate(getServerSessionPath(serverId, session.id)) + onDismiss() + }} + onDeleteActiveSession={() => { + onDismiss() + if (currentRepo) navigate(getRepoPath(currentRepo.id)) + }} + onSelectRepo={(selectedRepo) => { + navigate(getRepoPath(selectedRepo.id)) + onDismiss() + }} + onNavigate={(path) => { + navigate(path) + onDismiss() + }} + /> + ) +} + export function DesktopSidebar() { const location = useLocation() const navigate = useNavigate() @@ -26,7 +65,7 @@ export function DesktopSidebar() { const openNavItem = useOpenNavItem() const [collapsed, toggle] = useSidebarCollapsed() const [sidebarWidth, setSidebarWidth] = useSidebarWidth() - const [repoSwitcherOpen, setRepoSwitcherOpen] = useState(false) + const [sessionsOpen, setSessionsOpen] = useState(false) const { isAuthenticated, isLoading, logout } = useAuth() const isDesktop = useDesktop() @@ -51,15 +90,15 @@ export function DesktopSidebar() { p.set('settingsTab', 'account') p.delete('mobileTab') }, 'push') - } else if (item.key === 'repos') { - setRepoSwitcherOpen(true) + } else if (item.key === 'sessions') { + setSessionsOpen(true) } } const footerItems = items.filter((item) => FOOTER_ITEM_KEYS.has(item.key)) const collapsedItems: MoreDrawerItem[] = [ footerItems[0], - { key: 'repos', label: 'Repos', icon: FolderGit2 }, + { key: 'sessions', label: 'Sessions', icon: List }, ...footerItems.slice(1), ] @@ -133,10 +172,7 @@ export function DesktopSidebar() { )} - setRepoSwitcherOpen(false)} - /> + {sessionsOpen && setSessionsOpen(false)} />} ) } diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx index f6be0c2f0..c9aebae57 100644 --- a/frontend/src/components/navigation/LinkedServersNav.test.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -52,7 +52,7 @@ function SidebarServerRouteSync() { return null } -function renderNav(pathname = '/', variant?: 'sidebar' | 'rail' | 'sheet') { +function renderNav(pathname = '/', variant?: 'sidebar' | 'rail') { return render( @@ -253,18 +253,4 @@ describe('LinkedServersNav', () => { expect(screen.queryByRole('button', { name: /Switch server/ })).not.toBeInTheDocument() }) - - it('renders a thin trigger row in the sheet variant', async () => { - const user = userEvent.setup() - setServers([kitchen]) - - renderNav('/', 'sheet') - - expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: triggerName('OC Manager') })) - await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) - - expect(await screen.findByRole('button', { name: triggerName('Kitchen') })).toBeInTheDocument() - expect(screen.getByTestId('location')).toHaveTextContent('/') - }) }) diff --git a/frontend/src/components/navigation/LinkedServersNav.tsx b/frontend/src/components/navigation/LinkedServersNav.tsx index 6ff7ba7e6..63553c4ef 100644 --- a/frontend/src/components/navigation/LinkedServersNav.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.tsx @@ -20,10 +20,10 @@ import { cn } from '@/lib/utils' const LOCAL_SERVER_LABEL = 'OC Manager' -export type LinkedServersVariant = 'sidebar' | 'rail' | 'sheet' | 'header' +export type LinkedServersVariant = 'sidebar' | 'rail' | 'header' interface LinkedServersNavProps { - /** `sidebar` renders the full-width trigger row, `rail` the collapsed icon button, `sheet` the mobile sheet row, `header` a compact page-header trigger. */ + /** `sidebar` renders the full-width trigger row, `rail` the collapsed icon button, `header` a compact page-header trigger. */ variant?: LinkedServersVariant } @@ -220,7 +220,7 @@ export function LinkedServersNav({ variant = 'sidebar' }: LinkedServersNavProps) 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', isRail && 'mx-auto h-9 w-9 justify-center', variant === 'header' && 'h-8 min-w-0 max-w-[45vw] gap-2 px-2 sm:max-w-xs', - (variant === 'sidebar' || variant === 'sheet') && 'h-9 w-full gap-2 px-2', + variant === 'sidebar' && 'h-9 w-full gap-2 px-2', )} > diff --git a/frontend/src/components/navigation/MobileSheetHost.test.tsx b/frontend/src/components/navigation/MobileSheetHost.test.tsx index 1d425d251..be8194c27 100644 --- a/frontend/src/components/navigation/MobileSheetHost.test.tsx +++ b/frontend/src/components/navigation/MobileSheetHost.test.tsx @@ -8,9 +8,9 @@ vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: ({ isOpen, basePath, repoName }: { isOpen: boolean; basePath: string; repoName: string }) => isOpen ?
FileBrowserSheet
: null, })) -vi.mock('@/components/navigation/RepoQuickSwitchSheet', () => ({ - RepoQuickSwitchSheet: ({ isOpen }: { isOpen: boolean }) => - isOpen ?
RepoQuickSwitchSheet
: null, +vi.mock('@/components/session/SessionPickerSheet', () => ({ + SessionPickerSheet: ({ isOpen }: { isOpen: boolean }) => + isOpen ?
SessionPickerSheet
: null, })) vi.mock('@/components/navigation/NotificationsSheet', () => ({ NotificationsSheet: ({ isOpen }: { isOpen: boolean }) => @@ -58,18 +58,18 @@ describe('MobileSheetHost', () => { expect(container.firstChild).toBeNull() }) - it('renders RepoQuickSwitchSheet when mobileTab=repos', () => { + it('renders SessionPickerSheet when mobileTab=sessions', () => { vi.mocked(useMobileTabBar).mockReturnValue({ - openSheet: 'repos', + openSheet: 'sessions', open: vi.fn(), close: vi.fn(), }) render( - + , ) - expect(screen.getByTestId('repo-quick-switch-sheet')).toBeInTheDocument() + expect(screen.getByTestId('session-picker-sheet')).toBeInTheDocument() }) it('renders FileBrowserSheet with correct props when mobileTab=files', () => { @@ -134,15 +134,15 @@ describe('MobileSheetHost', () => { it('closes sheet when onClose is called', () => { const mockClose = vi.fn() vi.mocked(useMobileTabBar).mockReturnValue({ - openSheet: 'repos', + openSheet: 'sessions', open: vi.fn(), close: mockClose, }) render( - + , ) - expect(screen.getByTestId('repo-quick-switch-sheet')).toBeInTheDocument() + expect(screen.getByTestId('session-picker-sheet')).toBeInTheDocument() }) }) diff --git a/frontend/src/components/navigation/MobileSheetHost.tsx b/frontend/src/components/navigation/MobileSheetHost.tsx index 4134cd7f0..37499fa88 100644 --- a/frontend/src/components/navigation/MobileSheetHost.tsx +++ b/frontend/src/components/navigation/MobileSheetHost.tsx @@ -1,7 +1,7 @@ import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { useMobile } from '@/hooks/useMobile' import { FileBrowserSheet } from '@/components/file-browser/FileBrowserSheet' -import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' +import { SessionPickerSheet } from '@/components/session/SessionPickerSheet' import { NotificationsSheet } from '@/components/navigation/NotificationsSheet' import { MoreDrawer } from '@/components/navigation/MoreDrawer' import { isLocalOpenCodeServer, useRouteOpenCodeServerId } from '@/contexts/OpenCodeServerContext' @@ -15,7 +15,7 @@ export function MobileSheetHost() { return ( <> - + {openSheet === 'files' && !isRemoteRoute && ( { , ) - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.getByText('Git')).toBeInTheDocument() expect(screen.queryByText('Schedules')).not.toBeInTheDocument() const labels = screen.getAllByRole('button').map((button) => button.textContent) - expect(labels).toEqual(['Repos', 'Files', 'Git', 'Assistant', 'More']) + expect(labels).toEqual(['Sessions', 'Files', 'Git', 'Assistant', 'More']) }) it('opens the source control dialog when Git is clicked on repo detail path', async () => { @@ -109,7 +109,7 @@ describe('MobileTabBar', () => { , ) - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.getByText('Assistant')).toBeInTheDocument() expect(screen.getByText('Schedules')).toBeInTheDocument() }) @@ -226,7 +226,7 @@ describe('MobileTabBar', () => { , ) const labels = screen.getAllByRole('button').map((button) => button.textContent) - expect(labels).toEqual(['Repos', 'Git', 'Assistant', 'More']) + expect(labels).toEqual(['Sessions', 'Git', 'Assistant', 'More']) expect(screen.queryByText('Files')).not.toBeInTheDocument() expect(screen.queryByText('Schedules')).not.toBeInTheDocument() }) @@ -255,7 +255,7 @@ describe('MobileTabBar', () => { expect(screen.getByTestId('location')).toHaveTextContent('dialog=sourceControl') }) - it('opens the repos sheet from the Repos tab on a linked server project page', async () => { + it('opens the sessions sheet from the Sessions tab on a linked server project page', async () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() const user = userEvent.setup() @@ -273,8 +273,8 @@ describe('MobileTabBar', () => { , ) - await user.click(screen.getByRole('button', { name: 'Repos' })) - expect(screen.getByTestId('location')).toHaveTextContent('mobileTab=repos') + await user.click(screen.getByRole('button', { name: 'Sessions' })) + expect(screen.getByTestId('location')).toHaveTextContent('mobileTab=sessions') }) it('does not render tab bar on a linked server session path', () => { @@ -316,7 +316,7 @@ describe('MobileTabBar', () => {
, ) - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.getByText('Files')).toBeInTheDocument() expect(screen.getByText('Assistant')).toBeInTheDocument() expect(screen.getByText('Schedules')).toBeInTheDocument() @@ -336,7 +336,7 @@ describe('MobileTabBar', () => { , ) const labels = screen.getAllByRole('button').map((button) => button.textContent) - expect(labels).toEqual(['Repos', 'Assistant', 'Schedules', 'More']) + expect(labels).toEqual(['Sessions', 'Assistant', 'Schedules', 'More']) expect(screen.queryByText('Files')).not.toBeInTheDocument() }) @@ -350,11 +350,11 @@ describe('MobileTabBar', () => { , ) - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.getByText('Schedules')).toBeInTheDocument() }) - it('Repos tab is active when pathname is / and no sheet is open', () => { + it('Sessions tab is active when pathname is / and no sheet is open', () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() render( @@ -364,24 +364,24 @@ describe('MobileTabBar', () => { , ) - const reposButton = screen.getByText('Repos').closest('button') - expect(reposButton).toHaveClass('text-primary') - expect(reposButton).toHaveClass('border-primary') + const sessionsButton = screen.getByText('Sessions').closest('button') + expect(sessionsButton).toHaveClass('text-primary') + expect(sessionsButton).toHaveClass('border-primary') }) - it('Repos tab is active when openSheet is repos regardless of pathname', () => { + it('Sessions tab is active when openSheet is sessions regardless of pathname', () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() render( - + , ) - const reposButton = screen.getByText('Repos').closest('button') - expect(reposButton).toHaveClass('text-primary') - expect(reposButton).toHaveClass('border-primary') + const sessionsButton = screen.getByText('Sessions').closest('button') + expect(sessionsButton).toHaveClass('text-primary') + expect(sessionsButton).toHaveClass('border-primary') }) it('maintains stable callbacks when search changes but mobileTab does not', () => { @@ -396,8 +396,8 @@ describe('MobileTabBar', () => { , ) - const firstReposButton = screen.getByText('Repos').closest('button') - expect(firstReposButton).toBeInTheDocument() + const firstSessionsButton = screen.getByText('Sessions').closest('button') + expect(firstSessionsButton).toBeInTheDocument() rerender( @@ -407,8 +407,8 @@ describe('MobileTabBar', () => { , ) - const secondReposButton = screen.getByText('Repos').closest('button') - expect(secondReposButton).toBeInTheDocument() + const secondSessionsButton = screen.getByText('Sessions').closest('button') + expect(secondSessionsButton).toBeInTheDocument() }) it('does not render tab bar on SessionDetail path /repos/:id/sessions/:sid', () => { diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx index 7fba4cf33..ccc97af36 100644 --- a/frontend/src/components/navigation/MobileTabBar.tsx +++ b/frontend/src/components/navigation/MobileTabBar.tsx @@ -1,6 +1,6 @@ import { memo, useMemo } from 'react' import { useLocation, useNavigate } from 'react-router-dom' -import { FolderGit2, FolderOpen, CalendarClock, Menu, Info, History, Bot, GitCommitHorizontal } from 'lucide-react' +import { List, FolderOpen, CalendarClock, Menu, Info, History, Bot, GitCommitHorizontal } from 'lucide-react' import { cn } from '@/lib/utils' import { useMobile } from '@/hooks/useMobile' import { useMobileTabBar } from '@/hooks/useMobileTabBar' @@ -112,11 +112,11 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR return [ { - key: 'repos', - label: 'Repos', - icon: FolderGit2, - onClick: () => open('repos'), - active: openSheet === 'repos' || (pathname === '/' && !openSheet), + key: 'sessions', + label: 'Sessions', + icon: List, + onClick: () => open('sessions'), + active: openSheet === 'sessions' || (pathname === '/' && !openSheet), }, ...(showsFiles ? [{ diff --git a/frontend/src/components/navigation/MoreDrawer.test.tsx b/frontend/src/components/navigation/MoreDrawer.test.tsx index 60f1bc641..04903a1b2 100644 --- a/frontend/src/components/navigation/MoreDrawer.test.tsx +++ b/frontend/src/components/navigation/MoreDrawer.test.tsx @@ -323,28 +323,28 @@ describe('MoreDrawer', () => { expect(navigateMock).toHaveBeenCalledWith('/repos/1/schedules?returnTo=%2Frepos%2F1%2Fsessions%2Fsession-1%3Fassistant%3D1') }) - it('swaps the menu for the Repos sheet from a session', () => { + it('swaps the menu for the Sessions sheet from a session', () => { const navigateMock = vi.fn() vi.mocked(useNavigate).mockReturnValue(navigateMock) mockAuth() mockServerHealth() renderMoreDrawer({ initialEntry: '/repos/1/sessions/session-1?mobileTab=more', routePath: '/repos/:id/sessions/:sessionId' }) - fireEvent.click(screen.getByText('Repos')) + fireEvent.click(screen.getByText('Sessions')) expect(navigateMock).toHaveBeenCalledWith( - { search: 'mobileTab=repos' }, + { search: 'mobileTab=sessions' }, { state: { urlParamHistory: { previousSearch: '?mobileTab=more' } } }, ) }) - it('keeps Home and hides the Repos row outside a session', () => { + it('keeps Home and hides the Sessions row outside a session', () => { mockAuth() mockServerHealth() renderMoreDrawer({ initialEntry: '/repos/1', routePath: '/repos/:id' }) expect(screen.getByText('Home')).toBeInTheDocument() - expect(screen.queryByText('Repos')).not.toBeInTheDocument() + expect(screen.queryByText('Sessions')).not.toBeInTheDocument() }) it('offers the Assistant from a repo session', () => { @@ -420,7 +420,7 @@ describe('MoreDrawer', () => { expect(screen.getByText('Logout')).toBeInTheDocument() expect(screen.getByText('Terminal')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Activity' })).toBeInTheDocument() - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.queryByText('Files')).not.toBeInTheDocument() expect(screen.getByText('Source Control')).toBeInTheDocument() expect(screen.queryByText('Preview')).not.toBeInTheDocument() diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index eb865eba9..e8f4da899 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -1,6 +1,6 @@ import { useNavigate, useLocation, useParams } from 'react-router-dom' import { useRef, useEffect, type ReactNode } from 'react' -import { ChevronRight, FolderGit2, X, GitBranch, type LucideIcon } from 'lucide-react' +import { ChevronRight, List, X, GitBranch, type LucideIcon } from 'lucide-react' import { useAuth } from '@/hooks/useAuth' import { useServerHealth } from '@/hooks/useServerHealth' import { useUrlParams } from '@/hooks/useUrlParams' @@ -211,7 +211,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { )} {(isSessionDetail || isRemoteSessionDetail) && ( - openMobileSheet('repos')} trailing /> + openMobileSheet('sessions')} trailing /> )} {assistantCta?.to && ( navigate(assistantCta.to!)} /> diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx deleted file mode 100644 index 6d2260c13..000000000 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ /dev/null @@ -1,1128 +0,0 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { beforeEach, describe, it, expect, vi } from 'vitest' -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { RepoQuickSwitchSheet } from './RepoQuickSwitchSheet' -import { getRepoSiblings, listRepos } from '@/api/repos' -import { stubMatchMedia } from '@/test/test-utils' -import { listProjects, type OpenCodeProject } from '@/api/opencode' -import { useMobileTabBar } from '@/hooks/useMobileTabBar' -import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' -import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' -import { useSidebarServerStore } from '@/stores/sidebarServerStore' -import type { ActiveWorkItem } from '@/lib/activeWork' - -type SessionFixture = { - id: string - projectID: string - title: string - time: { created: number; updated: number } - location: { directory: string } - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } -} - -const { sessionsData, sessionsHookCalls, permissionSessions, createSessionDirectories } = vi.hoisted(() => ({ - sessionsData: [] as Array<{ - id: string - projectID: string - title: string - time: { created: number; updated: number } - location: { directory: string } - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - }>, - sessionsHookCalls: [] as string[][], - permissionSessions: { current: new Set() }, - createSessionDirectories: [] as string[], -})) - -const activityMocks = vi.hoisted(() => ({ - useActiveWork: vi.fn(), - useActivityWindowMinutes: vi.fn(), -})) - -const serverMocks = vi.hoisted(() => ({ - useOpenCodeServers: vi.fn(), - useOpenCodeServerStatuses: vi.fn(), -})) - -vi.mock('@/hooks/useOpenCodeServers', () => ({ - useOpenCodeServers: serverMocks.useOpenCodeServers, - useOpenCodeServerStatuses: serverMocks.useOpenCodeServerStatuses, -})) - -vi.mock('@/hooks/useActiveWork', () => ({ - useActiveWork: activityMocks.useActiveWork, - useActivityWindowMinutes: activityMocks.useActivityWindowMinutes, - useRepoActivityState: () => null, - useServerActivityState: () => null, - useLocalActivityState: () => null, - useProjectActivityState: () => null, -})) - -vi.mock('@/api/repos') - -vi.mock('@/api/opencode', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, listProjects: vi.fn() } -}) - -vi.mock('@/contexts/EventContext', () => ({ - usePermissions: () => ({ - hasForSession: (id: string) => permissionSessions.current.has(id), - }), - useForms: () => ({ hasForSession: () => false }), -})) - -vi.mock('@/hooks/useOpenCode', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useSessionsAcrossDirectories: (directories: string[]) => { - sessionsHookCalls.push(directories) - return { data: sessionsData, isLoading: false, isError: false } - }, - useCreateSession: (directory: string, onSuccess?: (session: { id: string }) => void) => ({ - mutate: () => { - createSessionDirectories.push(directory) - onSuccess?.({ id: 'new-session' }) - }, - }), - } -}) - -vi.mock('@/hooks/useSessionPins', () => ({ - useSessionPins: () => ({ data: [] }), -})) - -function createSession(id: string, updated: number, directory: string): SessionFixture { - return { - id, - projectID: 'proj-1', - title: `Session ${id}`, - time: { created: updated - 10000, updated }, - location: { directory }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - } -} - -function createRepo(id: number, name: string, fullPath: string) { - return { - id, - name, - repoUrl: `https://github.com/test/${name}.git`, - localPath: fullPath, - fullPath, - sourcePath: null, - currentBranch: 'main', - defaultBranch: 'main', - cloneStatus: 'ready' as const, - clonedAt: 0, - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } -} - -function createActiveWorkItem(): ActiveWorkItem { - const session = createSession('s1', Date.now(), '/path/to/repo1') - return { - key: '1:s1', - state: 'recent', - session, - repo: createRepo(1, 'repo1', '/path/to/repo1'), - updatedAt: session.time.updated, - path: '/repos/1/sessions/s1', - } -} - -function setActivityGroups(groups: { - needsInput?: ActiveWorkItem[] - running?: ActiveWorkItem[] - recent?: ActiveWorkItem[] -}) { - activityMocks.useActiveWork.mockReturnValue({ - groups: { - needsInput: groups.needsInput ?? [], - running: groups.running ?? [], - recent: groups.recent ?? [], - }, - isLoading: false, - isError: false, - }) -} - -function createLinkedServer(id: string, name: string, enabled = true) { - return { - id, - name, - baseUrl: `https://${id}.example.com`, - username: 'opencode', - enabled, - hasPassword: true, - createdAt: 1, - updatedAt: 2, - } -} - -function createProject(id: string, name: string, canonical: string): OpenCodeProject { - return { - id, - canonical, - name, - time: { created: 1, updated: 2 }, - sandboxes: [], - } as OpenCodeProject -} - -function setLinkedServers(servers: Array>) { - serverMocks.useOpenCodeServers.mockReturnValue({ servers, isLoading: false }) - serverMocks.useOpenCodeServerStatuses.mockReturnValue({ - statuses: [], - statusById: Object.fromEntries( - servers.map((server) => [ - server.id, - { serverId: server.id, connected: true, version: null, error: null }, - ]), - ), - isLoading: false, - }) -} - -function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ) -} - -function LocationSpy() { - const { pathname, search } = useLocation() - return
{`${pathname}${search}`}
-} - -function MobileSheetHarness() { - const { close } = useMobileTabBar() - return ( - <> - - - - ) -} - -describe('RepoQuickSwitchSheet', () => { - beforeEach(() => { - vi.clearAllMocks() - localStorage.clear() - useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) - vi.mocked(getRepoSiblings).mockResolvedValue([]) - vi.mocked(listProjects).mockResolvedValue([]) - sessionsData.splice(0, sessionsData.length) - sessionsHookCalls.splice(0, sessionsHookCalls.length) - permissionSessions.current.clear() - activityMocks.useActivityWindowMinutes.mockReturnValue(30) - setActivityGroups({}) - setLinkedServers([]) - stubMatchMedia(true) - }) - - it('renders with loading state', async () => { - vi.mocked(listRepos).mockImplementation(() => new Promise(() => {})) - const handleClose = vi.fn() - render( - , - { wrapper: createWrapper() }, - ) - await waitFor(() => { - expect(screen.getByPlaceholderText('Search repos...')).toBeInTheDocument() - }) - }) - - it('renders empty state when no repos', async () => { - vi.mocked(listRepos).mockResolvedValue([]) - const handleClose = vi.fn() - render( - , - { wrapper: createWrapper() }, - ) - await waitFor(() => { - expect(screen.getByText('No repos found')).toBeInTheDocument() - }) - }) - - it('renders repo list and filters on search', 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: 'develop', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - const handleClose = vi.fn() - render( - , - { wrapper: createWrapper() }, - ) - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - expect(screen.getByText('repo2')).toBeInTheDocument() - }) - const input = screen.getByPlaceholderText('Search repos...') - fireEvent.change(input, { target: { value: 'repo1' } }) - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - expect(screen.queryByText('repo2')).not.toBeInTheDocument() - }) - }) - - it('distinguishes worktrees of the same repo by branch', 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/repo1.git', - localPath: '/path/to/repo1/.worktrees/feature-a', - sourcePath: null, - currentBranch: 'feature-a', - isWorktree: true, - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { - id: 3, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1/.worktrees/feature-b', - sourcePath: null, - currentBranch: 'feature-b', - isWorktree: true, - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - const handleClose = vi.fn() - render( - , - { wrapper: createWrapper() }, - ) - await waitFor(() => { - expect(screen.getAllByText('repo1')).toHaveLength(3) - expect(screen.getByText('feature-a')).toBeInTheDocument() - expect(screen.getByText('feature-b')).toBeInTheDocument() - }) - }) - - it('shows assistant as the first repo option', async () => { - vi.mocked(listRepos).mockResolvedValue([ - { - id: ASSISTANT_REPO_ID, - repoUrl: 'Assistant', - localPath: '/assistant', - sourcePath: null, - currentBranch: null, - isLocal: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { - 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( - , - { wrapper: createWrapper() }, - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - const repoNames = screen.getAllByText(/^(Assistant|repo1)$/).map((node) => node.textContent) - expect(repoNames).toEqual(['Assistant', 'repo1']) - }) - - it('navigates on repo click and closes sheet', 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( - , - { wrapper: createWrapper() }, - ) - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - fireEvent.click(screen.getByText('repo1')) - expect(handleClose).toHaveBeenCalled() - }) - - it('navigates to assistant when mobileTabAction is 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(), - }, - ]) - const handleClose = vi.fn() - render( - - - - - - - - } - /> - - - , - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByText('repo1')) - - expect(screen.getByTestId('location')).toHaveTextContent('/assistant') - expect(handleClose).not.toHaveBeenCalled() - }) - - 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, - 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('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([ - { - 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(), - }, - ]) - - render( - - - - } /> - - - , - ) - - await waitFor(() => { - expect(screen.getByText('repo2')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByText('repo2')) - - expect(screen.getByTestId('location')).toHaveTextContent('/repos/2') - }) - - it('does not render sessions for collapsed repos and does not fetch them', async () => { - const now = Date.now() - vi.mocked(listRepos).mockResolvedValue([ - createRepo(1, 'repo1', '/path/to/repo1'), - createRepo(2, 'repo2', '/path/to/repo2'), - ]) - sessionsData.push(createSession('s1', now, '/path/to/repo1')) - - render( - , - { wrapper: createWrapper() }, - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - expect(screen.getByText('repo2')).toBeInTheDocument() - }) - - expect(screen.queryByText('Session s1')).not.toBeInTheDocument() - expect(sessionsHookCalls.some((directories) => directories.includes('/path/to/repo1'))).toBe(false) - }) - - it('expands a repo to show its sessions', async () => { - const now = Date.now() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - sessionsData.push(createSession('s1', now, '/path/to/repo1')) - - render( - , - { wrapper: createWrapper() }, - ) - - const toggle = await screen.findByRole('button', { name: 'Show sessions in repo1' }) - expect(toggle).toHaveAttribute('aria-expanded', 'false') - - fireEvent.click(toggle) - - expect(await screen.findByText('Session s1')).toBeInTheDocument() - expect(toggle).toHaveAttribute('aria-expanded', 'true') - expect(sessionsHookCalls.some((directories) => directories.includes('/path/to/repo1'))).toBe(true) - }) - - it('navigates to a session when it is tapped', async () => { - const now = Date.now() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - sessionsData.push(createSession('s1', now, '/path/to/repo1')) - - render( - - - - } /> - - - , - ) - - fireEvent.click(await screen.findByRole('button', { name: 'Show sessions in repo1' })) - fireEvent.click(await screen.findByText('Session s1')) - - expect(screen.getByTestId('location')).toHaveTextContent('/repos/1/sessions/s1') - }) - - it('expands the active repo by default and shows its sessions', async () => { - const now = Date.now() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - sessionsData.push(createSession('s1', now, '/path/to/repo1')) - - render( - - - - } /> - - - , - ) - - expect(await screen.findByText('Session s1')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Show sessions in repo1' })).toHaveAttribute( - 'aria-expanded', - 'true', - ) - }) - - it('clears the search with the clear button', async () => { - vi.mocked(listRepos).mockResolvedValue([ - createRepo(1, 'repo1', '/path/to/repo1'), - createRepo(2, 'repo2', '/path/to/repo2'), - ]) - - render(, { wrapper: createWrapper() }) - - const input = await screen.findByPlaceholderText('Search repos...') - await waitFor(() => expect(screen.getByText('repo2')).toBeInTheDocument()) - expect(screen.queryByLabelText('Clear search')).toBeNull() - - fireEvent.change(input, { target: { value: 'repo1' } }) - expect(screen.queryByText('repo2')).toBeNull() - - fireEvent.click(screen.getByLabelText('Clear search')) - - expect(input).toHaveValue('') - expect(screen.getByText('repo2')).toBeInTheDocument() - expect(screen.queryByLabelText('Clear search')).toBeNull() - }) - - it('keeps only one repo expanded at a time', async () => { - vi.mocked(listRepos).mockResolvedValue([ - createRepo(1, 'repo1', '/path/to/repo1'), - createRepo(2, 'repo2', '/path/to/repo2'), - ]) - - render( - - - - } /> - - - , - ) - - const repo1Toggle = await screen.findByRole('button', { name: 'Show sessions in repo1' }) - const repo2Toggle = screen.getByRole('button', { name: 'Show sessions in repo2' }) - - expect(repo1Toggle).toHaveAttribute('aria-expanded', 'true') - expect(repo2Toggle).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByText('HERE')).toBeNull() - expect(screen.getByText('repo1').closest('button')).toHaveAttribute('aria-current', 'page') - - fireEvent.click(repo2Toggle) - - expect(repo1Toggle).toHaveAttribute('aria-expanded', 'false') - expect(repo2Toggle).toHaveAttribute('aria-expanded', 'true') - - fireEvent.click(repo2Toggle) - - expect(repo1Toggle).toHaveAttribute('aria-expanded', 'false') - expect(repo2Toggle).toHaveAttribute('aria-expanded', 'false') - }) - - it('makes the expanded repo header sticky and leaves collapsed headers static', async () => { - vi.mocked(listRepos).mockResolvedValue([ - createRepo(1, 'repo1', '/path/to/repo1'), - createRepo(2, 'repo2', '/path/to/repo2'), - ]) - - render( - - - - } /> - - - , - ) - - const expandedToggle = await screen.findByRole('button', { name: 'Show sessions in repo1' }) - const collapsedToggle = screen.getByRole('button', { name: 'Show sessions in repo2' }) - - expect(expandedToggle.parentElement).toHaveClass('sticky') - expect(collapsedToggle.parentElement).not.toHaveClass('sticky') - }) - - it('renders a pending permission indicator on a session row', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - sessionsData.push(createSession('s1', Date.now(), '/path/to/repo1')) - permissionSessions.current.add('s1') - - render( - , - { wrapper: createWrapper() }, - ) - - fireEvent.click(await screen.findByRole('button', { name: 'Show sessions in repo1' })) - - expect(await screen.findByLabelText('Pending permission')).toBeInTheDocument() - }) - - it('renders the relative time on a session row', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - sessionsData.push(createSession('s1', Date.now() - 5 * 60_000, '/path/to/repo1')) - - render( - , - { wrapper: createWrapper() }, - ) - - fireEvent.click(await screen.findByRole('button', { name: 'Show sessions in repo1' })) - - expect(await screen.findByText('5m ago')).toBeInTheDocument() - }) - - it('renders a closing spacer at the end of the scroll area', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - - render( - , - { wrapper: createWrapper() }, - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - expect(document.body.querySelector('[aria-hidden="true"].h-16')).not.toBeNull() - }) - - it('renders the Activity section above the repo list when there are items', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - setActivityGroups({ recent: [createActiveWorkItem()] }) - - render(, { wrapper: createWrapper() }) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - const heading = screen.getByRole('heading', { name: 'Activity' }) - const repo = screen.getByText('repo1') - expect(screen.getByText('Session s1')).toBeInTheDocument() - expect(heading.compareDocumentPosition(repo) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - }) - - it('hides the Activity section when there are no items', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - setActivityGroups({}) - - render(, { wrapper: createWrapper() }) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - expect(screen.queryByRole('heading', { name: 'Activity' })).toBeNull() - expect(screen.queryByText('Session s1')).toBeNull() - }) - - it('hides the Activity section while a search query is entered', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - setActivityGroups({ recent: [createActiveWorkItem()] }) - - render(, { wrapper: createWrapper() }) - - expect(await screen.findByRole('heading', { name: 'Activity' })).toBeInTheDocument() - - fireEvent.change(screen.getByPlaceholderText('Search repos...'), { target: { value: 'repo' } }) - - expect(screen.queryByRole('heading', { name: 'Activity' })).toBeNull() - expect(screen.queryByText('Session s1')).toBeNull() - }) - - it('navigates to the item path and closes the sheet when an activity row is clicked', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - setActivityGroups({ recent: [createActiveWorkItem()] }) - const handleClose = vi.fn() - - render( - - - - - - - - } - /> - - - , - ) - - fireEvent.click(await screen.findByRole('button', { name: /Session s1/ })) - - expect(screen.getByTestId('location')).toHaveTextContent('/repos/1/sessions/s1') - expect(handleClose).toHaveBeenCalled() - }) - - it('starts a new session in a repo, opens it and closes the sheet', async () => { - createSessionDirectories.splice(0, createSessionDirectories.length) - vi.mocked(listRepos).mockResolvedValue([ - createRepo(1, 'repo1', '/path/to/repo1'), - createRepo(2, 'repo2', '/path/to/repo2'), - ]) - const handleClose = vi.fn() - let pathname = '' - function LocationProbe() { - pathname = useLocation().pathname - return null - } - - render( - - - - - - , - ) - - fireEvent.click(await screen.findByRole('button', { name: 'New session in repo2' })) - - expect(createSessionDirectories).toEqual(['/path/to/repo2']) - expect(pathname).toBe('/repos/2/sessions/new-session') - expect(handleClose).toHaveBeenCalled() - }) - - it('renders the server switcher when enabled linked servers exist', async () => { - const user = userEvent.setup() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - setLinkedServers([ - createLinkedServer('srv_kitchen', 'Kitchen'), - createLinkedServer('srv_office', 'Office', false), - ]) - - render(, { wrapper: createWrapper() }) - - const trigger = await screen.findByRole('button', { name: 'Switch server, current: OC Manager' }) - expect(trigger).toBeInTheDocument() - expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() - - await user.click(trigger) - - expect(await screen.findByRole('menuitem', { name: /Kitchen/ })).toBeInTheDocument() - expect(screen.queryByRole('menuitem', { name: /Office/ })).not.toBeInTheDocument() - expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() - }) - - it('hides the server switcher when no linked servers exist', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - - render(, { wrapper: createWrapper() }) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - expect(screen.queryByRole('button', { name: /Switch server/ })).toBeNull() - }) - - it('switches to a linked server without navigating or closing the sheet', async () => { - const user = userEvent.setup() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) - setLinkedServers([createLinkedServer('srv_kitchen', 'Kitchen')]) - const handleClose = vi.fn() - - render( - - - - - - - - } - /> - - - , - ) - - await user.click(await screen.findByRole('button', { name: 'Switch server, current: OC Manager' })) - await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) - - expect(await screen.findByText('Alpha')).toBeInTheDocument() - expect(screen.queryByText('repo1')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Add repository/ })).not.toBeInTheDocument() - expect(screen.getByTestId('location')).toHaveTextContent('/repos/1') - expect(handleClose).not.toHaveBeenCalled() - }) - - it('opens a remote project from the sheet and closes it', async () => { - const user = userEvent.setup() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) - setLinkedServers([createLinkedServer('srv_kitchen', 'Kitchen')]) - const handleClose = vi.fn() - - render( - - - - - - - - } - /> - - - , - ) - - await user.click(await screen.findByRole('button', { name: 'Switch server, current: OC Manager' })) - await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) - await user.click(await screen.findByRole('button', { name: /^Alpha/ })) - - expect(screen.getByTestId('location')).toHaveTextContent( - '/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha', - ) - expect(handleClose).toHaveBeenCalled() - }) - - it('leaves the search input unfocused on touch screens so the keyboard stays closed', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - stubMatchMedia(false) - - render(, { wrapper: createWrapper() }) - - const input = await screen.findByPlaceholderText('Search repos...') - expect(input).not.toHaveFocus() - }) - - it('focuses the search input when a fine pointer is available', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - stubMatchMedia(true) - - render(, { wrapper: createWrapper() }) - - const input = await screen.findByPlaceholderText('Search repos...') - await waitFor(() => expect(input).toHaveFocus()) - }) - - it('uses project wording for the search placeholder when a linked server is selected', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) - useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) - - render(, { wrapper: createWrapper() }) - - expect(await screen.findByPlaceholderText('Search projects...')).toBeInTheDocument() - }) -}) diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx deleted file mode 100644 index 94d9c827d..000000000 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useState, useMemo } from 'react' -import { useNavigate, useLocation } from 'react-router-dom' -import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' -import { useRefreshOnOpen } from '@/hooks/useRefreshOnOpen' -import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' -import { Input } from '@/components/ui/input' -import { BottomSheet, BottomSheetHeader, BottomSheetContent } from '@/components/ui/bottom-sheet' -import { Button } from '@/components/ui/button' -import { CompactActivityList } from '@/components/activity/CompactActivityList' -import { getRepoDisplayName } from '@/lib/utils' -import { AddRepoDialog } from '@/components/repo/AddRepoDialog' -import { FolderGit2, Plus } from 'lucide-react' -import { useUrlParams } from '@/hooks/useUrlParams' -import { getAssistantPath, getRepoPath, getSessionPath } from '@/lib/navigation' -import { NewSessionButton, RepoSessionNavList, SearchClearButton } from '@/components/navigation/RepoSessionNav' -import { LinkedServersNav } from '@/components/navigation/LinkedServersNav' -import { RemoteProjectNavList } from '@/components/navigation/RemoteProjectNav' -import { getActiveRepoId } from '@/components/navigation/sidebar-session-tree' -import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' -import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' - -interface RepoQuickSwitchSheetProps { - isOpen: boolean - onClose: () => void -} - -export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetProps) { - const navigate = useNavigate() - const location = useLocation() - const { searchParams } = useUrlParams() - const [searchQuery, setSearchQuery] = useState('') - const [addRepoOpen, setAddRepoOpen] = useState(false) - const [selectedServerId] = useSidebarServerSelection() - const isRemote = selectedServerId !== LOCAL_OPENCODE_SERVER_ID - const activeRepoId = getActiveRepoId(location.pathname) - const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) - - const { repos, isLoading, refetch } = useNavigableRepos(isOpen) - - useRefreshOnOpen(isOpen, refetch) - - const filteredRepos = useMemo(() => { - if (!searchQuery.trim()) return repos - const query = searchQuery.toLowerCase() - return repos.filter((repo) => - getRepoDisplayName(repo).toLowerCase().includes(query) - ) - }, [repos, searchQuery]) - - const isUrlControlledSheet = searchParams.get('mobileTab') === 'repos' - - const navigateAndClose = (path: string, options?: { replace?: boolean }) => { - navigate(path, options) - if (!isUrlControlledSheet) { - onClose() - } - } - - const handleClick = (id: number) => { - if (searchParams.get('mobileTabAction') === 'assistant') { - navigateAndClose(getAssistantPath(), { replace: true }) - return - } - - if (id === activeRepoId) { - onClose() - return - } - - navigateAndClose(getRepoPath(id), { replace: true }) - } - - return ( - <> - - -
-
- setSearchQuery(e.target.value)} - autoFocus={hasFinePointer} - className="pr-9" - autoComplete="off" - name="repo-quick-switch" - /> - {searchQuery.length > 0 && setSearchQuery('')} />} -
- {!isRemote && ( - - )} -
-
- - {isOpen && ( -
- -
- )} - {isOpen && !searchQuery.trim() && ( - navigateAndClose(path)} /> - )} - {isRemote ? ( - navigateAndClose(path, { replace: true })} - onSelectSession={(path) => navigateAndClose(path)} - /> - ) : isLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- ))} -
- ) : filteredRepos.length === 0 ? ( -
- -

No repos found

-
- ) : ( - navigateAndClose(path)} - renderActions={(repo) => ( - getSessionPath(repo.id, sessionId)} - onOpenSession={(path) => navigateAndClose(path)} - /> - )} - /> - )} - ) }) diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index 0e08e4037..35c323663 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ fetchNextPage: vi.fn(), deleteSessionMock: vi.fn(), deletedSessionServerId: null as string | null, + createSessionDirectories: [] as string[], togglePinMock: vi.fn(), getOpenCodeLocation: vi.fn(), sessionPins: [] as Array<{ sessionId: string; directory: string; pinnedAt: number }>, @@ -44,6 +45,12 @@ vi.mock('@/hooks/useSettings', () => ({ useSettings: () => ({ preferences: mocks.preferences }), })) +vi.mock('@/api/repos', () => ({ + listRepos: vi.fn().mockResolvedValue([]), + createRepo: vi.fn(), + discoverRepos: vi.fn(), +})) + vi.mock('@/api/opencode', async (importOriginal) => { const actual = await importOriginal() return { ...actual, getOpenCodeLocation: mocks.getOpenCodeLocation } @@ -87,6 +94,13 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { isPending: false, } }, + useCreateSession: (directory: string, onSuccess?: (session: { id: string }) => void) => ({ + mutate: () => { + mocks.createSessionDirectories.push(directory) + onSuccess?.({ id: 'ses_new' }) + }, + isPending: false, + }), } }) @@ -202,7 +216,7 @@ const openProjectsTab = () => { } const openActiveTab = () => { - fireEvent.mouseDown(screen.getByRole('tab', { name: 'Active' })) + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Activity' })) } describe('SessionPickerDialog', () => { @@ -212,6 +226,7 @@ describe('SessionPickerDialog', () => { localStorage.clear() mocks.deleteSessionMock.mockResolvedValue(undefined) mocks.deletedSessionServerId = null + mocks.createSessionDirectories.length = 0 mocks.locationData = { directory: '/w/a', project: { id: 'global', directory: '/w/a', canonical: '/w/a' }, @@ -305,6 +320,7 @@ describe('SessionPickerDialog', () => { expect(mocks.lastDirectories).toEqual(['/w/a']) expect(mocks.lastAllDirectories).toBe(false) + expect(screen.getByRole('tab', { name: 'This project' })).toBeInTheDocument() expect(screen.getByText('Alpha session')).toBeInTheDocument() expect(screen.queryByText('Beta session')).not.toBeInTheDocument() @@ -313,6 +329,7 @@ describe('SessionPickerDialog', () => { expect(mocks.lastDirectories).toEqual(['/w/a', '/w/b']) expect(mocks.lastAllDirectories).toBe(true) expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('true') + expect(screen.getByRole('tab', { name: 'All sessions' })).toBeInTheDocument() expect(screen.getByText('Beta session')).toBeInTheDocument() expect(screen.getByText('alpha')).toBeInTheDocument() expect(screen.getByText('beta')).toBeInTheDocument() @@ -321,6 +338,97 @@ describe('SessionPickerDialog', () => { expect(mocks.lastDirectories).toEqual(['/w/a']) expect(mocks.lastAllDirectories).toBe(false) expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('false') + expect(screen.getByRole('tab', { name: 'This project' })).toBeInTheDocument() + }) + + it('toggles the scope when the selected sessions tab is clicked again', async () => { + const user = userEvent.setup() + renderPicker() + + await user.click(screen.getByRole('tab', { name: 'This project' })) + + expect(screen.getByRole('tab', { name: 'All sessions', selected: true })).toBeInTheDocument() + expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('true') + + await user.click(screen.getByRole('tab', { name: 'All sessions' })) + + expect(screen.getByRole('tab', { name: 'This project', selected: true })).toBeInTheDocument() + expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('false') + }) + + it('selects the sessions tab without toggling the scope when it was not selected', async () => { + const user = userEvent.setup() + renderPicker() + + await user.click(screen.getByRole('tab', { name: 'Activity' })) + await user.click(screen.getByRole('tab', { name: 'This project' })) + + expect(screen.getByRole('tab', { name: 'This project', selected: true })).toBeInTheDocument() + expect(localStorage.getItem('oc:session-picker:all-projects')).toBeNull() + }) + + it('toggles the scope with Enter on the focused selected sessions tab', async () => { + const user = userEvent.setup() + renderPicker() + + act(() => { + screen.getByRole('tab', { name: 'This project' }).focus() + }) + await user.keyboard('{Enter}') + + expect(screen.getByRole('tab', { name: 'All sessions', selected: true })).toBeInTheDocument() + }) + + it('labels the sessions tab Sessions and lists every project without a current repo', () => { + mocks.sessionsData = [ + session('ses_a', 'Alpha session', '/w/a', 2000), + session('ses_b', 'Beta session', '/w/b', 1000), + ] + + renderPicker({ currentRepo: undefined }) + + expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + expect(mocks.lastDirectories).toEqual(['/w/a', '/w/b']) + expect(mocks.lastAllDirectories).toBe(true) + expect(screen.getByText('Beta session')).toBeInTheDocument() + expect(localStorage.getItem('oc:session-picker:all-projects')).toBeNull() + }) + + it('starts a new session in the current repo from the sessions tab header action', () => { + const onNavigate = vi.fn() + renderPicker({ onNavigate }) + + fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' })) + + expect(mocks.createSessionDirectories).toEqual(['/w/a']) + expect(onNavigate).toHaveBeenCalledWith('/repos/1/sessions/ses_new') + }) + + it('opens the add repository dialog from the projects tab header action', async () => { + renderPicker() + + openProjectsTab() + fireEvent.click(await screen.findByRole('button', { name: 'Add repository' })) + + expect(await screen.findByRole('dialog', { name: 'Add Repository' })).toBeInTheDocument() + }) + + it('starts a new session in a project row and marks a repo that is not ready', async () => { + const onNavigate = vi.fn() + mocks.repos = [ + repo(1, 'alpha', '/w/a'), + { ...repo(2, 'beta', '/w/b'), cloneStatus: 'cloning' }, + ] + renderPicker({ onNavigate }) + + openProjectsTab() + + fireEvent.click(await screen.findByRole('button', { name: 'New session in alpha' })) + + expect(mocks.createSessionDirectories).toEqual(['/w/a']) + expect(onNavigate).toHaveBeenCalledWith('/repos/1/sessions/ses_new') + expect(screen.queryByRole('button', { name: 'New session in beta' })).not.toBeInTheDocument() + expect(screen.getByText('Not ready')).toBeInTheDocument() }) it('lists project sessions across worktrees and labels them with the folder name', async () => { @@ -556,11 +664,11 @@ describe('SessionPickerDialog', () => { expect(screen.getByRole('option', { name: /Active session/ })).toHaveAttribute('aria-selected', 'true') }) - it('renders an Active/Sessions/Projects tab switcher and defaults to the sessions tab', () => { + it('renders an Activity/Sessions/Projects tab switcher and defaults to the sessions tab', () => { renderPicker() - expect(screen.getByRole('tab', { name: 'Sessions' })).toHaveAttribute('aria-selected', 'true') - expect(screen.getByRole('tab', { name: 'Active' })).toHaveAttribute('aria-selected', 'false') + expect(screen.getByRole('tab', { name: 'This project' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'false') expect(screen.getByRole('tab', { name: 'Projects' })).toHaveAttribute('aria-selected', 'false') expect(combobox()).toHaveAttribute('placeholder', 'Search sessions...') }) @@ -568,7 +676,7 @@ describe('SessionPickerDialog', () => { it('exposes a tabpanel labelled by the active tab and swaps panels on tab switch', async () => { renderPicker() - const sessionsPanel = screen.getByRole('tabpanel', { name: 'Sessions' }) + const sessionsPanel = screen.getByRole('tabpanel', { name: 'This project' }) expect(sessionsPanel).toHaveAttribute('data-state', 'active') expect(screen.queryByRole('tabpanel', { name: 'Projects' })).not.toBeInTheDocument() @@ -576,7 +684,7 @@ describe('SessionPickerDialog', () => { const projectsPanel = await screen.findByRole('tabpanel', { name: 'Projects' }) expect(projectsPanel).toHaveAttribute('data-state', 'active') - expect(screen.queryByRole('tabpanel', { name: 'Sessions' })).not.toBeInTheDocument() + expect(screen.queryByRole('tabpanel', { name: 'This project' })).not.toBeInTheDocument() }) it('focuses the projects search input after clicking the Projects tab and filters on typing', async () => { @@ -713,19 +821,19 @@ describe('SessionPickerDialog', () => { expect(screen.getByText('No projects found')).toBeInTheDocument() }) - it('cycles Active, Sessions and Projects with the default Ctrl+S binding', async () => { + it('cycles Activity, Sessions and Projects with the default Ctrl+S binding', async () => { renderPicker() - expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'This project', selected: true })).toBeInTheDocument() fireEvent.keyDown(combobox(), { key: 's', ctrlKey: true }) expect(await screen.findByRole('tab', { name: 'Projects', selected: true })).toBeInTheDocument() fireEvent.keyDown(combobox(), { key: 's', ctrlKey: true }) - expect(await screen.findByRole('tab', { name: 'Active', selected: true })).toBeInTheDocument() + expect(await screen.findByRole('tab', { name: 'Activity', selected: true })).toBeInTheDocument() fireEvent.keyDown(combobox(), { key: 's', ctrlKey: true }) - expect(await screen.findByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + expect(await screen.findByRole('tab', { name: 'This project', selected: true })).toBeInTheDocument() }) it('honors a configured switch binding and shows it in the footer', async () => { @@ -756,7 +864,7 @@ describe('SessionPickerDialog', () => { fireEvent.keyDown(combobox(), { key: 'o', ctrlKey: true }) - expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'This project', selected: true })).toBeInTheDocument() }) it('resets to the sessions tab when the dialog reopens', async () => { @@ -787,7 +895,7 @@ describe('SessionPickerDialog', () => { fireEvent.click(screen.getByText('open-picker')) await waitFor(() => - expect(screen.getByRole('tab', { name: 'Sessions' })).toHaveAttribute('aria-selected', 'true'), + expect(screen.getByRole('tab', { name: 'This project' })).toHaveAttribute('aria-selected', 'true'), ) }) @@ -918,7 +1026,7 @@ describe('SessionPickerDialog', () => { }) expect(screen.queryByRole('tab', { name: 'Projects' })).not.toBeInTheDocument() - expect(screen.getByRole('tab', { name: 'Active' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Activity' })).toBeInTheDocument() const row = await screen.findByRole('option', { name: /Remote session/ }) expect(row).toHaveTextContent('Kitchen') diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index 9f772d7d0..521ede245 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { format, startOfDay } from 'date-fns' -import { MoreHorizontal, Pin, PinOff } from 'lucide-react' +import { ChevronsUpDown, MoreHorizontal, Pin, PinOff, Plus } from 'lucide-react' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { getOpenCodeLocation } from '@/api/opencode' import { OpenCodeServerProvider, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' @@ -20,6 +20,9 @@ import { SessionStatusIndicator } from '@/components/ui/session-status-indicator import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { DeleteSessionDialog } from '@/components/session/DeleteSessionDialog' import { ProjectPickerContent } from '@/components/session/ProjectPickerContent' +import { AddRepoDialog } from '@/components/repo/AddRepoDialog' +import { NewSessionButton } from '@/components/navigation/RepoSessionNav' +import { RemoteProjectNavList } from '@/components/navigation/RemoteProjectNav' import { PickerSearchBar, optionId, @@ -27,7 +30,7 @@ import { usePickerKeyNav, } from '@/components/session/usePickerKeyboardNav' import { partitionSessions } from '@/components/session/session-partition' -import { buildRepoByDirectory } from '@/components/navigation/sidebar-session-tree' +import { buildRepoByDirectory, isRepoReady } from '@/components/navigation/sidebar-session-tree' import { useDeleteSession } from '@/hooks/useOpenCode' import { useActiveWork, useActivityWindowMinutes } from '@/hooks/useActiveWork' import { useSessionSearch } from '@/hooks/useSessionSearch' @@ -38,6 +41,7 @@ import { useSessionStatusForSession } from '@/stores/sessionStatusStore' import { useShortcutActions } from '@/contexts/KeyboardShortcutsContext' import { useSettings } from '@/hooks/useSettings' import { buildPinnedSessionKeys, getSessionKey } from '@/lib/sessionKey' +import { getSessionPath } from '@/lib/navigation' import { formatShortcutLabel, resolveShortcutBindings } from '@/lib/keyboardShortcuts' import { cn, formatShortRelativeTime, getRepoDisplayName } from '@/lib/utils' import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' @@ -52,7 +56,7 @@ const SESSION_PICKER_TABS: SessionPickerTab[] = ['active', 'sessions', 'projects const REMOTE_SESSION_PICKER_TABS: SessionPickerTab[] = ['active', 'sessions'] const SESSION_PICKER_TAB_LABELS: Record = { - active: 'Active', + active: 'Activity', sessions: 'Sessions', projects: 'Projects', } @@ -62,6 +66,11 @@ function nextSessionPickerTab(tab: SessionPickerTab, tabs: SessionPickerTab[]): return tabs[(index + 1) % tabs.length] } +function sessionsTabLabel(scopeEnabled: boolean, allProjects: boolean): string { + if (!scopeEnabled) return SESSION_PICKER_TAB_LABELS.sessions + return allProjects ? 'All sessions' : 'This project' +} + interface RemotePickerScope { serverId: string serverName?: string @@ -77,6 +86,8 @@ interface SessionPickerDialogProps { onSelectRemoteSession?: (session: Session, serverId: string) => void onDeleteActiveSession: () => void onSelectRepo: (repo: Repo) => void + /** Navigates to a path the panel produced, such as a new session created from the header action. */ + onNavigate: (path: string) => void /** Set on a linked-server route to list that server's sessions instead of Manager repos. */ remote?: RemotePickerScope } @@ -556,9 +567,12 @@ interface SessionPickerContentProps { onSelectSession: (session: Session, repo: Repo) => void onSelectRemoteSession?: (session: Session, serverId: string) => void onDeleteActiveSession: () => void + allProjects: boolean + scopeEnabled: boolean + onToggleAllProjects: () => void switchHint: string searchInputRef: React.RefObject - hasFinePointer: boolean + searchTrailing?: React.ReactNode } function SessionPickerContent({ @@ -568,11 +582,13 @@ function SessionPickerContent({ onSelectSession, onSelectRemoteSession, onDeleteActiveSession, + allProjects, + scopeEnabled, + onToggleAllProjects, switchHint, searchInputRef, - hasFinePointer, + searchTrailing, }: SessionPickerContentProps) { - const [allProjects, toggleAllProjects] = usePersistentBoolean(ALL_PROJECTS_STORAGE_KEY, false) const { repos } = useNavigableRepos() const listRef = useRef(null) const listId = useId() @@ -696,7 +712,7 @@ function SessionPickerContent({ moveCursor, onOpenRow: openRow, onDeleteRow: deleteRow, - onToggleAllProjects: toggleAllProjects, + onToggleAllProjects, }) const handleListScroll = useCallback( @@ -739,23 +755,7 @@ function SessionPickerContent({ onKeyDown={handleSearchKeyDown} placeholder="Search sessions..." name="session-picker-search" - trailing={ - - } + trailing={searchTrailing} />
- ↑↓ move · Enter open · Ctrl+D delete · Ctrl+A {allProjects ? 'this project' : 'all projects'} + ↑↓ move · Enter open · Ctrl+D delete + {scopeEnabled ? ` · Ctrl+A ${allProjects ? 'this project' : 'all sessions'}` : ''} {switchHint ? ` · ${switchHint} projects` : ''}
@@ -827,6 +828,7 @@ interface ActivePickerContentProps { onDeleteActiveSession: () => void switchHint: string searchInputRef: React.RefObject + searchTrailing?: React.ReactNode } function ActivePickerContent({ @@ -837,6 +839,7 @@ function ActivePickerContent({ onDeleteActiveSession, switchHint, searchInputRef, + searchTrailing, }: ActivePickerContentProps) { const windowMinutes = useActivityWindowMinutes() const { groups, isLoading, isError } = useActiveWork(windowMinutes * MS_PER_MINUTE) @@ -950,6 +953,7 @@ function ActivePickerContent({ onKeyDown={handleSearchKeyDown} placeholder="Search sessions..." name="active-picker-search" + trailing={searchTrailing} /> void switchHint: string searchInputRef: React.RefObject + searchTrailing?: React.ReactNode } function RemoteSessionPickerContent({ @@ -1003,6 +1008,7 @@ function RemoteSessionPickerContent({ onSelectSession, switchHint, searchInputRef, + searchTrailing, }: RemoteSessionPickerContentProps) { const listRef = useRef(null) const listId = useId() @@ -1019,7 +1025,9 @@ function RemoteSessionPickerContent({ isFetchingNextPage, isFetchNextPageError, canFetchNextPage, - } = useSessionSearch(projectDirectory ? [projectDirectory] : []) + } = useSessionSearch(projectDirectory ? [projectDirectory] : [], { + allDirectories: projectDirectory === undefined, + }) const sections = useMemo( () => buildSections(filteredSessions, EMPTY_PINNED_KEYS, getSessionKey), @@ -1085,6 +1093,7 @@ function RemoteSessionPickerContent({ onKeyDown={handleSearchKeyDown} placeholder="Search sessions..." name="remote-session-picker-search" + trailing={searchTrailing} /> + searchTrailing?: React.ReactNode + onOpenProject: (path: string) => void + onSelectSession: (path: string) => void +} + +function RemoteProjectPickerContent({ + serverId, + open, + searchInputRef, + searchTrailing, + onOpenProject, + onSelectSession, +}: RemoteProjectPickerContentProps) { + const listId = useId() + const [query, setQuery] = useState('') + + return ( +
+ undefined} + placeholder="Search projects..." + name="remote-project-picker-search" + trailing={searchTrailing} + /> + +
+ +
+
+ ) +} + +export interface SessionPickerPanelProps { + open: boolean + currentRepo: Repo | undefined + activeSessionID?: string + onSelectSession: (session: Session, repo: Repo) => void + onSelectRemoteSession?: (session: Session, serverId: string) => void + onDeleteActiveSession: () => void + onSelectRepo: (repo: Repo) => void + onNavigate: (path: string) => void + /** Set on a linked-server route to list that server's sessions instead of Manager repos. */ + remote?: RemotePickerScope + /** Lists the linked server's projects on a Projects tab; the desktop dialog leaves it off. */ + remoteProjects?: boolean + /** Extra controls rendered at the trailing end of every tab's search bar. */ + searchTrailing?: React.ReactNode + /** Hands the add-repository action to the host, which renders the dialog above its own surface. */ + onAddRepo?: () => void + /** Renders the dialog title element, which only a Radix Dialog can host. */ + renderTitle?: (label: string) => React.ReactNode +} + +/** The tab strip, search bars, lists and header actions shared by the picker dialog and sheet. */ +export function SessionPickerPanel({ open, - onOpenChange, currentRepo, activeSessionID, onSelectSession, onSelectRemoteSession, onDeleteActiveSession, onSelectRepo, + onNavigate, remote, -}: SessionPickerDialogProps) { + remoteProjects = false, + searchTrailing, + onAddRepo, + renderTitle, +}: SessionPickerPanelProps) { const searchInputRef = useRef(null) const activeSearchInputRef = useRef(null) const projectSearchInputRef = useRef(null) const focusSearchAfterSwitchRef = useRef(false) + const scopePressRef = useRef(false) const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) + const [addRepoOpen, setAddRepoOpen] = useState(false) + const [allProjectsPreference, toggleAllProjects] = usePersistentBoolean(ALL_PROJECTS_STORAGE_KEY, false) const { preferences } = useSettings() const switchHint = useMemo( () => { @@ -1175,7 +1261,7 @@ export function SessionPickerDialog({ [preferences], ) const isRemote = remote !== undefined - const pickerTabs = isRemote ? REMOTE_SESSION_PICKER_TABS : SESSION_PICKER_TABS + const pickerTabs = isRemote && !remoteProjects ? REMOTE_SESSION_PICKER_TABS : SESSION_PICKER_TABS const [tab, setTab] = useState('sessions') const [prevOpen, setPrevOpen] = useState(open) @@ -1184,7 +1270,10 @@ export function SessionPickerDialog({ if (open) setTab('sessions') } - const activeTab: SessionPickerTab = isRemote && tab === 'projects' ? 'sessions' : tab + const activeTab: SessionPickerTab = pickerTabs.includes(tab) ? tab : 'sessions' + const scopeEnabled = !isRemote && Boolean(currentRepo) + const allProjects = scopeEnabled ? allProjectsPreference : true + const readyRepo = scopeEnabled && currentRepo && isRepoReady(currentRepo) ? currentRepo : undefined const toggleTab = useCallback(() => { focusSearchAfterSwitchRef.current = true @@ -1208,93 +1297,214 @@ export function SessionPickerDialog({ return () => cancelAnimationFrame(frame) }, [open, activeTab, hasFinePointer]) + const addRepoButton = isRemote ? null : ( + + ) + + const newSessionButton = readyRepo ? ( + getSessionPath(readyRepo.id, sessionId)} + onOpenSession={onNavigate} + /> + ) : ( + addRepoButton + ) + + const withTrailing = (action: React.ReactNode): React.ReactNode => { + if (!searchTrailing && !action) return undefined + return ( +
+ {searchTrailing} + {action} +
+ ) + } + + const sessionsTrailing = withTrailing(newSessionButton) + const projectsTrailing = withTrailing(addRepoButton) + + const handleSessionsPointerDown = () => { + scopePressRef.current = scopeEnabled && activeTab === 'sessions' + } + + const handleSessionsClick = () => { + if (!scopePressRef.current) return + scopePressRef.current = false + toggleAllProjects() + } + + const handleSessionsKeyDown = (event: React.KeyboardEvent) => { + if (!scopeEnabled || activeTab !== 'sessions') return + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + toggleAllProjects() + } + return ( - - onOpenChange(false)} - onOpenAutoFocus={(event) => { - event.preventDefault() - if (hasFinePointer) { - searchInputRef.current?.focus() - } else { - ;(event.currentTarget as HTMLElement).focus() - } - }} + <> + setTab(value as SessionPickerTab)} + className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-0" > - setTab(value as SessionPickerTab)} - className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-0" +
+ {renderTitle?.(SESSION_PICKER_TAB_LABELS[activeTab])} + + {SESSION_PICKER_TAB_LABELS.active} + + {sessionsTabLabel(scopeEnabled, allProjects)} + {scopeEnabled && ( + + {pickerTabs.includes('projects') && ( + {SESSION_PICKER_TAB_LABELS.projects} + )} + +
+ -
- {SESSION_PICKER_TAB_LABELS[activeTab]} - - Active - Sessions - {isRemote ? null : Projects} - -
- - - - - + + + +
+ + {remote ? ( + onSelectRemoteSession?.(session, remote.serverId)} + switchHint={switchHint} + searchInputRef={searchInputRef} + searchTrailing={sessionsTrailing} + /> + ) : ( + + )} + + {pickerTabs.includes('projects') && ( {remote ? ( - onSelectRemoteSession?.(session, remote.serverId)} - switchHint={switchHint} - searchInputRef={searchInputRef} + searchInputRef={projectSearchInputRef} + searchTrailing={projectsTrailing} + onOpenProject={onNavigate} + onSelectSession={onNavigate} /> ) : ( - - )} - - {isRemote ? null : ( - - - )} -
+ )} + + )} +
+ {addRepoOpen && !onAddRepo && } + + ) +} + +export function SessionPickerDialog({ + open, + onOpenChange, + currentRepo, + activeSessionID, + onSelectSession, + onSelectRemoteSession, + onDeleteActiveSession, + onSelectRepo, + onNavigate, + remote, +}: SessionPickerDialogProps) { + const searchInputRef = useRef(null) + const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) + + return ( + + onOpenChange(false)} + onOpenAutoFocus={(event) => { + event.preventDefault() + if (hasFinePointer) { + searchInputRef.current?.focus() + } else { + ;(event.currentTarget as HTMLElement).focus() + } + }} + > + {label}} + /> ) diff --git a/frontend/src/components/session/SessionPickerSheet.test.tsx b/frontend/src/components/session/SessionPickerSheet.test.tsx new file mode 100644 index 000000000..62c3a192d --- /dev/null +++ b/frontend/src/components/session/SessionPickerSheet.test.tsx @@ -0,0 +1,332 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, fireEvent, act } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation, useNavigationType } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import type { Repo, Session } from '@/api/types' +import type { ActiveWorkGroups, ActiveWorkItem } from '@/lib/activeWork' +import { DEFAULT_ACTIVITY_WINDOW_MINUTES } from '@/api/types/settings' +import { KeyboardShortcutsProvider } from '@/contexts/KeyboardShortcutsContext' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' +import { stubMatchMedia } from '@/test/test-utils' +import { SessionPickerSheet } from './SessionPickerSheet' + +const mocks = vi.hoisted(() => ({ + sessionsData: [] as Session[], + lastDirectories: [] as string[], + lastAllDirectories: false, + createSessionDirectories: [] as string[], + deleteSessionMock: vi.fn(), + locationData: undefined as + | { directory: string; project: { id: string; directory: string; canonical: string } } + | undefined, + getOpenCodeLocation: vi.fn(), + listProjects: vi.fn(), + repos: [] as Repo[], + activeWorkGroups: { needsInput: [], running: [], recent: [] } as ActiveWorkGroups, +})) + +vi.mock('@/hooks/useSettings', () => ({ + useSettings: () => ({ preferences: undefined }), +})) + +vi.mock('@/api/opencode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getOpenCodeLocation: mocks.getOpenCodeLocation, listProjects: mocks.listProjects } +}) + +vi.mock('@/api/repos', () => ({ + listRepos: vi.fn().mockResolvedValue([]), + createRepo: vi.fn(), + discoverRepos: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSessionsAcrossDirectories: ( + directories: string[], + options?: { search?: string; allDirectories?: boolean }, + ) => { + mocks.lastDirectories = directories + mocks.lastAllDirectories = options?.allDirectories ?? false + const search = options?.search?.toLowerCase() ?? '' + const base = directories.length === 0 && !options?.allDirectories ? [] : mocks.sessionsData + const data = search + ? base.filter((session) => (session.title ?? '').toLowerCase().includes(search)) + : base + return { + data, + isLoading: false, + isPlaceholderData: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isFetchNextPageError: false, + } + }, + useDeleteSession: () => ({ + mutateAsync: mocks.deleteSessionMock, + isPending: false, + }), + useCreateSession: (directory: string, onSuccess?: (session: { id: string }) => void) => ({ + mutate: () => { + mocks.createSessionDirectories.push(directory) + onSuccess?.({ id: 'ses_new' }) + }, + isPending: false, + }), + } +}) + +vi.mock('@/hooks/useSessionPins', () => ({ + useSessionPins: () => ({ data: [] }), + useToggleSessionPin: () => ({ mutate: vi.fn() }), +})) + +vi.mock('@/hooks/useSidebarRepoGroups', () => ({ + useNavigableRepos: () => ({ repos: mocks.repos, isLoading: false, refetch: vi.fn() }), +})) + +vi.mock('@/hooks/useActiveWork', () => ({ + useActiveWork: () => ({ groups: mocks.activeWorkGroups, isLoading: false, isError: false }), + useActivityWindowMinutes: () => DEFAULT_ACTIVITY_WINDOW_MINUTES, + useProjectActivityState: () => null, +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: () => ({ + servers: [{ id: 'srv_kitchen', name: 'Kitchen', enabled: true }], + isLoading: false, + }), + useOpenCodeServerStatuses: () => ({ statuses: [], statusById: {}, isLoading: false }), +})) + +vi.mock('@/components/navigation/LinkedServersNav', () => ({ + LinkedServersNav: () =>
, +})) + +vi.mock('@/stores/sessionStatusStore', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useSessionStatusForSession: () => ({ type: 'idle' }) } +}) + +function session(id: string, title: string, directory: string, updated = Date.now()): Session { + return { + id, + projectID: 'proj', + title, + time: { created: updated, updated }, + location: { directory }, + cost: 0, + tokens: {}, + } as unknown as Session +} + +function repo(id: number, name: string, fullPath: string): Repo { + return { + id, + name, + localPath: fullPath, + fullPath, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: 0, + } +} + +function activeWorkItem(id: string, directory: string): ActiveWorkItem { + const now = Date.now() + return { + key: `1:${id}`, + state: 'recent', + session: session(id, `Active ${id}`, directory, now), + repo: repo(1, 'alpha', directory), + updatedAt: now, + path: `/repos/1/sessions/${id}`, + } +} + +function LocationProbe() { + const location = useLocation() + const navigationType = useNavigationType() + return ( +
{`${location.pathname}${location.search} ${navigationType}`}
+ ) +} + +function renderSheet(initialEntries: string[] = ['/repos/1?mobileTab=sessions']) { + const onClose = vi.fn() + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + + + + + + + } + /> + + + + , + ) + return { onClose } +} + +describe('SessionPickerSheet', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + stubMatchMedia(true) + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) + mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.sessionsData = [] + mocks.lastDirectories = [] + mocks.lastAllDirectories = false + mocks.createSessionDirectories = [] + mocks.activeWorkGroups = { needsInput: [], running: [], recent: [] } + mocks.listProjects.mockResolvedValue([]) + mocks.locationData = { + directory: '/w/a', + project: { id: 'global', directory: '/w/a', canonical: '/w/a' }, + } + mocks.getOpenCodeLocation.mockResolvedValue(mocks.locationData) + }) + + afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + }) + + it('renders the picker in a bottom sheet with Activity, Sessions and Projects tabs', () => { + renderSheet(['/']) + + expect(screen.getByRole('dialog', { name: 'Sessions' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Activity' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Projects' })).toBeInTheDocument() + expect(screen.getByTestId('linked-servers-nav')).toBeInTheDocument() + }) + + it('navigates to the chosen session, replacing the sheet history entry', async () => { + mocks.sessionsData = [session('ses_one', 'Solo session', '/w/a')] + + renderSheet() + + const row = await screen.findByRole('option', { name: /Solo session/ }) + fireEvent.click(row) + + expect(screen.getByTestId('location')).toHaveTextContent('/repos/1/sessions/ses_one REPLACE') + }) + + it('closes instead of navigating when the chosen session is already open', async () => { + mocks.sessionsData = [session('ses_one', 'Solo session', '/w/a')] + + const { onClose } = renderSheet(['/repos/1/sessions/ses_one?mobileTab=sessions']) + + const row = await screen.findByRole('option', { name: /Solo session/ }) + fireEvent.click(row) + + expect(onClose).toHaveBeenCalled() + expect(screen.getByTestId('location')).toHaveTextContent( + '/repos/1/sessions/ses_one?mobileTab=sessions POP', + ) + }) + + it('creates a session in the current repo from the header action and opens it', async () => { + renderSheet() + + fireEvent.click(await screen.findByRole('button', { name: 'New session in alpha' })) + + expect(mocks.createSessionDirectories).toEqual(['/w/a']) + expect(screen.getByTestId('location')).toHaveTextContent('/repos/1/sessions/ses_new REPLACE') + }) + + it('opens the add repository dialog from the projects tab header action', async () => { + const { onClose } = renderSheet() + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Projects' })) + fireEvent.click(await screen.findByRole('button', { name: 'Add repository' })) + + expect(await screen.findByRole('dialog', { name: 'Add Repository' })).toBeInTheDocument() + expect(onClose).toHaveBeenCalled() + }) + + it('lists and opens the linked server sessions when a linked server is selected', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.sessionsData = [session('ses_remote', 'Remote session', '/home/me/app')] + + renderSheet() + + const row = await screen.findByRole('option', { name: /Remote session/ }) + expect(row).toHaveTextContent('Kitchen') + expect(mocks.lastDirectories).toEqual([]) + expect(mocks.lastAllDirectories).toBe(true) + expect(screen.getByTestId('linked-servers-nav')).toBeInTheDocument() + expect(screen.queryByRole('tab', { name: 'This project' })).not.toBeInTheDocument() + + fireEvent.click(row) + + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_kitchen/sessions/ses_remote REPLACE', + ) + }) + + it('lists the linked server projects on the projects tab and opens one', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.listProjects.mockResolvedValue([ + { + id: 'p1', + name: 'Alpha', + canonical: '/projects/alpha', + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + ]) + + renderSheet() + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Projects' })) + fireEvent.click(await screen.findByText('Alpha')) + + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha REPLACE', + ) + }) + + it('follows the sidebar server selection to list that server', async () => { + mocks.sessionsData = [session('ses_remote', 'Remote session', '/home/me/app')] + + renderSheet() + + act(() => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + }) + + const row = await screen.findByRole('option', { name: /Remote session/ }) + expect(row).toHaveTextContent('Kitchen') + expect(screen.queryByRole('tab', { name: 'This project' })).not.toBeInTheDocument() + }) + + it('lists activity sessions and opens one', async () => { + mocks.activeWorkGroups = { + needsInput: [], + running: [], + recent: [activeWorkItem('ses_active', '/w/a')], + } + + renderSheet() + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Activity' })) + fireEvent.click(await screen.findByRole('option', { name: /Active ses_active/ })) + + expect(screen.getByTestId('location')).toHaveTextContent('/repos/1/sessions/ses_active REPLACE') + }) +}) diff --git a/frontend/src/components/session/SessionPickerSheet.tsx b/frontend/src/components/session/SessionPickerSheet.tsx new file mode 100644 index 000000000..547d5b70a --- /dev/null +++ b/frontend/src/components/session/SessionPickerSheet.tsx @@ -0,0 +1,94 @@ +import { useCallback, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import type { Repo, Session } from '@/api/types' +import { BottomSheet } from '@/components/ui/bottom-sheet' +import { LinkedServersNav } from '@/components/navigation/LinkedServersNav' +import { AddRepoDialog } from '@/components/repo/AddRepoDialog' +import { SessionPickerPanel } from '@/components/session/SessionPickerDialog' +import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' +import { useSessionPickerRouteScope } from '@/hooks/useSessionPickerRouteScope' +import { getRepoPath, getServerSessionPath, getSessionPath } from '@/lib/navigation' + +interface SessionPickerSheetProps { + isOpen: boolean + onClose: () => void +} + +/** The session picker in a bottom sheet; every navigation replaces the sheet's history entry. */ +export function SessionPickerSheet({ isOpen, onClose }: SessionPickerSheetProps) { + const navigate = useNavigate() + const [addRepoOpen, setAddRepoOpen] = useState(false) + const { currentRepo, activeSessionID, remote } = useSessionPickerRouteScope(isOpen) + const remoteServerId = remote?.serverId + + const navigateReplacing = useCallback( + (path: string) => { + navigate(path, { replace: true }) + }, + [navigate], + ) + + const handleSelectSession = useCallback( + (session: Session, repo: Repo) => { + if (session.id === activeSessionID) { + onClose() + return + } + navigateReplacing(getSessionPath(repo.id, session.id)) + }, + [activeSessionID, navigateReplacing, onClose], + ) + + const handleSelectRemoteSession = useCallback( + (session: Session) => { + if (!remoteServerId) return + if (session.id === activeSessionID) { + onClose() + return + } + navigateReplacing(getServerSessionPath(remoteServerId, session.id)) + }, + [activeSessionID, navigateReplacing, onClose, remoteServerId], + ) + + const handleDeleteActiveSession = useCallback(() => { + if (!currentRepo) { + onClose() + return + } + navigateReplacing(getRepoPath(currentRepo.id)) + }, [currentRepo, navigateReplacing, onClose]) + + const panel = isOpen ? ( + navigateReplacing(getRepoPath(repo.id))} + onNavigate={navigateReplacing} + remote={remote} + remoteProjects + searchTrailing={} + onAddRepo={() => { + onClose() + setAddRepoOpen(true) + }} + /> + ) : null + + return ( + <> + + {remote ? ( + {panel} + ) : ( + panel + )} + + + + ) +} diff --git a/frontend/src/hooks/useMobileTabBar.test.tsx b/frontend/src/hooks/useMobileTabBar.test.tsx index ea0c6ff19..466b76916 100644 --- a/frontend/src/hooks/useMobileTabBar.test.tsx +++ b/frontend/src/hooks/useMobileTabBar.test.tsx @@ -13,9 +13,9 @@ describe('useMobileTabBar', () => { it('returns the correct openSheet when mobileTab param is set', () => { const { result } = renderHook(() => useMobileTabBar(), { - wrapper: createRouterWrapper(['/?mobileTab=repos']), + wrapper: createRouterWrapper(['/?mobileTab=sessions']), }) - expect(result.current.openSheet).toBe('repos') + expect(result.current.openSheet).toBe('sessions') }) it('open sets the mobileTab param', () => { @@ -45,7 +45,7 @@ describe('useMobileTabBar', () => { }) it('handles all valid MobileSheetKey values', () => { - const validKeys = ['repos', 'files', 'notifications', 'more'] as const + const validKeys = ['sessions', 'files', 'notifications', 'more'] as const validKeys.forEach((key) => { const { result } = renderHook(() => useMobileTabBar(), { wrapper: createRouterWrapper([`/?mobileTab=${key}`]), @@ -56,7 +56,7 @@ describe('useMobileTabBar', () => { it('returns stable openSheet identity across rerenders when search is unchanged', () => { const { result, rerender } = renderHook(() => useMobileTabBar(), { - wrapper: createRouterWrapper(['/?mobileTab=repos']), + wrapper: createRouterWrapper(['/?mobileTab=sessions']), }) const firstOpenSheet = result.current.openSheet rerender() diff --git a/frontend/src/hooks/useMobileTabBar.ts b/frontend/src/hooks/useMobileTabBar.ts index ad06fa6d2..d93310ae2 100644 --- a/frontend/src/hooks/useMobileTabBar.ts +++ b/frontend/src/hooks/useMobileTabBar.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from 'react' import { useUrlParams } from './useUrlParams' -type MobileSheetKey = 'repos' | 'files' | 'notifications' | 'more' +type MobileSheetKey = 'sessions' | 'files' | 'notifications' | 'more' interface UseMobileTabBarReturn { openSheet: MobileSheetKey | null @@ -14,7 +14,7 @@ export function useMobileTabBar(): UseMobileTabBarReturn { const openSheet = useMemo(() => { const v = searchParams.get('mobileTab') - return (v === 'repos' || v === 'files' || v === 'notifications' || v === 'more') ? v : null + return (v === 'sessions' || v === 'files' || v === 'notifications' || v === 'more') ? v : null }, [searchParams]) const open = useCallback((key: MobileSheetKey) => { diff --git a/frontend/src/hooks/useOpenCode.test.tsx b/frontend/src/hooks/useOpenCode.test.tsx index 4a611dd66..f187e3991 100644 --- a/frontend/src/hooks/useOpenCode.test.tsx +++ b/frontend/src/hooks/useOpenCode.test.tsx @@ -174,6 +174,27 @@ describe('useSessionsAcrossDirectories', () => { expect(result.current.data.map((session) => session.id)).toEqual(['ses_all1', 'ses_all2']) }) + it('queries all directories when allDirectories is set without a directory scope', async () => { + mocks.listSessionPage.mockResolvedValue({ items: [sessionInfo('ses_all', '/w/a')] }) + + const queryClient = createQueryClient() + const { result } = renderHook( + () => useSessionsAcrossDirectories([], { allDirectories: true }), + { wrapper: createWrapper(queryClient) }, + ) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(mocks.listSessionPage).toHaveBeenCalledWith({ + limit: 25, + order: 'desc', + search: undefined, + }, 'local') + expect(result.current.data.map((session) => session.id)).toEqual(['ses_all']) + }) + it('requests child sessions and records includeChildren in the query key', async () => { mocks.listSessionPage.mockResolvedValue({ items: [sessionInfo('ses_child', '/w/a')] }) diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index 09df0032a..dc2db00a2 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -177,7 +177,7 @@ export const useSessionsAcrossDirectories = ( } : undefined, getNextPageParam: (lastPage) => lastPage.nextParam, - enabled: uniqueDirectories.length > 0, + enabled: uniqueDirectories.length > 0 || allDirectories, staleTime: 10000, refetchOnWindowFocus: true, refetchOnReconnect: true, diff --git a/frontend/src/hooks/useSessionPickerRouteScope.ts b/frontend/src/hooks/useSessionPickerRouteScope.ts new file mode 100644 index 000000000..aa1505a1b --- /dev/null +++ b/frontend/src/hooks/useSessionPickerRouteScope.ts @@ -0,0 +1,52 @@ +import { useLocation } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import type { Repo } from '@/api/types' +import { parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from '@/lib/navigation' +import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' +import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' +import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' + +export interface SessionPickerRouteScope { + currentRepo: Repo | undefined + activeSessionID: string | undefined + isRemote: boolean + remote: { serverId: string; serverName?: string; projectDirectory?: string } | undefined +} + +/** + * Resolves what a session picker opened on the current route should list: the Manager repo of a + * repo route, or the selected linked server and its project directory. + */ +export function useSessionPickerRouteScope(enabled: boolean): SessionPickerRouteScope { + const { pathname, search } = useLocation() + const [selectedServerId] = useSidebarServerSelection() + const { repos } = useNavigableRepos(enabled) + const { servers } = useOpenCodeServers({ enabled }) + + const isRemote = selectedServerId !== LOCAL_OPENCODE_SERVER_ID + const repoRoute = parseRepoRoute(pathname) + const currentRepo = isRemote || repoRoute.repoId === null + ? undefined + : repos.find((candidate) => candidate.id === repoRoute.repoId) + const activeSessionID = repoRoute.sessionId + ?? parseServerSessionRoute(pathname)?.sessionId + ?? undefined + const remoteProjectDirectory = isRemote + ? parseServerProjectRoute(pathname, search)?.directory + : undefined + + if (!isRemote) { + return { currentRepo, activeSessionID, isRemote, remote: undefined } + } + + return { + currentRepo, + activeSessionID, + isRemote, + remote: { + serverId: selectedServerId, + serverName: servers.find((server) => server.id === selectedServerId)?.name, + projectDirectory: remoteProjectDirectory, + }, + } +} diff --git a/frontend/src/hooks/useSettingsDialog.test.tsx b/frontend/src/hooks/useSettingsDialog.test.tsx index 3178eb2ce..ef08ad8f3 100644 --- a/frontend/src/hooks/useSettingsDialog.test.tsx +++ b/frontend/src/hooks/useSettingsDialog.test.tsx @@ -270,7 +270,7 @@ describe('useSettingsDialog', () => { } render( - + , ) @@ -285,7 +285,7 @@ describe('useSettingsDialog', () => { act(() => { screen.getByText('back').click() }) - expect(screen.getByTestId('search').textContent).toBe('?mobileTab=repos') + expect(screen.getByTestId('search').textContent).toBe('?mobileTab=sessions') expect(screen.getByTestId('dialog-state').textContent).toBe('closed') }) diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 25f9b8bb1..6b499e498 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -30,6 +30,7 @@ import { useShortcutActions } from "@/contexts/KeyboardShortcutsContext"; import { useSettingsDialog } from "@/hooks/useSettingsDialog"; import { useAutoScroll } from "@/hooks/useAutoScroll"; import { useMobile } from "@/hooks/useMobile"; +import { useMobileTabBar } from "@/hooks/useMobileTabBar"; import { useToolPanelLayout } from "@/hooks/useToolPanelLayout"; import { useVisualViewport } from "@/hooks/useVisualViewport"; import { useTTS } from "@/hooks/useTTS"; @@ -156,6 +157,7 @@ export function SessionDetail() { const [forkPickerLoading, setForkPickerLoading] = useState(false); const isMobile = useMobile(); + const { open: openMobileSheet } = useMobileTabBar(); const { docked, showRail } = useToolPanelLayout(); const sidePanel = useToolPanel(showRail); const { keyboardHeight } = useVisualViewport(); @@ -368,7 +370,13 @@ export function SessionDetail() { enabled: !isVoiceConversationActive, }); - const handleShowSessionsDialog = useCallback(() => setSessionsDialogOpen(true), []); + const handleShowSessionsDialog = useCallback(() => { + if (isMobile) { + openMobileSheet('sessions'); + return; + } + setSessionsDialogOpen(true); + }, [isMobile, openMobileSheet]); const handleShowMcpDialog = useCallback(() => setMcpDialogOpen(true), [setMcpDialogOpen]); const handleShowSkillsDialog = useCallback(() => setSkillsDialogOpen(true), [setSkillsDialogOpen]); const handleShowWalkthrough = useCallback((source?: WalkthroughSource) => { @@ -1057,6 +1065,10 @@ export function SessionDetail() { navigate(getRepoPath(selectedRepo.id)) setSessionsDialogOpen(false) }} + onNavigate={(path) => { + navigate(path) + setSessionsDialogOpen(false) + }} /> {sideQuestion && sessionId && ( diff --git a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx index 75efb2a8e..deeb1ac48 100644 --- a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx @@ -476,6 +476,37 @@ describe('SessionDetail command actions', () => { }) }) + it('opens the sessions picker dialog from the sessions command on desktop', async () => { + const { SessionPickerDialog } = await import('@/components/session/SessionPickerDialog') + + renderSessionDetail() + const showSessions = await commandHandler('showSessions') + + act(() => { + showSessions() + }) + + const props = vi.mocked(SessionPickerDialog).mock.calls.at(-1)?.[0] as Record + expect(props).toMatchObject({ open: true }) + }) + + it('opens the mobile sessions sheet instead of the dialog from the sessions command', async () => { + const { SessionPickerDialog } = await import('@/components/session/SessionPickerDialog') + mocks.useMobile.mockReturnValue(true) + const captured = { search: { current: '' }, pathname: { current: '' } } + + renderSessionDetail(captured) + const showSessions = await commandHandler('showSessions') + + act(() => { + showSessions() + }) + + await waitFor(() => expect(captured.search.current).toBe('?mobileTab=sessions')) + const props = vi.mocked(SessionPickerDialog).mock.calls.at(-1)?.[0] as Record + expect(props).toMatchObject({ open: false }) + }) + it('shows the error toast when the transcript fetch fails', async () => { mocks.listSessionMessages.mockRejectedValueOnce(new Error('page fetch failed')) mocks.copyTextToClipboard.mockImplementation(async (content: string | Promise) => { From 36d35cc974ab9debc325b512b22c4a2cdaf245f8 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:18:21 -0400 Subject: [PATCH 02/14] fix(frontend): order linked server projects by last activity and keep cached lists on refetch errors --- frontend/src/api/opencode.ts | 8 ++ .../navigation/RemoteProjectNav.test.tsx | 69 +++++++++++-- .../navigation/RemoteProjectNav.tsx | 12 ++- .../components/servers/RemoteProjectList.tsx | 19 ++-- .../__tests__/RemoteProjectList.test.tsx | 99 +++++++++++++++++-- 5 files changed, 185 insertions(+), 22 deletions(-) diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index b38aa8afb..e3eb90acd 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -37,6 +37,14 @@ export type OpenCodeLocationInfo = Awaited>[number] +/** + * The timestamp a project list sorts and labels on: the server's last-activity time, falling back + * to the record's update time for a project that has never recorded activity. + */ +export function projectActivityTimestamp(project: OpenCodeProject): number { + return project.time.active || project.time.updated +} + export type VcsInfo = Awaited>['data'] export type VcsFileStatus = Awaited>['data'][number] diff --git a/frontend/src/components/navigation/RemoteProjectNav.test.tsx b/frontend/src/components/navigation/RemoteProjectNav.test.tsx index 33b0181b2..afa5f6a78 100644 --- a/frontend/src/components/navigation/RemoteProjectNav.test.tsx +++ b/frontend/src/components/navigation/RemoteProjectNav.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { OpenCodeProject } from '@/api/opencode' @@ -87,14 +87,19 @@ const kitchen = { updatedAt: 2, } -function createProject(id: string, name: string | undefined, canonical: string): OpenCodeProject { +function createProject( + id: string, + name: string | undefined, + canonical: string, + time: OpenCodeProject['time'] = { created: 1, updated: 2, active: 0 }, +): OpenCodeProject { return { id, canonical, name, - time: { created: 1, updated: 2 }, + time, sandboxes: [], - } as OpenCodeProject + } } function createSession(id: string, directory: string, updated = Date.now()): SessionFixture { @@ -117,10 +122,12 @@ function LocationSpy() { return
{`${pathname}${search}`}
} -function createWrapper(initialEntry: string) { - const queryClient = new QueryClient({ +function createWrapper( + initialEntry: string, + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }) + }), +) { return ({ children }: { children: React.ReactNode }) => ( @@ -187,6 +194,32 @@ describe('RemoteProjectNavList', () => { expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') }) + it('orders projects by recent activity', async () => { + const now = Date.now() + mocks.listProjects.mockResolvedValue([ + createProject('p_stale', 'Stale', '/projects/stale', { + created: now - 10_800_000, + updated: now - 300_000, + active: now - 10_800_000, + }), + createProject('p_recent', 'Recent', '/projects/recent', { + created: now - 10_800_000, + updated: now - 10_800_000, + active: now - 300_000, + }), + ]) + + const { container } = render(, { + wrapper: createWrapper('/'), + }) + + await screen.findByText('Recent') + + const text = container.textContent ?? '' + expect(text.indexOf('Recent')).toBeGreaterThan(-1) + expect(text.indexOf('Recent')).toBeLessThan(text.indexOf('Stale')) + }) + it('expands the project of the current project route and loads its sessions', async () => { render(, { wrapper: createWrapper('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha'), @@ -287,6 +320,28 @@ describe('RemoteProjectNavList', () => { expect(await screen.findByText('Could not load projects')).toBeInTheDocument() }) + it('keeps the cached project list when a refetch fails', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + mocks.listProjects.mockResolvedValueOnce([alpha, beta]).mockRejectedValueOnce(new Error('offline')) + + render(, { + wrapper: createWrapper('/', queryClient), + }) + + await screen.findByText('Alpha') + await act(async () => { + await queryClient.refetchQueries() + }) + + await waitFor(() => { + expect(mocks.listProjects).toHaveBeenCalledTimes(2) + }) + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.queryByText('Could not load projects')).not.toBeInTheDocument() + }) + it('reports an unreachable server on a failed project load', async () => { mocks.listProjects.mockRejectedValue(new Error('offline')) mocks.useOpenCodeServerStatuses.mockReturnValue({ diff --git a/frontend/src/components/navigation/RemoteProjectNav.tsx b/frontend/src/components/navigation/RemoteProjectNav.tsx index d3b733911..dc2e4e419 100644 --- a/frontend/src/components/navigation/RemoteProjectNav.tsx +++ b/frontend/src/components/navigation/RemoteProjectNav.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' -import { listProjects, type OpenCodeProject } from '@/api/opencode' +import { listProjects, projectActivityTimestamp, type OpenCodeProject } from '@/api/opencode' import type { Session } from '@/api/types' import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' import { useSessionsAcrossDirectories, sessionQueryOptions } from '@/hooks/useOpenCode' @@ -393,14 +393,20 @@ export function RemoteProjectNavList({ retry: false, }) - const projects = useMemo(() => projectsQuery.data ?? [], [projectsQuery.data]) + const projects = useMemo( + () => + [...(projectsQuery.data ?? [])].sort( + (a, b) => projectActivityTimestamp(b) - projectActivityTimestamp(a), + ), + [projectsQuery.data], + ) const trimmedSearch = search.trim() if (isServersLoading) return Loading projects... if (!server) return Server not found if (!server.enabled) return Server is disabled if (projectsQuery.isLoading) return Loading projects... - if (projectsQuery.isError) { + if (projectsQuery.isError && projectsQuery.data === undefined) { return ( {statusById[serverId]?.connected === false ? 'Server unreachable' : 'Could not load projects'} diff --git a/frontend/src/components/servers/RemoteProjectList.tsx b/frontend/src/components/servers/RemoteProjectList.tsx index e333ea81d..c84912550 100644 --- a/frontend/src/components/servers/RemoteProjectList.tsx +++ b/frontend/src/components/servers/RemoteProjectList.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { Folder, FolderGit2, FolderPlus, GitBranch, Search, Server } from 'lucide-react' -import { listProjects, type OpenCodeProject } from '@/api/opencode' +import { listProjects, projectActivityTimestamp, type OpenCodeProject } from '@/api/opencode' import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' import { useSettingsDialog } from '@/hooks/useSettingsDialog' import { useProjectActivityState } from '@/hooks/useActiveWork' @@ -27,7 +27,7 @@ function projectName(project: OpenCodeProject): string { function RemoteProjectCard({ project, serverId }: { project: OpenCodeProject; serverId: string }) { const navigate = useNavigate() const activityState = useProjectActivityState(serverId, project.canonical) - const updatedAt = project.time.updated + const activeAt = projectActivityTimestamp(project) return ( {project.canonical} } - activityLabel={updatedAt > 0 ? formatShortRelativeTime(new Date(updatedAt)) : undefined} + activityLabel={activeAt > 0 ? formatShortRelativeTime(new Date(activeAt)) : undefined} isInteractive onClick={() => navigate(getServerProjectPath(serverId, project.canonical))} /> @@ -102,7 +102,14 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) { retry: false, }) - const projects = useMemo(() => projectsQuery.data ?? [], [projectsQuery.data]) + const projects = useMemo( + () => + [...(projectsQuery.data ?? [])].sort( + (a, b) => projectActivityTimestamp(b) - projectActivityTimestamp(a), + ), + [projectsQuery.data], + ) + const showsProjectError = projectsQuery.isError && projectsQuery.data === undefined const filteredProjects = useMemo(() => { const query = searchQuery.trim().toLowerCase() @@ -138,7 +145,7 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) { /> ) : null} - {server && !isDisabled && projectsQuery.isError ? ( + {server && !isDisabled && showsProjectError ? (
@@ -155,7 +162,7 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) { ) : null} - {server && !isDisabled && !projectsQuery.isError ? ( + {server && !isDisabled && !showsProjectError ? ( projectsQuery.isLoading ? ( ) : projects.length === 0 ? ( diff --git a/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx index aa9c6532c..050d0ffd0 100644 --- a/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx +++ b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' +import { act, 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' @@ -15,9 +15,10 @@ const mocks = vi.hoisted(() => ({ isMobile: true, })) -vi.mock('@/api/opencode', () => ({ - listProjects: mocks.listProjects, -})) +vi.mock('@/api/opencode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, listProjects: mocks.listProjects } +}) vi.mock('@/hooks/useOpenCode', () => ({ useCreateSession: mocks.useCreateSession, @@ -113,9 +114,9 @@ function createQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } } }) } -function renderProjectList(serverId = 'srv_kitchen') { +function renderProjectList(serverId = 'srv_kitchen', queryClient = createQueryClient()) { return render( - + } /> @@ -147,6 +148,66 @@ describe('RemoteProjectList', () => { expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') }) + it('orders projects by recent activity and labels rows with that activity time', async () => { + const now = Date.now() + mocks.listProjects.mockResolvedValue([ + { + ...project, + id: 'prj_stale', + name: 'Stale', + canonical: '/work/stale', + time: { created: now - 10_800_000, updated: now - 300_000, active: now - 10_800_000 }, + }, + { + ...project, + id: 'prj_recent', + name: 'Recent', + canonical: '/work/recent', + time: { created: now - 10_800_000, updated: now - 10_800_000, active: now - 300_000 }, + }, + ]) + + const { container } = renderProjectList() + + await screen.findByText('Recent') + + const text = container.textContent ?? '' + expect(text.indexOf('Recent')).toBeGreaterThan(-1) + expect(text.indexOf('Recent')).toBeLessThan(text.indexOf('5m ago')) + expect(text.indexOf('5m ago')).toBeLessThan(text.indexOf('Stale')) + expect(text.indexOf('Stale')).toBeLessThan(text.indexOf('3h ago')) + }) + + it('falls back to the update time when a project has no activity time', async () => { + const now = Date.now() + mocks.listProjects.mockResolvedValue([ + { + ...project, + id: 'prj_older', + name: 'Older', + canonical: '/work/older', + time: { created: now - 10_800_000, updated: now - 10_800_000 }, + }, + { + ...project, + id: 'prj_newer', + name: 'Newer', + canonical: '/work/newer', + time: { created: now - 10_800_000, updated: now - 300_000 }, + }, + ]) + + const { container } = renderProjectList() + + await screen.findByText('Newer') + + const text = container.textContent ?? '' + expect(text.indexOf('Newer')).toBeGreaterThan(-1) + expect(text.indexOf('Newer')).toBeLessThan(text.indexOf('5m ago')) + expect(text.indexOf('5m ago')).toBeLessThan(text.indexOf('Older')) + expect(text.indexOf('Older')).toBeLessThan(text.indexOf('3h ago')) + }) + it('opens the remote project page when a project card is clicked', async () => { const user = userEvent.setup() renderProjectList() @@ -342,6 +403,32 @@ describe('RemoteProjectList', () => { }) }) + it('shows the load error when no project data is cached', async () => { + mocks.listProjects.mockRejectedValue(new Error('offline')) + + renderProjectList() + + expect(await screen.findByText('Could not load projects')).toBeInTheDocument() + }) + + it('keeps the cached project list when a refetch fails', async () => { + const queryClient = createQueryClient() + mocks.listProjects.mockResolvedValueOnce([project]).mockRejectedValueOnce(new Error('offline')) + + renderProjectList('srv_kitchen', queryClient) + + await screen.findByText('App') + await act(async () => { + await queryClient.refetchQueries() + }) + + await waitFor(() => { + expect(mocks.listProjects).toHaveBeenCalledTimes(2) + }) + expect(screen.getByText('App')).toBeInTheDocument() + expect(screen.queryByText('Could not load projects')).not.toBeInTheDocument() + }) + it('scopes the list to the given server rather than the route', async () => { renderProjectList('srv_kitchen') From 52162d5c4a284c6305797ff480d26ed404a0f83a Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:18:21 -0400 Subject: [PATCH 03/14] feat(frontend): show context usage on the composer model pill The model pill shows the context percentage with a tone meter, and the model picker gains a context strip with a Compact action. --- .../message/PromptInput.command.test.tsx | 1 + .../message/PromptInput.composer.test.tsx | 71 +++++++++++++++++++ .../message/PromptInput.draft.test.tsx | 1 + .../message/PromptInput.goal.test.tsx | 1 + .../message/PromptInput.mention.test.tsx | 1 + .../message/PromptInput.stt.test.tsx | 1 + .../src/components/message/PromptInput.tsx | 52 ++++++++++++-- .../model/ModelQuickSelect.test.tsx | 70 ++++++++++++++++++ .../src/components/model/ModelQuickSelect.tsx | 70 ++++++++++++++++++ .../components/session/ContextUsageMeter.tsx | 29 ++++++++ frontend/src/hooks/useContextUsage.test.tsx | 21 ++++++ frontend/src/hooks/useContextUsage.ts | 10 +-- frontend/src/lib/contextUsage.test.ts | 59 +++++++++++++++ frontend/src/lib/contextUsage.ts | 64 +++++++++++++++++ 14 files changed, 438 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/session/ContextUsageMeter.tsx create mode 100644 frontend/src/lib/contextUsage.test.ts create mode 100644 frontend/src/lib/contextUsage.ts diff --git a/frontend/src/components/message/PromptInput.command.test.tsx b/frontend/src/components/message/PromptInput.command.test.tsx index eb07be053..226567b53 100644 --- a/frontend/src/components/message/PromptInput.command.test.tsx +++ b/frontend/src/components/message/PromptInput.command.test.tsx @@ -56,6 +56,7 @@ vi.mock('@/hooks/useOpenCode', async () => { ...actual, useAgents: () => ({ data: mocks.agents }), useSkills: () => ({ data: mocks.skills }), + useSession: () => ({ data: undefined }), } }) diff --git a/frontend/src/components/message/PromptInput.composer.test.tsx b/frontend/src/components/message/PromptInput.composer.test.tsx index f9fd93b6c..b560efcb4 100644 --- a/frontend/src/components/message/PromptInput.composer.test.tsx +++ b/frontend/src/components/message/PromptInput.composer.test.tsx @@ -21,6 +21,8 @@ const mocks = vi.hoisted(() => ({ useSendErrorStore: vi.fn(), useHasBackgroundActivity: vi.fn(), useSendPrompt: vi.fn(), + useContextUsage: vi.fn(), + sessionData: undefined as { revert?: { messageID: string } } | undefined, hasPendingPermission: vi.fn(() => false), setShowDialog: vi.fn(), })) @@ -54,9 +56,11 @@ vi.mock('@/hooks/useOpenCode', () => ({ useSendShell: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(() => Promise.resolve()), isPending: false }), useAgents: () => ({ data: [] }), useSkills: () => ({ data: [] }), + useSession: () => ({ data: mocks.sessionData }), })) vi.mock('@/hooks/useProviders', () => ({ useProviders: () => ({ data: undefined }) })) +vi.mock('@/hooks/useContextUsage', () => ({ useContextUsage: mocks.useContextUsage })) vi.mock('@/contexts/EventContext', () => ({ usePermissions: () => ({ @@ -153,6 +157,8 @@ describe('PromptInput composer layout', () => { mocks.useSessionAgentStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ setAgent: vi.fn() })) mocks.useSendErrorStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ errors: {} })) mocks.useHasBackgroundActivity.mockReturnValue(false) + mocks.useContextUsage.mockReturnValue({ usage: null, isLoading: false }) + mocks.sessionData = undefined }) it('anchors the activity bar in the gap above the controls row', () => { @@ -242,6 +248,71 @@ describe('PromptInput composer layout', () => { }) }) + describe('model pill context usage', () => { + const setUsage = (usage: { totalTokens: number, usagePercentage: number | null, contextLimit: number | null }) => { + mocks.useContextUsage.mockReturnValue({ usage, isLoading: false }) + } + + it('shows the percentage with its tone and the meter on the model pill', () => { + setUsage({ totalTokens: 84000, usagePercentage: 42, contextLimit: 200000 }) + renderComponent() + + const pill = screen.getByRole('button', { name: /test-model, context 42% used/ }) + const usage = screen.getByTestId('model-pill-context-usage') + + expect(pill).toContainElement(usage) + expect(usage).toHaveTextContent('42%') + expect(usage).toHaveClass('text-success') + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '42%' }) + }) + + it('tints the warning tone from 50 percent', () => { + setUsage({ totalTokens: 120000, usagePercentage: 50, contextLimit: 240000 }) + renderComponent() + + expect(screen.getByTestId('model-pill-context-usage')).toHaveClass('text-warning') + }) + + it('tints the destructive tone from 80 percent', () => { + setUsage({ totalTokens: 176000, usagePercentage: 88, contextLimit: 200000 }) + renderComponent() + + expect(screen.getByTestId('model-pill-context-usage')).toHaveClass('text-destructive') + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '88%' }) + }) + + it('caps the meter at full width when usage exceeds the context window', () => { + setUsage({ totalTokens: 260000, usagePercentage: 130, contextLimit: 200000 }) + renderComponent() + + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '100%' }) + }) + + it('falls back to a compact token count without a known context limit', () => { + setUsage({ totalTokens: 12400, usagePercentage: null, contextLimit: null }) + renderComponent() + + expect(screen.getByRole('button', { name: /test-model, context 12k tokens used/ })).toBeInTheDocument() + expect(screen.getByTestId('model-pill-context-usage')).toHaveTextContent('12k') + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '0%' }) + }) + + it('hides the usage without context data', () => { + renderComponent() + + expect(screen.getByRole('button', { name: 'test-model' })).toBeInTheDocument() + expect(screen.queryByTestId('model-pill-context-usage')).not.toBeInTheDocument() + expect(screen.queryByTestId('context-usage-meter')).not.toBeInTheDocument() + }) + + it('measures usage up to the staged revert boundary', () => { + mocks.sessionData = { revert: { messageID: 'msg_2' } } + renderComponent() + + expect(mocks.useContextUsage).toHaveBeenCalledWith('test-session', '/test', 'msg_2') + }) + }) + describe('mobile key bar', () => { beforeEach(() => { mocks.useMobile.mockReturnValue(true) diff --git a/frontend/src/components/message/PromptInput.draft.test.tsx b/frontend/src/components/message/PromptInput.draft.test.tsx index 207880829..087bf0d39 100644 --- a/frontend/src/components/message/PromptInput.draft.test.tsx +++ b/frontend/src/components/message/PromptInput.draft.test.tsx @@ -54,6 +54,7 @@ vi.mock('@/hooks/useOpenCode', () => ({ useSendShell: () => ({ mutate: mocks.useSendShell, mutateAsync: mocks.useSendShell, isPending: false }), useAgents: () => ({ data: [] }), useSkills: () => ({ data: [] }), + useSession: () => ({ data: undefined }), })) vi.mock('@/hooks/useProviders', () => ({ useProviders: () => ({ data: undefined }) })) diff --git a/frontend/src/components/message/PromptInput.goal.test.tsx b/frontend/src/components/message/PromptInput.goal.test.tsx index 2f63e25f3..011673e91 100644 --- a/frontend/src/components/message/PromptInput.goal.test.tsx +++ b/frontend/src/components/message/PromptInput.goal.test.tsx @@ -47,6 +47,7 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { useInterruptSession: () => ({ mutate: mocks.interrupt }), useAgents: () => ({ data: mocks.agents }), useSkills: () => ({ data: mocks.skills }), + useSession: () => ({ data: undefined }), } }) diff --git a/frontend/src/components/message/PromptInput.mention.test.tsx b/frontend/src/components/message/PromptInput.mention.test.tsx index 2721ecac2..aaf38ed15 100644 --- a/frontend/src/components/message/PromptInput.mention.test.tsx +++ b/frontend/src/components/message/PromptInput.mention.test.tsx @@ -44,6 +44,7 @@ vi.mock('@/hooks/useOpenCode', async () => { ...actual, useAgents: () => ({ data: mocks.agents }), useSkills: () => ({ data: mocks.skills }), + useSession: () => ({ data: undefined }), } }) diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index 22f4f146a..c09a9656f 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -59,6 +59,7 @@ vi.mock('@/hooks/useOpenCode', () => ({ useSendShell: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(() => Promise.resolve()), isPending: false }), useAgents: () => ({ data: [] }), useSkills: () => ({ data: [] }), + useSession: () => ({ data: undefined }), })) vi.mock('@/hooks/useCommands', () => ({ diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 624964824..a786fd1e1 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useMemo, useImperativeHandle, forwardRef, memo, useCallback, type KeyboardEvent } from 'react' -import { useSendPrompt, useInterruptSession, useSendShell, useAgents, useSkills } from '@/hooks/useOpenCode' +import { useContextUsage } from '@/hooks/useContextUsage' +import { useSendPrompt, useInterruptSession, useSendShell, useAgents, useSkills, useSession } from '@/hooks/useOpenCode' import { useCommands } from '@/hooks/useCommands' import { useCommandHandler } from '@/hooks/useCommandHandler' import { useFileSearch } from '@/hooks/useFileSearch' @@ -13,7 +14,7 @@ import { useSessionAgentStore } from '@/stores/sessionAgentStore' import { useSendErrorStore } from '@/stores/sendErrorStore' import { useComposerDraftStore, type ComposerDraft } from '@/stores/composerDraftStore' import { useRecentCommandsStore } from '@/stores/recentCommandsStore' -import { useHasBackgroundActivity } from '@/stores/sessionStatusStore' +import { useHasBackgroundActivity, useSessionStatusForSession } from '@/stores/sessionStatusStore' import { useTouchTapSelect } from '@/hooks/useTouchTapSelect' import { useMobile } from '@/hooks/useMobile' import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' @@ -32,6 +33,7 @@ import { AgentQuickSelect } from '@/components/agent/AgentQuickSelect' import { VoiceConversation, type VoiceConversationHandle } from './VoiceConversation' import { PermissionModeToggle } from '@/components/session/PermissionModeToggle' import { SessionSandboxMenu } from '@/components/session/SessionSandboxMenu' +import { ContextUsageMeter } from '@/components/session/ContextUsageMeter' import { ComposerToolsMenu } from './ComposerToolsMenu' import { useSessionGoal, useStartSessionGoal } from '@/hooks/useSessionGoals' import { useSessionPermissionMode } from '@/hooks/useSessionPermissionMode' @@ -39,7 +41,8 @@ import { detectMentionTrigger, parsePromptToInput, getFilename, getDirectory, ty import { matchText, rankByMatch, type MatchRange } from '@/lib/fuzzyMatch' import { getNextPrimaryAgentId } from '@/lib/primaryAgents' import { getNextFavoriteModel } from '@/lib/favoriteModels' -import { randomId } from '@/lib/utils' +import { randomId, cn } from '@/lib/utils' +import { CONTEXT_USAGE_TEXT_CLASS, contextUsageAccessibleLabel, contextUsageTone, formatContextUsage } from '@/lib/contextUsage' import { showToast } from '@/lib/toast' import { findModelInfo, isSameModelSelection } from '@opencode-manager/shared/opencode' import { isOpenSessionGoal } from '@opencode-manager/shared/schemas' @@ -1012,6 +1015,18 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) const { setShowDialog, hasForSession: hasPermissionsForSession } = usePermissions() const hasPendingPermissionForSession = hasPermissionsForSession(sessionID) const { hasVariants, currentVariant, cycleVariant } = useVariants(directory, modelSelectionSession) + const { data: session } = useSession(sessionID, directory) + const revertMessageID = session?.revert?.messageID + const { usage: contextUsage } = useContextUsage(sessionID, directory, revertMessageID) + const isCompacting = useSessionStatusForSession(sessionID).type === 'compact' + const modelPillVariant = hasVariants && currentVariant ? currentVariant : null + const modelPillUsage = contextUsage ? formatContextUsage(contextUsage) : null + const modelPillUsageTone = CONTEXT_USAGE_TEXT_CLASS[contextUsageTone(contextUsage?.usagePercentage ?? null)] + const modelPillLabel = [ + displayModelName || 'Select model', + modelPillVariant ? `${modelPillVariant} variant` : null, + contextUsage ? contextUsageAccessibleLabel(contextUsage) : null, + ].filter((part): part is string => part !== null).join(', ') const handleAgentChange = useCallback((agentId: string) => { setLocalMode(agentId) @@ -1345,13 +1360,38 @@ return ( open={isModelPickerOpen} onOpenChange={setIsModelPickerOpen} session={modelSelectionSession} + contextUsage={contextUsage} + isCompacting={isCompacting} + onCompact={() => commandActions.compact('')} > diff --git a/frontend/src/components/model/ModelQuickSelect.test.tsx b/frontend/src/components/model/ModelQuickSelect.test.tsx index 56a1867d1..b83902243 100644 --- a/frontend/src/components/model/ModelQuickSelect.test.tsx +++ b/frontend/src/components/model/ModelQuickSelect.test.tsx @@ -563,3 +563,73 @@ describe('ModelQuickSelect keyboard navigation', () => { } }) }) + +describe('ModelQuickSelect context strip', () => { + const session = { id: 'ses_1' } + const usage = { totalTokens: 12000, usagePercentage: 6, contextLimit: 200000 } + + it('shows the used and limit tokens with the percentage and meter', () => { + render() + + const summary = screen.getByTestId('context-usage-summary') + + expect(summary).toHaveTextContent('12k / 200k · 6%') + expect(summary).toHaveClass('text-success') + expect(summary).toHaveAttribute('aria-label', 'context 6% used') + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '6%' }) + }) + + it('tints the summary with the warning and destructive tones', () => { + const { rerender } = render( + , + ) + expect(screen.getByTestId('context-usage-summary')).toHaveClass('text-warning') + + rerender() + expect(screen.getByTestId('context-usage-summary')).toHaveClass('text-destructive') + }) + + it('compacts the session from the strip', async () => { + const user = userEvent.setup() + const onCompact = vi.fn() + + render() + + await user.click(screen.getByRole('button', { name: 'Compact session' })) + + expect(onCompact).toHaveBeenCalledTimes(1) + }) + + it('disables compaction while compacting or without a session', () => { + const onCompact = vi.fn() + const { rerender } = render( + , + ) + + expect(screen.getByRole('button', { name: 'Compact session' })).toBeDisabled() + + rerender() + + expect(screen.getByRole('button', { name: 'Compact session' })).toBeDisabled() + expect(onCompact).not.toHaveBeenCalled() + }) + + it('falls back to tokens and an empty meter without a known limit', () => { + render( + , + ) + + expect(screen.getByTestId('context-usage-summary')).toHaveTextContent('12k tokens') + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '0%' }) + }) + + it('omits the strip without context usage', () => { + render() + + expect(screen.queryByTestId('context-usage-strip')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/model/ModelQuickSelect.tsx b/frontend/src/components/model/ModelQuickSelect.tsx index 9d2d69c44..691b43711 100644 --- a/frontend/src/components/model/ModelQuickSelect.tsx +++ b/frontend/src/components/model/ModelQuickSelect.tsx @@ -14,8 +14,16 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' +import { ContextUsageMeter } from '@/components/session/ContextUsageMeter' import { buildModelSections, filterModelSections, type ModelOption } from '@/lib/modelSections' import { isTextEntryElement } from '@/lib/domTargets' +import { + CONTEXT_USAGE_TEXT_CLASS, + contextUsageAccessibleLabel, + contextUsageTone, + formatCompactTokens, + type ContextUsage, +} from '@/lib/contextUsage' interface ModelQuickSelectProps { directory?: string @@ -24,6 +32,9 @@ interface ModelQuickSelectProps { open?: boolean onOpenChange?: (open: boolean) => void session?: ModelSelectionSession + contextUsage?: ContextUsage | null + isCompacting?: boolean + onCompact?: () => void } interface ModelListItem { @@ -70,6 +81,50 @@ function formatModelCount(count: number) { return `${count} ${count === 1 ? 'model' : 'models'}` } +interface ContextUsageStripProps { + usage: ContextUsage + onCompact?: () => void + compactDisabled: boolean +} + +/** + * Compact context-window strip pinned above the picker header: the tone meter, then one line with the + * used and limit token counts, the percentage, and the session compaction action. + */ +function ContextUsageStrip({ usage, onCompact, compactDisabled }: ContextUsageStripProps) { + const percentage = usage.usagePercentage + const toneClass = CONTEXT_USAGE_TEXT_CLASS[contextUsageTone(percentage)] + const summary = usage.contextLimit === null || percentage === null + ? `${formatCompactTokens(usage.totalTokens)} tokens` + : `${formatCompactTokens(usage.totalTokens)} / ${formatCompactTokens(usage.contextLimit)} · ${percentage}%` + + return ( +
+ +
+ + {summary} + + {onCompact && ( + + )} +
+
+ ) +} + function VirtualizedList({ items, itemHeight, @@ -176,6 +231,9 @@ export function ModelQuickSelect({ open, onOpenChange, session, + contextUsage, + isCompacting = false, + onCompact, }: ModelQuickSelectProps) { const [internalIsOpen, setInternalIsOpen] = useState(false) const isOpen = open ?? internalIsOpen @@ -410,6 +468,11 @@ export function ModelQuickSelect({ setSearchQuery('') } + const handleCompact = () => { + onCompact?.() + handleOpenChange(false) + } + const getDescription = (item: ModelListItem) => { const context = item.model?.limit?.context if (context) { @@ -576,6 +639,13 @@ export function ModelQuickSelect({ className="z-[300] border-border bg-popover text-foreground shadow-2xl md:mx-auto md:max-w-lg" ariaLabel="Select model" > + {contextUsage && ( + + )}
{showAllModels ? ( <> diff --git a/frontend/src/components/session/ContextUsageMeter.tsx b/frontend/src/components/session/ContextUsageMeter.tsx new file mode 100644 index 000000000..5423a969d --- /dev/null +++ b/frontend/src/components/session/ContextUsageMeter.tsx @@ -0,0 +1,29 @@ +import { cn } from '@/lib/utils' +import { CONTEXT_USAGE_BG_CLASS, contextUsageTone } from '@/lib/contextUsage' + +interface ContextUsageMeterProps { + percentage: number | null + className?: string +} + +/** + * Two-pixel context-window meter: a muted track with a tone-coloured fill sized to `min(percentage, 100)`. + * Purely decorative, so the surrounding readout carries the accessible label. + */ +export function ContextUsageMeter({ percentage, className }: ContextUsageMeterProps) { + const width = percentage === null ? 0 : Math.min(percentage, 100) + + return ( + @@ -862,17 +871,10 @@ export function SessionDetail() { )}
- - +
@@ -1071,6 +1073,18 @@ export function SessionDetail() { }} /> + + {sideQuestion && sessionId && ( @@ -1160,7 +1174,7 @@ export function SessionDetail() { isOpen={!docked && sourceControlOpen} onClose={() => setSourceControlOpen(false)} currentBranch={repo?.currentBranch || repo?.branch || "main"} - repoName={workspaceDisplayName} + repoName={projectDisplayName} /> )} diff --git a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx index deeb1ac48..5d0b29fa5 100644 --- a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx @@ -124,6 +124,7 @@ vi.mock('@/hooks/useSettingsDialog', () => ({ vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile, useSwipeBack: vi.fn(() => ({ ref: vi.fn() })), + useSwipeDismiss: vi.fn(() => ({ bind: vi.fn(), swipeProgress: 0, swipeStyles: {} })), })) vi.mock('@/hooks/useVisualViewport', () => ({ diff --git a/frontend/src/pages/__tests__/SessionDetail.not-found.test.tsx b/frontend/src/pages/__tests__/SessionDetail.not-found.test.tsx index e8eea957c..b1b55e60d 100644 --- a/frontend/src/pages/__tests__/SessionDetail.not-found.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.not-found.test.tsx @@ -53,7 +53,7 @@ vi.mock('@/hooks/useModelSelection', () => ({ vi.mock('@/hooks/useTTS', () => ({ useTTS: vi.fn(() => ({ isEnabled: false })) })) vi.mock('@/hooks/useSettings', () => ({ useSettings: mocks.useSettings })) vi.mock('@/hooks/useSettingsDialog', () => ({ useSettingsDialog: mocks.useSettingsDialog })) -vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile })) +vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile, useSwipeBack: vi.fn(() => ({ bind: vi.fn(), swipeProgress: 0, swipeStyles: {} })) })) vi.mock('@/hooks/useVisualViewport', () => ({ useVisualViewport: mocks.useVisualViewport })) vi.mock('@/contexts/KeyboardShortcutsContext', () => ({ useShortcutActions: mocks.useShortcutActions })) vi.mock('@/hooks/useAutoScroll', () => ({ useAutoScroll: mocks.useAutoScroll })) @@ -87,7 +87,6 @@ vi.mock('@/components/message/MessageThread', () => ({ MessageThread: mocks.Mess vi.mock('@/components/message/PromptInput', () => ({ PromptInput: mocks.PromptInput })) vi.mock('@/components/message/FloatingTTSButton', () => ({ FloatingTTSButton: () => null })) vi.mock('@/components/message/MessageSkeleton', () => ({ MessageSkeleton: () => null })) -vi.mock('@/components/session/ContextUsageIndicator', () => ({ ContextUsageIndicator: () => null })) vi.mock('@/components/session/SessionSendErrorBanner', () => ({ SessionSendErrorBanner: () => null })) vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: () => null })) vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: () => null })) diff --git a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx index 4c2ef3712..26b81e476 100644 --- a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx @@ -8,7 +8,6 @@ import { SwipeNavigationProvider, useSwipeNavigation } from '@/contexts/SwipeNav import { getServerProjectPath } from '@/lib/navigation' import { LocationCatcher } from '@/test/test-utils' import { MessageThread } from '@/components/message/MessageThread' -import { ContextUsageIndicator } from '@/components/session/ContextUsageIndicator' import { SessionSendErrorBanner } from '@/components/session/SessionSendErrorBanner' import { PendingActionsGroup } from '@/components/notifications/PendingActionsGroup' import { SessionDetail } from '../SessionDetail' @@ -133,7 +132,6 @@ vi.mock('@/components/message/MessageThread', () => ({ vi.mock('@/components/message/PromptInput', () => ({ PromptInput: vi.fn(() => null) })) vi.mock('@/components/message/FloatingTTSButton', () => ({ FloatingTTSButton: vi.fn(() => null) })) vi.mock('@/components/message/MessageSkeleton', () => ({ MessageSkeleton: vi.fn(() => null) })) -vi.mock('@/components/session/ContextUsageIndicator', () => ({ ContextUsageIndicator: vi.fn(() => null) })) vi.mock('@/components/session/SessionSendErrorBanner', () => ({ SessionSendErrorBanner: vi.fn(() => null) })) vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: mocks.SessionGoalBar })) @@ -279,20 +277,21 @@ describe('SessionDetail remote server mode', () => { expect(mocks.useRepoSiblings).toHaveBeenCalledWith(undefined) }) - it('titles the header with the server name and session directory', async () => { + it('shows the remote project in the header without the server name', async () => { renderRemoteSession() await waitFor(() => { - expect(screen.getByText('Kitchen')).toHaveClass('text-info') + expect(screen.getByLabelText('Remote server')).toBeInTheDocument() }) - expect(screen.getByText('Kitchen').parentElement).toHaveTextContent('Kitchen · app') + expect(screen.getByText('app')).toBeInTheDocument() + expect(screen.queryByText(/Kitchen/)).not.toBeInTheDocument() }) it('returns to the project page from the header back button', async () => { const { capturedPathname, capturedSearch } = renderRemoteSession() await waitFor(() => { - expect(screen.getByText('Kitchen').parentElement).toHaveTextContent('Kitchen · app') + expect(screen.getByLabelText('Remote server')).toBeInTheDocument() }) fireEvent.click(screen.getByRole('button', { name: 'Go back' })) @@ -452,9 +451,6 @@ describe('SessionDetail remote server mode', () => { expect(screen.getByTestId('message-thread')).toBeInTheDocument() }) - expect(vi.mocked(ContextUsageIndicator).mock.calls.at(-1)?.[0]).toEqual( - expect.objectContaining({ isConnected: false, isReconnecting: true }), - ) expect(vi.mocked(SessionSendErrorBanner).mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ isConnected: false, isReconnecting: true }), ) @@ -467,9 +463,6 @@ describe('SessionDetail remote server mode', () => { expect(screen.getByTestId('message-thread')).toBeInTheDocument() }) - expect(vi.mocked(ContextUsageIndicator).mock.calls.at(-1)?.[0]).toEqual( - expect.objectContaining({ isConnected: true, isReconnecting: false }), - ) expect(vi.mocked(SessionSendErrorBanner).mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ isConnected: true, isReconnecting: false }), ) @@ -484,11 +477,34 @@ describe('SessionDetail remote server mode', () => { expect(screen.getByTestId('message-thread')).toBeInTheDocument() }) - expect(vi.mocked(ContextUsageIndicator).mock.calls.at(-1)?.[0]).toEqual( + expect(vi.mocked(SessionSendErrorBanner).mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ isConnected: false, isReconnecting: true }), ) }) + it('navigates to the parent session from the header pill', async () => { + mocks.useSession.mockReturnValue({ + data: { ...remoteSession, parentID: 'ses_parent' }, + isLoading: false, + error: undefined, + }) + + const { capturedPathname } = renderRemoteSession() + + fireEvent.click(await screen.findByRole('button', { name: 'Parent session' })) + + expect(capturedPathname.current).toBe('/servers/srv_kitchen/sessions/ses_parent') + }) + + it('opens the session details sheet from the header title', async () => { + renderRemoteSession() + + fireEvent.click(await screen.findByRole('button', { name: 'Session details' })) + + expect(await screen.findByRole('dialog')).toHaveAccessibleName('Session details') + expect(screen.getByText('/home/me/app')).toBeInTheDocument() + }) + it('navigates child sessions within the linked server route', async () => { const { capturedPathname } = renderRemoteSession() diff --git a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx index 70b102259..52a379df2 100644 --- a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx @@ -79,6 +79,7 @@ vi.mock('@/hooks/useSettingsDialog', () => ({ vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile, useSwipeBack: vi.fn(() => ({ ref: vi.fn() })), + useSwipeDismiss: vi.fn(() => ({ bind: vi.fn(), swipeProgress: 0, swipeStyles: {} })), })) vi.mock('@/hooks/useVisualViewport', () => ({ From df9200880e220e88b5d8f135b424b82185caa1d7 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:52:17 -0400 Subject: [PATCH 05/14] fix: apply CodeRabbit auto-fixes --- frontend/src/api/opencode.ts | 4 -- .../src/components/model/ModelQuickSelect.tsx | 4 -- .../components/session/ContextUsageMeter.tsx | 4 -- .../components/session/ReconnectingBanner.tsx | 8 ---- .../session/SessionDetailsSheet.test.tsx | 12 +++++ .../session/SessionDetailsSheet.tsx | 10 +--- .../session/SessionPickerDialog.test.tsx | 12 +++++ .../session/SessionPickerDialog.tsx | 11 +---- .../session/SessionPickerSheet.test.tsx | 47 +++++++++++++++++-- .../components/session/SessionPickerSheet.tsx | 9 ++-- .../src/hooks/useSessionPickerRouteScope.ts | 4 -- frontend/src/hooks/useVcsInfo.ts | 11 ----- frontend/src/lib/contextUsage.ts | 16 ------- frontend/src/pages/SessionDetail.tsx | 22 +++++---- .../__tests__/SessionDetail.remote.test.tsx | 2 +- 15 files changed, 87 insertions(+), 89 deletions(-) diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index e3eb90acd..5d9afd2a4 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -37,10 +37,6 @@ export type OpenCodeLocationInfo = Awaited>[number] -/** - * The timestamp a project list sorts and labels on: the server's last-activity time, falling back - * to the record's update time for a project that has never recorded activity. - */ export function projectActivityTimestamp(project: OpenCodeProject): number { return project.time.active || project.time.updated } diff --git a/frontend/src/components/model/ModelQuickSelect.tsx b/frontend/src/components/model/ModelQuickSelect.tsx index 691b43711..40e588085 100644 --- a/frontend/src/components/model/ModelQuickSelect.tsx +++ b/frontend/src/components/model/ModelQuickSelect.tsx @@ -87,10 +87,6 @@ interface ContextUsageStripProps { compactDisabled: boolean } -/** - * Compact context-window strip pinned above the picker header: the tone meter, then one line with the - * used and limit token counts, the percentage, and the session compaction action. - */ function ContextUsageStrip({ usage, onCompact, compactDisabled }: ContextUsageStripProps) { const percentage = usage.usagePercentage const toneClass = CONTEXT_USAGE_TEXT_CLASS[contextUsageTone(percentage)] diff --git a/frontend/src/components/session/ContextUsageMeter.tsx b/frontend/src/components/session/ContextUsageMeter.tsx index 5423a969d..d2545f620 100644 --- a/frontend/src/components/session/ContextUsageMeter.tsx +++ b/frontend/src/components/session/ContextUsageMeter.tsx @@ -6,10 +6,6 @@ interface ContextUsageMeterProps { className?: string } -/** - * Two-pixel context-window meter: a muted track with a tone-coloured fill sized to `min(percentage, 100)`. - * Purely decorative, so the surrounding readout carries the accessible label. - */ export function ContextUsageMeter({ percentage, className }: ContextUsageMeterProps) { const width = percentage === null ? 0 : Math.min(percentage, 100) diff --git a/frontend/src/components/session/ReconnectingBanner.tsx b/frontend/src/components/session/ReconnectingBanner.tsx index 208fac94c..38198635b 100644 --- a/frontend/src/components/session/ReconnectingBanner.tsx +++ b/frontend/src/components/session/ReconnectingBanner.tsx @@ -4,14 +4,6 @@ export interface ReconnectingBannerProps { connected: boolean } -/** - * Full-width strip under the session header that reports a paused event - * stream. It stays hidden until the stream has connected at least once, so a - * first paint before the subscription settles does not read as a dropped - * connection. - * - * @param connected Whether the session's event stream is currently connected. - */ export function ReconnectingBanner({ connected }: ReconnectingBannerProps) { const [hasConnected, setHasConnected] = useState(false) diff --git a/frontend/src/components/session/SessionDetailsSheet.test.tsx b/frontend/src/components/session/SessionDetailsSheet.test.tsx index 0b204dc84..46c4e8ccd 100644 --- a/frontend/src/components/session/SessionDetailsSheet.test.tsx +++ b/frontend/src/components/session/SessionDetailsSheet.test.tsx @@ -154,6 +154,18 @@ describe('SessionDetailsSheet', () => { ) }) + it('ignores Enter while an IME composition is active', () => { + const mutation = mockRename() + renderSheet() + + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + const input = screen.getByLabelText('Session title') + fireEvent.change(input, { target: { value: 'にほんご' } }) + fireEvent.keyDown(input, { key: 'Enter', isComposing: true }) + + expect(mutation.mutate).not.toHaveBeenCalled() + }) + it('cancels editing and resets the rename error', () => { const mutation = mockRename({ error: new Error('nope') }) renderSheet() diff --git a/frontend/src/components/session/SessionDetailsSheet.tsx b/frontend/src/components/session/SessionDetailsSheet.tsx index 801ae06e6..5d1b10b76 100644 --- a/frontend/src/components/session/SessionDetailsSheet.tsx +++ b/frontend/src/components/session/SessionDetailsSheet.tsx @@ -40,15 +40,6 @@ function InfoRow({ label, value, mono, leading }: InfoRowProps) { ) } -/** - * Session details shown from the session header title. It lists the title, - * server, project, branch and directory untruncated, and renames the session - * inline so the sheet stays open while the new title lands through the session - * query. It renders as a bottom sheet on mobile and a dialog on desktop. - * - * Dismissal is blocked while a rename is pending, and the mutation error resets - * when the sheet reopens so a failed rename cannot leak into the next open. - */ export function SessionDetailsSheet({ open, onOpenChange, @@ -120,6 +111,7 @@ export function SessionDetailsSheet({ value={draft} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return if (event.key !== 'Enter') return event.preventDefault() save() diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index 35c323663..63b74c45d 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -394,6 +394,18 @@ describe('SessionPickerDialog', () => { expect(localStorage.getItem('oc:session-picker:all-projects')).toBeNull() }) + it('leaves the persisted scope preference unchanged on Ctrl+A without a current repo', () => { + localStorage.setItem('oc:session-picker:all-projects', 'false') + + renderPicker({ currentRepo: undefined }) + + fireEvent.keyDown(combobox(), { key: 'a', ctrlKey: true }) + + expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('false') + expect(mocks.lastAllDirectories).toBe(true) + expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + }) + it('starts a new session in the current repo from the sessions tab header action', () => { const onNavigate = vi.fn() renderPicker({ onNavigate }) diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index 521ede245..a438d6b06 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -86,7 +86,6 @@ interface SessionPickerDialogProps { onSelectRemoteSession?: (session: Session, serverId: string) => void onDeleteActiveSession: () => void onSelectRepo: (repo: Repo) => void - /** Navigates to a path the panel produced, such as a new session created from the header action. */ onNavigate: (path: string) => void /** Set on a linked-server route to list that server's sessions instead of Manager repos. */ remote?: RemotePickerScope @@ -569,7 +568,7 @@ interface SessionPickerContentProps { onDeleteActiveSession: () => void allProjects: boolean scopeEnabled: boolean - onToggleAllProjects: () => void + onToggleAllProjects?: () => void switchHint: string searchInputRef: React.RefObject searchTrailing?: React.ReactNode @@ -1212,19 +1211,13 @@ export interface SessionPickerPanelProps { onDeleteActiveSession: () => void onSelectRepo: (repo: Repo) => void onNavigate: (path: string) => void - /** Set on a linked-server route to list that server's sessions instead of Manager repos. */ remote?: RemotePickerScope - /** Lists the linked server's projects on a Projects tab; the desktop dialog leaves it off. */ remoteProjects?: boolean - /** Extra controls rendered at the trailing end of every tab's search bar. */ searchTrailing?: React.ReactNode - /** Hands the add-repository action to the host, which renders the dialog above its own surface. */ onAddRepo?: () => void - /** Renders the dialog title element, which only a Radix Dialog can host. */ renderTitle?: (label: string) => React.ReactNode } -/** The tab strip, search bars, lists and header actions shared by the picker dialog and sheet. */ export function SessionPickerPanel({ open, currentRepo, @@ -1423,7 +1416,7 @@ export function SessionPickerPanel({ onDeleteActiveSession={onDeleteActiveSession} allProjects={allProjects} scopeEnabled={scopeEnabled} - onToggleAllProjects={toggleAllProjects} + onToggleAllProjects={scopeEnabled ? toggleAllProjects : undefined} switchHint={switchHint} searchInputRef={searchInputRef} searchTrailing={sessionsTrailing} diff --git a/frontend/src/components/session/SessionPickerSheet.test.tsx b/frontend/src/components/session/SessionPickerSheet.test.tsx index 62c3a192d..2181373c4 100644 --- a/frontend/src/components/session/SessionPickerSheet.test.tsx +++ b/frontend/src/components/session/SessionPickerSheet.test.tsx @@ -136,13 +136,19 @@ function repo(id: number, name: string, fullPath: string): Repo { } } -function activeWorkItem(id: string, directory: string): ActiveWorkItem { +function activeWorkItem( + id: string, + directory: string, + server?: { id: string; name: string }, +): ActiveWorkItem { const now = Date.now() return { - key: `1:${id}`, + key: `${server?.id ?? LOCAL_OPENCODE_SERVER_ID}:${id}`, state: 'recent', session: session(id, `Active ${id}`, directory, now), - repo: repo(1, 'alpha', directory), + repo: server ? null : repo(1, 'alpha', directory), + serverId: server?.id ?? null, + serverName: server?.name ?? null, updatedAt: now, path: `/repos/1/sessions/${id}`, } @@ -329,4 +335,39 @@ describe('SessionPickerSheet', () => { expect(screen.getByTestId('location')).toHaveTextContent('/repos/1/sessions/ses_active REPLACE') }) + + it('opens a remote activity row on its own server while the local server is selected', async () => { + mocks.activeWorkGroups = { + needsInput: [], + running: [], + recent: [activeWorkItem('ses_kitchen', '/home/me/app', { id: 'srv_kitchen', name: 'Kitchen' })], + } + + renderSheet() + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Activity' })) + fireEvent.click(await screen.findByRole('option', { name: /Active ses_kitchen/ })) + + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_kitchen/sessions/ses_kitchen REPLACE', + ) + }) + + it('opens an activity row of another linked server than the selected one', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.activeWorkGroups = { + needsInput: [], + running: [], + recent: [activeWorkItem('ses_hall', '/home/me/hall', { id: 'srv_hall', name: 'Hall' })], + } + + renderSheet() + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Activity' })) + fireEvent.click(await screen.findByRole('option', { name: /Active ses_hall/ })) + + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_hall/sessions/ses_hall REPLACE', + ) + }) }) diff --git a/frontend/src/components/session/SessionPickerSheet.tsx b/frontend/src/components/session/SessionPickerSheet.tsx index 547d5b70a..2934046a3 100644 --- a/frontend/src/components/session/SessionPickerSheet.tsx +++ b/frontend/src/components/session/SessionPickerSheet.tsx @@ -14,12 +14,10 @@ interface SessionPickerSheetProps { onClose: () => void } -/** The session picker in a bottom sheet; every navigation replaces the sheet's history entry. */ export function SessionPickerSheet({ isOpen, onClose }: SessionPickerSheetProps) { const navigate = useNavigate() const [addRepoOpen, setAddRepoOpen] = useState(false) const { currentRepo, activeSessionID, remote } = useSessionPickerRouteScope(isOpen) - const remoteServerId = remote?.serverId const navigateReplacing = useCallback( (path: string) => { @@ -40,15 +38,14 @@ export function SessionPickerSheet({ isOpen, onClose }: SessionPickerSheetProps) ) const handleSelectRemoteSession = useCallback( - (session: Session) => { - if (!remoteServerId) return + (session: Session, serverId: string) => { if (session.id === activeSessionID) { onClose() return } - navigateReplacing(getServerSessionPath(remoteServerId, session.id)) + navigateReplacing(getServerSessionPath(serverId, session.id)) }, - [activeSessionID, navigateReplacing, onClose, remoteServerId], + [activeSessionID, navigateReplacing, onClose], ) const handleDeleteActiveSession = useCallback(() => { diff --git a/frontend/src/hooks/useSessionPickerRouteScope.ts b/frontend/src/hooks/useSessionPickerRouteScope.ts index aa1505a1b..f9625292f 100644 --- a/frontend/src/hooks/useSessionPickerRouteScope.ts +++ b/frontend/src/hooks/useSessionPickerRouteScope.ts @@ -13,10 +13,6 @@ export interface SessionPickerRouteScope { remote: { serverId: string; serverName?: string; projectDirectory?: string } | undefined } -/** - * Resolves what a session picker opened on the current route should list: the Manager repo of a - * repo route, or the selected linked server and its project directory. - */ export function useSessionPickerRouteScope(enabled: boolean): SessionPickerRouteScope { const { pathname, search } = useLocation() const [selectedServerId] = useSidebarServerSelection() diff --git a/frontend/src/hooks/useVcsInfo.ts b/frontend/src/hooks/useVcsInfo.ts index 2a2e20091..fd2525fd6 100644 --- a/frontend/src/hooks/useVcsInfo.ts +++ b/frontend/src/hooks/useVcsInfo.ts @@ -6,16 +6,6 @@ export interface UseVcsInfoOptions { enabled?: boolean } -/** - * Reads the version-control info of a directory through the OpenCode server in - * scope. The query is keyed by directory and server id, so the session header, - * the project header and the source control panel share one cached read, and a - * linked server reports its own checkout rather than the Manager's. - * - * @param directory Absolute checkout directory; the query stays disabled without one. - * @param serverId Linked server to read from, or the local Manager when omitted. - * @param options.enabled Whether to read the info; defaults to `true`. - */ export function useVcsInfo( directory: string | undefined, serverId: string | undefined, @@ -28,6 +18,5 @@ export function useVcsInfo( queryFn: () => getVcsInfo(directory ?? '', serverId), enabled: enabled && Boolean(directory), retry: false, - staleTime: 60_000, }) } diff --git a/frontend/src/lib/contextUsage.ts b/frontend/src/lib/contextUsage.ts index d5e85f011..9a0bf04e7 100644 --- a/frontend/src/lib/contextUsage.ts +++ b/frontend/src/lib/contextUsage.ts @@ -18,20 +18,12 @@ export const CONTEXT_USAGE_BG_CLASS: Record = { destructive: 'bg-destructive', } -/** - * Maps a context-window percentage to a semantic tone: under 50 is `success`, under 80 is `warning`, - * and 80 or more is `destructive`. An unknown percentage falls back to `success`. - */ export function contextUsageTone(percentage: number | null): ContextUsageTone { if (percentage === null || percentage < 50) return 'success' if (percentage < 80) return 'warning' return 'destructive' } -/** - * Abbreviates a token count so it fits a one-line readout: exact below a thousand, whole thousands up - * to a million (`41k`), and millions to at most one decimal (`1M`, `1.3M`). - */ export function formatCompactTokens(tokens: number): string { if (tokens < 1000) return String(tokens) if (tokens < 1000000) { @@ -41,10 +33,6 @@ export function formatCompactTokens(tokens: number): string { return `${Number((tokens / 1000000).toFixed(1))}M` } -/** - * Formats context usage for a compact readout: the percentage of the model's context window when both - * a limit and a percentage are known, otherwise a compact token count such as `41k`. - */ export function formatContextUsage(usage: ContextUsage): string { if (usage.contextLimit !== null && usage.usagePercentage !== null) { return `${usage.usagePercentage}%` @@ -52,10 +40,6 @@ export function formatContextUsage(usage: ContextUsage): string { return formatCompactTokens(usage.totalTokens) } -/** - * Builds the screen-reader description of a context-window readout, e.g. `context 42% used` or, when - * the limit is unknown, `context 12k tokens used`. - */ export function contextUsageAccessibleLabel(usage: ContextUsage): string { if (usage.usagePercentage !== null) { return `context ${usage.usagePercentage}% used` diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index e34292f1e..5c2640d54 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -817,16 +817,18 @@ export function SessionDetail() {
- +

+ +

{session?.parentID ? ( + + ) +}) diff --git a/frontend/src/components/message/PromptInput.composer.test.tsx b/frontend/src/components/message/PromptInput.composer.test.tsx index b560efcb4..e7080deed 100644 --- a/frontend/src/components/message/PromptInput.composer.test.tsx +++ b/frontend/src/components/message/PromptInput.composer.test.tsx @@ -1,10 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useSyncExternalStore } from 'react' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' import { useComposerDraftStore } from '@/stores/composerDraftStore' import { useSessionStatus } from '@/stores/sessionStatusStore' import { createCommandActionsMock, createVoiceConversationControllerMock } from '@/test/test-utils' +import type { ContextUsage } from '@/lib/contextUsage' const mocks = vi.hoisted(() => ({ useSTT: vi.fn(), @@ -22,6 +24,9 @@ const mocks = vi.hoisted(() => ({ useHasBackgroundActivity: vi.fn(), useSendPrompt: vi.fn(), useContextUsage: vi.fn(), + contextUsageListeners: new Set<() => void>(), + contextUsageValue: null as ContextUsage | null, + agentQuickSelectRenders: { current: 0 }, sessionData: undefined as { revert?: { messageID: string } } | undefined, hasPendingPermission: vi.fn(() => false), setShowDialog: vi.fn(), @@ -70,7 +75,10 @@ vi.mock('@/contexts/EventContext', () => ({ })) vi.mock('@/components/agent/AgentQuickSelect', () => ({ - AgentQuickSelect: () =>
, + AgentQuickSelect: () => { + mocks.agentQuickSelectRenders.current += 1 + return
+ }, })) vi.mock('@/components/model/ModelQuickSelect', () => ({ @@ -91,6 +99,22 @@ vi.mock('@/hooks/useSessionGoals', () => ({ })) vi.mock('@/hooks/useSessionPermissionMode', () => ({ useSessionPermissionMode: () => ({ data: undefined }) })) +function subscribeContextUsage(listener: () => void) { + mocks.contextUsageListeners.add(listener) + return () => { + mocks.contextUsageListeners.delete(listener) + } +} + +function getContextUsageSnapshot() { + return mocks.contextUsageValue +} + +function setContextUsage(usage: ContextUsage | null) { + mocks.contextUsageValue = usage + mocks.contextUsageListeners.forEach((listener) => listener()) +} + const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) @@ -157,7 +181,10 @@ describe('PromptInput composer layout', () => { mocks.useSessionAgentStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ setAgent: vi.fn() })) mocks.useSendErrorStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ errors: {} })) mocks.useHasBackgroundActivity.mockReturnValue(false) - mocks.useContextUsage.mockReturnValue({ usage: null, isLoading: false }) + mocks.useContextUsage.mockReturnValue(null) + mocks.contextUsageListeners.clear() + mocks.contextUsageValue = null + mocks.agentQuickSelectRenders.current = 0 mocks.sessionData = undefined }) @@ -249,8 +276,8 @@ describe('PromptInput composer layout', () => { }) describe('model pill context usage', () => { - const setUsage = (usage: { totalTokens: number, usagePercentage: number | null, contextLimit: number | null }) => { - mocks.useContextUsage.mockReturnValue({ usage, isLoading: false }) + const setUsage = (usage: ContextUsage) => { + mocks.useContextUsage.mockReturnValue(usage) } it('shows the percentage with its tone and the meter on the model pill', () => { @@ -305,6 +332,24 @@ describe('PromptInput composer layout', () => { expect(screen.queryByTestId('context-usage-meter')).not.toBeInTheDocument() }) + it('re-renders only the pill when the context usage changes', () => { + mocks.useContextUsage.mockImplementation(() => useSyncExternalStore(subscribeContextUsage, getContextUsageSnapshot)) + setContextUsage({ totalTokens: 84000, usagePercentage: 42, contextLimit: 200000 }) + renderComponent() + + const siblingRenders = mocks.agentQuickSelectRenders.current + expect(screen.getByTestId('model-pill-context-usage')).toHaveTextContent('42%') + expect(siblingRenders).toBeGreaterThan(0) + + act(() => { + setContextUsage({ totalTokens: 100000, usagePercentage: 50, contextLimit: 200000 }) + }) + + expect(screen.getByTestId('model-pill-context-usage')).toHaveTextContent('50%') + expect(screen.getByTestId('model-pill-context-usage')).toHaveClass('text-warning') + expect(mocks.agentQuickSelectRenders.current).toBe(siblingRenders) + }) + it('measures usage up to the staged revert boundary', () => { mocks.sessionData = { revert: { messageID: 'msg_2' } } renderComponent() diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index a786fd1e1..2be610038 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -1,6 +1,5 @@ import { useState, useRef, useEffect, useMemo, useImperativeHandle, forwardRef, memo, useCallback, type KeyboardEvent } from 'react' -import { useContextUsage } from '@/hooks/useContextUsage' -import { useSendPrompt, useInterruptSession, useSendShell, useAgents, useSkills, useSession } from '@/hooks/useOpenCode' +import { useSendPrompt, useInterruptSession, useSendShell, useAgents, useSkills } from '@/hooks/useOpenCode' import { useCommands } from '@/hooks/useCommands' import { useCommandHandler } from '@/hooks/useCommandHandler' import { useFileSearch } from '@/hooks/useFileSearch' @@ -28,12 +27,11 @@ import { IconToggleButton } from '@/components/ui/icon-toggle-button' import { PromptSuggestions, type PromptSuggestion } from './PromptSuggestions' import { ComposerActivityBar } from './ComposerActivityBar' -import { ModelQuickSelect } from '@/components/model/ModelQuickSelect' import { AgentQuickSelect } from '@/components/agent/AgentQuickSelect' import { VoiceConversation, type VoiceConversationHandle } from './VoiceConversation' import { PermissionModeToggle } from '@/components/session/PermissionModeToggle' import { SessionSandboxMenu } from '@/components/session/SessionSandboxMenu' -import { ContextUsageMeter } from '@/components/session/ContextUsageMeter' +import { ComposerModelPill } from './ComposerModelPill' import { ComposerToolsMenu } from './ComposerToolsMenu' import { useSessionGoal, useStartSessionGoal } from '@/hooks/useSessionGoals' import { useSessionPermissionMode } from '@/hooks/useSessionPermissionMode' @@ -41,8 +39,7 @@ import { detectMentionTrigger, parsePromptToInput, getFilename, getDirectory, ty import { matchText, rankByMatch, type MatchRange } from '@/lib/fuzzyMatch' import { getNextPrimaryAgentId } from '@/lib/primaryAgents' import { getNextFavoriteModel } from '@/lib/favoriteModels' -import { randomId, cn } from '@/lib/utils' -import { CONTEXT_USAGE_TEXT_CLASS, contextUsageAccessibleLabel, contextUsageTone, formatContextUsage } from '@/lib/contextUsage' +import { randomId } from '@/lib/utils' import { showToast } from '@/lib/toast' import { findModelInfo, isSameModelSelection } from '@opencode-manager/shared/opencode' import { isOpenSessionGoal } from '@opencode-manager/shared/schemas' @@ -1015,18 +1012,9 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) const { setShowDialog, hasForSession: hasPermissionsForSession } = usePermissions() const hasPendingPermissionForSession = hasPermissionsForSession(sessionID) const { hasVariants, currentVariant, cycleVariant } = useVariants(directory, modelSelectionSession) - const { data: session } = useSession(sessionID, directory) - const revertMessageID = session?.revert?.messageID - const { usage: contextUsage } = useContextUsage(sessionID, directory, revertMessageID) const isCompacting = useSessionStatusForSession(sessionID).type === 'compact' const modelPillVariant = hasVariants && currentVariant ? currentVariant : null - const modelPillUsage = contextUsage ? formatContextUsage(contextUsage) : null - const modelPillUsageTone = CONTEXT_USAGE_TEXT_CLASS[contextUsageTone(contextUsage?.usagePercentage ?? null)] - const modelPillLabel = [ - displayModelName || 'Select model', - modelPillVariant ? `${modelPillVariant} variant` : null, - contextUsage ? contextUsageAccessibleLabel(contextUsage) : null, - ].filter((part): part is string => part !== null).join(', ') + const handleCompact = useCallback(() => commandActions.compact(''), [commandActions]) const handleAgentChange = useCallback((agentId: string) => { setLocalMode(agentId) @@ -1355,46 +1343,17 @@ return ( ) : null} - commandActions.compact('')} - > - - + onCompact={handleCompact} + /> )}
diff --git a/frontend/src/components/model/ModelQuickSelect.test.tsx b/frontend/src/components/model/ModelQuickSelect.test.tsx index b83902243..a179016f0 100644 --- a/frontend/src/components/model/ModelQuickSelect.test.tsx +++ b/frontend/src/components/model/ModelQuickSelect.test.tsx @@ -633,3 +633,70 @@ describe('ModelQuickSelect context strip', () => { expect(screen.queryByTestId('context-usage-strip')).not.toBeInTheDocument() }) }) + +describe('ModelQuickSelect model descriptions', () => { + const limitedProviders: Provider[] = [ + { + id: 'anthropic', + name: 'Anthropic', + models: [ + { + id: 'claude-sonnet-4', + key: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + released: 0, + free: false, + limit: { context: 200000, output: 8192 }, + }, + { + id: 'claude-opus-5-5', + key: 'claude-opus-5-5', + name: 'Claude Opus 5.5', + released: 0, + free: false, + limit: { context: 1000000, output: 8192 }, + }, + ], + }, + ] + + function selectModel(modelID: string) { + const modelState = { favorite: [], recent: [], variant: {} } + mocks.useModelSelection.mockReturnValue({ + model: { providerID: 'anthropic', modelID }, + modelString: `anthropic/${modelID}`, + info: undefined, + activeAgent: null, + recentModels: [], + favoriteModels: [], + configured: null, + modelState, + setModel: vi.fn(), + setActiveAgent: vi.fn(), + toggleFavorite: vi.fn(), + removeRecentModel: vi.fn(), + isModelReady: true, + }) + mocks.useModelSections.mockReturnValue({ + providers: limitedProviders, + sections: buildModelSections(limitedProviders, modelState, null), + defaultModel: null, + modelState, + isLoading: false, + }) + } + + it('formats the context limit like the context strip', () => { + selectModel('claude-sonnet-4') + render() + + expect(screen.getByText('Anthropic · 200k context', { selector: 'p' })).toBeInTheDocument() + }) + + it('formats million-token limits without a trailing decimal', () => { + selectModel('claude-opus-5-5') + render() + + expect(screen.getByText('Anthropic · 1M context', { selector: 'p' })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/model/ModelQuickSelect.tsx b/frontend/src/components/model/ModelQuickSelect.tsx index 40e588085..d20991815 100644 --- a/frontend/src/components/model/ModelQuickSelect.tsx +++ b/frontend/src/components/model/ModelQuickSelect.tsx @@ -472,8 +472,7 @@ export function ModelQuickSelect({ const getDescription = (item: ModelListItem) => { const context = item.model?.limit?.context if (context) { - const formattedContext = context >= 1000000 ? `${(context / 1000000).toFixed(1)}M` : context.toLocaleString() - return `${item.providerName} · ${formattedContext} context` + return `${item.providerName} · ${formatCompactTokens(context)} context` } return item.providerName diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index 5b19e28bb..62b19cfb4 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -7,6 +7,7 @@ import { useAuth } from '@/hooks/useAuth' import { useUrlParams } from '@/hooks/useUrlParams' import { useOpenNavItem } from '@/hooks/useOpenNavItem' import { useSessionPickerRouteScope } from '@/hooks/useSessionPickerRouteScope' +import { useSessionPickerNavigation } from '@/hooks/useSessionPickerNavigation' import { buildNavModel, type MoreDrawerItem, type NavPrimaryCta } from '@/components/navigation/moreDrawerItems' import { SessionPickerDialog } from '@/components/session/SessionPickerDialog' import { DesktopSessionTree } from '@/components/navigation/DesktopSessionTree' @@ -16,14 +17,13 @@ import { SidebarSection, SidebarItem, } from '@/components/ui/sidebar' -import { getRepoPath, getServerSessionPath, getSessionPath } from '@/lib/navigation' import { List } from 'lucide-react' const FOOTER_ITEM_KEYS = new Set(['home', 'settings', 'logout']) function DesktopSessionsPicker({ onDismiss }: { onDismiss: () => void }) { - const navigate = useNavigate() const { currentRepo, activeSessionID, remote } = useSessionPickerRouteScope(true) + const pickerNavigation = useSessionPickerNavigation({ mode: 'push', onClose: onDismiss }) return ( void }) { }} currentRepo={currentRepo} activeSessionID={activeSessionID} + {...pickerNavigation} remote={remote} - onSelectSession={(session, sessionRepo) => { - navigate(getSessionPath(sessionRepo.id, session.id)) - onDismiss() - }} - onSelectRemoteSession={(session, serverId) => { - navigate(getServerSessionPath(serverId, session.id)) - onDismiss() - }} - onDeleteActiveSession={() => { - onDismiss() - if (currentRepo) navigate(getRepoPath(currentRepo.id)) - }} - onSelectRepo={(selectedRepo) => { - navigate(getRepoPath(selectedRepo.id)) - onDismiss() - }} - onNavigate={(path) => { - navigate(path) - onDismiss() - }} /> ) } diff --git a/frontend/src/components/navigation/LinkedServersNav.tsx b/frontend/src/components/navigation/LinkedServersNav.tsx index 63553c4ef..809732f34 100644 --- a/frontend/src/components/navigation/LinkedServersNav.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.tsx @@ -20,7 +20,7 @@ import { cn } from '@/lib/utils' const LOCAL_SERVER_LABEL = 'OC Manager' -export type LinkedServersVariant = 'sidebar' | 'rail' | 'header' +type LinkedServersVariant = 'sidebar' | 'rail' | 'header' interface LinkedServersNavProps { /** `sidebar` renders the full-width trigger row, `rail` the collapsed icon button, `header` a compact page-header trigger. */ diff --git a/frontend/src/components/navigation/RemoteProjectNav.test.tsx b/frontend/src/components/navigation/RemoteProjectNav.test.tsx index afa5f6a78..c9b329514 100644 --- a/frontend/src/components/navigation/RemoteProjectNav.test.tsx +++ b/frontend/src/components/navigation/RemoteProjectNav.test.tsx @@ -194,32 +194,6 @@ describe('RemoteProjectNavList', () => { expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') }) - it('orders projects by recent activity', async () => { - const now = Date.now() - mocks.listProjects.mockResolvedValue([ - createProject('p_stale', 'Stale', '/projects/stale', { - created: now - 10_800_000, - updated: now - 300_000, - active: now - 10_800_000, - }), - createProject('p_recent', 'Recent', '/projects/recent', { - created: now - 10_800_000, - updated: now - 10_800_000, - active: now - 300_000, - }), - ]) - - const { container } = render(, { - wrapper: createWrapper('/'), - }) - - await screen.findByText('Recent') - - const text = container.textContent ?? '' - expect(text.indexOf('Recent')).toBeGreaterThan(-1) - expect(text.indexOf('Recent')).toBeLessThan(text.indexOf('Stale')) - }) - it('expands the project of the current project route and loads its sessions', async () => { render(, { wrapper: createWrapper('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha'), diff --git a/frontend/src/components/navigation/RemoteProjectNav.tsx b/frontend/src/components/navigation/RemoteProjectNav.tsx index dc2e4e419..9143b3427 100644 --- a/frontend/src/components/navigation/RemoteProjectNav.tsx +++ b/frontend/src/components/navigation/RemoteProjectNav.tsx @@ -1,21 +1,18 @@ import { useEffect, useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' -import { listProjects, projectActivityTimestamp, type OpenCodeProject } from '@/api/opencode' +import { remoteProjectName, type OpenCodeProject } from '@/api/opencode' import type { Session } from '@/api/types' import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' -import { useSessionsAcrossDirectories, sessionQueryOptions } from '@/hooks/useOpenCode' +import { useSessionsAcrossDirectories } from '@/hooks/useOpenCode' +import { useActiveRemoteDirectory } from '@/hooks/useActiveRemoteDirectory' +import { useRemoteProjects } from '@/hooks/useRemoteProjects' import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers' import { useProjectActivityState } from '@/hooks/useActiveWork' import { useSessionSearch } from '@/hooks/useSessionSearch' -import { serverScopedKey } from '@/lib/serverScope' import { getServerProjectPath, getServerSessionPath, - parseServerProjectRoute, - parseServerSessionRoute, } from '@/lib/navigation' -import { getDirectoryName } from '@/lib/utils' import { getSessionKey } from '@/lib/sessionKey' import { NewSessionButton, @@ -31,10 +28,6 @@ import { type SidebarSessionItem, } from '@/components/navigation/sidebar-session-tree' -function projectName(project: OpenCodeProject): string { - return project.name?.trim() || getDirectoryName(project.canonical) -} - function buildRemoteItem(session: Session, serverId: string, projectLabel: string): SidebarSessionItem { return { key: getSessionKey(session), @@ -55,20 +48,6 @@ function RemoteProjectActivityIndicator({ serverId, directory }: { serverId: str return } -function useActiveRemoteDirectory(serverId: string): string | null { - const { pathname, search } = useLocation() - const projectRoute = parseServerProjectRoute(pathname, search) - const sessionRoute = parseServerSessionRoute(pathname) - const sessionID = sessionRoute?.serverId === serverId ? sessionRoute.sessionId : undefined - const { data: session } = useQuery({ - ...sessionQueryOptions(sessionID, undefined, serverId), - enabled: Boolean(sessionID), - }) - - if (projectRoute?.serverId === serverId) return projectRoute.directory - return session?.location.directory ?? null -} - interface RemoteSessionRowsProps { serverId: string projectLabel: string @@ -135,7 +114,7 @@ function RemoteProjectSessions({ serverId, project, onSelectSession }: RemotePro return ( projects.filter( (project) => - projectName(project).toLowerCase().includes(needle) || + remoteProjectName(project).toLowerCase().includes(needle) || project.canonical.toLowerCase().includes(needle), ), [projects, needle], @@ -342,7 +321,7 @@ function RemoteProjectSearchResults({ {matchedSessions ? ( candidate.id === serverId) const activeDirectory = useActiveRemoteDirectory(serverId) - const projectsQuery = useQuery({ - queryKey: serverScopedKey(['opencode', 'projects'], serverId), - queryFn: () => listProjects(serverId), - enabled: server?.enabled === true, - retry: false, - }) - - const projects = useMemo( - () => - [...(projectsQuery.data ?? [])].sort( - (a, b) => projectActivityTimestamp(b) - projectActivityTimestamp(a), - ), - [projectsQuery.data], - ) + const { projects, isLoading: projectsLoading, isError: projectsError, hasData: hasProjects } = + useRemoteProjects(serverId) const trimmedSearch = search.trim() if (isServersLoading) return Loading projects... if (!server) return Server not found if (!server.enabled) return Server is disabled - if (projectsQuery.isLoading) return Loading projects... - if (projectsQuery.isError && projectsQuery.data === undefined) { + if (projectsLoading) return Loading projects... + if (projectsError && !hasProjects) { return ( {statusById[serverId]?.connected === false ? 'Server unreachable' : 'Could not load projects'} diff --git a/frontend/src/components/servers/RemoteProjectList.tsx b/frontend/src/components/servers/RemoteProjectList.tsx index c84912550..b8468000a 100644 --- a/frontend/src/components/servers/RemoteProjectList.tsx +++ b/frontend/src/components/servers/RemoteProjectList.tsx @@ -1,14 +1,13 @@ import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' import { Folder, FolderGit2, FolderPlus, GitBranch, Search, Server } from 'lucide-react' -import { listProjects, projectActivityTimestamp, type OpenCodeProject } from '@/api/opencode' +import { projectActivityTimestamp, remoteProjectName, type OpenCodeProject } from '@/api/opencode' import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' +import { useRemoteProjects } from '@/hooks/useRemoteProjects' import { useSettingsDialog } from '@/hooks/useSettingsDialog' import { useProjectActivityState } from '@/hooks/useActiveWork' -import { serverScopedKey } from '@/lib/serverScope' import { getServerProjectPath } from '@/lib/navigation' -import { formatShortRelativeTime, getDirectoryName } from '@/lib/utils' +import { formatShortRelativeTime } from '@/lib/utils' import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' import { Button } from '@/components/ui/button' @@ -20,10 +19,6 @@ import { RepoCardShell } from '@/components/repo/RepoCardShell' import { RepoCardSkeleton } from '@/components/repo/RepoCardSkeleton' import { RepoSearchInput } from '@/components/repo/RepoSearchInput' -function projectName(project: OpenCodeProject): string { - return project.name?.trim() || getDirectoryName(project.canonical) -} - function RemoteProjectCard({ project, serverId }: { project: OpenCodeProject; serverId: string }) { const navigate = useNavigate() const activityState = useProjectActivityState(serverId, project.canonical) @@ -31,7 +26,7 @@ function RemoteProjectCard({ project, serverId }: { project: OpenCodeProject; se return ( } actions={} @@ -95,28 +90,15 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) { const server = servers.find((candidate) => candidate.id === serverId) const isDisabled = server !== undefined && !server.enabled - const projectsQuery = useQuery({ - queryKey: serverScopedKey(['opencode', 'projects'], serverId), - queryFn: () => listProjects(serverId), - enabled: server?.enabled === true, - retry: false, - }) - - const projects = useMemo( - () => - [...(projectsQuery.data ?? [])].sort( - (a, b) => projectActivityTimestamp(b) - projectActivityTimestamp(a), - ), - [projectsQuery.data], - ) - const showsProjectError = projectsQuery.isError && projectsQuery.data === undefined + const { projects, isLoading, isError, hasData, error, refetch } = useRemoteProjects(serverId) + const showsProjectError = isError && !hasData const filteredProjects = useMemo(() => { const query = searchQuery.trim().toLowerCase() if (!query) return projects return projects.filter((project) => - projectName(project).toLowerCase().includes(query) || + remoteProjectName(project).toLowerCase().includes(query) || project.canonical.toLowerCase().includes(query), ) }, [projects, searchQuery]) @@ -151,19 +133,19 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) {

Could not load projects

- {projectsQuery.error instanceof Error - ? projectsQuery.error.message + {error instanceof Error + ? error.message : 'The server did not respond.'}

- ) : null} {server && !isDisabled && !showsProjectError ? ( - projectsQuery.isLoading ? ( + isLoading ? ( ) : projects.length === 0 ? (
diff --git a/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx index 050d0ffd0..f9899bbc4 100644 --- a/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx +++ b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx @@ -148,64 +148,34 @@ describe('RemoteProjectList', () => { expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') }) - it('orders projects by recent activity and labels rows with that activity time', async () => { + it('labels a row with its project activity time', async () => { const now = Date.now() mocks.listProjects.mockResolvedValue([ { ...project, - id: 'prj_stale', - name: 'Stale', - canonical: '/work/stale', - time: { created: now - 10_800_000, updated: now - 300_000, active: now - 10_800_000 }, - }, - { - ...project, - id: 'prj_recent', - name: 'Recent', - canonical: '/work/recent', time: { created: now - 10_800_000, updated: now - 10_800_000, active: now - 300_000 }, }, ]) - const { container } = renderProjectList() - - await screen.findByText('Recent') + renderProjectList() - const text = container.textContent ?? '' - expect(text.indexOf('Recent')).toBeGreaterThan(-1) - expect(text.indexOf('Recent')).toBeLessThan(text.indexOf('5m ago')) - expect(text.indexOf('5m ago')).toBeLessThan(text.indexOf('Stale')) - expect(text.indexOf('Stale')).toBeLessThan(text.indexOf('3h ago')) + await screen.findByText('App') + expect(screen.getByText('5m ago')).toBeInTheDocument() }) - it('falls back to the update time when a project has no activity time', async () => { + it('labels a row with the update time when the project has no activity time', async () => { const now = Date.now() mocks.listProjects.mockResolvedValue([ { ...project, - id: 'prj_older', - name: 'Older', - canonical: '/work/older', - time: { created: now - 10_800_000, updated: now - 10_800_000 }, - }, - { - ...project, - id: 'prj_newer', - name: 'Newer', - canonical: '/work/newer', time: { created: now - 10_800_000, updated: now - 300_000 }, }, ]) - const { container } = renderProjectList() - - await screen.findByText('Newer') + renderProjectList() - const text = container.textContent ?? '' - expect(text.indexOf('Newer')).toBeGreaterThan(-1) - expect(text.indexOf('Newer')).toBeLessThan(text.indexOf('5m ago')) - expect(text.indexOf('5m ago')).toBeLessThan(text.indexOf('Older')) - expect(text.indexOf('Older')).toBeLessThan(text.indexOf('3h ago')) + await screen.findByText('App') + expect(screen.getByText('5m ago')).toBeInTheDocument() }) it('opens the remote project page when a project card is clicked', async () => { diff --git a/frontend/src/components/session/ContextUsageMeter.tsx b/frontend/src/components/session/ContextUsageMeter.tsx index d2545f620..6b71370a9 100644 --- a/frontend/src/components/session/ContextUsageMeter.tsx +++ b/frontend/src/components/session/ContextUsageMeter.tsx @@ -1,3 +1,4 @@ +import { Progress } from '@/components/ui/progress' import { cn } from '@/lib/utils' import { CONTEXT_USAGE_BG_CLASS, contextUsageTone } from '@/lib/contextUsage' @@ -6,20 +7,19 @@ interface ContextUsageMeterProps { className?: string } +/** + * Thin context-window fill bar. Its accessible label lives on the model pill, so the bar itself is hidden + * from assistive technology. + */ export function ContextUsageMeter({ percentage, className }: ContextUsageMeterProps) { - const width = percentage === null ? 0 : Math.min(percentage, 100) - return ( -