diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba389943..8d1613fd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ import { ThemeProvider, useThemeMode } from './contexts/ThemeContext' import { AuthProvider } from './contexts/AuthContext' import { KeyboardShortcutsProvider } from './contexts/KeyboardShortcutsContext' import { EventProvider, usePermissions, useEventContext } from '@/contexts/EventContext' +import { RepoWorktreesProvider } from '@/contexts/RepoWorktreesContext' import { SwipeNavigationProvider, useSwipeNavigation } from '@/contexts/SwipeNavigationContext' import { PermissionRequestDialog } from './components/session/PermissionRequestDialog' import { SSHHostKeyDialog } from './components/ssh/SSHHostKeyDialog' @@ -208,26 +209,28 @@ function AppShell() { - - -
- -
- -
- -
+ + + +
+ +
+ +
+ +
+
-
- - - - - - - - - + + + + + + + + + + diff --git a/frontend/src/components/activity/ActivityList.test.tsx b/frontend/src/components/activity/ActivityList.test.tsx index 53dda080..31352532 100644 --- a/frontend/src/components/activity/ActivityList.test.tsx +++ b/frontend/src/components/activity/ActivityList.test.tsx @@ -144,6 +144,23 @@ describe('ActivityList', () => { expect(onNavigate).toHaveBeenCalledWith(item) }) + it('labels a worktree session with its worktree branch', () => { + setGroups({ + recent: [ + createItem({ + state: 'recent', + session: createSession('c1', 'Worktree'), + repo: createRepo(3, 'Gamma'), + worktreeBranch: 'feature/wt', + }), + ], + }) + + renderList() + + expect(screen.getByRole('button', { name: /Worktree/ })).toHaveTextContent('Gamma · feature/wt') + }) + it('marks the currently open session with aria-current', () => { setGroups({ recent: [ diff --git a/frontend/src/components/activity/ActivityList.tsx b/frontend/src/components/activity/ActivityList.tsx index 161e242f..00654c78 100644 --- a/frontend/src/components/activity/ActivityList.tsx +++ b/frontend/src/components/activity/ActivityList.tsx @@ -63,7 +63,7 @@ interface ActivityRowProps { } function ActivityRow({ item, isCurrent, onSelect }: ActivityRowProps) { - const branch = getRepoBranchLabel(item.repo) + const branch = item.worktreeBranch ?? getRepoBranchLabel(item.repo) const repoLine = branch ? `${getRepoDisplayName(item.repo)} · ${branch}` : getRepoDisplayName(item.repo) diff --git a/frontend/src/components/navigation/DesktopSessionTree.test.tsx b/frontend/src/components/navigation/DesktopSessionTree.test.tsx index f3333272..f028c5cb 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.test.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.test.tsx @@ -25,6 +25,7 @@ type SessionPinFixture = { sessionId: string; directory: string; pinnedAt: numbe const { listReposMock, + getRepoSiblingsMock, sessionsData, sessionsHookCalls, createSessionCalls, @@ -37,6 +38,7 @@ const { searchError, } = vi.hoisted(() => ({ listReposMock: vi.fn(), + getRepoSiblingsMock: vi.fn(), sessionsData: [] as SessionFixture[], sessionsHookCalls: [] as SessionsHookCall[], createSessionCalls: [] as Array<{ directory?: string; vars?: unknown }>, @@ -51,7 +53,7 @@ const { vi.mock('@/api/repos', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, listRepos: listReposMock } + return { ...actual, listRepos: listReposMock, getRepoSiblings: getRepoSiblingsMock } }) vi.mock('@/api/opencode', async (importOriginal) => { @@ -156,6 +158,8 @@ describe('DesktopSessionTree', () => { beforeEach(() => { listReposMock.mockReset() listReposMock.mockResolvedValue([repoA, repoB, repoC]) + getRepoSiblingsMock.mockReset() + getRepoSiblingsMock.mockResolvedValue([]) sessionsData.splice( 0, sessionsData.length, @@ -197,6 +201,18 @@ describe('DesktopSessionTree', () => { expect(fetchedDirectories()).toEqual(new Set(['/repos/a'])) }) + it('shows a worktree session under its repo with the worktree branch tag', async () => { + getRepoSiblingsMock.mockResolvedValue([ + { fullPath: '/repos/a/.worktrees/feat', currentBranch: 'feat/x', worktreeSource: 'git' }, + ]) + sessionsData.push(createSession('w1', '/repos/a/.worktrees/feat', Date.now() - 1000)) + + render(, { wrapper: createWrapper(['/repos/1']) }) + + const row = await screen.findByRole('button', { name: /^Session w1/ }) + expect(row).toHaveTextContent('feat/x') + }) + it('opens nothing outside a repo', async () => { render(, { wrapper: createWrapper(['/']) }) @@ -409,6 +425,14 @@ describe('DesktopSessionTree', () => { expect(await screen.findByText('Failed to load sessions')).toBeTruthy() }) + it('shows the error state when the worktree siblings lookup fails', async () => { + getRepoSiblingsMock.mockRejectedValue(new Error('siblings failed')) + + render(, { wrapper: createWrapper(['/repos/1']) }) + + expect(await screen.findByText('Failed to load sessions')).toBeTruthy() + }) + it('creates a session in the repo full path', async () => { const user = userEvent.setup() render(, { wrapper: createWrapper(['/']) }) diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx index beb8e825..14641d52 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx @@ -3,7 +3,7 @@ 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 { listRepos } from '@/api/repos' +import { getRepoSiblings, listRepos } from '@/api/repos' import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import type { ActiveWorkItem } from '@/lib/activeWork' @@ -167,6 +167,7 @@ function MobileSheetHarness() { describe('RepoQuickSwitchSheet', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(getRepoSiblings).mockResolvedValue([]) sessionsData.splice(0, sessionsData.length) sessionsHookCalls.splice(0, sessionsHookCalls.length) permissionSessions.current.clear() diff --git a/frontend/src/components/navigation/RepoSessionNav.tsx b/frontend/src/components/navigation/RepoSessionNav.tsx index 7907995c..926744f6 100644 --- a/frontend/src/components/navigation/RepoSessionNav.tsx +++ b/frontend/src/components/navigation/RepoSessionNav.tsx @@ -5,7 +5,9 @@ import { SessionStatusIndicator } from '@/components/ui/session-status-indicator import { RepoActivityIndicator } from '@/components/activity/RepoActivityIndicator' import { useForms, usePermissions } from '@/contexts/EventContext' import { useCreateSession } from '@/hooks/useOpenCode' +import { useRepoSiblings } from '@/hooks/useRepoSiblings' import { useSidebarRepoGroups } from '@/hooks/useSidebarRepoGroups' +import { buildWorktreeActivity, type ActiveWorktree } from '@/lib/activeWork' import { cn, formatShortRelativeTime, getRepoBranchLabel, getRepoDisplayName } from '@/lib/utils' import { getRepoPath, getSessionPath } from '@/lib/navigation' import { @@ -118,13 +120,14 @@ interface SessionNavRowProps { export function SessionNavRow({ item, isCurrent, onSelect, repoLabel }: SessionNavRowProps) { const { hasForSession: hasPermission } = usePermissions() const { hasForSession: hasForm } = useForms() + const branchLabel = item.worktreeBranch ?? item.branchLabel return (