Refine app loading skeletons (#1001)

This commit is contained in:
klopez4212
2026-06-15 20:44:40 +01:00
committed by GitHub
parent ee34ca818b
commit c30d7274c2
14 changed files with 1429 additions and 340 deletions

View File

@@ -186,6 +186,7 @@ export function AppShell() {
React.useState(false);
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
const [topbarSearchHidden, setTopbarSearchHidden] = React.useState(false);
const [topbarSearchLoading, setTopbarSearchLoading] = React.useState(false);
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
@@ -746,6 +747,7 @@ export function AppShell() {
isFollowingThread,
isNotifiedForThread,
setTopbarSearchHidden,
setTopbarSearchLoading,
threadActivityItems,
}}
>
@@ -765,6 +767,7 @@ export function AppShell() {
}}
onOpenResult={handleOpenSearchResult}
searchHidden={topbarSearchHidden}
searchLoading={topbarSearchLoading}
searchFocusRequest={searchFocusRequest}
/>
) : null}

View File

@@ -22,6 +22,7 @@ type AppShellContextValue = {
isFollowingThread: (rootId: string) => boolean;
isNotifiedForThread: (rootId: string) => boolean;
setTopbarSearchHidden: (hidden: boolean) => void;
setTopbarSearchLoading: (loading: boolean) => void;
threadActivityItems: ThreadActivityItem[];
};
@@ -38,6 +39,7 @@ const AppShellContext = React.createContext<AppShellContextValue>({
isFollowingThread: () => false,
isNotifiedForThread: () => false,
setTopbarSearchHidden: () => {},
setTopbarSearchLoading: () => {},
threadActivityItems: [],
});

View File

