mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Update desktop navigation chrome and search (#779)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
⌘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">
|
||||
⌘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,
|
||||
};
|
||||
}
|
||||
@@ -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">⌘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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "sprout-workspace",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.2.2",
|
||||
"packageManager": "pnpm@11.4.0",
|
||||
"scripts": {
|
||||
"check": "pnpm -r check"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user