From bfbbbf08ef35886c9ef99d58cc72ce66a5e66c48 Mon Sep 17 00:00:00 2001 From: Chris Scott <> Date: Fri, 16 Oct 2026 20:36:56 +0000 Subject: [PATCH 1/2] fix(activity): surface worktree sessions in active work, the sidebar and repo badges - Resolve sessions in repo worktrees to the owning repo in the activity list; show the worktree branch and open the Workspaces tab - Look up worktrees only for unmatched sessions in the window or under an active root, and hold the loading state until it settles - Show schedule-worktree sessions only while running or needing input - Light repo badges for worktree sessions through one app-wide worktree lookup - List worktree sessions under the expanded repo in the sidebar - Subscribe the event stream to active session directories so worktree permissions and forms arrive - Keep the worktree tab when navigating from the desktop Activity panel - Remove the braille spinner's always-dim bottom row --- frontend/src/App.tsx | 41 ++-- .../components/activity/ActivityList.test.tsx | 17 ++ .../src/components/activity/ActivityList.tsx | 2 +- .../navigation/DesktopSessionTree.test.tsx | 18 +- .../navigation/RepoQuickSwitchSheet.test.tsx | 3 +- .../components/navigation/RepoSessionNav.tsx | 35 ++- .../navigation/ToolSidePanel.test.tsx | 18 ++ .../components/navigation/ToolSidePanel.tsx | 3 +- .../navigation/sidebar-session-tree.test.ts | 54 +++++ .../navigation/sidebar-session-tree.ts | 37 ++- .../components/ui/activity-spinner.test.tsx | 10 +- .../src/components/ui/activity-spinner.tsx | 8 +- frontend/src/contexts/EventContext.test.tsx | 39 +++ frontend/src/contexts/EventContext.tsx | 30 ++- .../contexts/RepoWorktreesContext.test.tsx | 119 ++++++++++ .../src/contexts/RepoWorktreesContext.tsx | 58 +++++ frontend/src/hooks/useActiveWork.test.tsx | 66 +++++- frontend/src/hooks/useActiveWork.ts | 42 ++-- frontend/src/hooks/useRepoWorktrees.ts | 28 +++ frontend/src/hooks/useSidebarRepoGroups.ts | 23 +- frontend/src/lib/activeWork.test.ts | 222 ++++++++++++++++++ frontend/src/lib/activeWork.ts | 130 +++++++++- frontend/src/lib/navigation.ts | 10 +- 23 files changed, 932 insertions(+), 81 deletions(-) create mode 100644 frontend/src/contexts/RepoWorktreesContext.test.tsx create mode 100644 frontend/src/contexts/RepoWorktreesContext.tsx create mode 100644 frontend/src/hooks/useRepoWorktrees.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba389943a..8d1613fdc 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 53dda0804..313525326 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 161e242fd..00654c78e 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 f33332729..68797fe0d 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(['/']) }) diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx index beb8e825b..14641d520 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 7907995ce..d24c8d63e 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 (