Update desktop navigation chrome and search (#779)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
thomaspblock
2026-05-29 08:26:41 -07:00
committed by GitHub
co-authored by Cursor
parent 5ee2cd0517
commit d77b111b15
36 changed files with 1246 additions and 996 deletions
+1 -1
View File
@@ -1 +1 @@
.pnpm-11.1.3.pkg
.pnpm-11.4.0.pkg
+16 -43
View File
@@ -1,4 +1,3 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import * as React from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useQueryClient } from "@tanstack/react-query";
@@ -9,6 +8,7 @@ import {
AppShellOverlays,
type BrowseDialogType,
} from "@/app/AppShellOverlays";
import { AppTopChrome } from "@/app/AppTopChrome";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
@@ -62,12 +62,7 @@ import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks";
import { Button } from "@/shared/ui/button";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/shared/ui/sidebar";
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
type AppView =
| "home"
@@ -174,7 +169,7 @@ export function AppShell() {
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
@@ -365,7 +360,7 @@ export function AppShell() {
void refetchChannels();
}, [refetchChannels]);
const handleOpenSearch = React.useCallback(() => {
setIsSearchOpen(true);
setSearchFocusRequest((request) => request + 1);
void refetchChannels();
}, [refetchChannels]);
@@ -400,7 +395,6 @@ export function AppShell() {
const handleOpenSettings = React.useCallback(
(section: SettingsSection = DEFAULT_SETTINGS_SECTION) => {
setIsSearchOpen(false);
setIsChannelManagementOpen(false);
setSettingsSection(section);
setSettingsOpen(true);
@@ -659,36 +653,19 @@ export function AppShell() {
<HuddleProvider>
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
<div
aria-hidden="true"
className="fixed inset-x-0 top-0 z-20 h-10 cursor-default select-none"
data-tauri-drag-region
<AppTopChrome
canGoBack={canGoBack}
canGoForward={canGoForward}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
onGoBack={goBack}
onGoForward={goForward}
onOpenChannel={(channelId) => {
void goChannel(channelId);
}}
onOpenResult={handleOpenSearchResult}
searchFocusRequest={searchFocusRequest}
/>
<div className="fixed left-[80px] top-[9px] z-50 flex items-center gap-0.5">
<SidebarTrigger className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-back"
disabled={!canGoBack}
onClick={goBack}
size="icon"
variant="ghost"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<Button
aria-label="Go forward"
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-forward"
disabled={!canGoForward}
onClick={goForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
<AppSidebar
activeWorkspace={workspacesHook.activeWorkspace}
channels={sidebarChannels}
@@ -770,7 +747,6 @@ export function AppShell() {
});
await goChannel(directMessage.id);
}}
onOpenSearch={handleOpenSearch}
onSelectAgents={() => {
void goAgents();
}}
@@ -821,7 +797,6 @@ export function AppShell() {
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isSearchOpen={isSearchOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
@@ -829,8 +804,6 @@ export function AppShell() {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onOpenSearchResult={handleOpenSearchResult}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
+1 -25
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import type { Channel, SearchHit } from "@/shared/api/types";
import type { Channel } from "@/shared/api/types";
const ChannelBrowserDialog = React.lazy(async () => {
const module = await import("@/features/channels/ui/ChannelBrowserDialog");
@@ -12,11 +12,6 @@ const ChannelManagementSheet = React.lazy(async () => {
return { default: module.ChannelManagementSheet };
});
const SearchDialog = React.lazy(async () => {
const module = await import("@/features/search/ui/SearchDialog");
return { default: module.SearchDialog };
});
export type BrowseDialogType = "stream" | "forum" | null;
type AppShellOverlaysProps = {
@@ -25,13 +20,10 @@ type AppShellOverlaysProps = {
channels: Channel[];
currentPubkey?: string;
isChannelManagementOpen: boolean;
isSearchOpen: boolean;
onBrowseChannelJoin: (channelId: string) => Promise<void>;
onBrowseDialogOpenChange: (open: boolean) => void;
onChannelManagementOpenChange: (open: boolean) => void;
onDeleteActiveChannel: () => void;
onOpenSearchResult: (hit: SearchHit) => void;
onSearchOpenChange: (open: boolean) => void;
onSelectChannel: (channelId: string) => void;
};
@@ -41,13 +33,10 @@ export function AppShellOverlays({
channels,
currentPubkey,
isChannelManagementOpen,
isSearchOpen,
onBrowseChannelJoin,
onBrowseDialogOpenChange,
onChannelManagementOpenChange,
onDeleteActiveChannel,
onOpenSearchResult,
onSearchOpenChange,
onSelectChannel,
}: AppShellOverlaysProps) {
return (
@@ -65,19 +54,6 @@ export function AppShellOverlays({
</React.Suspense>
) : null}
{isSearchOpen ? (
<React.Suspense fallback={null}>
<SearchDialog
channels={channels}
currentPubkey={currentPubkey}
onOpenChannel={onSelectChannel}
onOpenResult={onOpenSearchResult}
onOpenChange={onSearchOpenChange}
open={true}
/>
</React.Suspense>
) : null}
{isChannelManagementOpen && activeChannel !== null ? (
<React.Suspense fallback={null}>
<ChannelManagementSheet
+86
View File
@@ -0,0 +1,86 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
import type { Channel, SearchHit } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { SidebarTrigger, useSidebar } from "@/shared/ui/sidebar";
type AppTopChromeProps = {
canGoBack: boolean;
canGoForward: boolean;
channels: Channel[];
currentPubkey?: string;
onGoBack: () => void;
onGoForward: () => void;
onOpenChannel: (channelId: string) => void;
onOpenResult: (hit: SearchHit) => void;
searchFocusRequest: number;
};
function GlobalTopDivider() {
const { state } = useSidebar();
return (
<div
aria-hidden="true"
className="pointer-events-none fixed right-0 top-10 z-50 h-px bg-border/35"
style={{ left: state === "expanded" ? "var(--sidebar-width)" : 0 }}
/>
);
}
export function AppTopChrome({
canGoBack,
canGoForward,
channels,
currentPubkey,
onGoBack,
onGoForward,
onOpenChannel,
onOpenResult,
searchFocusRequest,
}: AppTopChromeProps) {
return (
<>
<div
aria-hidden="true"
className="fixed inset-x-0 top-0 z-20 h-10 cursor-default select-none"
data-tauri-drag-region
/>
<GlobalTopDivider />
<div className="fixed left-[80px] top-[9px] z-[80] flex items-center gap-0.5">
<SidebarTrigger className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-back"
disabled={!canGoBack}
onClick={onGoBack}
size="icon"
variant="ghost"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<Button
aria-label="Go forward"
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-forward"
disabled={!canGoForward}
onClick={onGoForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
<TopbarSearch
channels={channels}
className="fixed left-1/2 top-[7px] z-[80] hidden w-[300px] max-w-[34vw] -translate-x-1/2 md:block lg:w-[360px] lg:max-w-[38vw] xl:w-[420px] xl:max-w-[42vw] 2xl:w-[480px] 2xl:max-w-[44vw]"
currentPubkey={currentPubkey}
focusRequest={searchFocusRequest}
onOpenChannel={onOpenChannel}
onOpenResult={onOpenResult}
/>
</>
);
}
+3
View File
@@ -20,6 +20,9 @@ function HomeRouteComponent() {
<HomeScreen
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
onOpenChannel={(channelId) => {
void goChannel(channelId);
}}
onOpenContext={(channelId, messageId) => {
void goChannel(channelId, { messageId });
}}
@@ -1,6 +1,5 @@
import * as React from "react";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const AgentsView = React.lazy(async () => {
@@ -10,19 +9,10 @@ const AgentsView = React.lazy(async () => {
export function AgentsScreen() {
return (
<>
<ChatHeader
description="Choose personas from Persona Catalog, create local ACP workers, and monitor the relay-visible agent directory."
mode="agents"
overlaysContent
title="Agents"
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<React.Suspense fallback={<ViewLoadingFallback kind="agents" />}>
<AgentsView />
</React.Suspense>
</div>
</>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<React.Suspense fallback={<ViewLoadingFallback kind="agents" />}>
<AgentsView />
</React.Suspense>
</div>
);
}
@@ -68,11 +68,7 @@ export function AgentSessionThreadPanel({
<>
{isOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(
PANEL_BASE_CLASS,
!isOverlay && "pt-11",
isOverlay && PANEL_OVERLAY_CLASS,
)}
className={cn(PANEL_BASE_CLASS, isOverlay && PANEL_OVERLAY_CLASS)}
data-testid="agent-session-thread-panel"
style={{ width: `${widthPx}px` }}
>
@@ -94,7 +90,22 @@ export function AgentSessionThreadPanel({
</button>
)}
<div className="flex items-center gap-3 border-b border-border/70 px-4 py-2.5">
{!isOverlay ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[76px] bg-background/45 backdrop-blur-xl after:absolute after:bottom-0 after:left-0 after:top-10 after:w-px after:bg-border/80 supports-[backdrop-filter]:bg-background/35"
/>
) : null}
<div
className={cn(
"z-50 flex cursor-default select-none items-center gap-3 px-4",
isOverlay
? "relative min-h-[44px] shrink-0 border-b border-border/70 bg-background/70 py-2.5 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
: "absolute inset-x-0 top-11 min-h-[32px] py-[4px]",
)}
data-tauri-drag-region
>
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold tracking-tight">
@@ -157,7 +168,10 @@ export function AgentSessionThreadPanel({
<div
ref={scrollRef}
onScroll={onScroll}
className="min-h-0 flex-1 overflow-y-auto px-3 py-4"
className={cn(
"min-h-0 flex-1 overflow-y-auto px-3 pb-4",
isOverlay ? "pt-4" : "pt-[76px]",
)}
>
<ManagedAgentSessionPanel
agent={agent}
@@ -100,6 +100,20 @@ export function ChannelMembersBar({
<Plus className="h-3 w-3" />
</Button>
<Button
aria-label={`View channel members (${memberCount})`}
className="h-7 gap-1 rounded-full px-2"
data-testid="channel-members-trigger"
onClick={onToggleMembers}
type="button"
variant="outline"
>
<Users className="h-3 w-3" />
<span className="min-w-[1ch] text-[11px] font-medium tabular-nums">
{memberCount}
</span>
</Button>
<HuddleIndicator
className="h-7 w-7"
channelId={channel.id}
@@ -116,20 +130,6 @@ export function ChannelMembersBar({
startDisabled={!canAddAgents || isStartingHuddle}
/>
<Button
aria-label={`View channel members (${memberCount})`}
className="h-7 gap-1 rounded-full px-2"
data-testid="channel-members-trigger"
onClick={onToggleMembers}
type="button"
variant="outline"
>
<Users className="h-3 w-3" />
<span className="min-w-[1ch] text-[11px] font-medium tabular-nums">
{memberCount}
</span>
</Button>
<Button
aria-label="Manage channel"
className="h-7 w-7 rounded-full"
@@ -40,6 +40,9 @@ export function ChannelScreenHeader({
return (
<ChatHeader
actionsPlacement="top-right"
belowSystemChrome
density="compact"
actions={
activeChannel ? (
showJoinButton ? (
@@ -64,7 +67,6 @@ export function ChannelScreenHeader({
}
channelType={activeChannel?.channelType}
description={getChannelDescription(activeChannel)}
overlaysContent
statusBadge={
<ChannelHeaderStatusBadge
channelType={activeChannel?.channelType}
+42 -13
View File
@@ -10,14 +10,17 @@ import {
Zap,
} from "lucide-react";
import type * as React from "react";
import { createPortal } from "react-dom";
import type { ChannelType, ChannelVisibility } from "@/shared/api/types";
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
import { cn } from "@/shared/lib/cn";
import { useSidebar } from "@/shared/ui/sidebar";
type ChatHeaderProps = {
actions?: React.ReactNode;
actionsPlacement?: "inline" | "top-right";
belowSystemChrome?: boolean;
density?: "default" | "compact";
title: string;
description?: string;
channelType?: ChannelType;
@@ -28,6 +31,7 @@ type ChatHeaderProps = {
};
const HEADER_ICON_CLASS = "h-[14px] w-[14px] text-muted-foreground";
const CHANNEL_HASH_ICON_CLASS = "h-[14px] w-[14px] translate-y-px";
function ChannelIcon({
channelType,
@@ -70,11 +74,14 @@ function ChannelIcon({
return <FileText className={HEADER_ICON_CLASS} />;
}
return <Hash className={HEADER_ICON_CLASS} />;
return <Hash className={CHANNEL_HASH_ICON_CLASS} color="gray" />;
}
export function ChatHeader({
actions,
actionsPlacement = "inline",
belowSystemChrome = false,
density = "default",
title,
description,
channelType,
@@ -84,15 +91,21 @@ export function ChatHeader({
statusBadge,
}: ChatHeaderProps) {
const trimmedDescription = description?.trim() ?? "";
const { state: sidebarState } = useSidebar();
const reserveGlobalControls = sidebarState === "collapsed";
const topRightActions = (
<div className="fixed right-3 top-[9px] z-[70] flex shrink-0 items-center gap-1">
<UpdateIndicator />
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
);
return (
const header = (
<header
className={cn(
"relative z-30 flex min-h-[44px] min-w-0 shrink-0 cursor-default select-none items-center gap-[10px] bg-background/70 py-[6px] pl-[16px] pr-[8px] backdrop-blur-xl transition-[margin,padding] duration-200 ease-linear supports-[backdrop-filter]:bg-background/55 sm:pl-[24px] sm:pr-[12px]",
overlaysContent && "-mb-[44px]",
reserveGlobalControls && "md:pl-[160px]",
"relative z-30 flex min-w-0 shrink-0 cursor-default select-none items-center gap-[10px] bg-transparent pl-[16px] pr-[8px] transition-[margin,padding] duration-200 ease-linear sm:pl-[24px] sm:pr-[12px]",
density === "compact"
? "min-h-[32px] py-[4px]"
: "min-h-[44px] py-[6px]",
overlaysContent && !belowSystemChrome && "-mb-[44px]",
)}
data-testid="chat-header"
data-tauri-drag-region
@@ -105,7 +118,7 @@ export function ChatHeader({
visibility={visibility}
/>
<h1
className="min-w-0 truncate text-sm font-semibold leading-5 tracking-tight"
className="min-w-0 translate-y-px truncate text-sm font-semibold leading-5 tracking-tight"
data-testid="chat-title"
title={trimmedDescription || undefined}
>
@@ -119,10 +132,26 @@ export function ChatHeader({
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<UpdateIndicator />
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
{actionsPlacement === "top-right" ? (
typeof document === "undefined" ? null : (
createPortal(topRightActions, document.body)
)
) : (
<div className="flex shrink-0 items-center gap-1">
<UpdateIndicator />
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>
)}
</header>
);
if (!belowSystemChrome) {
return header;
}
return (
<div className="relative z-30 h-[76px] -mb-[76px] bg-background/70 pt-[42px] backdrop-blur-xl supports-[backdrop-filter]:bg-background/55">
{header}
</div>
);
}
@@ -0,0 +1,34 @@
import { createPortal } from "react-dom";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
import type { Channel } from "@/shared/api/types";
type HomeChannelActionsProps = {
channel: Channel | null;
currentPubkey?: string;
onOpenChannel: (channelId: string) => void;
};
export function HomeChannelActions({
channel,
currentPubkey,
onOpenChannel,
}: HomeChannelActionsProps) {
if (!channel || typeof document === "undefined") {
return null;
}
return createPortal(
<div className="fixed right-3 top-[9px] z-[70] flex shrink-0 items-center gap-1">
<UpdateIndicator />
<ChannelMembersBar
channel={channel}
currentPubkey={currentPubkey}
onManageChannel={() => onOpenChannel(channel.id)}
onToggleMembers={() => onOpenChannel(channel.id)}
/>
</div>,
document.body,
);
}
@@ -0,0 +1,32 @@
import { Skeleton } from "@/shared/ui/skeleton";
export function HomeLoadingState() {
return (
<div className="flex-1 overflow-hidden">
<div className="grid h-full min-h-0 w-full lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="overflow-hidden border-r border-border/70 bg-background/60">
<div className="border-b border-border/70 px-4 pb-4 pt-14">
<Skeleton className="h-4 w-20" />
<Skeleton className="mt-2 h-4 w-28" />
<Skeleton className="mt-4 h-10 rounded-md" />
</div>
<div className="space-y-3 px-4 py-4">
{["a", "b", "c", "d"].map((row) => (
<Skeleton className="h-20 rounded-md" key={row} />
))}
</div>
</div>
<div className="overflow-hidden bg-background/60">
<div className="border-b border-border/70 px-5 pb-4 pt-14">
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-3 h-8 w-72" />
</div>
<div className="px-5 py-5">
<Skeleton className="h-64 rounded-md" />
</div>
</div>
</div>
</div>
);
}
+19 -26
View File
@@ -1,7 +1,6 @@
import * as React from "react";
import { useAppShell } from "@/app/AppShellContext";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels";
@@ -10,12 +9,14 @@ import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
type HomeScreenProps = {
availableChannelIds: ReadonlySet<string>;
currentPubkey?: string;
onOpenChannel: (channelId: string) => void;
onOpenContext: (channelId: string, messageId: string) => void;
};
export function HomeScreen({
availableChannelIds,
currentPubkey,
onOpenChannel,
onOpenContext,
}: HomeScreenProps) {
const homeFeedQuery = useHomeFeedQuery();
@@ -52,31 +53,23 @@ export function HomeScreen({
}, [homeFeedQuery.data, threadActivityItems]);
return (
<>
<ChatHeader
description="Personalized activity feed for mentions, reminders, channel activity, and agent work."
mode="home"
overlaysContent
title="Home"
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
feed={augmentedFeed}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={onOpenChannel}
onOpenContext={onOpenContext}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
feed={augmentedFeed}
isLoading={homeFeedQuery.isLoading}
onOpenContext={onOpenContext}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
</div>
</>
</div>
);
}
+11 -34
View File
@@ -14,6 +14,8 @@ import { useFeedItemState } from "@/features/home/useFeedItemState";
import { useHomeInboxReadState } from "@/features/home/useHomeInboxReadState";
import { useInboxThreadContext } from "@/features/home/useInboxThreadContext";
import { useResizableInboxListWidth } from "@/features/home/useResizableInboxListWidth";
import { HomeChannelActions } from "@/features/home/ui/HomeChannelActions";
import { HomeLoadingState } from "@/features/home/ui/HomeLoadingState";
import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane";
import { InboxListPane } from "@/features/home/ui/InboxListPane";
import {
@@ -29,7 +31,6 @@ import type { HomeFeedResponse, RelayEvent } from "@/shared/api/types";
import { KIND_REACTION } from "@/shared/constants/kinds";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
function matchesInboxFilter(
item: { categories: InboxFilter[] },
@@ -42,37 +43,6 @@ function matchesInboxFilter(
return item.categories.includes(filter);
}
function HomeLoadingState() {
return (
<div className="flex-1 overflow-hidden">
<div className="grid h-full min-h-0 w-full lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="overflow-hidden border-r border-border/70 bg-background/60">
<div className="border-b border-border/70 px-4 pb-4 pt-14">
<Skeleton className="h-4 w-20" />
<Skeleton className="mt-2 h-4 w-28" />
<Skeleton className="mt-4 h-10 rounded-md" />
</div>
<div className="space-y-3 px-4 py-4">
{["a", "b", "c", "d"].map((row) => (
<Skeleton className="h-20 rounded-md" key={row} />
))}
</div>
</div>
<div className="overflow-hidden bg-background/60">
<div className="border-b border-border/70 px-5 pb-4 pt-14">
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-3 h-8 w-72" />
</div>
<div className="px-5 py-5">
<Skeleton className="h-64 rounded-md" />
</div>
</div>
</div>
</div>
);
}
function getContextMessageDepth(
event: RelayEvent,
eventById: ReadonlyMap<string, RelayEvent>,
@@ -107,6 +77,7 @@ type HomeViewProps = {
errorMessage?: string;
currentPubkey?: string;
availableChannelIds: ReadonlySet<string>;
onOpenChannel: (channelId: string) => void;
onOpenContext: (channelId: string, messageId: string) => void;
onRefresh: () => void;
};
@@ -117,6 +88,7 @@ export function HomeView({
errorMessage,
currentPubkey,
availableChannelIds,
onOpenChannel,
onOpenContext,
onRefresh,
}: HomeViewProps) {
@@ -354,6 +326,11 @@ export function HomeView({
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<HomeChannelActions
channel={selectedChannel}
currentPubkey={currentPubkey}
onOpenChannel={onOpenChannel}
/>
<div
className="relative grid min-h-0 flex-1 w-full lg:grid-cols-[var(--home-inbox-list-width)_minmax(0,1fr)]"
data-testid="home-inbox"
@@ -377,7 +354,7 @@ export function HomeView({
<button
aria-label="Resize inbox list"
className="group absolute inset-y-0 z-20 hidden w-3 -translate-x-1/2 cursor-col-resize lg:block"
className="group absolute inset-y-0 z-[60] hidden w-3 -translate-x-1/2 cursor-col-resize lg:block"
data-testid="home-inbox-list-resize-handle"
onDoubleClick={
canResetInboxListWidth ? handleInboxListWidthReset : undefined
@@ -391,7 +368,7 @@ export function HomeView({
}
type="button"
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80 group-focus-visible:bg-border/80" />
<span className="absolute bottom-0 left-1/2 top-10 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80 group-focus-visible:bg-border/80" />
</button>
<InboxDetailPane
@@ -1,5 +1,6 @@
import {
CheckCheck,
Hash,
Mail,
MailOpen,
MoreHorizontal,
@@ -193,9 +194,8 @@ export function InboxDetailPane({
}
: null;
const channelContextName = contextChannelName ?? item.channelLabel;
const contextLabel = channelContextName
? formatInboxTypeLabel({ ...item, channelLabel: channelContextName })
: formatInboxTypeLabel(item);
const contextLabel = channelContextName ?? formatInboxTypeLabel(item);
const hasChannelContext = Boolean(channelContextName);
const contextChannelId = item.item.channelId;
const handleSelectReplyTarget = (message: InboxDisplayMessage) => {
@@ -212,23 +212,33 @@ export function InboxDetailPane({
ref={detailPaneRef}
>
<div className="relative min-h-0 flex-1 overflow-hidden">
<div className="absolute inset-x-0 top-0 z-40 flex min-h-[44px] items-center justify-between gap-3 bg-background/70 py-[6px] pl-6 pr-3 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[76px] bg-background/45 backdrop-blur-xl supports-[backdrop-filter]:bg-background/35"
/>
<div className="absolute inset-x-0 top-[38px] z-50 flex min-h-[32px] items-center justify-between gap-3 py-[4px] pl-6 pr-3">
<div className="min-w-0">
{canOpenChannel && contextChannelId && onOpenContext ? (
<button
className="truncate text-left text-sm font-semibold leading-5 tracking-tight text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
className="flex min-w-0 items-center gap-[4px] text-left text-sm font-semibold leading-5 tracking-tight text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={() => onOpenContext(contextChannelId, item.id)}
title={item.fullTimestampLabel}
type="button"
>
{contextLabel}
{hasChannelContext ? (
<Hash className="h-[14px] w-[14px] shrink-0" color="gray" />
) : null}
<span className="min-w-0 truncate">{contextLabel}</span>
</button>
) : (
<h2
className="truncate text-sm font-semibold leading-5 tracking-tight text-foreground"
className="flex min-w-0 items-center gap-[4px] text-sm font-semibold leading-5 tracking-tight text-foreground"
title={item.fullTimestampLabel}
>
{contextLabel}
{hasChannelContext ? (
<Hash className="h-[14px] w-[14px] shrink-0" color="gray" />
) : null}
<span className="min-w-0 truncate">{contextLabel}</span>
</h2>
)}
</div>
@@ -246,7 +256,7 @@ export function InboxDetailPane({
</TooltipProvider>
</div>
<div className="absolute inset-0 overflow-y-auto overscroll-contain pb-32 pt-14">
<div className="absolute inset-0 overflow-y-auto overscroll-contain pb-32 pt-[76px]">
<div>
{isThreadContextLoading ? (
<div className="px-6 pb-3 text-[11px] text-muted-foreground">
+48 -14
View File
@@ -1,3 +1,5 @@
import { ChevronDown, Inbox } from "lucide-react";
import {
formatInboxTypeLabel,
type InboxFilter,
@@ -5,6 +7,13 @@ import {
} from "@/features/home/lib/inbox";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [
@@ -32,30 +41,55 @@ export function InboxListPane({
onSelect,
selectedId,
}: InboxListPaneProps) {
const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter);
return (
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60">
<div className="px-5 pb-3 pt-14">
<div className="-mx-5 overflow-x-auto px-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex flex-nowrap gap-1">
{FILTER_OPTIONS.map((option) => (
<section className="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[76px] bg-background/45 backdrop-blur-xl supports-[backdrop-filter]:bg-background/35"
/>
<div className="absolute inset-x-0 top-[42px] z-50 min-h-[32px] px-5 py-[4px]">
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-[6px]">
<Inbox className="h-[14px] w-[14px] shrink-0 text-muted-foreground" />
<h2 className="truncate text-sm font-semibold leading-5 tracking-tight">
Inbox
</h2>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="h-7 rounded-full border border-transparent px-1.5 text-[10.5px] font-medium text-muted-foreground data-[active=true]:border-border/70 data-[active=true]:bg-background/80 data-[active=true]:text-foreground data-[active=true]:shadow-xs data-[active=true]:backdrop-blur-sm"
data-active={filter === option.value}
key={option.value}
onClick={() => onFilterChange(option.value)}
className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full border-border/70 bg-background/70 px-2.5 text-[11px] font-medium leading-[1] text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/60 hover:text-foreground"
size="sm"
type="button"
variant="ghost"
variant="outline"
>
{option.label}
<span>{activeFilter?.label ?? "All"}</span>
<ChevronDown className="h-3 w-3" />
</Button>
))}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[10rem]">
<DropdownMenuRadioGroup
onValueChange={(value) => onFilterChange(value as InboxFilter)}
value={filter}
>
{FILTER_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pt-[76px]"
data-testid="home-inbox-list"
>
{items.length === 0 ? (
@@ -165,18 +165,14 @@ export function MessageThreadPanel({
<>
{isOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(
PANEL_BASE_CLASS,
!isOverlay && "pt-11",
isOverlay && PANEL_OVERLAY_CLASS,
)}
className={cn(PANEL_BASE_CLASS, isOverlay && PANEL_OVERLAY_CLASS)}
data-testid="message-thread-panel"
style={{ width: `${widthPx}px` }}
>
{!isOverlay && (
<button
aria-label="Resize thread panel"
className="group absolute inset-y-0 left-0 z-20 w-3 -translate-x-1/2 cursor-col-resize"
className="peer/thread-resize group/thread-resize absolute inset-y-0 left-0 z-[60] w-3 -translate-x-1/2 cursor-col-resize"
data-testid="message-thread-resize-handle"
onDoubleClick={canResetWidth ? onResetWidth : undefined}
onPointerDown={onResizeStart}
@@ -187,28 +183,47 @@ export function MessageThreadPanel({
}
type="button"
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80" />
<span className="absolute bottom-0 left-1/2 top-10 w-px -translate-x-1/2 bg-transparent transition-colors group-hover/thread-resize:bg-border/80 group-focus-visible/thread-resize:bg-border/80" />
</button>
)}
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
{!isOverlay ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-[76px] bg-transparent after:absolute after:bottom-0 after:-left-px after:top-10 after:w-px after:bg-border/45 after:transition-colors peer-hover/thread-resize:after:bg-border/80 peer-focus-visible/thread-resize:after:bg-border/80"
/>
) : null}
<div
className={cn(
"z-50 flex cursor-default select-none items-center gap-3 px-3",
isOverlay
? "relative min-h-[44px] shrink-0 bg-background/70 py-[6px] backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
: "absolute inset-x-0 top-11 min-h-[32px] py-[4px]",
)}
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>
</div>
<Button
aria-label="Close thread"
className="ml-auto 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-4 w-4" />
<X className="h-2.5 w-2.5" />
</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-[76px]",
)}
data-testid="message-thread-body"
onScroll={syncScrollState}
ref={threadBodyRef}
@@ -10,7 +10,6 @@ import {
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { useProjectQuery } from "@/features/projects/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
@@ -71,66 +70,50 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
if (projectQuery.isError) {
return (
<>
<ChatHeader
description=""
mode="projects"
overlaysContent
title="Error"
/>
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
<FolderGit2 className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-red-400">Failed to load project</p>
<div className="flex items-center gap-2">
<Button
onClick={() => void projectQuery.refetch()}
size="sm"
variant="outline"
>
Retry
</Button>
<Button
onClick={() => {
void goProjects();
}}
size="sm"
variant="ghost"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Back to Projects
</Button>
</div>
</div>
</>
);
}
if (!project) {
return (
<>
<ChatHeader
description=""
mode="projects"
overlaysContent
title="Project not found"
/>
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
<FolderGit2 className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
This project could not be found.
</p>
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
<FolderGit2 className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-red-400">Failed to load project</p>
<div className="flex items-center gap-2">
<Button
onClick={() => void projectQuery.refetch()}
size="sm"
variant="outline"
>
Retry
</Button>
<Button
onClick={() => {
void goProjects();
}}
size="sm"
variant="outline"
variant="ghost"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Back to Projects
</Button>
</div>
</>
</div>
);
}
if (!project) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
<FolderGit2 className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
This project could not be found.
</p>
<Button
onClick={() => {
void goProjects();
}}
size="sm"
variant="outline"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Back to Projects
</Button>
</div>
);
}
@@ -140,118 +123,109 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
);
return (
<>
<ChatHeader
description={project.description}
mode="projects"
overlaysContent
title={project.name}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4 pt-14">
<div className="mb-4">
<Button
className="gap-1.5 text-muted-foreground"
onClick={() => {
void goProjects();
}}
size="sm"
variant="ghost"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to Projects
</Button>
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4 pt-14">
<div className="mb-4">
<Button
className="gap-1.5 text-muted-foreground"
onClick={() => {
void goProjects();
}}
size="sm"
variant="ghost"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back to Projects
</Button>
</div>
<div className="mx-auto w-full max-w-2xl space-y-6">
<section className="space-y-2">
<div className="flex items-center gap-2">
<FolderGit2 className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold">{project.name}</h2>
</div>
{project.description ? (
<p className="text-sm text-muted-foreground">
{project.description}
</p>
) : null}
</section>
{project.cloneUrls.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Clone
</h3>
<div className="space-y-1.5">
{project.cloneUrls.map((url) => (
<CloneUrlRow key={url} url={url} />
))}
</div>
</section>
) : null}
{project.webUrl && isSafeUrl(project.webUrl) ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Web
</h3>
<a
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
href={project.webUrl}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="h-3.5 w-3.5" />
{project.webUrl}
</a>
</section>
) : null}
{project.contributors.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
<span className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
Contributors ({project.contributors.length})
</span>
</h3>
<div className="space-y-1.5">
{project.contributors.map((pubkey) => {
const label = resolveUserLabel({ pubkey, profiles });
const avatarUrl =
profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null;
return (
<div
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-1.5"
key={pubkey}
>
<UserAvatar
avatarUrl={avatarUrl}
displayName={label}
size="xs"
/>
<span className="truncate text-sm text-muted-foreground">
{label}
</span>
</div>
);
})}
</div>
</section>
<div className="mx-auto w-full max-w-2xl space-y-6">
<section className="space-y-2">
<div className="flex items-center gap-2">
<FolderGit2 className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold">{project.name}</h2>
</div>
{project.description ? (
<p className="text-sm text-muted-foreground">
{project.description}
</p>
) : null}
</section>
{project.cloneUrls.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Details
Clone
</h3>
<div className="space-y-1 text-sm text-muted-foreground">
<p>Created: {createdDate}</p>
<p className="truncate">
Owner: {resolveUserLabel({ pubkey: project.owner, profiles })}
</p>
<div className="space-y-1.5">
{project.cloneUrls.map((url) => (
<CloneUrlRow key={url} url={url} />
))}
</div>
</section>
</div>
) : null}
{project.webUrl && isSafeUrl(project.webUrl) ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Web
</h3>
<a
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
href={project.webUrl}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="h-3.5 w-3.5" />
{project.webUrl}
</a>
</section>
) : null}
{project.contributors.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
<span className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
Contributors ({project.contributors.length})
</span>
</h3>
<div className="space-y-1.5">
{project.contributors.map((pubkey) => {
const label = resolveUserLabel({ pubkey, profiles });
const avatarUrl =
profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null;
return (
<div
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-1.5"
key={pubkey}
>
<UserAvatar
avatarUrl={avatarUrl}
displayName={label}
size="xs"
/>
<span className="truncate text-sm text-muted-foreground">
{label}
</span>
</div>
);
})}
</div>
</section>
) : null}
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Details
</h3>
<div className="space-y-1 text-sm text-muted-foreground">
<p>Created: {createdDate}</p>
<p className="truncate">
Owner: {resolveUserLabel({ pubkey: project.owner, profiles })}
</p>
</div>
</section>
</div>
</>
</div>
);
}
@@ -1,19 +1,9 @@
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { ProjectsView } from "@/features/projects/ui/ProjectsView";
export function ProjectsScreen() {
return (
<>
<ChatHeader
description="Repositories and projects on this relay."
mode="projects"
overlaysContent
title="Projects"
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ProjectsView />
</div>
</>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ProjectsView />
</div>
);
}
@@ -1,7 +1,6 @@
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { useOpenDmMutation } from "@/features/channels/hooks";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { PulseView } from "@/features/pulse/ui/PulseView";
@@ -28,12 +27,6 @@ export function PulseScreen() {
return (
<ProfilePanelProvider onOpenProfilePanel={setProfilePanelPubkey}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatHeader
description="Notes from people and agents you follow"
mode="pulse"
overlaysContent
title="Pulse"
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<PulseView currentPubkey={identityQuery.data?.pubkey} />
@@ -23,7 +23,7 @@ export function PulseTabBar({
onTabChange,
}: PulseTabBarProps) {
return (
<div className="relative z-40 shrink-0 px-4 pt-2 sm:px-6">
<div className="relative z-40 shrink-0 px-4 pt-11 sm:px-6">
<div className="relative mx-auto flex w-full max-w-2xl items-center justify-center">
<div className="min-w-0 max-w-full">
<div className="-mx-4 overflow-x-auto px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
@@ -1,332 +0,0 @@
import * as React from "react";
import {
LoaderCircle,
MessagesSquare,
Search,
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 {
ChannelResultBody,
MessageResultBody,
resultIcon,
resultKey,
resultTestId,
SearchResultShell,
type SearchResult,
} from "@/features/search/ui/SearchResultItem";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Skeleton } from "@/shared/ui/skeleton";
const MIN_QUERY_LENGTH = 2;
function SearchState({
icon: Icon,
title,
description,
}: {
icon: LucideIcon;
title: string;
description: string;
}) {
return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div>
<p className="mt-4 text-base font-semibold tracking-tight">{title}</p>
<p className="mt-2 max-w-md text-sm text-muted-foreground">
{description}
</p>
</div>
);
}
function SearchLoadingState() {
return (
<div className="space-y-3 px-3 py-3" data-testid="search-loading">
{["first", "second", "third"].map((row) => (
<div
className="rounded-2xl border border-border/80 bg-card/60 p-4"
key={row}
>
<Skeleton className="h-4 w-32" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-2 h-4 w-3/4" />
</div>
))}
</div>
);
}
type SearchDialogProps = {
channels: Channel[];
currentPubkey?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onOpenChannel: (channelId: string) => void;
onOpenResult: (hit: SearchHit) => void;
};
export function SearchDialog({
channels,
currentPubkey,
open,
onOpenChange,
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 openResult = React.useCallback(
(result: SearchResult) => {
onOpenChange(false);
if (result.kind === "channel") {
onOpenChannel(result.channel.id);
return;
}
onOpenResult(result.hit);
},
[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
className="gap-0 overflow-hidden p-0"
data-testid="search-dialog"
onOpenAutoFocus={(event) => {
event.preventDefault();
inputRef.current?.focus();
}}
>
<DialogHeader className="border-b border-border/80 px-6 py-5">
<DialogTitle className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-xs">
<Search className="h-4 w-4" />
</span>
Search
</DialogTitle>
<DialogDescription>
Full-text search across accessible channels.
</DialogDescription>
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-input bg-card px-3 py-3 shadow-xs">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
autoFocus
className="h-auto border-0 bg-transparent px-0 py-0 text-base shadow-none focus-visible:ring-0"
data-testid="search-input"
onChange={(event) => {
setQuery(event.target.value);
setSelectedIndex(0);
}}
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 === "Enter" &&
!event.nativeEvent.isComposing &&
selectedResult
) {
event.preventDefault();
openResult(selectedResult);
}
}}
placeholder="Search messages, approvals, and forum posts"
ref={inputRef}
value={query}
/>
<span className="hidden shrink-0 text-xs text-muted-foreground/50 sm:block">
&#x2318;K
</span>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
{debouncedQuery.length < MIN_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}
title="Search message history"
/>
) : searchQuery.isLoading && results.length === 0 ? (
<SearchLoadingState />
) : searchQuery.error instanceof Error && results.length === 0 ? (
<SearchState
description={searchQuery.error.message}
icon={LoaderCircle}
title="Search unavailable"
/>
) : results.length === 0 ? (
<SearchState
description="Try a different keyword, channel name, or phrase from the message body."
icon={Search}
title="No matches found"
/>
) : (
<div className="p-3" data-testid="search-results">
<div className="mb-3 flex items-center justify-between px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<span>
{channelResults.length +
(searchQuery.data?.found ?? messageResults.length)}{" "}
results
</span>
<span>Enter to open</span>
</div>
<div className="space-y-2">
{results.map((result, index) => (
<SearchResultShell
icon={resultIcon(result, channelLookup)}
isSelected={index === selectedIndex}
key={resultKey(result)}
onClick={() => openResult(result)}
onMouseEnter={() => setSelectedIndex(index)}
testId={resultTestId(result)}
>
{result.kind === "channel" ? (
<ChannelResultBody channel={result.channel} />
) : (
<MessageResultBody
currentPubkey={currentPubkey}
hit={result.hit}
resultProfiles={resultProfiles}
/>
)}
</SearchResultShell>
))}
</div>
</div>
)}
</div>
<div className="border-t border-border/80 bg-card/50 px-6 py-3 text-xs text-muted-foreground">
Search is relay-backed and scoped to channels you can access.
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,307 @@
import { LoaderCircle, Search } from "lucide-react";
import * as React from "react";
import { resolveUserLabel } from "@/features/profile/lib/identity";
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";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type TopbarSearchProps = {
channels: Channel[];
className?: string;
currentPubkey?: string;
focusRequest?: number;
onOpenChannel: (channelId: string) => void;
onOpenResult: (hit: SearchHit) => void;
};
function describeSearchHit(hit: SearchHit) {
switch (hit.kind) {
case 45001:
return "Forum post";
case 45003:
return "Forum reply";
case 43001:
return "Agent job";
case 43003:
return "Agent update";
case 46010:
return "Approval";
default:
return "Message";
}
}
function truncateResultText(content: string, maxLength = 96) {
const trimmed = content.trim();
if (trimmed.length === 0) {
return "No message body.";
}
if (trimmed.length <= maxLength) {
return trimmed;
}
return `${trimmed.slice(0, maxLength - 3).trimEnd()}...`;
}
function formatRelativeTime(unixSeconds: number) {
const diff = Math.floor(Date.now() / 1_000) - unixSeconds;
if (diff < 60) {
return "now";
}
if (diff < 60 * 60) {
return `${Math.floor(diff / 60)}m`;
}
if (diff < 60 * 60 * 24) {
return `${Math.floor(diff / (60 * 60))}h`;
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
}).format(new Date(unixSeconds * 1_000));
}
export function TopbarSearch({
channels,
className,
currentPubkey,
focusRequest = 0,
onOpenChannel,
onOpenResult,
}: TopbarSearchProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [selectedMenuIndex, setSelectedMenuIndex] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const rootRef = React.useRef<HTMLDivElement>(null);
const {
channelLookup,
debouncedQuery,
query,
resultProfiles,
results,
searchQuery,
setQuery,
} = useSearchResults({ channels, enabled: isOpen, limit: 8 });
const trimmedQuery = query.trim();
const showSuggestions = isOpen;
const selectableCount = showSuggestions ? results.length : 0;
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);
};
}, []);
React.useEffect(() => {
if (focusRequest === 0) {
return;
}
setIsOpen(true);
inputRef.current?.focus();
inputRef.current?.select();
}, [focusRequest]);
React.useEffect(() => {
setSelectedMenuIndex((current) => {
if (selectableCount === 0) {
return 0;
}
return Math.min(current, selectableCount - 1);
});
}, [selectableCount]);
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"
ref={inputRef}
onChange={(event) => {
setIsOpen(true);
setQuery(event.target.value);
setSelectedMenuIndex(0);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={(event) => {
if (event.key === "ArrowDown" && selectableCount > 0) {
event.preventDefault();
setSelectedMenuIndex((current) =>
Math.min(current + 1, selectableCount - 1),
);
return;
}
if (event.key === "ArrowUp" && selectableCount > 0) {
event.preventDefault();
setSelectedMenuIndex((current) => Math.max(current - 1, 0));
return;
}
if (event.key === "Escape") {
event.preventDefault();
setIsOpen(false);
return;
}
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
event.preventDefault();
const result = results[selectedMenuIndex];
if (result) {
openResult(result);
}
}
}}
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-[620px] max-w-[min(82vw,620px)] -translate-x-1/2 overflow-hidden rounded-xl border border-border/80 bg-popover text-popover-foreground shadow-xl"
data-testid="search-results"
>
{debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH ? (
<div className="px-3 py-3 text-[11px] text-muted-foreground">
<p>Type at least two characters for live suggestions.</p>
</div>
) : 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 for{" "}
<span className="font-semibold">{trimmedQuery}</span>.
</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-3 rounded-lg px-3 py-2 text-left transition-colors",
index === selectedMenuIndex
? "bg-accent text-accent-foreground"
: "hover:bg-accent/70",
)}
key={resultKey(result)}
onClick={() => openResult(result)}
onMouseEnter={() => setSelectedMenuIndex(index)}
type="button"
data-testid={resultTestId(result)}
>
{result.kind === "message" ? (
<UserAvatar
avatarUrl={
resultProfiles?.[result.hit.pubkey.toLowerCase()]
?.avatarUrl ?? null
}
className="h-7 w-7 rounded-md"
displayName={resolveUserLabel({
currentPubkey,
profiles: resultProfiles,
pubkey: result.hit.pubkey,
preferResolvedSelfLabel: true,
})}
size="sm"
/>
) : (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/70 text-muted-foreground">
{React.createElement(resultIcon(result, channelLookup), {
className: "h-4 w-4",
})}
</span>
)}
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-semibold">
{result.kind === "channel"
? result.channel.name
: resolveUserLabel({
currentPubkey,
profiles: resultProfiles,
pubkey: result.hit.pubkey,
preferResolvedSelfLabel: true,
})}
</span>
<span className="truncate text-xs text-muted-foreground">
{result.kind === "channel"
? result.channel.channelType
: `in #${result.hit.channelName ?? "unknown"}`}
</span>
</span>
<span className="block truncate text-xs text-muted-foreground">
{result.kind === "channel"
? result.channel.description || "Channel"
: truncateResultText(result.hit.content)}
</span>
</span>
<span className="shrink-0 text-[11px] text-muted-foreground/75">
{result.kind === "channel"
? "Channel"
: `${describeSearchHit(result.hit)} · ${formatRelativeTime(result.hit.createdAt)}`}
</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
@@ -44,7 +44,6 @@ import type {
UserStatus,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
ContextMenu,
ContextMenuContent,
@@ -131,7 +130,6 @@ type AppSidebarProps = {
onOpenAddWorkspace: () => void;
onOpenBrowseChannels: () => void;
onOpenBrowseForums: () => void;
onOpenSearch: () => void;
onHideDm: (channelId: string) => void;
onMarkChannelUnread: (
channelId: string,
@@ -390,7 +388,6 @@ export function AppSidebar({
onOpenAddWorkspace,
onOpenBrowseChannels,
onOpenBrowseForums,
onOpenSearch,
onHideDm,
onMarkChannelUnread,
onMarkChannelRead,
@@ -521,33 +518,9 @@ export function AppSidebar({
variant="sidebar"
>
<SidebarHeader
className="cursor-default select-none gap-3 pt-10"
className="cursor-default select-none pt-11"
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
@@ -752,69 +725,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>
@@ -1,6 +1,5 @@
import * as React from "react";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import type { Channel } from "@/shared/api/types";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
@@ -23,24 +22,15 @@ export function WorkflowsScreen({
selectedWorkflowId,
}: WorkflowsScreenProps) {
return (
<>
<ChatHeader
description="Create, manage, and monitor automated workflows across your channels."
mode="workflows"
overlaysContent
title="Workflows"
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<WorkflowsView
channels={channels}
onCloseWorkflow={onCloseWorkflow}
onSelectWorkflow={onSelectWorkflow}
selectedWorkflowId={selectedWorkflowId}
/>
</React.Suspense>
</div>
</>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<WorkflowsView
channels={channels}
onCloseWorkflow={onCloseWorkflow}
onSelectWorkflow={onSelectWorkflow}
selectedWorkflowId={selectedWorkflowId}
/>
</React.Suspense>
</div>
);
}
@@ -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}
+1 -1
View File
@@ -1200,7 +1200,7 @@ test("manage channel can delete an owned stream", async ({ page }) => {
).toBeVisible();
await page.getByTestId("channel-delete-confirm").click();
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
});
+2 -2
View File
@@ -297,7 +297,7 @@ test("live mentions refetch the home feed without waiting for polling", async ({
// the new mention — so the assertion that the refetch happened is the
// inbox-list content, not the badge.
await targetPage.getByRole("button", { name: "Home" }).click();
await expect(targetPage.getByTestId("chat-title")).toHaveText("Home");
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toContainText(
message,
{ timeout: relayDeliveryTimeoutMs },
@@ -365,7 +365,7 @@ test("live forum mentions refetch the home feed without waiting for polling", as
]);
await targetPage.getByRole("button", { name: "Home" }).click();
await expect(targetPage.getByTestId("chat-title")).toHaveText("Home");
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toContainText(
message,
+2 -2
View File
@@ -478,7 +478,7 @@ test("opens a single-level thread panel with inline expansion", async ({
return rowRect.top - bodyRect.top;
});
})
.toBeLessThanOrEqual(160);
.toBeLessThanOrEqual(240);
const firstReplyId = await firstReplyRow.getAttribute("data-message-id");
if (!firstReplyId) {
@@ -532,7 +532,7 @@ test("opens a single-level thread panel with inline expansion", async ({
return rowRect.top - bodyRect.top;
});
})
.toBeLessThanOrEqual(160);
.toBeLessThanOrEqual(240);
await firstReplySummaryRow.click();
await expect(
+9 -5
View File
@@ -79,6 +79,10 @@ async function expectShellHidden(page: Page) {
await expect(page.getByTestId("chat-title")).toHaveCount(0);
}
async function expectHomeView(page: Page) {
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
}
async function expectIncompleteOnboarding(page: Page) {
await expect(page.getByTestId("onboarding-gate")).toBeVisible();
await expectShellHidden(page);
@@ -101,7 +105,7 @@ test("completed users skip the loading gate while profile is still settling", as
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
});
test("identity fallback text does not count as a real onboarding name", async ({
@@ -160,7 +164,7 @@ test("first-run onboarding keeps the shell hidden through both pages and only ma
await page.getByTestId("onboarding-finish").click();
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
await expectHomeSeenCount(page, 2);
});
@@ -172,7 +176,7 @@ test("existing relay profile auto-skips onboarding without localStorage completi
await page.goto("/");
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
});
test("finishing onboarding auto-joins the #general channel for a new member", async ({
@@ -186,7 +190,7 @@ test("finishing onboarding auto-joins the #general channel for a new member", as
await continueToSetupPage(page);
await page.getByTestId("onboarding-finish").click();
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
await expect(page.getByTestId("channel-general")).toBeVisible();
});
@@ -250,5 +254,5 @@ test("failed first profile saves can be skipped for the current session", async
await page.getByTestId("onboarding-skip").click();
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
});
+10 -6
View File
@@ -3,6 +3,10 @@ import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openProfileMenu, openSettings } from "../helpers/settings";
async function expectHomeView(page: import("@playwright/test").Page) {
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
@@ -33,7 +37,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
await expect(page.getByTestId("profile-about")).toHaveValue(about);
await page.getByTestId("settings-close").click();
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
await expect(page.getByTestId("open-settings")).toBeVisible();
await openSettings(page, "profile");
@@ -198,7 +202,7 @@ test("notification settings drive the Home badge and desktop alerts", async ({
await expect.poll(getAppBadgeCount).toBe(baseline + 1);
await page.getByRole("button", { name: "Home" }).click();
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0);
await expect.poll(getAppBadgeCount).toBe(baseline);
});
@@ -213,7 +217,7 @@ test("desktop notification clicks open the matching forum thread", async ({
"On",
);
await page.getByTestId("settings-close").click();
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
await page.evaluate(() => {
const win = window as Window & {
@@ -281,7 +285,7 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
await page.keyboard.press(
process.platform === "darwin" ? "Meta+," : "Control+,",
@@ -345,12 +349,12 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
process.platform === "darwin" ? "Meta+," : "Control+,",
);
await expect(page.getByTestId("settings-view")).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
});
test("supports webview zoom keyboard shortcuts", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
const getTextScaleState = () =>
page.evaluate(() => ({
+45 -55
View File
@@ -40,46 +40,45 @@ async function ensureTimelineScrollable(
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight + 160);
}
async function openSearchDialogWithShortcut(
async function focusTopbarSearchWithShortcut(
page: import("@playwright/test").Page,
) {
const searchDialog = page.getByTestId("search-dialog");
const openSearchButton = page.getByTestId("open-search");
await expect(openSearchButton).toBeVisible();
await expect
.poll(async () => {
if (await searchDialog.isVisible()) {
return true;
}
await page.evaluate(() => {
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "KeyK",
ctrlKey: !isMac,
key: "k",
metaKey: isMac,
}),
);
});
return searchDialog.isVisible();
})
.toBe(true);
await page.evaluate(() => {
const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
window.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "KeyK",
ctrlKey: !isMac,
key: "k",
metaKey: isMac,
}),
);
});
await expect(openSearchButton).toBeFocused();
await expect(page.getByTestId("search-results")).toBeVisible();
await expect(page.getByTestId("search-dialog")).toHaveCount(0);
}
async function openSearchDialogWithButton(
page: import("@playwright/test").Page,
) {
const searchDialog = page.getByTestId("search-dialog");
const openSearchButton = page.getByTestId("open-search");
async function expectHomeView(page: import("@playwright/test").Page) {
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
}
await expect(openSearchButton).toBeVisible();
await openSearchButton.click();
await expect(searchDialog).toBeVisible();
async function selectHomeInboxFilter(
page: import("@playwright/test").Page,
label: "Activity" | "Agents",
) {
await page
.getByTestId("home-inbox")
.getByRole("button", {
name: /^(All|Mentions|Needs Action|Activity|Agents)$/,
})
.click();
await page.getByRole("menuitemradio", { name: label }).click();
}
test.beforeEach(async ({ page }) => {
@@ -107,7 +106,7 @@ test("creates a new mocked stream", async ({ page }) => {
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("chat-title")).toContainText(channelName);
});
test("create agent supports parallelism and system prompt overrides", async ({
@@ -149,7 +148,7 @@ test("opens a mocked channel from the home feed", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expectHomeView(page);
await expect(inboxList).toContainText("Please review the release checklist.");
await inboxList
@@ -171,18 +170,12 @@ test("home feed shows channel and agent activity sections", async ({
await page.goto("/");
await page
.getByTestId("home-inbox")
.getByRole("button", { name: "Activity" })
.click();
await selectHomeInboxFilter(page, "Activity");
await expect(inboxList).toContainText(
"Engineering shipped the desktop build.",
);
await page
.getByTestId("home-inbox")
.getByRole("button", { name: "Agents" })
.click();
await selectHomeInboxFilter(page, "Agents");
await expect(inboxList).toContainText(
"Agent progress: channel index complete.",
);
@@ -197,10 +190,7 @@ test("opens a mocked forum activity item from the home feed", async ({
}) => {
await page.goto("/");
await page
.getByTestId("home-inbox")
.getByRole("button", { name: "Activity" })
.click();
await selectHomeInboxFilter(page, "Activity");
await expect(page.getByTestId("home-inbox-list")).toContainText(
"Engineering shipped the desktop build.",
);
@@ -220,14 +210,14 @@ test("home feed renders resolved author labels", async ({ page }) => {
await expect(page.getByTestId("home-inbox-list")).not.toContainText("You");
});
test("opens relay-backed search from the sidebar and loads the exact result", async ({
test("opens topbar search with the shortcut and loads the exact result", async ({
page,
}) => {
await page.goto("/");
await openSearchDialogWithShortcut(page);
await focusTopbarSearchWithShortcut(page);
await page.getByTestId("search-input").fill("shipped");
await page.getByTestId("open-search").fill("shipped");
await expect(page.getByTestId("search-results")).toContainText(
"Engineering shipped the desktop build.",
);
@@ -249,9 +239,9 @@ test("opens relay-backed search from the sidebar and loads the exact result", as
test("opens channel matches from search", async ({ page }) => {
await page.goto("/");
await openSearchDialogWithButton(page);
await focusTopbarSearchWithShortcut(page);
await page.getByTestId("search-input").fill("engineering");
await page.getByTestId("open-search").fill("engineering");
const results = page.getByTestId("search-results");
await expect(results).toContainText("engineering");
@@ -281,9 +271,9 @@ test("search results use your resolved profile label instead of You", async ({
}) => {
await page.goto("/");
await openSearchDialogWithButton(page);
await focusTopbarSearchWithShortcut(page);
await page.getByTestId("search-input").fill("welcome");
await page.getByTestId("open-search").fill("welcome");
const results = page.getByTestId("search-results");
await expect(results).toContainText("Welcome to #general");
@@ -296,9 +286,9 @@ test("opens accessible unjoined channels from search in read-only mode", async (
}) => {
await page.goto("/");
await openSearchDialogWithButton(page);
await focusTopbarSearchWithShortcut(page);
await page.getByTestId("search-input").fill("critique");
await page.getByTestId("open-search").fill("critique");
const results = page.getByTestId("search-results");
await expect(results).toContainText(
+1 -1
View File
@@ -169,8 +169,8 @@ test("loads the home feed from the relay", async ({ browser }) => {
await page.goto("/");
await senderPage.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expect(page.getByTestId("home-inbox")).toBeVisible();
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await sendChannelMessage(senderPage, {
channelName: "general",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sprout-workspace",
"private": true,
"packageManager": "pnpm@11.2.2",
"packageManager": "pnpm@11.4.0",
"scripts": {
"check": "pnpm -r check"
},