diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 7f93cd2..14aea07 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -16,17 +16,23 @@
* Contact: alex@kuleshov.tech
*/
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App'
import { useEditorStore } from './stores/editor'
+import { useSpaceStore } from './stores/space'
import { useTreeStore } from './stores/tree'
import { useUiStore } from './stores/ui'
import { useViewerStore } from './stores/viewer'
vi.mock('./lib/api', () => ({
+ setCurrentSpaceSlug: vi.fn(),
+ listSpaces: vi.fn(async () => [
+ { id: 's1', slug: 'default', name: 'Default', createdAt: '2026-01-01T00:00:00Z' },
+ { id: 's2', slug: 'docs', name: 'Docs', createdAt: '2026-01-01T00:00:00Z' },
+ ]),
getAuthConfig: vi.fn(async () => ({
authDisabled: true,
publicAccess: true,
@@ -130,6 +136,11 @@ describe('App', () => {
loading: false,
error: null,
})
+ useSpaceStore.setState({
+ spaces: [],
+ activeSlug: 'default',
+ loaded: false,
+ })
useUiStore.setState({
isDark: false,
sidebarVisible: true,
@@ -148,7 +159,20 @@ describe('App', () => {
,
)
- expect(await screen.findByTitle('Image version sha-1234567')).toHaveTextContent('sha-1234567')
+ const sidebar = await screen.findByTestId('sidebar')
+ expect(within(sidebar).getByTitle('Image version sha-1234567')).toHaveTextContent('sha-1234567')
+ expect(within(screen.getByRole('banner')).queryByTitle('Image version sha-1234567')).not.toBeInTheDocument()
+ })
+
+ it('shows a standalone space switcher when there is no account menu', async () => {
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByRole('button', { name: 'Switch space, current space Default' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /Account menu/ })).not.toBeInTheDocument()
})
it('renders the shell and loads the root page without crashing', async () => {
diff --git a/frontend/src/features/layout/AppLayout.tsx b/frontend/src/features/layout/AppLayout.tsx
index cad62b5..9fd1a37 100644
--- a/frontend/src/features/layout/AppLayout.tsx
+++ b/frontend/src/features/layout/AppLayout.tsx
@@ -23,6 +23,7 @@ import { Link } from 'react-router-dom'
import { Sidebar } from '../sidebar/Sidebar'
import { Toolbar } from '../toolbar/Toolbar'
import type { WikiNodeKind, WikiTreeNode } from '../../types'
+import { SpaceSwitcher } from './SpaceSwitcher'
import { UserMenu } from './UserMenu'
interface AppLayoutProps {
@@ -133,11 +134,7 @@ export function AppLayout({
Import
) : null}
- {displayImageVersion ? (
-
- {displayImageVersion}
-
- ) : null}
+ {currentUsername ? null : }
{currentUsername ? (
>
diff --git a/frontend/src/features/layout/SpaceSwitcher.test.tsx b/frontend/src/features/layout/SpaceSwitcher.test.tsx
new file mode 100644
index 0000000..1d87a4b
--- /dev/null
+++ b/frontend/src/features/layout/SpaceSwitcher.test.tsx
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2026 Aleksei Kuleshov
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Contact: alex@kuleshov.tech
+ */
+
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useSpaceStore } from '../../stores/space'
+import { useTreeStore } from '../../stores/tree'
+import type { Space } from '../../types'
+import { SpaceSwitcher } from './SpaceSwitcher'
+
+const spaces: Space[] = [
+ { id: 's1', slug: 'default', name: 'Default', createdAt: '2026-01-01T00:00:00Z' },
+ { id: 's2', slug: 'product', name: 'Product', createdAt: '2026-01-01T00:00:00Z' },
+]
+
+function LocationProbe() {
+ const location = useLocation()
+ return
{location.pathname}
+}
+
+function renderSwitcher() {
+ return render(
+
+
+
+ } />
+
+ ,
+ )
+}
+
+describe('SpaceSwitcher', () => {
+ beforeEach(() => {
+ useSpaceStore.setState({
+ spaces,
+ activeSlug: 'default',
+ loaded: true,
+ })
+ useTreeStore.setState({ reloadTree: vi.fn(async () => undefined) })
+ })
+
+ it('switches the active space, reloads the tree, and navigates home', async () => {
+ const reloadTree = vi.fn(async () => undefined)
+ useTreeStore.setState({ reloadTree })
+ const user = userEvent.setup()
+
+ renderSwitcher()
+ await user.click(screen.getByRole('button', { name: 'Switch space, current space Default' }))
+ await user.click(await screen.findByRole('menuitemradio', { name: 'Product' }))
+
+ await waitFor(() => {
+ expect(useSpaceStore.getState().activeSlug).toBe('product')
+ })
+ expect(reloadTree).toHaveBeenCalledTimes(1)
+ expect(screen.getByTestId('location')).toHaveTextContent('/')
+ })
+
+ it('stays hidden when there is only one space', () => {
+ useSpaceStore.setState({ spaces: [spaces[0]], activeSlug: 'default', loaded: true })
+
+ renderSwitcher()
+
+ expect(screen.queryByRole('button', { name: /Switch space/i })).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/features/layout/SpaceSwitcher.tsx b/frontend/src/features/layout/SpaceSwitcher.tsx
new file mode 100644
index 0000000..0826471
--- /dev/null
+++ b/frontend/src/features/layout/SpaceSwitcher.tsx
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2026 Aleksei Kuleshov
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Contact: alex@kuleshov.tech
+ */
+
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import { Check, ChevronDown, Layers } from 'lucide-react'
+import { useNavigate } from 'react-router-dom'
+
+import { useSpaceStore } from '../../stores/space'
+import { useTreeStore } from '../../stores/tree'
+
+export function SpaceSwitcher() {
+ const spaces = useSpaceStore((state) => state.spaces)
+ const activeSlug = useSpaceStore((state) => state.activeSlug)
+ const setActiveSlug = useSpaceStore((state) => state.setActiveSlug)
+ const reloadTree = useTreeStore((state) => state.reloadTree)
+ const navigate = useNavigate()
+
+ if (spaces.length <= 1) {
+ return null
+ }
+
+ const activeSpace = spaces.find((space) => space.slug === activeSlug) ?? spaces[0]
+ const activeName = activeSpace?.name ?? activeSlug
+
+ const handleSpaceChange = (slug: string) => {
+ if (!slug || slug === activeSlug) {
+ return
+ }
+ setActiveSlug(slug)
+ navigate('/')
+ void reloadTree()
+ }
+
+ return (
+
+
+
+
+
+
+ Switch space
+
+ {spaces.map((space) => (
+
+
+ {space.name}
+
+
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/sidebar/Sidebar.tsx b/frontend/src/features/sidebar/Sidebar.tsx
index 9d09905..e35db10 100644
--- a/frontend/src/features/sidebar/Sidebar.tsx
+++ b/frontend/src/features/sidebar/Sidebar.tsx
@@ -41,6 +41,7 @@ interface SidebarProps {
onExpandAll: () => void
onCollapseAll: () => void
onOpenSearch: () => void
+ imageVersion?: string | null
}
export function Sidebar({
@@ -61,6 +62,7 @@ export function Sidebar({
onExpandAll,
onCollapseAll,
onOpenSearch,
+ imageVersion,
}: SidebarProps) {
const [activeTab, setActiveTab] = useState<'tree' | 'search'>('tree')
@@ -170,6 +172,13 @@ export function Sidebar({
/>
)}
+ {imageVersion ? (
+
+
+ {imageVersion}
+
+
+ ) : null}
)
diff --git a/frontend/src/features/viewer/PageViewer.tsx b/frontend/src/features/viewer/PageViewer.tsx
index 3261f57..7bcf21a 100644
--- a/frontend/src/features/viewer/PageViewer.tsx
+++ b/frontend/src/features/viewer/PageViewer.tsx
@@ -30,6 +30,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { ensurePage } from '../../lib/api'
import { normalizeWikiPath } from '../../lib/paths'
+import { useSpaceStore } from '../../stores/space'
import { useViewerStore } from '../../stores/viewer'
export function PageViewer() {
@@ -39,6 +40,7 @@ export function PageViewer() {
const error = useViewerStore((state) => state.error)
const loading = useViewerStore((state) => state.loading)
const loadPageData = useViewerStore((state) => state.loadPageData)
+ const activeSpaceSlug = useSpaceStore((state) => state.activeSlug)
const setActiveNodeId = useTreeStore((state) => state.setActiveNodeId)
const openAncestorsForPath = useTreeStore((state) => state.openAncestorsForPath)
const getPageByPath = useTreeStore((state) => state.getPageByPath)
@@ -55,7 +57,7 @@ export function PageViewer() {
useEffect(() => {
void loadPageData(currentPath)
- }, [currentPath, loadPageData])
+ }, [activeSpaceSlug, currentPath, loadPageData])
useEffect(() => {
const treeNode = getPageByPath(currentPath)
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 2221c73..f81b415 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -142,7 +142,7 @@
}
.sidebar__inner {
- @apply block h-full w-full bg-sidebar;
+ @apply flex h-full w-full flex-col bg-sidebar;
}
.sidebar__tabs {
@@ -166,7 +166,15 @@
}
.sidebar__content {
- @apply h-[calc(100%-48px)] w-full;
+ @apply min-h-0 flex-1 w-full;
+ }
+
+ .sidebar__footer {
+ @apply border-t border-surface-border px-3 py-2;
+ }
+
+ .sidebar__image-version {
+ @apply inline-flex max-w-full truncate rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs font-medium text-sidebar-foreground/70;
}
.app-layout__header {
@@ -201,10 +209,6 @@
@apply ml-auto flex min-h-full items-center gap-1 md:gap-2;
}
- .app-layout__image-version {
- @apply max-w-28 truncate rounded-full border border-surface-border bg-background/50 px-2 py-1 text-xs font-medium text-muted;
- }
-
.app-layout__header-spacer {
@apply h-16 w-full md:h-[68px];
}
@@ -273,6 +277,42 @@
@apply ml-auto inline-flex items-center justify-center text-accent;
}
+ .space-switcher__trigger {
+ @apply max-w-[42vw] rounded-lg px-2 sm:max-w-56 sm:px-3;
+ }
+
+ .space-switcher__trigger-label {
+ @apply hidden min-w-0 truncate sm:inline;
+ }
+
+ .space-switcher__trigger-chevron {
+ @apply hidden sm:inline;
+ }
+
+ .space-switcher__content {
+ @apply z-50 min-w-56 max-w-[calc(100vw-1rem)] rounded-lg border border-surface-border bg-surface p-2 shadow-2xl outline-none;
+ }
+
+ .space-switcher__label {
+ @apply px-3 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-muted;
+ }
+
+ .space-switcher__item {
+ @apply flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground outline-none transition hover:bg-surface-alt focus:bg-surface-alt data-[highlighted]:bg-surface-alt;
+ }
+
+ .space-switcher__item--radio {
+ @apply pr-8;
+ }
+
+ .space-switcher__item-label {
+ @apply truncate;
+ }
+
+ .space-switcher__item-indicator {
+ @apply ml-auto inline-flex items-center justify-center text-accent;
+ }
+
.app-layout__chromeless-main {
@apply flex min-h-screen flex-col items-stretch overflow-auto;
}