Skip to content

Commit f554886

Browse files
committed
feat: add persistent workspace breadcrumbs and contextual navigation highlight
1 parent c273064 commit f554886

7 files changed

Lines changed: 265 additions & 233 deletions

File tree

frontend/src/App.svelte

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -506,10 +506,6 @@
506506
background-color: var(--ds-background-neutral-hovered);
507507
}
508508
509-
:global(.themed-nav .nav-button.nav-button-emphasized) {
510-
background-color: color-mix(in srgb, var(--ds-interactive) 8%, transparent);
511-
}
512-
513509
/* Exception: Primary buttons should keep their original colors and hover behavior */
514510
:global(.themed-nav .bg-primary) {
515511
color: var(--ds-text-inverse) !important;

frontend/src/lib/layout/MainSidebar.svelte

Lines changed: 9 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { t } from '../stores/i18n.svelte.js';
55
import { aiStore } from '../stores/aiStore.svelte.js';
66
import { getShortcutDisplay } from '../utils/keyboardShortcuts.js';
7-
import { workspaceIconMap } from '../utils/icons.js';
7+
import { workspaceMenuItems } from '../navigation/workspaceMenu.js';
88
import { isTauri as getIsTauri } from '../utils/isTauri.js';
99
import DropdownMenu from './DropdownMenu.svelte';
1010
import Tooltip from '../components/Tooltip.svelte';
@@ -13,7 +13,7 @@
1313
import NotificationTray from '../features/notifications/NotificationTray.svelte';
1414
import ScrollableSidebar from './ScrollableSidebar.svelte';
1515
import {
16-
IconSearch, IconSettings, IconPlus, IconGridDots, IconUserScan,
16+
IconSearch, IconPlus, IconGridDots, IconUserScan,
1717
IconFolders, IconLayoutSidebarLeftExpand, IconLayoutSidebarLeftCollapse,
1818
IconMessage, IconTerminal2,
1919
} from '@tabler/icons-svelte-runes';
@@ -32,90 +32,12 @@
3232
3333
let workspaceSearchQuery = $state('');
3434
35-
// Derived workspace dropdown items that automatically updates when store or search changes
36-
const workspacesDropdownItems = $derived.by(() => {
37-
const items = [];
38-
39-
// Add search input at the top
40-
items.push({
41-
type: 'search',
42-
id: 'search',
43-
testid: 'workspaces-search',
44-
placeholder: t('nav.searchWorkspaces'),
45-
value: workspaceSearchQuery,
46-
onInput: (value) => {
47-
workspaceSearchQuery = value;
48-
}
49-
});
50-
51-
// Filter workspaces based on search query (inactive workspaces are
52-
// hidden here even for admins — Manage Workspaces is the only surface
53-
// that shows them).
54-
const activeRegularWorkspaces = $workspacesStore.regularWorkspaces.filter(ws => ws.active);
55-
const search = workspaceSearchQuery?.trim().toLowerCase();
56-
const filteredWorkspaces = !search
57-
? activeRegularWorkspaces
58-
: activeRegularWorkspaces.filter(workspace => {
59-
const nameMatch = workspace.name?.toLowerCase().includes(search);
60-
const keyMatch = workspace.key?.toLowerCase().includes(search);
61-
const descriptionMatch = workspace.description?.toLowerCase().includes(search);
62-
return nameMatch || keyMatch || descriptionMatch;
63-
});
64-
65-
// Add workspace items
66-
if (filteredWorkspaces.length > 0) {
67-
const maxVisible = 10;
68-
const hasMore = filteredWorkspaces.length > maxVisible;
69-
const visibleWorkspaces = filteredWorkspaces.slice(0, maxVisible);
70-
const workspaceItems = visibleWorkspaces.map(workspace => {
71-
const hasAvatar = workspace.avatar_url;
72-
const workspaceIcon = workspaceIconMap[workspace.icon] || workspaceIconMap.Package;
73-
74-
return {
75-
id: workspace.id,
76-
type: 'regular',
77-
testid: 'workspace-dropdown-item',
78-
icon: hasAvatar ? null : workspaceIcon,
79-
iconColor: hasAvatar ? null : workspace.color,
80-
avatarUrl: hasAvatar ? workspace.avatar_url : null,
81-
title: workspace.name,
82-
subtitle: workspace.description,
83-
href: `/workspaces/${workspace.id}`
84-
};
85-
});
86-
87-
items.push({ type: 'group', items: workspaceItems });
88-
if (hasMore) {
89-
items.push({ type: 'text', text: t('nav.searchToFindMore') });
90-
}
91-
items.push({ type: 'divider' });
92-
} else if (activeRegularWorkspaces.length > 0 && workspaceSearchQuery) {
93-
// Show "no results" only if there are workspaces but search didn't match
94-
items.push(
95-
{ type: 'text', text: t('nav.noWorkspacesMatch') },
96-
{ type: 'divider' }
97-
);
98-
} else if (activeRegularWorkspaces.length === 0) {
99-
items.push(
100-
{ type: 'text', text: t('nav.noWorkspacesFound') },
101-
{ type: 'divider' }
102-
);
103-
}
104-
105-
// Add combined manage workspaces action
106-
items.push({
107-
id: 'manage',
108-
type: 'regular',
109-
icon: IconSettings,
110-
title: t('nav.manageWorkspaces'),
111-
subtitle: t('nav.manageWorkspacesSubtitle'),
112-
color: 'var(--ds-text-link)',
113-
class: 'font-medium',
114-
href: '/workspaces'
115-
});
116-
117-
return items;
118-
});
35+
const workspacesDropdownItems = $derived(workspaceMenuItems(
36+
$workspacesStore.regularWorkspaces,
37+
workspaceSearchQuery,
38+
(value) => workspaceSearchQuery = value,
39+
t
40+
));
11941
12042
// Filter nav items based on permissions (registry: navigation/mainNavigation.js)
12143
const filteredMainNav = $derived(
@@ -174,7 +96,7 @@
17496
triggerGap="gap-3"
17597
triggerText={$uiStore.navExpanded ? t('nav.workspaces') : ''}
17698
triggerLabel={t('nav.workspaces')}
177-
triggerClass="w-full px-3 h-10 rounded flex items-center justify-start cursor-pointer nav-button nav-button-emphasized {isWorkspaceRoute($currentRoute.view) || activeSurface === 'workspaces' ? 'nav-button-selected' : ''} {!$workspacesStore.loaded ? 'opacity-50 cursor-wait' : ''}"
99+
triggerClass="w-full px-3 h-10 rounded flex items-center justify-start cursor-pointer nav-button {isWorkspaceRoute($currentRoute.view) ? 'nav-button-selected' : ''} {!$workspacesStore.loaded ? 'opacity-50 cursor-wait' : ''}"
178100
triggerTestid="workspaces-dropdown-trigger"
179101
items={workspacesDropdownItems}
180102
maxWidth="max-w-xs"
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { IconSettings } from '@tabler/icons-svelte-runes';
2+
import { workspaceIconMap } from '../utils/icons.js';
3+
4+
export function workspaceMenuItems(workspaces, searchQuery, onSearch, t) {
5+
const items = [];
6+
7+
// Add search input at the top
8+
items.push({
9+
type: 'search',
10+
id: 'search',
11+
testid: 'workspaces-search',
12+
placeholder: t('nav.searchWorkspaces'),
13+
value: searchQuery,
14+
onInput: (value) => {
15+
onSearch(value);
16+
},
17+
});
18+
19+
// Filter workspaces based on search query (inactive workspaces are
20+
// hidden here even for admins — Manage Workspaces is the only surface
21+
// that shows them).
22+
const activeRegularWorkspaces = workspaces.filter((ws) => ws.active);
23+
const search = searchQuery?.trim().toLowerCase();
24+
const filteredWorkspaces = !search
25+
? activeRegularWorkspaces
26+
: activeRegularWorkspaces.filter((workspace) => {
27+
const nameMatch = workspace.name?.toLowerCase().includes(search);
28+
const keyMatch = workspace.key?.toLowerCase().includes(search);
29+
const descriptionMatch = workspace.description?.toLowerCase().includes(search);
30+
return nameMatch || keyMatch || descriptionMatch;
31+
});
32+
33+
// Add workspace items
34+
if (filteredWorkspaces.length > 0) {
35+
const maxVisible = 10;
36+
const hasMore = filteredWorkspaces.length > maxVisible;
37+
const visibleWorkspaces = filteredWorkspaces.slice(0, maxVisible);
38+
const workspaceItems = visibleWorkspaces.map((workspace) => {
39+
const hasAvatar = workspace.avatar_url;
40+
const workspaceIcon = workspaceIconMap[workspace.icon] || workspaceIconMap.Package;
41+
42+
return {
43+
id: workspace.id,
44+
type: 'regular',
45+
testid: 'workspace-dropdown-item',
46+
icon: hasAvatar ? null : workspaceIcon,
47+
iconColor: hasAvatar ? null : workspace.color,
48+
avatarUrl: hasAvatar ? workspace.avatar_url : null,
49+
title: workspace.name,
50+
subtitle: workspace.description,
51+
href: `/workspaces/${workspace.id}`,
52+
};
53+
});
54+
55+
items.push({ type: 'group', items: workspaceItems });
56+
if (hasMore) {
57+
items.push({ type: 'text', text: t('nav.searchToFindMore') });
58+
}
59+
items.push({ type: 'divider' });
60+
} else if (activeRegularWorkspaces.length > 0 && searchQuery) {
61+
// Show "no results" only if there are workspaces but search didn't match
62+
items.push({ type: 'text', text: t('nav.noWorkspacesMatch') }, { type: 'divider' });
63+
} else if (activeRegularWorkspaces.length === 0) {
64+
items.push({ type: 'text', text: t('nav.noWorkspacesFound') }, { type: 'divider' });
65+
}
66+
67+
// Add combined manage workspaces action
68+
items.push({
69+
id: 'manage',
70+
type: 'regular',
71+
icon: IconSettings,
72+
title: t('nav.manageWorkspaces'),
73+
subtitle: t('nav.manageWorkspacesSubtitle'),
74+
color: 'var(--ds-text-link)',
75+
class: 'font-medium',
76+
href: '/workspaces',
77+
});
78+
79+
return items;
80+
}

frontend/src/lib/navigation/workspaceNavigation.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ export const workspaceViewItems = [
4848
icon: SquareKanban,
4949
testId: 'workspace-nav-board',
5050
},
51-
{ id: 'list', labelKey: 'workspaceSettings.views.list', icon: List },
51+
{
52+
id: 'list',
53+
labelKey: 'workspaceSettings.views.list',
54+
icon: List,
55+
testId: 'workspace-nav-list',
56+
},
5257
{ id: 'tree', labelKey: 'workspaceSettings.views.tree', icon: ListTree },
5358
{ id: 'map', labelKey: 'workspaceSettings.views.map', icon: MapPin },
5459
{

0 commit comments

Comments
 (0)