@@ -19,7 +19,6 @@ import type {
} from "@/shared/api/types";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
@@ -332,7 +331,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
size="sm"
variant="ghost"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
<Trash2 className="mr-1.5 h-4 w-4" />
Remove stopped
</Button>
</div>
@@ -474,18 +473,72 @@ function SectionHeader({
function LoadingSkeleton() {
return (
<Card className="overflow-hidden">
{["a", "b", "c"].map((k) => (
<div className="space-y-3">
{["a", "b", "c"].map((k, index) => (
<div
className="flex items-center gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
className="overflow-hidden rounded-xl border border-border/70 bg-card/40"
key={k}
>
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-16 rounded-full" />
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<Skeleton className="h-4 w-4 shrink-0 rounded-sm" />
<Skeleton className="h-8 w-8 shrink-0 rounded-lg" />
<div className="flex min-w-0 items-center gap-2">
<Skeleton className={index === 2 ? "h-4 w-36" : "h-4 w-32"} />
<Skeleton className="h-5 w-14 rounded-full" />
</div>
<Skeleton className="ml-1 h-3 w-20 shrink-0" />
</div>
{index === 1 ? (
<Skeleton className="h-5 w-16 rounded-full" />
) : null}
<Skeleton className="h-8 w-8 shrink-0 rounded-lg" />
</div>
<div className="divide-y divide-border/50 border-t border-border/50">
<div className="flex items-start gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.8fr)_minmax(120px,0.8fr)_minmax(0,1.1fr)] lg:gap-4">
<div className="min-w-0">
<div className="flex items-start gap-3">
<Skeleton className="mt-0.5 h-4 w-4 shrink-0 rounded-sm" />
<Skeleton className="mt-1 h-2 w-2 shrink-0 rounded-full" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-24" />
</div>
{index === 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
) : null}
</div>
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-3 w-24" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
</div>
<div className="flex shrink-0 items-start gap-2 lg:pt-0.5">
<Skeleton className="h-7 w-24 rounded-md" />
<Skeleton className="h-7 w-7 rounded-md" />
</div>
</div>
</div>
</div>
))}
</Card>
</div>
);
}

View File

@@ -4,7 +4,10 @@ import { Bot, Hash, LogIn, Plus, Sparkles, UserPlus } from "lucide-react";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel";
import {
MessageThreadPanel,
MessageThreadPanelSkeleton,
} from "@/features/messages/ui/MessageThreadPanel";
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
@@ -714,7 +717,7 @@ export const ChannelPane = React.memo(function ChannelPane({
size="sm"
variant="default"
>
<LogIn className="mr-1.5 h-3.5 w-3.5" />
<LogIn className="mr-1.5 h-4 w-4" />
{isJoining ? "Joining..." : "Join to participate"}
</Button>
</div>
@@ -854,27 +857,15 @@ export const ChannelPane = React.memo(function ChannelPane({
panel
);
})()
: activeChannel && selectedAgent
: openThreadHeadId && activeChannel
? (() => {
const panel = (
<AgentSessionThreadPanel
agent={selectedAgent}
canInterruptTurn={selectedAgent.canInterruptTurn}
channel={activeChannel}
isWorking={botTypingEntries.some(
(entry) =>
entry.pubkey.toLowerCase() ===
selectedAgent.pubkey.toLowerCase(),
)}
<MessageThreadPanelSkeleton
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
profiles={profiles}
onBackToProfile={() =>
onOpenProfilePanel(selectedAgent.pubkey)
}
onClose={onCloseAgentSession}
onClose={onCloseThread}
widthPx={threadPanelWidthPx}
/>
);
@@ -883,7 +874,7 @@ export const ChannelPane = React.memo(function ChannelPane({
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="agent-session-thread-panel"
testId="message-thread-panel"
widthPx={threadPanelWidthPx}
>
{panel}
@@ -892,19 +883,27 @@ export const ChannelPane = React.memo(function ChannelPane({
panel
);
})()
: profilePanelPubkey
: activeChannel && selectedAgent
? (() => {
const panel = (
<UserProfilePanel
currentPubkey={currentPubkey}
<AgentSessionThreadPanel
agent={selectedAgent}
canInterruptTurn={selectedAgent.canInterruptTurn}
channel={activeChannel}
isWorking={botTypingEntries.some(
(entry) =>
entry.pubkey.toLowerCase() ===
selectedAgent.pubkey.toLowerCase(),
)}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
pubkey={profilePanelPubkey}
splitPaneClamp
profiles={profiles}
onBackToProfile={() =>
onOpenProfilePanel(selectedAgent.pubkey)
}
onClose={onCloseAgentSession}
widthPx={threadPanelWidthPx}
/>
);
@@ -913,7 +912,7 @@ export const ChannelPane = React.memo(function ChannelPane({
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="user-profile-panel"
testId="agent-session-thread-panel"
widthPx={threadPanelWidthPx}
>
{panel}
@@ -922,7 +921,37 @@ export const ChannelPane = React.memo(function ChannelPane({
panel
);
})()
: null}
: profilePanelPubkey
? (() => {
const panel = (
<UserProfilePanel
currentPubkey={currentPubkey}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
pubkey={profilePanelPubkey}
splitPaneClamp
widthPx={threadPanelWidthPx}
/>
);
return useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="user-profile-panel"
widthPx={threadPanelWidthPx}
>
{panel}
</RightAuxiliaryPane>
) : (
panel
);
})()
: null}
</div>
);
});

View File

@@ -429,11 +429,13 @@ export function ChannelScreen({
setThreadReplyTargetId,
setThreadScrollTargetId,
});
const hasTimelineData = messagesQuery.data !== undefined;
const isTimelineLoading =
activeChannel !== null &&
activeChannel.channelType !== "forum" &&
(messagesQuery.isPending ||
(messagesQuery.isFetching && resolvedMessages.length === 0));
!hasTimelineData &&
messagesQuery.isPending;
const shouldShowInitialChannelLoading = isTimelineLoading;
const resetComposerTargets = React.useCallback(
(_channelId: string | null) => {
setOpenThreadHeadId(null);
@@ -549,7 +551,9 @@ export function ChannelScreen({
ref={channelContentRef}
>
{activeChannel ? (
activeChannel.channelType === "forum" ? (
shouldShowInitialChannelLoading ? (
<ViewLoadingFallback includeHeader kind="channel" />
) : activeChannel.channelType === "forum" ? (
<>
{channelHeader}
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
@@ -564,7 +568,9 @@ export function ChannelScreen({
</React.Suspense>
</>
) : (
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="channel" />}
>
<ChannelPane
activeChannel={activeChannel}
agentPubkeys={agentPubkeys}

View File

@@ -2,28 +2,112 @@ import { Skeleton } from "@/shared/ui/skeleton";
export function HomeLoadingState() {
return (
<div className="flex-1 overflow-hidden">
<div className="min-h-0 flex-1 overflow-hidden">
<div className="grid h-full min-h-0 w-full lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="relative overflow-hidden bg-background/60 after:absolute after:bottom-0 after:right-0 after:top-10 after:w-px after:bg-border/70 after:content-['']">
<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 className="px-5 py-1 pt-14">
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-[6px]">
<Skeleton className="h-4 w-4 shrink-0 rounded-md" />
<Skeleton className="h-4 w-14" />
</div>
<Skeleton className="h-7 w-14 rounded-full" />
</div>
</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 className="min-h-0 flex-1 overflow-y-auto">
{["first", "second", "third", "fourth", "fifth"].map((row) => (
<div
className="flex w-full items-start gap-2.5 px-5 py-2"
key={row}
>
<div className="relative shrink-0">
<Skeleton className="h-8 w-8 rounded-full" />
{row === "first" || row === "third" ? (
<Skeleton className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-24" />
{row === "second" ? (
<Skeleton className="h-3 w-20" />
) : null}
</div>
</div>
<Skeleton className="h-3 w-10" />
</div>
<div className="mt-1 space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<Skeleton className="mt-2 h-3 w-16" />
</div>
</div>
))}
</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 className="relative flex min-h-0 flex-col overflow-hidden bg-background/60">
<div className="px-5 py-1 pr-3 pt-14">
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-[4px]">
<Skeleton className="h-4 w-4 shrink-0 rounded-md" />
<Skeleton className="h-4 w-40" />
</div>
<div className="flex shrink-0 items-center gap-1">
<Skeleton className="h-8 w-20 rounded-full" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
</div>
</div>
<div className="px-5 py-5">
<Skeleton className="h-64 rounded-md" />
<div className="min-h-0 flex-1 overflow-y-auto pb-32">
{["selected", "context-a", "context-b"].map((row, index) => (
<div className="relative px-5 py-2" key={row}>
{index === 1 ? (
<div className="mx-1 mb-3 border-t border-border/60" />
) : null}
<article className="relative flex items-start gap-2.5">
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-24" />
</div>
<div className="mt-1 space-y-1.5">
<Skeleton className="h-4 w-full max-w-2xl" />
<Skeleton
className={
index === 0
? "h-4 w-5/6 max-w-xl"
: "h-4 w-2/3 max-w-lg"
}
/>
</div>
<div className="mt-2 flex items-center gap-4">
<Skeleton className="h-4 w-8 rounded-full" />
<Skeleton className="h-4 w-8 rounded-full" />
</div>
</div>
</article>
</div>
))}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div className="pointer-events-auto px-4 pb-4 sm:px-4">
<div className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md sm:px-4">
<Skeleton className="h-5 w-48" />
<div className="mt-4 flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="ml-auto h-8 w-20 rounded-full" />
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -34,6 +34,7 @@ export const MessageRow = React.memo(
channelId = null,
highlighted = false,
hoverBackground = true,
actionBarPlacement = "floating",
isFollowingThread,
layoutVariant = "default",
message,
@@ -53,6 +54,7 @@ export const MessageRow = React.memo(
channelId?: string | null;
highlighted?: boolean;
hoverBackground?: boolean;
actionBarPlacement?: "floating" | "inside";
isFollowingThread?: boolean;
layoutVariant?: "default" | "thread-reply";
message: TimelineMessage;
@@ -255,7 +257,14 @@ export const MessageRow = React.memo(
);
const actionBarNode = (
<div className="absolute right-2 top-1 z-10">
<div
className={cn(
"absolute right-2 top-1 z-10",
actionBarPlacement === "floating"
? "sm:top-0 sm:-translate-y-1/2"
: "sm:top-1 sm:translate-y-0",
)}
>
<MessageActionBar
channelId={channelId}
isFollowingThread={isFollowingThread}

View File

@@ -23,6 +23,7 @@ import {
PANEL_OVERLAY_CLASS,
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { Skeleton } from "@/shared/ui/skeleton";
import { MessageComposer } from "./MessageComposer";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
@@ -80,6 +81,13 @@ type MessageThreadPanelProps = {
onUnfollowThread?: () => void;
};
type MessageThreadPanelSkeletonProps = {
isSinglePanelView?: boolean;
layout?: "standalone" | "split";
onClose: () => void;
widthPx: number;
};
function canManageMessage(
message: TimelineMessage,
currentPubkey: string | undefined,
@@ -91,6 +99,159 @@ function canManageMessage(
);
}
function ThreadMessageSkeleton({ isHead = false }: { isHead?: boolean }) {
return (
<article className="relative flex items-start gap-2.5 rounded-2xl px-3 py-2">
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
<div className="-mt-1 min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
<Skeleton className="h-[15px] w-28" />
<Skeleton className="h-3 w-16" />
</div>
<div className="mt-1 space-y-1.5 pb-2">
<Skeleton className="h-4 w-full" />
<Skeleton className={isHead ? "h-4 w-4/5" : "h-4 w-2/3"} />
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-8 rounded-full" />
<Skeleton className="h-4 w-8 rounded-full" />
<Skeleton className="h-4 w-8 rounded-full" />
</div>
</div>
</article>
);
}
function ThreadComposerSkeleton() {
return (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div className="pointer-events-auto">
<div className="relative z-10 shrink-0 bg-transparent px-4 pb-2 pt-0">
<div className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md sm:px-4">
<Skeleton className="h-5 w-48 max-w-full" />
<div className="mt-4 flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="ml-auto h-8 w-20 rounded-full" />
</div>
</div>
</div>
<div className="-mt-1 h-7 bg-background px-4 pb-1 pt-0 sm:px-6" />
</div>
</div>
);
}
export function MessageThreadPanelSkeleton({
isSinglePanelView = false,
layout = "standalone",
onClose,
widthPx,
}: MessageThreadPanelSkeletonProps) {
const isOverlay = useIsThreadPanelOverlay();
const isFloatingOverlay = isOverlay && !isSinglePanelView;
const isSplitLayout = layout === "split";
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const threadHeaderContent = (
<>
<AuxiliaryPanelHeaderGroup>
{isSinglePanelView ? (
<Button
aria-label="Back to conversation"
className="shrink-0"
onClick={onClose}
size="icon"
type="button"
variant="outline"
>
<ArrowLeft />
</Button>
) : null}
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
<Button
aria-label="Close thread"
className="ml-auto"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
</>
);
const threadBody = (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain pb-24 [overflow-anchor:none]",
isSplitLayout && auxiliaryPanelContentPaddingClass,
!isSplitLayout && !isFloatingOverlay && "pt-[4.75rem]",
)}
data-testid="message-thread-loading"
>
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head-loading">
<ThreadMessageSkeleton isHead />
</div>
<div className="space-y-2.5 px-3 pb-3 pt-1">
<ThreadMessageSkeleton />
<ThreadMessageSkeleton />
<div className="ml-[58px] flex items-center gap-1.5 pt-0.5">
<Skeleton className="h-7 w-7 rounded-full" />
<Skeleton className="h-7 w-7 rounded-full" />
<Skeleton className="h-4 w-28 rounded-full" />
</div>
</div>
</div>
);
if (isSplitLayout) {
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<AuxiliaryPanelHeader>{threadHeaderContent}</AuxiliaryPanelHeader>
{threadBody}
<ThreadComposerSkeleton />
</div>
);
}
return (
<>
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(
PANEL_BASE_CLASS,
isSinglePanelView && "border-l-0",
isFloatingOverlay && PANEL_OVERLAY_CLASS,
)}
data-testid="message-thread-panel"
style={{
width: isSinglePanelView
? "100%"
: `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`,
}}
>
<div
className={cn(
"flex cursor-default select-none items-center",
isSinglePanelView
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[4.75rem] min-h-[4.75rem] shrink-0 gap-2.5 bg-background/80 pb-[0.1875rem] pl-4 pr-2 pt-[2.6875rem] backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:pr-3 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55`
: "relative z-50 min-h-11 shrink-0 gap-3 bg-background/80 px-3 py-1.5 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55",
)}
data-tauri-drag-region
>
{threadHeaderContent}
</div>
{threadBody}
<ThreadComposerSkeleton />
</aside>
</>
);
}
export function MessageThreadPanel({
agentPubkeys,
channel,
@@ -190,6 +351,7 @@ export function MessageThreadPanel({
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
<div className="rounded-2xl">
<MessageRow
actionBarPlacement="inside"
agentPubkeys={agentPubkeys}
channelId={channelId}
isFollowingThread={isFollowingThread}

View File

@@ -9,8 +9,9 @@ import { cn } from "@/shared/lib/cn";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { SkeletonReveal } from "@/shared/ui/skeleton";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { TimelineSkeleton } from "./TimelineSkeleton";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
@@ -192,6 +193,11 @@ export const MessageTimeline = React.memo(function MessageTimeline({
directMessageIntro === null &&
channelIntro === null;
const showMessageList = !isLoading && messages.length > 0;
const timelineSkeletonRows = useTimelineSkeletonRows({
channelId,
isLoading,
messages,
});
return (
<TooltipProvider delayDuration={200}>
@@ -218,175 +224,187 @@ export const MessageTimeline = React.memo(function MessageTimeline({
{isFetchingOlder ? (
<div className="flex justify-center py-2">
<Spinner className="h-4 w-4 text-muted-foreground" />
<Spinner className="h-4 w-4 border-2 text-muted-foreground" />
</div>
) : null}
{isLoading ? <TimelineSkeleton /> : null}
{showDirectMessageIntro ? (
<div
className="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
data-testid="message-dm-intro"
>
<ProfileAvatar
avatarUrl={directMessageIntro.avatarUrl}
className="h-[60px] w-[60px] text-base"
iconClassName="h-6 w-6"
label={directMessageIntro.displayName}
testId="message-dm-intro-avatar"
/>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
{directMessageIntro.displayName}
</p>
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
This is the beginning of your direct message with{" "}
<span className="font-medium text-foreground">
{directMessageIntro.displayName}
</span>
.
</p>
</div>
) : null}
{showChannelIntro ? (
<div
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
data-testid="message-channel-intro"
>
<SkeletonReveal
className={cn(
"min-h-[18rem]",
(showIntro || showGenericEmpty) && "min-h-full",
showMessageList && !showIntro && "mt-auto",
)}
contentClassName={cn(
"flex flex-col gap-2",
(showIntro || showGenericEmpty) && "min-h-full",
)}
loading={isLoading}
skeleton={<TimelineSkeleton rows={timelineSkeletonRows} />}
>
{showDirectMessageIntro ? (
<div
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
data-testid="message-channel-intro-icon"
className="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
data-testid="message-dm-intro"
>
{channelIntro.icon ?? (
<Hash aria-hidden className="h-7 w-7" />
)}
</div>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
#{channelIntro.channelName}
</p>
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{channelIntro.channelKindLabel}
</span>
.
</p>
{channelIntro.description ? (
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
{channelIntro.description}
<ProfileAvatar
avatarUrl={directMessageIntro.avatarUrl}
className="h-[60px] w-[60px] text-base"
iconClassName="h-6 w-6"
label={directMessageIntro.displayName}
testId="message-dm-intro-avatar"
/>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
{directMessageIntro.displayName}
</p>
) : null}
{channelIntro.actions?.length ? (
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
{channelIntro.actions.map((action) => {
const hasDescription = Boolean(action.description);
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
This is the beginning of your direct message with{" "}
<span className="font-medium text-foreground">
{directMessageIntro.displayName}
</span>
.
</p>
</div>
) : null}
return (
<button
className={cn(
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
hasDescription
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
: "h-28 w-64 flex-col rounded-xl p-4",
)}
data-testid={action.testId}
key={action.label}
onClick={action.onClick}
type="button"
>
<span
{showChannelIntro ? (
<div
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
data-testid="message-channel-intro"
>
<div
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
data-testid="message-channel-intro-icon"
>
{channelIntro.icon ?? (
<Hash aria-hidden className="h-7 w-7" />
)}
</div>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
#{channelIntro.channelName}
</p>
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{channelIntro.channelKindLabel}
</span>
.
</p>
{channelIntro.description ? (
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
{channelIntro.description}
</p>
) : null}
{channelIntro.actions?.length ? (
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
{channelIntro.actions.map((action) => {
const hasDescription = Boolean(action.description);
return (
<button
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
hasDescription
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
: "h-10 w-10 [&_svg]:h-5 [&_svg]:w-5",
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
: "h-28 w-64 flex-col rounded-xl p-4",
)}
data-testid={
action.testId
? `${action.testId}-icon`
: undefined
}
data-testid={action.testId}
key={action.label}
onClick={action.onClick}
type="button"
>
{action.icon}
</span>
<span className="mt-auto min-w-0">
<span
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
hasDescription
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
: "h-10 w-10 [&_svg]:h-5 [&_svg]:w-5",
)}
data-testid={
action.testId
? `${action.testId}-title`
? `${action.testId}-icon`
: undefined
}
>
{action.label}
{action.icon}
</span>
{action.description ? (
<span className="mt-auto min-w-0">
<span
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
data-testid={
action.testId
? `${action.testId}-description`
? `${action.testId}-title`
: undefined
}
>
{action.description}
{action.label}
</span>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
) : null}
{action.description ? (
<span
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
data-testid={
action.testId
? `${action.testId}-description`
: undefined
}
>
{action.description}
</span>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
) : null}
{showGenericEmpty ? (
<div
className="mt-auto rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
data-testid="message-empty"
>
<p className="text-base font-semibold tracking-tight">
{emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
{showGenericEmpty ? (
<div
className="mt-auto rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
data-testid="message-empty"
>
<p className="text-base font-semibold tracking-tight">
{emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
{showMessageList ? (
<div
className={cn("flex flex-col gap-2", !showIntro && "mt-auto")}
>
<TimelineMessageList
agentPubkeys={agentPubkeys}
channelId={channelId}
channelName={channelName}
channelType={channelType}
currentPubkey={currentPubkey}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
messageFooters={messageFooters}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onReply={onReply}
isSendingVideoReviewComment={isSendingVideoReviewComment}
onSendVideoReviewComment={onSendVideoReviewComment}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
unfollowThreadById={unfollowThreadById}
/>
</div>
) : null}
{showMessageList ? (
<div
className={cn("flex flex-col gap-2", !showIntro && "mt-auto")}
>
<TimelineMessageList
agentPubkeys={agentPubkeys}
channelId={channelId}
channelName={channelName}
channelType={channelType}
currentPubkey={currentPubkey}
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
isFollowingThreadById={isFollowingThreadById}
messageFooters={messageFooters}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onReply={onReply}
isSendingVideoReviewComment={isSendingVideoReviewComment}
onSendVideoReviewComment={onSendVideoReviewComment}
onToggleReaction={onToggleReaction}
personaLookup={personaLookup}
profiles={profiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
unfollowThreadById={unfollowThreadById}
/>
</div>
) : null}
</SkeletonReveal>
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
@@ -400,7 +418,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
)}
>
<Button
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-[11px] font-medium text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-3.5"
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-[11px] font-medium text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"
data-testid="message-scroll-to-latest"
onClick={() => {
scrollToBottom("smooth");

View File

@@ -1,22 +1,242 @@
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { cn } from "@/shared/lib/cn";
import { Skeleton } from "@/shared/ui/skeleton";
export function TimelineSkeleton() {
const skeletonRows = ["first", "second", "third", "fourth"];
const TIMELINE_SKELETON_CACHE_PREFIX = "buzz-timeline-skeleton-shape.v1";
const timelineSkeletonWidthClasses = [
"w-10",
"w-12",
"w-16",
"w-20",
"w-24",
"w-28",
"w-32",
"w-1/2",
"w-2/3",
"w-3/4",
"w-4/5",
"w-5/6",
"w-11/12",
"w-full",
] as const;
const timelineSkeletonActionKeys = ["reply", "react", "more"] as const;
const timelineSkeletonBodyLineKeys = ["primary", "secondary", "tertiary"];
type TimelineSkeletonWidthClass = (typeof timelineSkeletonWidthClasses)[number];
export type TimelineSkeletonRowShape = {
actionCount: number;
authorWidthClass: TimelineSkeletonWidthClass;
bodyLineWidthClasses: TimelineSkeletonWidthClass[];
key: string;
};
type TimelineSkeletonCachePayload = {
rows: TimelineSkeletonRowShape[];
version: 1;
};
const fallbackTimelineSkeletonRows: TimelineSkeletonRowShape[] = [
{
actionCount: 3,
authorWidthClass: "w-28",
bodyLineWidthClasses: ["w-full", "w-4/5"],
key: "first",
},
{
actionCount: 3,
authorWidthClass: "w-24",
bodyLineWidthClasses: ["w-full", "w-2/3"],
key: "second",
},
{
actionCount: 3,
authorWidthClass: "w-32",
bodyLineWidthClasses: ["w-5/6"],
key: "third",
},
{
actionCount: 3,
authorWidthClass: "w-20",
bodyLineWidthClasses: ["w-full", "w-4/5"],
key: "fourth",
},
];
function isTimelineWidthClass(
value: unknown,
): value is TimelineSkeletonWidthClass {
return timelineSkeletonWidthClasses.includes(
value as TimelineSkeletonWidthClass,
);
}
function parseTimelineSkeletonRows(
rows: unknown,
): TimelineSkeletonRowShape[] | null {
if (!Array.isArray(rows)) return null;
const parsed = rows
.slice(0, 4)
.filter((row: unknown): row is TimelineSkeletonRowShape => {
if (typeof row !== "object" || row === null) return false;
const record = row as Record<string, unknown>;
return (
typeof record.key === "string" &&
Number.isInteger(record.actionCount) &&
Number(record.actionCount) >= 0 &&
Number(record.actionCount) <= timelineSkeletonActionKeys.length &&
isTimelineWidthClass(record.authorWidthClass) &&
Array.isArray(record.bodyLineWidthClasses) &&
record.bodyLineWidthClasses.length > 0 &&
record.bodyLineWidthClasses.length <= 3 &&
record.bodyLineWidthClasses.every(isTimelineWidthClass)
);
});
return parsed.length > 0 ? parsed : null;
}
function timelineSkeletonCacheKey(channelId?: string | null) {
return channelId ? `${TIMELINE_SKELETON_CACHE_PREFIX}:${channelId}` : null;
}
function readTimelineSkeletonRows(
channelId?: string | null,
): TimelineSkeletonRowShape[] | null {
const cacheKey = timelineSkeletonCacheKey(channelId);
if (!cacheKey || typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(cacheKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null) return null;
const payload = parsed as Record<string, unknown>;
if (payload.version !== 1) return null;
return parseTimelineSkeletonRows(payload.rows);
} catch {
return null;
}
}
function writeTimelineSkeletonRows(
channelId: string | null | undefined,
rows: TimelineSkeletonRowShape[],
) {
const cacheKey = timelineSkeletonCacheKey(channelId);
if (!cacheKey || rows.length === 0 || typeof window === "undefined") return;
const payload: TimelineSkeletonCachePayload = {
rows: rows.slice(0, 4),
version: 1,
};
try {
window.localStorage.setItem(cacheKey, JSON.stringify(payload));
} catch {
// localStorage can be unavailable or full in embedded webviews.
}
}
function widthClassForText(text: string): TimelineSkeletonWidthClass {
const length = text.trim().length;
if (length >= 18) return "w-32";
if (length >= 14) return "w-28";
if (length >= 10) return "w-24";
if (length >= 7) return "w-20";
return "w-16";
}
function bodyLineWidthsForText(text: string): TimelineSkeletonWidthClass[] {
const length = text.replace(/\s+/g, " ").trim().length;
if (length >= 140) return ["w-full", "w-11/12", "w-2/3"];
if (length >= 90) return ["w-full", "w-4/5"];
if (length >= 48) return ["w-5/6", "w-2/3"];
if (length >= 24) return ["w-2/3"];
return ["w-1/2"];
}
function rowsFromMessages(
messages: TimelineMessage[],
): TimelineSkeletonRowShape[] {
return messages.slice(-4).map((message, index) => ({
actionCount: Math.min(
timelineSkeletonActionKeys.length,
Math.max(2, (message.reactions?.length ?? 0) + 2),
),
authorWidthClass: widthClassForText(message.author),
bodyLineWidthClasses: bodyLineWidthsForText(message.body),
key: message.renderKey ?? message.id ?? `message-${index}`,
}));
}
export function useTimelineSkeletonRows({
channelId,
isLoading,
messages,
}: {
channelId?: string | null;
isLoading: boolean;
messages: TimelineMessage[];
}) {
const liveRows = React.useMemo(() => rowsFromMessages(messages), [messages]);
const cachedRows = React.useMemo(
() => readTimelineSkeletonRows(channelId),
[channelId],
);
React.useEffect(() => {
if (isLoading || liveRows.length === 0) return;
writeTimelineSkeletonRows(channelId, liveRows);
}, [channelId, isLoading, liveRows]);
return liveRows.length > 0 ? liveRows : (cachedRows ?? undefined);
}
type TimelineSkeletonProps = {
rows?: TimelineSkeletonRowShape[];
};
export function TimelineSkeleton({ rows }: TimelineSkeletonProps) {
const skeletonRows = rows?.length ? rows : fallbackTimelineSkeletonRows;
return (
<>
{skeletonRows.map((row, index) => (
<div className="flex gap-2.5" key={row}>
<Skeleton className="h-9 w-9 shrink-0 rounded-xl" />
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-baseline gap-2">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-12" />
{skeletonRows.map((row) => (
<article
className="relative flex items-start gap-2.5 rounded-2xl px-3 py-2"
key={row.key}
>
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
<div className="-mt-1 min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
<Skeleton className={cn("h-[15px]", row.authorWidthClass)} />
<Skeleton className="h-3 w-10" />
</div>
<div className="mt-1 space-y-1.5 pb-2">
{timelineSkeletonBodyLineKeys
.slice(0, row.bodyLineWidthClasses.length)
.map((lineKey, index) => (
<Skeleton
className={cn("h-4", row.bodyLineWidthClasses[index])}
key={`${row.key}-body-${lineKey}`}
/>
))}
</div>
<div className="flex items-center gap-4">
{timelineSkeletonActionKeys
.slice(0, row.actionCount)
.map((key) => (
<Skeleton className="h-4 w-8 rounded-full" key={key} />
))}
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className={index % 2 === 0 ? "h-4 w-4/5" : "h-4 w-2/3"} />
</div>
</div>
</article>
))}
</>
);

View File

@@ -1,4 +1,4 @@
import { LoaderCircle, Search } from "lucide-react";
import { Search } from "lucide-react";
import * as React from "react";
import { resolveUserLabel } from "@/features/profile/lib/identity";
@@ -14,6 +14,7 @@ import {
} from "@/features/search/ui/SearchResultItem";
import type { Channel, SearchHit } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Skeleton } from "@/shared/ui/skeleton";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type TopbarSearchProps = {
@@ -76,6 +77,62 @@ function formatRelativeTime(unixSeconds: number) {
}).format(new Date(unixSeconds * 1_000));
}
const searchSkeletonRows = [
{
iconShape: "rounded-md",
key: "channel",
metaWidth: "w-16",
previewWidth: "w-48",
titleWidth: "w-28",
trailingWidth: "w-14",
},
{
iconShape: "rounded-full",
key: "message",
metaWidth: "w-24",
previewWidth: "w-72",
titleWidth: "w-24",
trailingWidth: "w-20",
},
{
iconShape: "rounded-full",
key: "note",
metaWidth: "w-20",
previewWidth: "w-60",
titleWidth: "w-32",
trailingWidth: "w-16",
},
] as const;
function SearchResultsSkeleton() {
return (
<div
aria-hidden="true"
className="max-h-[360px] overflow-y-auto p-1.5"
data-testid="search-results-loading"
>
{searchSkeletonRows.map((row) => (
<div
className="flex w-full items-center gap-3 rounded-lg px-3 py-2"
key={row.key}
>
<Skeleton className={cn("h-7 w-7 shrink-0", row.iconShape)} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<Skeleton className={cn("h-4", row.titleWidth)} />
<Skeleton className={cn("h-3", row.metaWidth)} />
</div>
<Skeleton
className={cn("mt-1.5 h-3 max-w-full", row.previewWidth)}
/>
</div>
<Skeleton className={cn("h-3 shrink-0", row.trailingWidth)} />
</div>
))}
</div>
);
}
export function TopbarSearch({
channels,
className,
@@ -156,11 +213,13 @@ export function TopbarSearch({
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" />
<div className="group/search flex h-7 items-center gap-2 rounded-lg border border-border/70 bg-background px-2.5 text-xs text-muted-foreground shadow-xs transition-colors duration-150 ease-out focus-within:border-border focus-within:bg-muted/70 focus-within:text-foreground hover:bg-muted/70">
<Search className="h-4 w-4 shrink-0 text-muted-foreground/55 transition-colors duration-150 ease-out group-focus-within/search:text-foreground group-hover/search:text-muted-foreground" />
<input
aria-label="Search everything"
className="min-w-0 flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none"
autoCapitalize="none"
autoCorrect="off"
className="min-w-0 translate-y-px flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground/55 outline-none transition-colors duration-150 ease-out group-focus-within/search:placeholder:text-foreground group-hover/search:placeholder:text-muted-foreground"
data-testid="open-search"
ref={inputRef}
onChange={(event) => {
@@ -199,6 +258,7 @@ export function TopbarSearch({
}
}}
placeholder="Search everything"
spellCheck={false}
value={query}
/>
<kbd className="shrink-0 text-[10px] text-muted-foreground/70">
@@ -208,6 +268,7 @@ export function TopbarSearch({
{showSuggestions ? (
<div
aria-busy={searchQuery.isLoading && results.length === 0}
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"
>
@@ -216,10 +277,7 @@ export function TopbarSearch({
<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>
<SearchResultsSkeleton />
) : searchQuery.error instanceof Error && results.length === 0 ? (
<p className="px-3 py-3 text-xs text-destructive">
{searchQuery.error.message}

View File

@@ -43,6 +43,10 @@ import {
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
import {
SidebarLoadingContent,
useSidebarLoadingShape,
} from "@/features/sidebar/ui/sidebarLoadingSkeleton";
import { SECTION_ACTION_VISIBILITY_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles";
import type {
Channel,
@@ -56,16 +60,12 @@ import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarRail,
} from "@/shared/ui/sidebar";
@@ -222,7 +222,6 @@ export function AppSidebar({
onStarChannel,
onUnstarChannel,
}: AppSidebarProps) {
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal;
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
@@ -375,6 +374,14 @@ export function AppSidebar({
fallbackDisplayName,
profileDisplayName: profile?.displayName,
});
const sidebarLoadingShape = useSidebarLoadingShape({
activeWorkspaceId: activeWorkspace?.id,
currentPubkey,
directMessages,
dmChannelLabels,
isLoading,
streamChannels,
});
const shouldLoadAgentCount = useDeferredLoad({
immediate: selectedView === "agents",
timeoutMs: 250,
@@ -528,16 +535,7 @@ export function AppSidebar({
) : null}
<SidebarContent className="pb-32" ref={scrollRef}>
{isLoading ? (
<SidebarGroup>
<SidebarGroupLabel>Channels</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu data-testid="sidebar-loading">
{skeletonRows.map((row) => (
<SidebarMenuSkeleton key={row} showIcon />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarLoadingContent shape={sidebarLoadingShape} />
) : null}
{!isLoading ? (

View File

@@ -0,0 +1,282 @@
import * as React from "react";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
import { Skeleton } from "@/shared/ui/skeleton";
const SIDEBAR_SKELETON_CACHE_PREFIX = "buzz-sidebar-skeleton-shape.v1";
const sidebarLoadingWidthClasses = [
"w-14",
"w-16",
"w-20",
"w-24",
"w-28",
"w-32",
] as const;
type SidebarLoadingWidthClass = (typeof sidebarLoadingWidthClasses)[number];
type SidebarLoadingRowShape = {
avatar?: boolean;
key: string;
unread?: boolean;
widthClass: SidebarLoadingWidthClass;
};
type SidebarLoadingShape = {
channels: SidebarLoadingRowShape[];
directMessages: SidebarLoadingRowShape[];
};
type SidebarLoadingCachePayload = SidebarLoadingShape & {
version: 1;
};
const fallbackSidebarLoadingShape: SidebarLoadingShape = {
channels: [
{ key: "agents", widthClass: "w-20" },
{ key: "engineering", widthClass: "w-28" },
{ key: "general", widthClass: "w-20" },
],
directMessages: [
{ key: "alice", widthClass: "w-24" },
{ key: "bob", widthClass: "w-20" },
],
};
function isSidebarLoadingWidthClass(
value: unknown,
): value is SidebarLoadingWidthClass {
return sidebarLoadingWidthClasses.includes(value as SidebarLoadingWidthClass);
}
function parseSidebarLoadingRows(
rows: unknown,
maxRows: number,
): SidebarLoadingRowShape[] {
if (!Array.isArray(rows)) return [];
return rows
.slice(0, maxRows)
.filter((row: unknown): row is SidebarLoadingRowShape => {
if (typeof row !== "object" || row === null) return false;
const record = row as Record<string, unknown>;
return (
typeof record.key === "string" &&
isSidebarLoadingWidthClass(record.widthClass) &&
(record.unread === undefined || typeof record.unread === "boolean") &&
(record.avatar === undefined || typeof record.avatar === "boolean")
);
});
}
function parseSidebarLoadingShape(value: unknown): SidebarLoadingShape | null {
if (typeof value !== "object" || value === null) return null;
const record = value as Record<string, unknown>;
if (record.version !== 1) return null;
const shape = {
channels: parseSidebarLoadingRows(record.channels, 3),
directMessages: parseSidebarLoadingRows(record.directMessages, 2),
};
return hasSidebarLoadingRows(shape) ? shape : null;
}
function hasSidebarLoadingRows(shape: SidebarLoadingShape) {
return shape.channels.length > 0 || shape.directMessages.length > 0;
}
function sidebarSkeletonCacheKey(
workspaceId: string | null | undefined,
pubkey: string | undefined,
) {
if (!workspaceId) return null;
return `${SIDEBAR_SKELETON_CACHE_PREFIX}:${workspaceId}:${pubkey ?? "anonymous"}`;
}
function readSidebarLoadingShape(
cacheKey: string | null,
): SidebarLoadingShape | null {
if (!cacheKey || typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(cacheKey);
return raw ? parseSidebarLoadingShape(JSON.parse(raw)) : null;
} catch {
return null;
}
}
function writeSidebarLoadingShape(
cacheKey: string | null,
shape: SidebarLoadingShape,
) {
if (
!cacheKey ||
!hasSidebarLoadingRows(shape) ||
typeof window === "undefined"
) {
return;
}
const payload: SidebarLoadingCachePayload = {
channels: shape.channels.slice(0, 3),
directMessages: shape.directMessages.slice(0, 2),
version: 1,
};
try {
window.localStorage.setItem(cacheKey, JSON.stringify(payload));
} catch {
// localStorage can be unavailable or full in embedded webviews.
}
}
function sidebarWidthClassForText(text: string): SidebarLoadingWidthClass {
const length = text.trim().length;
if (length >= 20) return "w-32";
if (length >= 14) return "w-28";
if (length >= 10) return "w-24";
if (length >= 6) return "w-20";
return "w-16";
}
function createSidebarLoadingShape({
directMessages,
dmChannelLabels,
streamChannels,
}: {
directMessages: Channel[];
dmChannelLabels: Record<string, string>;
streamChannels: Channel[];
}): SidebarLoadingShape {
return {
channels: streamChannels.slice(0, 3).map((channel) => ({
key: channel.id,
widthClass: sidebarWidthClassForText(channel.name),
})),
directMessages: directMessages.slice(0, 2).map((channel) => ({
avatar: true,
key: channel.id,
widthClass: sidebarWidthClassForText(
dmChannelLabels[channel.id] ?? channel.name,
),
})),
};
}
export function useSidebarLoadingShape({
activeWorkspaceId,
directMessages,
dmChannelLabels,
isLoading,
currentPubkey,
streamChannels,
}: {
activeWorkspaceId: string | null | undefined;
directMessages: Channel[];
dmChannelLabels: Record<string, string>;
isLoading: boolean;
currentPubkey?: string;
streamChannels: Channel[];
}) {
const cacheKey = React.useMemo(
() => sidebarSkeletonCacheKey(activeWorkspaceId, currentPubkey),
[activeWorkspaceId, currentPubkey],
);
const liveShape = React.useMemo(
() =>
createSidebarLoadingShape({
directMessages,
dmChannelLabels,
streamChannels,
}),
[directMessages, dmChannelLabels, streamChannels],
);
const cachedShape = React.useMemo(
() => readSidebarLoadingShape(cacheKey),
[cacheKey],
);
React.useEffect(() => {
if (isLoading || !hasSidebarLoadingRows(liveShape)) return;
writeSidebarLoadingShape(cacheKey, liveShape);
}, [cacheKey, isLoading, liveShape]);
if (hasSidebarLoadingRows(liveShape)) return liveShape;
return cachedShape ?? fallbackSidebarLoadingShape;
}
function SidebarLoadingRow({
avatar = false,
widthClass,
}: {
avatar?: boolean;
widthClass: string;
}) {
return (
<SidebarMenuItem>
<div className="flex h-8 items-center gap-2 rounded-md px-2">
<Skeleton
className={cn(
"shrink-0",
avatar ? "h-5 w-5 rounded-full" : "h-4 w-4 rounded-sm",
)}
/>
<Skeleton className={cn("h-4 min-w-0", widthClass)} />
</div>
</SidebarMenuItem>
);
}
function SidebarLoadingSection({
children,
titleWidthClass,
}: {
children: React.ReactNode;
titleWidthClass: string;
}) {
return (
<SidebarGroup>
<div className="group/sidebar-section relative">
<SidebarGroupLabel asChild>
<div className="flex h-7 w-fit max-w-[calc(100%-3rem)] items-center gap-1">
<Skeleton className={cn("h-3.5", titleWidthClass)} />
</div>
</SidebarGroupLabel>
</div>
<SidebarGroupContent>
<SidebarMenu>{children}</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
export function SidebarLoadingContent({
shape,
}: {
shape: SidebarLoadingShape;
}) {
return (
<div data-testid="sidebar-loading">
<SidebarLoadingSection titleWidthClass="w-16">
{shape.channels.map((row) => (
<SidebarLoadingRow key={row.key} widthClass={row.widthClass} />
))}
</SidebarLoadingSection>
<SidebarLoadingSection titleWidthClass="w-24">
{shape.directMessages.map((row) => (
<SidebarLoadingRow avatar key={row.key} widthClass={row.widthClass} />
))}
</SidebarLoadingSection>
</div>
);
}

View File

@@ -1,9 +1,12 @@
import * as React from "react";
import { useAppShell } from "@/app/AppShellContext";
import { Card } from "@/shared/ui/card";
import { useSidebar } from "@/shared/ui/sidebar";
import { Skeleton } from "@/shared/ui/skeleton";
import { cn } from "@/shared/lib/cn";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
type ViewLoadingFallbackKind =
| "agents"
@@ -19,26 +22,18 @@ type ViewLoadingFallbackProps = {
};
function LoadingHeaderSkeleton() {
const { state: sidebarState } = useSidebar();
return (
<TopChromeInsetHeader data-tauri-drag-region>
<header
className={cn(
"flex min-h-[44px] min-w-0 cursor-default select-none items-center gap-[10px] py-[6px] pl-[16px] pr-[8px] transition-[padding] duration-200 ease-linear sm:pl-[24px] sm:pr-[12px]",
sidebarState === "collapsed" && "md:pl-[160px]",
)}
>
<header className="flex min-h-8 min-w-0 cursor-default select-none items-center gap-2.5 py-1.5 pl-4 pr-2 sm:pr-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-[6px]">
<Skeleton className="h-3.5 w-3.5 rounded-xs" />
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
<Skeleton className="h-4 w-4 shrink-0 rounded-sm" />
<Skeleton className="h-4 w-28 max-w-[50vw]" />
</div>
<Skeleton className="mt-2 h-4 w-full max-w-2xl" />
</div>
<div className="hidden shrink-0 items-center gap-2 sm:flex">
<Skeleton className="h-6 w-6 rounded-lg" />
<Skeleton className="h-6 w-6 rounded-lg" />
<div className="hidden shrink-0 items-center gap-1 sm:flex">
<Skeleton className="h-8 w-16 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</header>
</TopChromeInsetHeader>
@@ -48,93 +43,232 @@ function LoadingHeaderSkeleton() {
function MessageRowsSkeleton() {
return (
<>
{["first", "second", "third", "fourth", "fifth"].map((row, index) => (
<div className="flex gap-3" key={row}>
{["first", "second", "third", "fourth"].map((row, index) => (
<article
className="relative flex items-start gap-2.5 rounded-2xl px-3 py-2"
key={row}
>
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-1 pt-0.5">
<div className="flex items-baseline gap-2">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-12" />
<div className="-mt-1 min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
<Skeleton className="h-[15px] w-28" />
<Skeleton className="h-3 w-10" />
</div>
<div className="mt-1 space-y-1.5 pb-2">
<Skeleton className="h-4 w-full" />
<Skeleton
className={index % 2 === 0 ? "h-4 w-4/5" : "h-4 w-2/3"}
/>
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-8 rounded-full" />
<Skeleton className="h-4 w-8 rounded-full" />
<Skeleton className="h-4 w-8 rounded-full" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className={index % 2 === 0 ? "h-4 w-4/5" : "h-4 w-2/3"} />
</div>
</div>
</article>
))}
</>
);
}
const agentLoadingGroups = [
{
badgeWidth: "w-14",
instanceWidth: "w-20",
key: "persona-one",
rows: ["one-a", "one-b"],
titleWidth: "w-32",
},
{
badgeWidth: "w-16",
instanceWidth: "w-24",
key: "persona-two",
rows: ["two-a"],
titleWidth: "w-28",
},
{
badgeWidth: "w-12",
instanceWidth: "w-20",
key: "custom-agents",
rows: ["custom-a"],
titleWidth: "w-36",
},
] as const;
function AgentRowSkeleton({ variant = 0 }: { variant?: number }) {
return (
<div className="flex items-start gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.8fr)_minmax(120px,0.8fr)_minmax(0,1.1fr)] lg:gap-4">
<div className="min-w-0">
<div className="flex items-start gap-3">
<Skeleton className="mt-0.5 h-4 w-4 shrink-0 rounded-sm" />
<Skeleton className="mt-1 h-2 w-2 shrink-0 rounded-full" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Skeleton
className={cn("h-4", variant % 2 === 0 ? "w-36" : "w-28")}
/>
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-24" />
</div>
{variant === 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
) : null}
</div>
</div>
</div>
<div className="space-y-1 lg:pt-0.5">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton
className={cn("h-3", variant % 2 === 0 ? "w-24" : "w-28")}
/>
</div>
<div className="space-y-1 lg:pt-0.5">
<Skeleton className="h-3 w-28" />
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Skeleton className="h-3 w-20" />
{variant === 0 ? <Skeleton className="h-3 w-24" /> : null}
</div>
</div>
</div>
</div>
<div className="flex shrink-0 items-start gap-2 lg:pt-0.5">
<Skeleton className="h-7 w-24 rounded-md" />
<Skeleton className="h-7 w-7 rounded-md" />
</div>
</div>
);
}
function AgentGroupSkeleton({
badgeWidth,
instanceWidth,
rows,
titleWidth,
}: {
badgeWidth: string;
instanceWidth: string;
rows: readonly string[];
titleWidth: string;
}) {
return (
<div className="overflow-hidden rounded-xl border border-border/70 bg-card/40">
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<Skeleton className="h-4 w-4 shrink-0 rounded-sm" />
<Skeleton className="h-8 w-8 shrink-0 rounded-lg" />
<div className="flex min-w-0 items-center gap-2">
<Skeleton className={cn("h-4", titleWidth)} />
<Skeleton className={cn("h-5 rounded-full", badgeWidth)} />
</div>
<Skeleton className={cn("ml-1 h-3 shrink-0", instanceWidth)} />
</div>
<Skeleton className="h-7 w-7 shrink-0 rounded-md" />
</div>
<div className="divide-y divide-border/50 border-t border-border/50">
{rows.map((row, index) => (
<AgentRowSkeleton key={row} variant={index} />
))}
</div>
</div>
);
}
function AgentsLibrarySkeleton() {
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<Skeleton className="h-4 w-24" />
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-7 w-7 rounded-md" />
<Skeleton className="h-8 w-16 rounded-lg" />
</div>
</div>
<div className="space-y-3">
{agentLoadingGroups.map((group) => (
<AgentGroupSkeleton
badgeWidth={group.badgeWidth}
instanceWidth={group.instanceWidth}
key={group.key}
rows={group.rows}
titleWidth={group.titleWidth}
/>
))}
</div>
</section>
);
}
function AgentTeamsSkeleton() {
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<Skeleton className="h-4 w-20" />
<Skeleton className="mt-2 h-4 w-96 max-w-full" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="hidden h-8 w-40 rounded-md sm:block" />
<Skeleton className="h-8 w-20 rounded-lg" />
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{["team-one", "team-two", "team-three"].map((key, index) => (
<Card className="p-3" key={key}>
<div className="flex items-start justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 shrink-0 rounded-sm" />
<Skeleton
className={cn("h-4", index === 0 ? "w-28" : "w-36")}
/>
{index === 1 ? (
<Skeleton className="h-4 w-8 rounded-full" />
) : null}
</div>
<div className="mt-2 flex items-center gap-2">
<div className="flex -space-x-1.5">
<Skeleton className="h-6 w-6 rounded-full border-2 border-card" />
<Skeleton className="h-6 w-6 rounded-full border-2 border-card" />
<Skeleton className="h-6 w-6 rounded-full border-2 border-card" />
</div>
<Skeleton className="h-3 w-20" />
</div>
</div>
<Skeleton className="h-6 w-6 rounded-md" />
</div>
</Card>
))}
</div>
</section>
);
}
function AgentsLoadingBody() {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 pt-14 sm:px-6">
<div className="space-y-6">
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="space-y-2">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{["first", "second", "third", "fourth"].map((card) => (
<Card className="p-3" key={card}>
<div className="flex items-center gap-2.5">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16 rounded-full" />
</div>
</div>
</Card>
))}
</div>
</section>
<section className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-4 w-80 max-w-full" />
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{["alpha", "beta", "gamma"].map((card) => (
<Card className="p-4" key={card}>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20 rounded-full" />
</div>
<Skeleton className="h-7 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</Card>
))}
</div>
</section>
<section className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
<Card className="overflow-hidden">
{["one", "two", "three"].map((row) => (
<div
className="flex items-center gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
key={row}
>
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-4 w-24" />
<Skeleton className="ml-auto h-8 w-24 rounded-lg" />
</div>
))}
</Card>
</section>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-4 pt-14 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<div className="flex flex-col gap-6">
<AgentsLibrarySkeleton />
<AgentTeamsSkeleton />
</div>
</div>
</div>
);
@@ -181,8 +315,8 @@ function CardListLoadingBody() {
function ChannelLoadingBody({ hasHeader = false }: { hasHeader?: boolean }) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-4 pt-1 sm:px-6">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-4 pb-32 pt-1 sm:px-6">
<div
className={cn(
"flex w-full flex-col gap-4",
@@ -195,13 +329,25 @@ function ChannelLoadingBody({ hasHeader = false }: { hasHeader?: boolean }) {
</div>
</div>
<div className="border-t border-border/60 bg-background px-4 py-4 sm:px-6">
<div className="w-full space-y-3">
<Skeleton className="h-10 w-full rounded-2xl" />
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-20 rounded-lg" />
<Skeleton className="h-8 w-16 rounded-lg" />
<Skeleton className="ml-auto h-8 w-24 rounded-lg" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div className="pointer-events-auto">
<div className="relative z-10 shrink-0 bg-transparent px-4 pb-2 pt-0">
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-5 bg-background"
/>
<div className="relative isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55 sm:px-4">
<Skeleton className="h-5 w-56 max-w-full" />
<div className="mt-4 flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="ml-auto h-8 w-8 rounded-full" />
</div>
</div>
</div>
<div className="h-7 bg-background px-4 pb-1 pt-0 sm:px-6">
<div className="flex h-full w-full items-center gap-2" />
</div>
</div>
</div>
@@ -250,18 +396,37 @@ export function ViewLoadingFallback({
includeHeader = false,
kind,
}: ViewLoadingFallbackProps) {
const { setTopbarSearchLoading } = useAppShell();
const shouldShowChannelHeader =
includeHeader && (kind === "channel" || kind === "forum");
React.useLayoutEffect(() => {
if (!includeHeader) return;
setTopbarSearchLoading(true);
return () => {
setTopbarSearchLoading(false);
};
}, [includeHeader, setTopbarSearchLoading]);
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{includeHeader ? <LoadingHeaderSkeleton /> : null}
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{shouldShowChannelHeader ? (
<LoadingHeaderSkeleton />
) : includeHeader ? (
<TopChromeBackdrop />
) : null}
{kind === "agents" ? <AgentsLoadingBody /> : null}
{kind === "workflows" ? <CardListLoadingBody /> : null}
{kind === "projects" ? <CardListLoadingBody /> : null}
{kind === "channel" ? (
<ChannelLoadingBody hasHeader={includeHeader} />
<ChannelLoadingBody hasHeader={shouldShowChannelHeader} />
) : null}
{kind === "forum" ? (
<ForumLoadingBody hasHeader={shouldShowChannelHeader} />
) : null}
{kind === "forum" ? <ForumLoadingBody hasHeader={includeHeader} /> : null}
{kind === "pulse" ? (
<ChannelLoadingBody hasHeader={includeHeader} />
<ChannelLoadingBody hasHeader={shouldShowChannelHeader} />
) : null}
</div>
);