Skip to content

Commit 5868128

Browse files
refactor: add desktop sidebar navigation and consolidate header actions
- Add DesktopSidebar component with collapsible navigation - Add useDesktop and useSidebarAction hooks for sidebar state management - Add useSidebarCollapsed hook with localStorage persistence and error handling - Integrate sidebar into App layout with tooltip support - Remove repoUrl and isRepoWorktree props from SourceControlPanel - BranchesTab now fetches repo data internally using getRepo query - Remove unused to prop from SidebarItem - Simplify RepoList conditional rendering - Add comprehensive test coverage for new components
1 parent 3776a70 commit 5868128

26 files changed

Lines changed: 1286 additions & 424 deletions

frontend/src/App.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { VersionNotifier } from './components/VersionNotifier'
1818
import { PwaUpdatePrompt } from '@/components/PwaUpdatePrompt'
1919
import { MobileTabBar } from '@/components/navigation/MobileTabBar'
2020
import { MobileSheetHost } from '@/components/navigation/MobileSheetHost'
21+
import { DesktopSidebar } from '@/components/navigation/DesktopSidebar'
2122
import { useTheme } from './hooks/useTheme'
2223
import { useSwipeBack } from './hooks/useMobile'
2324
import { TTSProvider } from './contexts/TTSContext'
@@ -143,8 +144,11 @@ function AppShell() {
143144
return (
144145
<AuthProvider>
145146
<EventProvider>
146-
<div ref={rootRef} className="contents">
147-
<Outlet />
147+
<div ref={rootRef} className="flex h-dvh w-full min-w-0">
148+
<DesktopSidebar />
149+
<div className="flex-1 min-w-0 min-h-0 flex flex-col">
150+
<Outlet />
151+
</div>
148152
</div>
149153
<MobileTabBar />
150154
<MobileSheetHost />

frontend/src/api/files.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export async function fetchFileRange(path: string, startLine: number, endLine: n
5252
}
5353

5454
export async function applyFilePatches(path: string, patches: PatchOperation[]): Promise<{ success: boolean; totalLines: number }> {
55-
return fetchWrapper(getFileApiUrl(path), {
55+
return fetchWrapper(getFileApiUrl(path, { route: 'patches' }), {
5656
method: 'PATCH',
5757
headers: { 'Content-Type': 'application/json' },
5858
body: JSON.stringify({ patches }),
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { render, screen, fireEvent } from '@testing-library/react'
3+
import { MemoryRouter, useLocation } from 'react-router-dom'
4+
import { DesktopSidebar } from './DesktopSidebar'
5+
import * as useDesktopModule from '@/hooks/useDesktop'
6+
import * as useSidebarCollapsedModule from '@/hooks/useSidebarCollapsed'
7+
import * as useMemoryPluginStatusModule from '@/hooks/useMemoryPluginStatus'
8+
import * as useAuthModule from '@/hooks/useAuth'
9+
10+
vi.mock('@/hooks/useDesktop')
11+
vi.mock('@/hooks/useSidebarCollapsed')
12+
vi.mock('@/hooks/useMemoryPluginStatus')
13+
vi.mock('@/hooks/useAuth')
14+
15+
function LocationDisplay() {
16+
const location = useLocation()
17+
18+
return <div data-testid="location">{location.pathname}{location.search}</div>
19+
}
20+
21+
describe('DesktopSidebar', () => {
22+
beforeEach(() => {
23+
vi.clearAllMocks()
24+
})
25+
26+
it('returns null when user is not authenticated', () => {
27+
vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(false)
28+
vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()])
29+
vi.spyOn(useMemoryPluginStatusModule, 'useMemoryPluginStatus').mockReturnValue({ memoryPluginEnabled: false })
30+
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
31+
isAuthenticated: false,
32+
isLoading: false,
33+
logout: vi.fn(),
34+
} as any)
35+
36+
const { container } = render(
37+
<MemoryRouter>
38+
<DesktopSidebar />
39+
</MemoryRouter>,
40+
)
41+
42+
expect(container.firstChild).toBeNull()
43+
})
44+
45+
it('returns null when auth state is loading', () => {
46+
vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(false)
47+
vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()])
48+
vi.spyOn(useMemoryPluginStatusModule, 'useMemoryPluginStatus').mockReturnValue({ memoryPluginEnabled: false })
49+
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
50+
isAuthenticated: true,
51+
isLoading: true,
52+
logout: vi.fn(),
53+
} as any)
54+
55+
const { container } = render(
56+
<MemoryRouter>
57+
<DesktopSidebar />
58+
</MemoryRouter>,
59+
)
60+
61+
expect(container.firstChild).toBeNull()
62+
})
63+
64+
it('returns null when not desktop', () => {
65+
vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(false)
66+
vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()])
67+
vi.spyOn(useMemoryPluginStatusModule, 'useMemoryPluginStatus').mockReturnValue({ memoryPluginEnabled: false })
68+
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
69+
isAuthenticated: true,
70+
isLoading: false,
71+
logout: vi.fn(),
72+
} as any)
73+
74+
const { container } = render(
75+
<MemoryRouter>
76+
<DesktopSidebar />
77+
</MemoryRouter>
78+
)
79+
80+
expect(container.firstChild).toBeNull()
81+
})
82+
83+
it('renders primary CTA for root path', () => {
84+
vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true)
85+
vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()])
86+
vi.spyOn(useMemoryPluginStatusModule, 'useMemoryPluginStatus').mockReturnValue({ memoryPluginEnabled: false })
87+
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
88+
isAuthenticated: true,
89+
isLoading: false,
90+
logout: vi.fn(),
91+
} as any)
92+
93+
render(
94+
<MemoryRouter initialEntries={['/']}>
95+
<DesktopSidebar />
96+
</MemoryRouter>
97+
)
98+
99+
expect(screen.getByText('New Repo')).toBeInTheDocument()
100+
expect(screen.getByText('Assistant')).toBeInTheDocument()
101+
})
102+
103+
it('renders primary CTAs for repo detail', () => {
104+
vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true)
105+
vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()])
106+
vi.spyOn(useMemoryPluginStatusModule, 'useMemoryPluginStatus').mockReturnValue({ memoryPluginEnabled: false })
107+
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
108+
isAuthenticated: true,
109+
isLoading: false,
110+
logout: vi.fn(),
111+
} as any)
112+
113+
render(
114+
<MemoryRouter initialEntries={['/repos/5']}>
115+
<DesktopSidebar />
116+
</MemoryRouter>
117+
)
118+
119+
expect(screen.getByText('New Session')).toBeInTheDocument()
120+
expect(screen.getByText('Assistant')).toBeInTheDocument()
121+
})
122+
123+
it('renders primary CTA for session detail', () => {
124+
vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true)
125+
vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()])
126+
vi.spyOn(useMemoryPluginStatusModule, 'useMemoryPluginStatus').mockReturnValue({ memoryPluginEnabled: false })
127+
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
128+
isAuthenticated: true,
129+
isLoading: false,
130+
logout: vi.fn(),
131+
} as any)
132+
133+
render(
134+
<MemoryRouter initialEntries={['/repos/5/sessions/abc']}>
135+
<DesktopSidebar />
136+
</MemoryRouter>
137+
)
138+
139+
expect(screen.getByText('Assistant')).toBeInTheDocument()
140+
})
141+
142+
it('renders primary CTA for schedules routes', () => {
143+
vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true)
144+
vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()])
145+
vi.spyOn(useMemoryPluginStatusModule, 'useMemoryPluginStatus').mockReturnValue({ memoryPluginEnabled: false })
146+
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
147+
isAuthenticated: true,
148+
isLoading: false,
149+
logout: vi.fn(),
150+
} as any)
151+
152+
render(
153+
<MemoryRouter initialEntries={['/schedules']}>
154+
<DesktopSidebar />
155+
</MemoryRouter>
156+
)
157+
158+
expect(screen.getByText('New Schedule')).toBeInTheDocument()
159+
expect(screen.getByText('Assistant')).toBeInTheDocument()
160+
})
161+
162+
it('dispatches oc:sidebar:action event when primary CTA is clicked', () => {
163+
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent')
164+
vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true)
165+
vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()])
166+
vi.spyOn(useMemoryPluginStatusModule, 'useMemoryPluginStatus').mockReturnValue({ memoryPluginEnabled: false })
167+
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
168+
isAuthenticated: true,
169+
isLoading: false,
170+
logout: vi.fn(),
171+
} as any)
172+
173+
render(
174+
<MemoryRouter initialEntries={['/repos/5']}>
175+
<DesktopSidebar />
176+
</MemoryRouter>
177+
)
178+
179+
fireEvent.click(screen.getByText('New Session'))
180+
181+
expect(dispatchEventSpy).toHaveBeenCalledWith(
182+
expect.objectContaining({
183+
type: 'oc:sidebar:action',
184+
detail: { action: 'new-session' },
185+
})
186+
)
187+
})
188+
189+
it('opens dialog items by updating the dialog query param', () => {
190+
vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true)
191+
vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()])
192+
vi.spyOn(useMemoryPluginStatusModule, 'useMemoryPluginStatus').mockReturnValue({ memoryPluginEnabled: false })
193+
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
194+
isAuthenticated: true,
195+
isLoading: false,
196+
logout: vi.fn(),
197+
} as any)
198+
199+
render(
200+
<MemoryRouter initialEntries={['/repos/5/sessions/abc?assistant=1']}>
201+
<DesktopSidebar />
202+
<LocationDisplay />
203+
</MemoryRouter>
204+
)
205+
206+
fireEvent.click(screen.getByText('Files'))
207+
208+
expect(screen.getByTestId('location').textContent).toBe('/repos/5/sessions/abc?assistant=1&dialog=files')
209+
})
210+
211+
it('opens settings by updating settings query params', () => {
212+
vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true)
213+
vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()])
214+
vi.spyOn(useMemoryPluginStatusModule, 'useMemoryPluginStatus').mockReturnValue({ memoryPluginEnabled: false })
215+
vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({
216+
isAuthenticated: true,
217+
isLoading: false,
218+
logout: vi.fn(),
219+
} as any)
220+
221+
render(
222+
<MemoryRouter initialEntries={['/?dialog=files']}>
223+
<DesktopSidebar />
224+
<LocationDisplay />
225+
</MemoryRouter>
226+
)
227+
228+
fireEvent.click(screen.getByText('Settings'))
229+
230+
expect(screen.getByTestId('location').textContent).toBe('/?dialog=files&settings=open&tab=account')
231+
})
232+
})
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { useLocation, useNavigate } from 'react-router-dom'
2+
import { useDesktop } from '@/hooks/useDesktop'
3+
import { useSidebarCollapsed } from '@/hooks/useSidebarCollapsed'
4+
import { useMemoryPluginStatus } from '@/hooks/useMemoryPluginStatus'
5+
import { useAuth } from '@/hooks/useAuth'
6+
import { buildNavModel } from '@/components/navigation/moreDrawerItems'
7+
import {
8+
Sidebar,
9+
SidebarSection,
10+
SidebarItem,
11+
SidebarCollapseToggle,
12+
} from '@/components/ui/sidebar'
13+
import { LayoutGrid } from 'lucide-react'
14+
15+
export function DesktopSidebar() {
16+
const location = useLocation()
17+
const navigate = useNavigate()
18+
const [collapsed, toggle] = useSidebarCollapsed()
19+
const { memoryPluginEnabled } = useMemoryPluginStatus()
20+
const { isAuthenticated, isLoading, logout } = useAuth()
21+
22+
const isDesktop = useDesktop()
23+
24+
if (isLoading || !isAuthenticated) {
25+
return null
26+
}
27+
28+
if (!isDesktop) {
29+
return null
30+
}
31+
32+
const { primary, items } = buildNavModel(location.pathname, { memoryPluginEnabled })
33+
34+
const handlePrimaryClick = (item: (typeof primary)[number]) => {
35+
if (item.to) {
36+
navigate(item.to)
37+
} else if (item.onSelect) {
38+
window.dispatchEvent(
39+
new CustomEvent('oc:sidebar:action', {
40+
detail: { action: item.onSelect },
41+
})
42+
)
43+
}
44+
}
45+
46+
const handleItemClick = (item: (typeof items)[number]) => {
47+
if (item.to) {
48+
navigate(item.to)
49+
} else if (item.dialog) {
50+
const params = new URLSearchParams(location.search)
51+
params.set('dialog', item.dialog)
52+
params.delete('mobileTab')
53+
navigate({ search: params.toString() }, { replace: true })
54+
} else if (item.key === 'logout') {
55+
logout()
56+
} else if (item.key === 'settings') {
57+
const params = new URLSearchParams(location.search)
58+
params.set('settings', 'open')
59+
params.set('tab', 'account')
60+
navigate({ search: params.toString() }, { replace: true })
61+
}
62+
}
63+
64+
return (
65+
<Sidebar collapsed={collapsed} onToggle={toggle}>
66+
<div className="flex items-center justify-between p-2 border-b border-border">
67+
{!collapsed && (
68+
<span className="text-sm font-semibold px-2">OpenCode</span>
69+
)}
70+
{collapsed && (
71+
<div className="w-full flex justify-center">
72+
<LayoutGrid className="h-5 w-5" />
73+
</div>
74+
)}
75+
</div>
76+
77+
{primary.length > 0 && (
78+
<SidebarSection collapsed={collapsed}>
79+
{primary.map((item) => (
80+
<SidebarItem
81+
key={item.key}
82+
icon={item.icon}
83+
label={item.label}
84+
collapsed={collapsed}
85+
onClick={() => handlePrimaryClick(item)}
86+
asPrimary
87+
variant={item.variant}
88+
/>
89+
))}
90+
</SidebarSection>
91+
)}
92+
93+
<SidebarSection label="Navigation" collapsed={collapsed}>
94+
{items.map((item) => (
95+
<SidebarItem
96+
key={item.key}
97+
icon={item.icon}
98+
label={item.label}
99+
collapsed={collapsed}
100+
onClick={() => handleItemClick(item)}
101+
danger={item.danger}
102+
/>
103+
))}
104+
</SidebarSection>
105+
106+
<div className="mt-auto">
107+
<SidebarCollapseToggle collapsed={collapsed} onToggle={toggle} />
108+
</div>
109+
</Sidebar>
110+
)
111+
}

0 commit comments

Comments
 (0)