From 6c6eac02e0c3fc1715139dbc2dc1023b8137cab3 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:25:23 -0400 Subject: [PATCH 1/4] feat(sidebar): add repo-grouped session navigation with live status - Add a searchable, collapsible session tree to the desktop sidebar, grouped by repository with pinned and time-bucketed ordering. - Track per-session busy, retry, and compact status in a shared store fed by the SSE event stream and scoped by directory. - Extract shared navigation components reused by the desktop tree and the mobile repo quick-switch sheet. - Show a Git tab and hide Schedules inside a repo on the mobile tab bar; add Repos and Assistant entries to the session More drawer. - Replace the CommitsTab local relative-time formatter with a shared formatShortRelativeTime util. --- .../navigation/DesktopSessionTree.test.tsx | 312 +++++++++++++++++ .../navigation/DesktopSessionTree.tsx | 113 ++++++ .../navigation/DesktopSidebar.test.tsx | 49 ++- .../components/navigation/DesktopSidebar.tsx | 65 +++- .../navigation/MobileTabBar.test.tsx | 28 +- .../components/navigation/MobileTabBar.tsx | 37 +- .../components/navigation/MoreDrawer.test.tsx | 40 +++ .../src/components/navigation/MoreDrawer.tsx | 28 +- .../navigation/RepoQuickSwitchSheet.test.tsx | 323 +++++++++++++++++- .../navigation/RepoQuickSwitchSheet.tsx | 126 ++----- .../components/navigation/RepoSessionNav.tsx | 273 +++++++++++++++ .../navigation/moreDrawerItems.test.ts | 23 +- .../components/navigation/moreDrawerItems.ts | 10 +- .../navigation/sidebar-session-tree.test.ts | 197 +++++++++++ .../navigation/sidebar-session-tree.ts | 113 ++++++ .../src/components/session/SessionList.tsx | 29 +- .../session/session-partition.test.ts | 49 ++- .../components/session/session-partition.ts | 30 +- .../components/source-control/CommitsTab.tsx | 24 +- frontend/src/contexts/EventContext.test.tsx | 273 +++++++++++++++ frontend/src/contexts/EventContext.tsx | 67 +++- frontend/src/hooks/useOpenCode.ts | 4 +- frontend/src/hooks/useSSE.test.tsx | 112 +++++- frontend/src/hooks/useSSE.ts | 21 +- frontend/src/hooks/useSidebarRepoGroups.ts | 43 +++ frontend/src/lib/sessionKey.test.ts | 11 + frontend/src/lib/sessionKey.ts | 6 + frontend/src/lib/utils.test.ts | 21 +- frontend/src/lib/utils.ts | 17 + .../src/stores/sessionStatusStore.test.ts | 230 +++++++++++++ frontend/src/stores/sessionStatusStore.ts | 256 ++++++++++++-- 31 files changed, 2691 insertions(+), 239 deletions(-) create mode 100644 frontend/src/components/navigation/DesktopSessionTree.test.tsx create mode 100644 frontend/src/components/navigation/DesktopSessionTree.tsx create mode 100644 frontend/src/components/navigation/RepoSessionNav.tsx create mode 100644 frontend/src/components/navigation/sidebar-session-tree.test.ts create mode 100644 frontend/src/components/navigation/sidebar-session-tree.ts create mode 100644 frontend/src/hooks/useSidebarRepoGroups.ts create mode 100644 frontend/src/lib/sessionKey.test.ts create mode 100644 frontend/src/stores/sessionStatusStore.test.ts diff --git a/frontend/src/components/navigation/DesktopSessionTree.test.tsx b/frontend/src/components/navigation/DesktopSessionTree.test.tsx new file mode 100644 index 000000000..764b871bf --- /dev/null +++ b/frontend/src/components/navigation/DesktopSessionTree.test.tsx @@ -0,0 +1,312 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, useLocation } from 'react-router-dom' +import type { ReactNode } from 'react' +import { TooltipProvider } from '@/components/ui/tooltip' +import { DesktopSessionTree } from './DesktopSessionTree' +import { SIDEBAR_SESSIONS_PER_REPO } from './sidebar-session-tree' +import type { Repo } from '@/api/types' + +type SessionFixture = { + id: string + projectID: string + title: string + time: { created: number; updated: number; archived?: number } + location: { directory: string } + parentID?: string + cost: number + tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } +} + +type SessionsHookCall = { directories: string[]; options?: { search?: string; limit?: number } } + +const { + listReposMock, + sessionsData, + sessionsHookCalls, + createSessionCalls, + permissionSessions, + formSessions, + emptyOnSearch, +} = vi.hoisted(() => ({ + listReposMock: vi.fn(), + sessionsData: [] as SessionFixture[], + sessionsHookCalls: [] as SessionsHookCall[], + createSessionCalls: [] as Array<{ directory?: string; vars?: unknown }>, + permissionSessions: { current: new Set() }, + formSessions: { current: new Set() }, + emptyOnSearch: { current: false }, +})) + +vi.mock('@/api/repos', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, listRepos: listReposMock } +}) + +vi.mock('@/hooks/useOpenCode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSessionsAcrossDirectories: (directories: string[], options?: { search?: string; limit?: number }) => { + sessionsHookCalls.push({ directories, options }) + const data = + options?.search && emptyOnSearch.current + ? [] + : sessionsData.filter((session) => directories.includes(session.location.directory)) + return { data, isLoading: false, isError: false } + }, + useCreateSession: (directory?: string) => ({ + mutate: (vars?: unknown) => { + createSessionCalls.push({ directory, vars }) + }, + }), + } +}) + +vi.mock('@/hooks/useSessionPins', () => ({ + useSessionPins: () => ({ data: [] }), +})) + +vi.mock('@/contexts/EventContext', () => ({ + usePermissions: () => ({ + hasForSession: (sessionID: string) => permissionSessions.current.has(sessionID), + }), + useForms: () => ({ + hasForSession: (sessionID: string) => formSessions.current.has(sessionID), + }), +})) + +function createRepo(overrides: Partial & { id: number; fullPath: string }): Repo { + return { + localPath: `repos/${overrides.id}`, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: 0, + ...overrides, + } +} + +function createSession(id: string, directory: string, updated = Date.now()): 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 } }, + } +} + +const repoA = createRepo({ id: 1, fullPath: '/repos/a', name: 'Alpha', lastAccessedAt: 20 }) +const repoB = createRepo({ id: 2, fullPath: '/repos/b', name: 'Beta', lastAccessedAt: 10 }) +const repoC = createRepo({ id: 3, fullPath: '/repos/c', name: 'Gamma', lastAccessedAt: 30 }) + +function LocationDisplay() { + const location = useLocation() + return
{location.pathname}
+} + +function createWrapper(initialEntries: string[]) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return ({ children }: { children: ReactNode }) => ( + + + + {children} + + + + + ) +} + +const toggleFor = (name: string) => screen.getByRole('button', { name: `Show sessions in ${name}` }) + +const fetchedDirectories = () => new Set(sessionsHookCalls.flatMap((call) => call.directories)) + +describe('DesktopSessionTree', () => { + beforeEach(() => { + listReposMock.mockReset() + listReposMock.mockResolvedValue([repoA, repoB, repoC]) + sessionsData.splice( + 0, + sessionsData.length, + createSession('a1', '/repos/a'), + createSession('b1', '/repos/b'), + createSession('c1', '/repos/c'), + ) + sessionsHookCalls.splice(0, sessionsHookCalls.length) + createSessionCalls.splice(0, createSessionCalls.length) + permissionSessions.current = new Set() + formSessions.current = new Set() + emptyOnSearch.current = false + }) + + it('lists every repo by last access, even inside a repo, with no Recent section', async () => { + render(, { wrapper: createWrapper(['/repos/1']) }) + + await screen.findByText('Alpha') + const names = screen + .getAllByRole('button', { name: /^Show sessions in / }) + .map((button) => button.getAttribute('aria-label')) + + expect(names).toEqual(['Show sessions in Gamma', 'Show sessions in Alpha', 'Show sessions in Beta']) + expect(screen.queryByText('Recent')).toBeNull() + }) + + it('opens the active repo by default and lazily fetches only its sessions', async () => { + render(, { wrapper: createWrapper(['/repos/1']) }) + + expect(await screen.findByRole('button', { name: /^Session a1/ })).toBeTruthy() + expect(toggleFor('Alpha')).toHaveAttribute('aria-expanded', 'true') + expect(toggleFor('Beta')).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('button', { name: /^Session b1/ })).toBeNull() + expect(fetchedDirectories()).toEqual(new Set(['/repos/a'])) + }) + + it('opens nothing outside a repo', async () => { + render(, { wrapper: createWrapper(['/']) }) + + await screen.findByText('Alpha') + + expect(screen.queryByRole('button', { name: /^Session / })).toBeNull() + expect(sessionsHookCalls).toHaveLength(0) + }) + + it('keeps only one repo open at a time', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper(['/repos/1']) }) + + await screen.findByRole('button', { name: /^Session a1/ }) + await user.click(toggleFor('Beta')) + + expect(toggleFor('Alpha')).toHaveAttribute('aria-expanded', 'false') + expect(toggleFor('Beta')).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('button', { name: /^Session b1/ })).toBeTruthy() + expect(screen.queryByRole('button', { name: /^Session a1/ })).toBeNull() + + await user.click(toggleFor('Beta')) + + expect(toggleFor('Beta')).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('button', { name: /^Session / })).toBeNull() + }) + + it('opens the repo page when the repo name is clicked', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper(['/']) }) + + await user.click(await screen.findByRole('button', { name: 'Beta' })) + + expect(screen.getByTestId('location').textContent).toBe('/repos/2') + }) + + it('navigates to a session and marks it current', async () => { + const user = userEvent.setup() + sessionsData.push(createSession('a2', '/repos/a', Date.now() - 1000)) + render(, { wrapper: createWrapper(['/repos/1/sessions/a1']) }) + + expect(await screen.findByRole('button', { name: /^Session a1/ })).toHaveAttribute('aria-current', 'page') + + await user.click(screen.getByRole('button', { name: /^Session a2/ })) + + expect(screen.getByTestId('location').textContent).toBe('/repos/1/sessions/a2') + expect(screen.getByRole('button', { name: /^Session a2/ })).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('button', { name: /^Session a1/ })).not.toHaveAttribute('aria-current') + }) + + it('offers All sessions once the open repo reaches the page size', async () => { + const user = userEvent.setup() + sessionsData.push( + ...Array.from({ length: SIDEBAR_SESSIONS_PER_REPO }, (_, index) => + createSession(`s${index + 1}`, '/repos/a', Date.now() - index - 1), + ), + ) + render(, { wrapper: createWrapper(['/repos/1/sessions/a1']) }) + + await user.click(await screen.findByText('All sessions')) + + expect(screen.getByTestId('location').textContent).toBe('/repos/1') + }) + + it('hides All sessions below the page size', async () => { + render(, { wrapper: createWrapper(['/repos/1']) }) + + await screen.findByRole('button', { name: /^Session a1/ }) + + expect(screen.queryByText('All sessions')).toBeNull() + }) + + it('commits the search on Enter across ready repos and clears with Escape', async () => { + const user = userEvent.setup() + listReposMock.mockResolvedValue([repoA, repoB, createRepo({ id: 4, fullPath: '/repos/d', name: 'Delta', cloneStatus: 'cloning' })]) + render(, { wrapper: createWrapper(['/']) }) + + const input = await screen.findByLabelText('Search sessions') + await screen.findByText('Alpha') + await user.type(input, 'Session{Enter}') + + const searchCall = sessionsHookCalls.find((call) => call.options?.search === 'Session') + expect(searchCall?.directories).toEqual(['/repos/a', '/repos/b']) + expect(await screen.findByRole('button', { name: /^Session a1/ })).toBeTruthy() + expect(screen.getByRole('button', { name: /^Session b1/ })).toBeTruthy() + expect(screen.queryByRole('button', { name: /^Show sessions in / })).toBeNull() + + await user.type(input, '{Escape}') + + expect(input).toHaveValue('') + await waitFor(() => expect(screen.queryByRole('button', { name: /^Session / })).toBeNull()) + expect(toggleFor('Alpha')).toBeTruthy() + }) + + it('clears a committed search with the clear button', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper(['/']) }) + + const input = await screen.findByLabelText('Search sessions') + await user.type(input, 'Session{Enter}') + await screen.findByRole('button', { name: /^Session a1/ }) + + await user.click(screen.getByLabelText('Clear search')) + + expect(input).toHaveValue('') + expect(screen.queryByLabelText('Clear search')).toBeNull() + expect(toggleFor('Alpha')).toBeTruthy() + }) + + it('shows the empty search state when a committed search returns nothing', async () => { + const user = userEvent.setup() + emptyOnSearch.current = true + render(, { wrapper: createWrapper(['/']) }) + + const input = await screen.findByLabelText('Search sessions') + await user.type(input, 'nothing{Enter}') + + expect(await screen.findByText('No sessions found')).toBeTruthy() + }) + + it('creates a session in the repo full path', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper(['/']) }) + + await user.click(await screen.findByRole('button', { name: 'New session in Beta' })) + + expect(createSessionCalls).toContainEqual({ directory: '/repos/b', vars: { agent: undefined } }) + }) + + it('renders permission and form badges for sessions with pending requests', async () => { + sessionsData.push(createSession('a2', '/repos/a', Date.now() - 1000)) + permissionSessions.current = new Set(['a1']) + formSessions.current = new Set(['a2']) + render(, { wrapper: createWrapper(['/repos/1']) }) + + await screen.findByRole('button', { name: /^Session a1/ }) + + expect(screen.getByLabelText('Pending permission')).toBeTruthy() + expect(screen.getByLabelText('Pending form')).toBeTruthy() + }) +}) diff --git a/frontend/src/components/navigation/DesktopSessionTree.tsx b/frontend/src/components/navigation/DesktopSessionTree.tsx new file mode 100644 index 000000000..201c429d1 --- /dev/null +++ b/frontend/src/components/navigation/DesktopSessionTree.tsx @@ -0,0 +1,113 @@ +import { useMemo, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { Search } from 'lucide-react' +import type { Repo } from '@/api/types' +import { Input } from '@/components/ui/input' +import { useNavigableRepos, useSidebarRepoGroups } from '@/hooks/useSidebarRepoGroups' +import { + NewSessionButton, + RepoNavGroup, + RepoSessionNavList, + SearchClearButton, + SessionNavRow, + SessionNavStatus, +} from '@/components/navigation/RepoSessionNav' +import { getActiveRepoId, isCurrentSessionItem } from '@/components/navigation/sidebar-session-tree' + +function SessionSearchResults({ repos, search }: { repos: Repo[]; search: string }) { + const navigate = useNavigate() + const location = useLocation() + const searchableRepos = useMemo(() => repos.filter((repo) => repo.cloneStatus === 'ready'), [repos]) + const { groups, isLoading, isError } = useSidebarRepoGroups({ repos: searchableRepos, search }) + const matchingGroups = groups.filter((group) => group.items.length > 0) + + if (isLoading) return Loading sessions... + if (isError) return Failed to load sessions + if (matchingGroups.length === 0) return No sessions found + + return ( + <> + {matchingGroups.map((group) => ( + navigate(`/repos/${group.repo.id}`)} + actions={} + > + {group.items.map((item) => ( + + ))} + + ))} + + ) +} + +export function DesktopSessionTree() { + const navigate = useNavigate() + const location = useLocation() + const [searchDraft, setSearchDraft] = useState('') + const [search, setSearch] = useState('') + const { repos, isLoading } = useNavigableRepos() + + const clearSearch = () => { + setSearchDraft('') + setSearch('') + } + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + setSearch(searchDraft.trim()) + } else if (event.key === 'Escape') { + clearSearch() + } + } + + return ( +
+
+
+ + setSearchDraft(event.target.value)} + onKeyDown={handleSearchKeyDown} + aria-label="Search sessions" + placeholder="Search sessions..." + autoComplete="off" + name="sidebar-session-search" + className="h-8 pl-8 pr-9" + /> + {(searchDraft.length > 0 || search.length > 0) && } +
+
+ +
+ {isLoading ? ( + Loading repos... + ) : search ? ( + + ) : ( + navigate(`/repos/${repoId}`)} + onSelectSession={navigate} + renderActions={(repo) => } + /> + )} +
+
+ ) +} diff --git a/frontend/src/components/navigation/DesktopSidebar.test.tsx b/frontend/src/components/navigation/DesktopSidebar.test.tsx index f9ea9df51..3b2e4041b 100644 --- a/frontend/src/components/navigation/DesktopSidebar.test.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { TooltipProvider } from '@/components/ui/tooltip' import { DesktopSidebar } from './DesktopSidebar' import * as useDesktopModule from '@/hooks/useDesktop' import * as useSidebarCollapsedModule from '@/hooks/useSidebarCollapsed' @@ -10,6 +11,9 @@ import * as useAuthModule from '@/hooks/useAuth' vi.mock('@/hooks/useDesktop') vi.mock('@/hooks/useSidebarCollapsed') vi.mock('@/hooks/useAuth') +vi.mock('@/components/navigation/DesktopSessionTree', () => ({ + DesktopSessionTree: () =>
, +})) function LocationDisplay() { const location = useLocation() @@ -34,7 +38,7 @@ function createWrapper(initialEntries?: string[]) { return ({ children }: { children: React.ReactNode }) => ( - {children} + {children} ) @@ -125,7 +129,7 @@ describe('DesktopSidebar', () => { render(, { wrapper: createWrapper(['/repos/5']) }) - expect(screen.getByText('New Session')).toBeInTheDocument() + expect(screen.queryByText('New Session')).toBeNull() expect(screen.getByText('Assistant')).toBeInTheDocument() }) @@ -140,7 +144,7 @@ describe('DesktopSidebar', () => { render(, { wrapper: createWrapper(['/repos/5/sessions/abc']) }) - expect(screen.getByText('New Session')).toBeInTheDocument() + expect(screen.queryByText('New Session')).toBeNull() expect(screen.getByText('Assistant')).toBeInTheDocument() }) @@ -169,14 +173,14 @@ describe('DesktopSidebar', () => { logout: vi.fn(), } as any) - render(, { wrapper: createWrapper(['/repos/5']) }) + render(, { wrapper: createWrapper(['/']) }) - fireEvent.click(screen.getByText('New Session')) + fireEvent.click(screen.getByText('New Repo')) expect(dispatchEventSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'oc:sidebar:action', - detail: { action: 'new-session' }, + detail: { action: 'new-repo' }, }) ) }) @@ -250,4 +254,37 @@ describe('DesktopSidebar', () => { expect(screen.getByTestId('location').textContent).toBe('/repos/5/schedules?returnTo=%2Frepos%2F5%2Fsessions%2Fabc%3Fassistant%3D1') }) + + it('renders the session tree, tool toolbar and account footer when expanded', () => { + vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) + vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) + vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ + isAuthenticated: true, + isLoading: false, + logout: vi.fn(), + } as any) + + render(, { wrapper: createWrapper(['/repos/5']) }) + + expect(screen.getByTestId('session-tree')).toBeInTheDocument() + expect(screen.getByText('Files')).toBeInTheDocument() + expect(screen.getByText('Settings')).toBeInTheDocument() + expect(screen.getByText('Logout')).toBeInTheDocument() + }) + + it('renders the vertical nav list 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({ + isAuthenticated: true, + isLoading: false, + logout: vi.fn(), + } as any) + + render(, { wrapper: createWrapper(['/repos/5']) }) + + expect(screen.queryByTestId('session-tree')).toBeNull() + expect(screen.getByText('Files')).toBeInTheDocument() + expect(screen.getByText('Settings')).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index a6a195d52..3a3ab3e43 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -8,6 +8,7 @@ import { useUrlParams } from '@/hooks/useUrlParams' import { buildNavModel, type MoreDrawerItem, type NavPrimaryCta } from '@/components/navigation/moreDrawerItems' import { getPathWithReturnTo } from '@/lib/navigation' import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' +import { DesktopSessionTree } from '@/components/navigation/DesktopSessionTree' import { Sidebar, SidebarSection, @@ -15,6 +16,8 @@ import { } from '@/components/ui/sidebar' import { FolderGit2 } from 'lucide-react' +const ACCOUNT_ITEM_KEYS = new Set(['settings', 'logout']) + export function DesktopSidebar() { const location = useLocation() const navigate = useNavigate() @@ -73,10 +76,12 @@ export function DesktopSidebar() { { key: 'repos', label: 'Repos', icon: FolderGit2 }, ...routeItems, ] + const toolItems = navItems.filter((item) => !ACCOUNT_ITEM_KEYS.has(item.key)) + const accountItems = navItems.filter((item) => ACCOUNT_ITEM_KEYS.has(item.key)) return ( <> - + {primary.length > 0 && ( {primary.map((item: NavPrimaryCta) => ( @@ -93,18 +98,52 @@ export function DesktopSidebar() { )} -
- {navItems.map((item: MoreDrawerItem) => ( - handleItemClick(item)} - danger={item.danger} - /> - ))} -
+ {collapsed ? ( +
+ {navItems.map((item: MoreDrawerItem) => ( + handleItemClick(item)} + danger={item.danger} + /> + ))} +
+ ) : ( + <> +
+ +
+ +
+ {toolItems.map((item: MoreDrawerItem) => ( + handleItemClick(item)} + danger={item.danger} + /> + ))} +
+ +
+ {accountItems.map((item: MoreDrawerItem) => ( + handleItemClick(item)} + danger={item.danger} + /> + ))} +
+ + )}
{ , ) expect(screen.getByText('Repos')).toBeInTheDocument() - expect(screen.getByText('Schedules')).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']) + }) + + it('opens the source control dialog when Git is clicked on repo detail path', async () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + const user = userEvent.setup() + + render( + + + + + + + } /> + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Git' })) + expect(screen.getByTestId('location')).toHaveTextContent('dialog=sourceControl') }) it('renders global tabs on assistant session list path', () => { @@ -152,6 +177,7 @@ describe('MobileTabBar', () => { expect(screen.getByText('Assistant')).toBeInTheDocument() expect(screen.getByText('Schedules')).toBeInTheDocument() expect(screen.getByText('More')).toBeInTheDocument() + expect(screen.queryByText('Git')).not.toBeInTheDocument() }) it('renders tab bar on /schedules path', () => { diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx index c20880bcc..d3e2322bb 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 } from 'lucide-react' +import { FolderGit2, 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' @@ -64,9 +64,15 @@ function getMobileTabRouteState(pathname: string): MobileTabRouteState { } function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideRepo, repoId, updateParams }: GlobalTabsArgs): TabDef[] { + const openRepoDialog = (dialog: string) => { + updateParams((p) => { p.set('dialog', dialog); p.delete('mobileTab') }, 'push') + } + + const inRepo = isInsideRepo && repoId !== null + const handleFilesClick = () => { - if (isInsideRepo && repoId) { - updateParams((p) => { p.set('dialog', 'files'); p.delete('mobileTab') }, 'push') + if (inRepo) { + openRepoDialog('files') } else { open('files') } @@ -92,6 +98,15 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR onClick: handleFilesClick, active: openSheet === 'files', }, + ...(inRepo + ? [{ + key: 'source-control', + label: 'Git', + icon: GitCommitHorizontal, + onClick: () => openRepoDialog('sourceControl'), + active: false, + }] + : []), { key: 'assistant', label: 'Assistant', @@ -99,13 +114,15 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR onClick: handleAssistantClick, active: isAssistantPath(pathname) && !openSheet, }, - { - key: 'schedules', - label: 'Schedules', - icon: CalendarClock, - onClick: () => navigate('/schedules'), - active: pathname === '/schedules' && !openSheet, - }, + ...(!inRepo + ? [{ + key: 'schedules', + label: 'Schedules', + icon: CalendarClock, + onClick: () => navigate('/schedules'), + active: pathname === '/schedules' && !openSheet, + }] + : []), { key: 'more', label: 'More', diff --git a/frontend/src/components/navigation/MoreDrawer.test.tsx b/frontend/src/components/navigation/MoreDrawer.test.tsx index bdd0e2fcd..e89ad1d2d 100644 --- a/frontend/src/components/navigation/MoreDrawer.test.tsx +++ b/frontend/src/components/navigation/MoreDrawer.test.tsx @@ -262,4 +262,44 @@ 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', () => { + 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')) + + expect(navigateMock).toHaveBeenCalledWith({ search: 'mobileTab=repos' }, { replace: false }) + }) + + it('keeps Home and hides the Repos row outside a session', () => { + mockAuth() + mockServerHealth() + renderMoreDrawer({ initialEntry: '/repos/1', routePath: '/repos/:id' }) + + expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.queryByText('Repos')).not.toBeInTheDocument() + }) + + it('offers the Assistant from a repo session', () => { + const navigateMock = vi.fn() + vi.mocked(useNavigate).mockReturnValue(navigateMock) + mockAuth() + mockServerHealth() + renderMoreDrawer({ initialEntry: '/repos/1/sessions/session-1', routePath: '/repos/:id/sessions/:sessionId' }) + + fireEvent.click(screen.getByRole('button', { name: 'Assistant' })) + + expect(navigateMock).toHaveBeenCalledWith('/assistant') + }) + + it('does not offer the Assistant from an assistant session', () => { + mockAuth() + mockServerHealth() + renderMoreDrawer({ initialEntry: '/repos/1/sessions/session-1?assistant=1', routePath: '/repos/:id/sessions/:sessionId' }) + + expect(screen.queryByRole('button', { name: 'Assistant' })).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 809208601..02a65d49f 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 { useState, useRef, useEffect } from 'react' -import { ChevronDown, ChevronRight, Command as CommandIcon, FileText, X, GitBranch } from 'lucide-react' +import { ChevronDown, ChevronRight, Command as CommandIcon, FileText, FolderGit2, X, GitBranch } from 'lucide-react' import { useAuth } from '@/hooks/useAuth' import { useServerHealth } from '@/hooks/useServerHealth' import { useCommands } from '@/hooks/useCommands' @@ -11,11 +11,12 @@ import { getRepo } from '@/api/repos' import { useRefreshOnOpen } from '@/hooks/useRefreshOnOpen' import { SideDrawer, SideDrawerContent } from '@/components/ui/side-drawer' import { FileBrowserSheet } from '@/components/file-browser/FileBrowserSheet' -import { buildMoreItems } from './moreDrawerItems' +import { buildMoreItems, buildNavModel } from './moreDrawerItems' import { useSwipeBack } from '@/hooks/useMobile' import { getRepoDisplayName } from '@/lib/utils' import { getPathWithReturnTo, isAssistantPath } from '@/lib/navigation' import type { CommandInfo } from '@opencode-manager/shared/opencode' +import { useMobileTabBar } from '@/hooks/useMobileTabBar' interface MoreDrawerProps { isOpen: boolean @@ -41,6 +42,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { const activePromptFileBasePath = useUIState((state) => state.activePromptFileBasePath) const selectPromptCommand = useUIState((state) => state.selectPromptCommand) const selectPromptFile = useUIState((state) => state.selectPromptFile) + const { open: openMobileSheet } = useMobileTabBar() useEffect(() => { if (isOpen && swipeRef.current) { @@ -116,6 +118,9 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { } const items = buildMoreItems(location.pathname) + const assistantCta = isSessionDetail && !isAssistantSession + ? buildNavModel(location.pathname).primary.find((cta) => cta.key === 'assistant') + : undefined const commands = filterCommands('') const opencodeVersion = health?.opencodeVersion @@ -160,6 +165,25 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { {isSessionDetail && (
+ + {assistantCta?.to && ( + + )}
- + {isLoading ? ( -
+
{Array.from({ length: 5 }).map((_, i) => (
))}
) : filteredRepos.length === 0 ? ( -
+

No repos found

) : ( -
- {filteredRepos.map((repo) => { - const isActive = repo.id === activeRepoId - const branchToDisplay = repo.currentBranch || repo.branch - return ( - - ) - })} -
+ navigateAndClose(path)} + renderActions={(repo) => navigateAndClose(path)} />} + /> )} +