Update desktop navigation chrome

Move search, workspace switching, and thread controls into the top/profile chrome, including a centered live search variation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-05-28 11:06:52 -04:00
co-authored by Cursor
parent 9823ff52e2
commit 5f2fcad0f7
7 changed files with 617 additions and 302 deletions
+25 -2
View File
@@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronLeft, ChevronRight, Search } from "lucide-react";
import * as React from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useQueryClient } from "@tanstack/react-query";
@@ -49,6 +49,7 @@ import {
DEFAULT_SETTINGS_SECTION,
type SettingsSection,
} from "@/features/settings/ui/SettingsPanels";
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
import { HuddleBar, HuddleProvider } from "@/features/huddle";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
@@ -640,6 +641,29 @@ export function AppShell() {
<ChevronRight className="h-3 w-3" />
</Button>
</div>
<TopbarSearch
channels={channels}
className="fixed left-1/2 top-[7px] z-50 hidden w-[360px] max-w-[42vw] -translate-x-1/2 md:block"
currentPubkey={identityQuery.data?.pubkey}
onOpenChannel={(channelId) => {
void goChannel(channelId);
}}
onOpenResult={handleOpenSearchResult}
/>
<div className="fixed right-3 top-[9px] z-50 flex items-center gap-0.5 md:hidden">
<Button
aria-label="Search everything"
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="open-search-compact"
onClick={handleOpenSearch}
size="icon"
title="Search everything"
type="button"
variant="ghost"
>
<Search className="h-3 w-3" />
</Button>
</div>
<AppSidebar
activeWorkspace={workspacesHook.activeWorkspace}
channels={sidebarChannels}
@@ -721,7 +745,6 @@ export function AppShell() {
});
await goChannel(directMessage.id);
}}
onOpenSearch={handleOpenSearch}
onSelectAgents={() => {
void goAgents();
}}
@@ -161,7 +161,6 @@ export function MessageThreadPanel({
<aside
className={cn(
PANEL_BASE_CLASS,
!isOverlay && "pt-11",
isOverlay && PANEL_OVERLAY_CLASS,
)}
data-testid="message-thread-panel"
@@ -185,24 +184,34 @@ export function MessageThreadPanel({
</button>
)}
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div
className={cn(
"z-40 flex min-h-[44px] cursor-default select-none items-center gap-3 bg-background/70 px-4 py-[6px] backdrop-blur-xl supports-[backdrop-filter]:bg-background/55",
isOverlay ? "relative shrink-0" : "absolute left-0 top-0",
)}
data-tauri-drag-region
>
<div className="flex min-w-0 items-center gap-1.5">
<h2 className="text-sm font-semibold tracking-tight">Thread</h2>
<Button
aria-label="Close thread"
className="h-4 w-4 rounded-full text-muted-foreground/45 opacity-70 hover:bg-muted/60 hover:text-foreground hover:opacity-100 focus-visible:opacity-100"
data-testid="message-thread-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-2.5 w-2.5" />
</Button>
</div>
<Button
aria-label="Close thread"
data-testid="message-thread-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto pb-24"
className={cn(
"min-h-0 flex-1 overflow-y-auto pb-24",
!isOverlay && "pt-11",
)}
data-testid="message-thread-body"
onScroll={syncScrollState}
ref={threadBodyRef}
+19 -104
View File
@@ -6,9 +6,11 @@ import {
type LucideIcon,
} from "lucide-react";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useSearchMessagesQuery } from "@/features/search/hooks";
import type { Channel, SearchHit } from "@/shared/api/types";
import {
MIN_SEARCH_QUERY_LENGTH,
useSearchResults,
} from "@/features/search/useSearchResults";
import {
ChannelResultBody,
MessageResultBody,
@@ -28,8 +30,6 @@ import {
import { Input } from "@/shared/ui/input";
import { Skeleton } from "@/shared/ui/skeleton";
const MIN_QUERY_LENGTH = 2;
function SearchState({
icon: Icon,
title,
@@ -86,70 +86,21 @@ export function SearchDialog({
onOpenChannel,
onOpenResult,
}: SearchDialogProps) {
const [query, setQuery] = React.useState("");
const [debouncedQuery, setDebouncedQuery] = React.useState("");
const [selectedIndex, setSelectedIndex] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const channelLookup = React.useMemo(
() => new Map(channels.map((channel) => [channel.id, channel])),
[channels],
);
const searchQuery = useSearchMessagesQuery(debouncedQuery, {
enabled: open,
limit: 12,
});
const messageResults = searchQuery.data?.hits ?? [];
const channelResults = React.useMemo(() => {
if (debouncedQuery.length < MIN_QUERY_LENGTH) {
return [];
}
const normalizedQuery = debouncedQuery.toLowerCase();
return channels
.filter(
(channel) =>
channel.channelType !== "dm" &&
(channel.archivedAt
? channel.isMember
: channel.visibility === "open" || channel.isMember) &&
(channel.name.toLowerCase().includes(normalizedQuery) ||
channel.description.toLowerCase().includes(normalizedQuery)),
)
.sort((a, b) => {
const aNameMatches = a.name.toLowerCase().includes(normalizedQuery);
const bNameMatches = b.name.toLowerCase().includes(normalizedQuery);
if (aNameMatches !== bNameMatches) {
return aNameMatches ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
.slice(0, 5);
}, [channels, debouncedQuery]);
const results = React.useMemo<SearchResult[]>(
() => [
...channelResults.map((channel) => ({
kind: "channel" as const,
channel,
})),
...messageResults.map((hit) => ({
kind: "message" as const,
hit,
})),
],
[channelResults, messageResults],
);
const resultProfilesQuery = useUsersBatchQuery(
messageResults.map((hit) => hit.pubkey),
{
enabled: open && messageResults.length > 0,
},
);
const resultProfiles = resultProfilesQuery.data?.profiles;
const {
channelLookup,
channelResults,
debouncedQuery,
messageResults,
query,
resultProfiles,
results,
searchQuery,
selectedIndex,
selectedResult,
setQuery,
setSelectedIndex,
} = useSearchResults({ channels, enabled: open, limit: 12 });
const openResult = React.useCallback(
(result: SearchResult) => {
@@ -165,42 +116,6 @@ export function SearchDialog({
[onOpenChange, onOpenChannel, onOpenResult],
);
React.useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < MIN_QUERY_LENGTH) {
setDebouncedQuery("");
return;
}
const timeout = window.setTimeout(() => {
setDebouncedQuery(trimmed);
}, 300);
return () => {
window.clearTimeout(timeout);
};
}, [query]);
React.useEffect(() => {
if (!open) {
setQuery("");
setDebouncedQuery("");
setSelectedIndex(0);
}
}, [open]);
React.useEffect(() => {
setSelectedIndex((current) => {
if (results.length === 0) {
return 0;
}
return Math.min(current, results.length - 1);
});
}, [results]);
const selectedResult = results[selectedIndex];
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
@@ -266,7 +181,7 @@ export function SearchDialog({
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
{debouncedQuery.length < MIN_QUERY_LENGTH ? (
{debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH ? (
<SearchState
description="Type at least two characters to search the relay-backed history for streams, forums, DMs, approvals, and agent updates."
icon={MessagesSquare}
@@ -0,0 +1,206 @@
import { LoaderCircle, Search } from "lucide-react";
import * as React from "react";
import {
MIN_SEARCH_QUERY_LENGTH,
useSearchResults,
} from "@/features/search/useSearchResults";
import {
resultIcon,
resultKey,
resultTestId,
type SearchResult,
} from "@/features/search/ui/SearchResultItem";
import type { Channel, SearchHit } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
type TopbarSearchProps = {
channels: Channel[];
className?: string;
currentPubkey?: string;
onOpenChannel: (channelId: string) => void;
onOpenResult: (hit: SearchHit) => void;
};
function resultTitle(result: SearchResult) {
if (result.kind === "channel") {
return result.channel.name;
}
return result.hit.channelName ?? "Message";
}
function resultSummary(result: SearchResult) {
if (result.kind === "channel") {
return result.channel.description || result.channel.channelType;
}
return result.hit.content.trim() || "No message body.";
}
export function TopbarSearch({
channels,
className,
currentPubkey,
onOpenChannel,
onOpenResult,
}: TopbarSearchProps) {
const [isOpen, setIsOpen] = React.useState(false);
const rootRef = React.useRef<HTMLDivElement>(null);
const {
channelLookup,
debouncedQuery,
query,
resultProfiles,
results,
searchQuery,
selectedIndex,
selectedResult,
setQuery,
setSelectedIndex,
} = useSearchResults({ channels, enabled: isOpen, limit: 8 });
const openResult = React.useCallback(
(result: SearchResult) => {
setIsOpen(false);
setQuery("");
if (result.kind === "channel") {
onOpenChannel(result.channel.id);
return;
}
onOpenResult(result.hit);
},
[onOpenChannel, onOpenResult, setQuery],
);
React.useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (
event.target instanceof Node &&
rootRef.current?.contains(event.target)
) {
return;
}
setIsOpen(false);
}
window.addEventListener("pointerdown", handlePointerDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
};
}, []);
const showSuggestions = isOpen && query.trim().length > 0;
return (
<div className={cn("relative", className)} ref={rootRef}>
<div className="flex h-7 items-center gap-2 rounded-lg border border-border/70 bg-muted/45 px-2.5 text-xs text-muted-foreground shadow-xs backdrop-blur transition-colors focus-within:border-border focus-within:bg-muted/70 focus-within:text-foreground hover:bg-muted/70 supports-[backdrop-filter]:bg-muted/35">
<Search className="h-3.5 w-3.5 shrink-0" />
<input
aria-label="Search everything"
className="min-w-0 flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none"
data-testid="open-search"
onChange={(event) => {
setIsOpen(true);
setQuery(event.target.value);
setSelectedIndex(0);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={(event) => {
if (event.key === "ArrowDown" && results.length > 0) {
event.preventDefault();
setSelectedIndex((current) =>
Math.min(current + 1, results.length - 1),
);
return;
}
if (event.key === "ArrowUp" && results.length > 0) {
event.preventDefault();
setSelectedIndex((current) => Math.max(current - 1, 0));
return;
}
if (event.key === "Escape") {
event.preventDefault();
setIsOpen(false);
return;
}
if (
event.key === "Enter" &&
!event.nativeEvent.isComposing &&
selectedResult
) {
event.preventDefault();
openResult(selectedResult);
}
}}
placeholder="Search everything"
value={query}
/>
<kbd className="shrink-0 text-[10px] text-muted-foreground/70">
&#x2318;K
</kbd>
</div>
{showSuggestions ? (
<div className="absolute left-1/2 top-full z-50 mt-1 w-[560px] max-w-[min(80vw,560px)] -translate-x-1/2 overflow-hidden rounded-xl border border-border/80 bg-popover text-popover-foreground shadow-xl">
{debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH ? (
<p className="px-3 py-3 text-xs text-muted-foreground">
Type at least two characters to search.
</p>
) : searchQuery.isLoading && results.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
Searching...
</div>
) : searchQuery.error instanceof Error && results.length === 0 ? (
<p className="px-3 py-3 text-xs text-destructive">
{searchQuery.error.message}
</p>
) : results.length === 0 ? (
<p className="px-3 py-3 text-xs text-muted-foreground">
No matches found.
</p>
) : (
<div className="max-h-[360px] overflow-y-auto p-1.5">
{results.map((result, index) => (
<button
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors",
index === selectedIndex
? "bg-accent text-accent-foreground"
: "hover:bg-accent/70",
)}
key={resultKey(result)}
onClick={() => openResult(result)}
onMouseEnter={() => setSelectedIndex(index)}
type="button"
data-testid={resultTestId(result)}
>
{React.createElement(resultIcon(result, channelLookup), {
className: "h-3.5 w-3.5 shrink-0 text-muted-foreground",
})}
<span className="min-w-0 flex-1 truncate text-xs">
<span className="font-medium">{resultTitle(result)}</span>
<span className="mx-1.5 text-muted-foreground">-</span>
<span className="text-muted-foreground">
{resultSummary(result)}
</span>
</span>
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground/70">
{result.kind === "channel" ? "Channel" : "Message"}
</span>
</button>
))}
</div>
)}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,133 @@
import * as React from "react";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useSearchMessagesQuery } from "@/features/search/hooks";
import type { SearchResult } from "@/features/search/ui/SearchResultItem";
import type { Channel } from "@/shared/api/types";
export const MIN_SEARCH_QUERY_LENGTH = 2;
export function useSearchResults({
channels,
enabled,
limit = 12,
}: {
channels: Channel[];
enabled: boolean;
limit?: number;
}) {
const [query, setQuery] = React.useState("");
const [debouncedQuery, setDebouncedQuery] = React.useState("");
const [selectedIndex, setSelectedIndex] = React.useState(0);
const channelLookup = React.useMemo(
() => new Map(channels.map((channel) => [channel.id, channel])),
[channels],
);
const searchQuery = useSearchMessagesQuery(debouncedQuery, {
enabled,
limit,
});
const messageResults = searchQuery.data?.hits ?? [];
const channelResults = React.useMemo(() => {
if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) {
return [];
}
const normalizedQuery = debouncedQuery.toLowerCase();
return channels
.filter(
(channel) =>
channel.channelType !== "dm" &&
(channel.archivedAt
? channel.isMember
: channel.visibility === "open" || channel.isMember) &&
(channel.name.toLowerCase().includes(normalizedQuery) ||
channel.description.toLowerCase().includes(normalizedQuery)),
)
.sort((a, b) => {
const aNameMatches = a.name.toLowerCase().includes(normalizedQuery);
const bNameMatches = b.name.toLowerCase().includes(normalizedQuery);
if (aNameMatches !== bNameMatches) {
return aNameMatches ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
.slice(0, 5);
}, [channels, debouncedQuery]);
const results = React.useMemo<SearchResult[]>(
() => [
...channelResults.map((channel) => ({
kind: "channel" as const,
channel,
})),
...messageResults.map((hit) => ({
kind: "message" as const,
hit,
})),
],
[channelResults, messageResults],
);
const resultProfilesQuery = useUsersBatchQuery(
messageResults.map((hit) => hit.pubkey),
{
enabled: enabled && messageResults.length > 0,
},
);
React.useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < MIN_SEARCH_QUERY_LENGTH) {
setDebouncedQuery("");
return;
}
const timeout = window.setTimeout(() => {
setDebouncedQuery(trimmed);
}, 300);
return () => {
window.clearTimeout(timeout);
};
}, [query]);
React.useEffect(() => {
if (!enabled) {
setQuery("");
setDebouncedQuery("");
setSelectedIndex(0);
}
}, [enabled]);
React.useEffect(() => {
setSelectedIndex((current) => {
if (results.length === 0) {
return 0;
}
return Math.min(current, results.length - 1);
});
}, [results]);
return {
channelLookup,
channelResults,
debouncedQuery,
messageResults,
query,
resultProfiles: resultProfilesQuery.data?.profiles,
results,
searchQuery,
selectedIndex,
selectedResult: results[selectedIndex],
setQuery,
setSelectedIndex,
};
}
+72 -88
View File
@@ -39,7 +39,6 @@ import type {
UserStatus,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
ContextMenu,
ContextMenuContent,
@@ -126,7 +125,6 @@ type AppSidebarProps = {
onOpenAddWorkspace: () => void;
onOpenBrowseChannels: () => void;
onOpenBrowseForums: () => void;
onOpenSearch: () => void;
onHideDm: (channelId: string) => void;
onMarkChannelUnread: (
channelId: string,
@@ -385,7 +383,6 @@ export function AppSidebar({
onOpenAddWorkspace,
onOpenBrowseChannels,
onOpenBrowseForums,
onOpenSearch,
onHideDm,
onMarkChannelUnread,
onMarkChannelRead,
@@ -508,33 +505,9 @@ export function AppSidebar({
variant="sidebar"
>
<SidebarHeader
className="cursor-default select-none gap-3 pt-10"
className="cursor-default select-none pt-10"
data-tauri-drag-region
>
<div className="px-0.5">
<WorkspaceSwitcher
activeWorkspace={activeWorkspace}
onAddWorkspace={onOpenAddWorkspace}
onRemoveWorkspace={onRemoveWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
workspaces={workspaces}
/>
</div>
<Button
className="w-full justify-between rounded-xl border border-sidebar-border/80 bg-sidebar-accent/60 px-3 text-sidebar-foreground/80 shadow-xs hover:bg-sidebar-accent hover:text-sidebar-foreground"
data-testid="open-search"
onClick={onOpenSearch}
size="sm"
type="button"
variant="ghost"
>
<span className="flex items-center gap-2">
<Search className="h-4 w-4" />
Search messages
</span>
<span className="text-xs text-sidebar-foreground/50">&#x2318;K</span>
</Button>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
@@ -718,69 +691,80 @@ export function AppSidebar({
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<ProfilePopover
open={profilePopoverOpen}
onOpenChange={setProfilePopoverOpen}
displayName={resolvedDisplayName}
nip05={profile?.nip05Handle}
avatarUrl={profile?.avatarUrl ?? null}
currentStatus={selfPresenceStatus}
isStatusPending={isPresencePending}
userStatusText={selfUserStatus?.text}
userStatusEmoji={selfUserStatus?.emoji}
onSetStatus={onSetPresenceStatus ?? (() => {})}
onSetUserStatus={onSetUserStatus}
onClearUserStatus={onClearUserStatus}
onOpenSettings={onSelectSettings}
<div
className="rounded-xl px-2 py-2 transition-colors hover:bg-sidebar-accent/70 focus-within:bg-sidebar-accent/70"
data-testid="sidebar-profile-card"
>
<SidebarMenuButton
className="h-auto gap-3 rounded-xl px-2 py-2"
data-testid="open-settings"
type="button"
>
<div
className="flex min-w-0 flex-1 items-center gap-3"
data-testid="sidebar-profile-card"
>
<div className="relative shrink-0">
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-10 w-10 rounded-2xl text-sm"
iconClassName="h-5 w-5"
label={resolvedDisplayName}
testId="sidebar-profile-avatar"
<div className="flex min-w-0 items-center gap-3">
<div className="relative shrink-0">
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-10 w-10 rounded-2xl text-sm"
iconClassName="h-5 w-5"
label={resolvedDisplayName}
testId="sidebar-profile-avatar"
/>
<span
aria-label={getPresenceLabel(selfPresenceStatus)}
className="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-sidebar"
data-testid="self-presence-badge"
role="img"
>
<PresenceDot
className="h-2.5 w-2.5"
status={selfPresenceStatus}
/>
<span
aria-label={getPresenceLabel(selfPresenceStatus)}
className="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-sidebar"
data-testid="self-presence-badge"
role="img"
>
<PresenceDot
className="h-2.5 w-2.5"
status={selfPresenceStatus}
/>
</span>
</div>
<div className="min-w-0">
<p
className="truncate text-sm font-semibold text-current"
data-testid="sidebar-profile-name"
>
{resolvedDisplayName}
</p>
{selfUserStatus?.text || selfUserStatus?.emoji ? (
<p className="truncate text-xs text-sidebar-foreground/50">
{selfUserStatus.emoji ? (
<span className="mr-1">{selfUserStatus.emoji}</span>
) : null}
{selfUserStatus.text}
</p>
) : null}
</div>
</span>
</div>
</SidebarMenuButton>
</ProfilePopover>
<div className="min-w-0 flex-1">
<ProfilePopover
open={profilePopoverOpen}
onOpenChange={setProfilePopoverOpen}
displayName={resolvedDisplayName}
nip05={profile?.nip05Handle}
avatarUrl={profile?.avatarUrl ?? null}
currentStatus={selfPresenceStatus}
isStatusPending={isPresencePending}
userStatusText={selfUserStatus?.text}
userStatusEmoji={selfUserStatus?.emoji}
onSetStatus={onSetPresenceStatus ?? (() => {})}
onSetUserStatus={onSetUserStatus}
onClearUserStatus={onClearUserStatus}
onOpenSettings={onSelectSettings}
>
<button
className="block w-full min-w-0 text-left text-sidebar-foreground"
data-testid="open-settings"
type="button"
>
<p
className="truncate text-sm font-semibold text-current"
data-testid="sidebar-profile-name"
>
{resolvedDisplayName}
</p>
</button>
</ProfilePopover>
<WorkspaceSwitcher
activeWorkspace={activeWorkspace}
onAddWorkspace={onOpenAddWorkspace}
onRemoveWorkspace={onRemoveWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
variant="profile"
workspaces={workspaces}
/>
{selfUserStatus?.text || selfUserStatus?.emoji ? (
<p className="mt-0.5 truncate text-xs text-sidebar-foreground/50">
{selfUserStatus.emoji ? (
<span className="mr-1">{selfUserStatus.emoji}</span>
) : null}
{selfUserStatus.text}
</p>
) : null}
</div>
</div>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
@@ -41,6 +41,7 @@ const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
type WorkspaceSwitcherProps = {
activeWorkspace: Workspace | null;
workspaces: Workspace[];
variant?: "sidebar" | "profile";
onSwitchWorkspace: (id: string) => void;
onAddWorkspace: () => void;
onUpdateWorkspace: (
@@ -53,6 +54,7 @@ type WorkspaceSwitcherProps = {
export function WorkspaceSwitcher({
activeWorkspace,
workspaces,
variant = "sidebar",
onSwitchWorkspace,
onAddWorkspace,
onUpdateWorkspace,
@@ -65,102 +67,145 @@ export function WorkspaceSwitcher({
const degraded = isRelayConnectionDegraded(connectionState);
const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
const triggerContent = (
<>
{degraded ? (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-hidden="false"
className={
variant === "profile"
? "flex h-5 w-5 shrink-0 animate-pulse items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-destructive"
: "flex h-5 w-5 shrink-0 animate-pulse items-center justify-center text-destructive"
}
data-testid="relay-connection-warning"
role="img"
>
<WifiOff
className={variant === "profile" ? "h-3 w-3" : "h-4 w-4"}
/>
</span>
</TooltipTrigger>
<TooltipContent side={variant === "profile" ? "top" : "bottom"}>
{connectionLabel}
</TooltipContent>
</Tooltip>
) : (
<span
className={
variant === "profile"
? "flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-[10px] leading-none"
: "flex h-5 w-5 shrink-0 items-center justify-center text-xs leading-none"
}
>
🌱
</span>
)}
<span
className={
degraded
? "min-w-0 flex-1 truncate font-medium text-destructive animate-pulse"
: "min-w-0 flex-1 truncate font-medium"
}
>
{activeWorkspace?.name ?? "No workspace"}
</span>
<ChevronDown
className={
variant === "profile"
? "h-3 w-3 shrink-0 text-sidebar-foreground/45"
: "h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50"
}
/>
</>
);
const switcherDropdown = (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
{variant === "profile" ? (
<button
aria-label={
degraded
? `${activeWorkspace?.name ?? "Workspace"}${connectionLabel}`
: "Switch workspace"
}
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md py-0.5 text-left text-xs text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground data-[state=open]:text-sidebar-foreground"
data-testid="workspace-switcher"
type="button"
>
{triggerContent}
</button>
) : (
<SidebarMenuButton
aria-label={
degraded
? `${activeWorkspace?.name ?? "Workspace"}${connectionLabel}`
: undefined
}
className="h-auto gap-2 rounded-xl px-2.5 py-2 data-[state=open]:bg-sidebar-accent"
data-testid="workspace-switcher"
type="button"
>
{triggerContent}
</SidebarMenuButton>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-(--radix-dropdown-menu-trigger-width) min-w-[220px]"
onCloseAutoFocus={(e) => e.preventDefault()}
side={variant === "profile" ? "top" : "bottom"}
sideOffset={4}
>
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
className="group flex items-center gap-2 pr-1"
onSelect={() => {
onSwitchWorkspace(workspace.id);
}}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{activeWorkspace?.id === workspace.id ? (
<Check className="h-3.5 w-3.5 text-primary" />
) : null}
</span>
<span className="min-w-0 flex-1 truncate">{workspace.name}</span>
<button
aria-label={`Edit ${workspace.name}`}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus:opacity-100"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDropdownOpen(false);
setEditingWorkspace(workspace);
}}
type="button"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onAddWorkspace}>
<Plus className="h-4 w-4" />
<span>Add Workspace</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
aria-label={
degraded
? `${activeWorkspace?.name ?? "Workspace"}${connectionLabel}`
: undefined
}
className="h-auto gap-2 rounded-xl px-2.5 py-2 data-[state=open]:bg-sidebar-accent"
data-testid="workspace-switcher"
type="button"
>
{degraded ? (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-hidden="false"
className="flex h-5 w-5 shrink-0 animate-pulse items-center justify-center text-destructive"
data-testid="relay-connection-warning"
role="img"
>
<WifiOff className="h-4 w-4" />
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{connectionLabel}
</TooltipContent>
</Tooltip>
) : (
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs leading-none">
🌱
</span>
)}
<span
className={
degraded
? "min-w-0 flex-1 truncate text-sm font-medium text-destructive animate-pulse"
: "min-w-0 flex-1 truncate text-sm font-medium"
}
>
{activeWorkspace?.name ?? "No workspace"}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-(--radix-dropdown-menu-trigger-width) min-w-[220px]"
onCloseAutoFocus={(e) => e.preventDefault()}
side="bottom"
sideOffset={4}
>
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
className="group flex items-center gap-2 pr-1"
onSelect={() => {
onSwitchWorkspace(workspace.id);
}}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{activeWorkspace?.id === workspace.id ? (
<Check className="h-3.5 w-3.5 text-primary" />
) : null}
</span>
<span className="min-w-0 flex-1 truncate">
{workspace.name}
</span>
<button
aria-label={`Edit ${workspace.name}`}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus:opacity-100"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDropdownOpen(false);
setEditingWorkspace(workspace);
}}
type="button"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onAddWorkspace}>
<Plus className="h-4 w-4" />
<span>Add Workspace</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
{variant === "profile" ? (
switcherDropdown
) : (
<SidebarMenu>
<SidebarMenuItem>{switcherDropdown}</SidebarMenuItem>
</SidebarMenu>
)}
<EditWorkspaceDialog
canRemove={workspaces.length > 1}