mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
Refine app loading skeletons (#1001)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
282
desktop/src/features/sidebar/ui/sidebarLoadingSkeleton.tsx
Normal file
282
desktop/src/features/sidebar/ui/sidebarLoadingSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user