mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
Refine app loading skeletons (#1001)
This commit is contained in:
@@ -186,6 +186,7 @@ export function AppShell() {
|
|||||||
React.useState(false);
|
React.useState(false);
|
||||||
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
|
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
|
||||||
const [topbarSearchHidden, setTopbarSearchHidden] = React.useState(false);
|
const [topbarSearchHidden, setTopbarSearchHidden] = React.useState(false);
|
||||||
|
const [topbarSearchLoading, setTopbarSearchLoading] = React.useState(false);
|
||||||
const [browseDialogType, setBrowseDialogType] =
|
const [browseDialogType, setBrowseDialogType] =
|
||||||
React.useState<BrowseDialogType>(null);
|
React.useState<BrowseDialogType>(null);
|
||||||
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
|
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
|
||||||
@@ -746,6 +747,7 @@ export function AppShell() {
|
|||||||
isFollowingThread,
|
isFollowingThread,
|
||||||
isNotifiedForThread,
|
isNotifiedForThread,
|
||||||
setTopbarSearchHidden,
|
setTopbarSearchHidden,
|
||||||
|
setTopbarSearchLoading,
|
||||||
threadActivityItems,
|
threadActivityItems,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -765,6 +767,7 @@ export function AppShell() {
|
|||||||
}}
|
}}
|
||||||
onOpenResult={handleOpenSearchResult}
|
onOpenResult={handleOpenSearchResult}
|
||||||
searchHidden={topbarSearchHidden}
|
searchHidden={topbarSearchHidden}
|
||||||
|
searchLoading={topbarSearchLoading}
|
||||||
searchFocusRequest={searchFocusRequest}
|
searchFocusRequest={searchFocusRequest}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type AppShellContextValue = {
|
|||||||
isFollowingThread: (rootId: string) => boolean;
|
isFollowingThread: (rootId: string) => boolean;
|
||||||
isNotifiedForThread: (rootId: string) => boolean;
|
isNotifiedForThread: (rootId: string) => boolean;
|
||||||
setTopbarSearchHidden: (hidden: boolean) => void;
|
setTopbarSearchHidden: (hidden: boolean) => void;
|
||||||
|
setTopbarSearchLoading: (loading: boolean) => void;
|
||||||
threadActivityItems: ThreadActivityItem[];
|
threadActivityItems: ThreadActivityItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ const AppShellContext = React.createContext<AppShellContextValue>({
|
|||||||
isFollowingThread: () => false,
|
isFollowingThread: () => false,
|
||||||
isNotifiedForThread: () => false,
|
isNotifiedForThread: () => false,
|
||||||
setTopbarSearchHidden: () => {},
|
setTopbarSearchHidden: () => {},
|
||||||
|
setTopbarSearchLoading: () => {},
|
||||||
threadActivityItems: [],
|
threadActivityItems: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import type {
|
|||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
import { Badge } from "@/shared/ui/badge";
|
import { Badge } from "@/shared/ui/badge";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Card } from "@/shared/ui/card";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -332,7 +331,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||||
Remove stopped
|
Remove stopped
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,18 +473,72 @@ function SectionHeader({
|
|||||||
|
|
||||||
function LoadingSkeleton() {
|
function LoadingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<div className="space-y-3">
|
||||||
{["a", "b", "c"].map((k) => (
|
{["a", "b", "c"].map((k, index) => (
|
||||||
<div
|
<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}
|
key={k}
|
||||||
>
|
>
|
||||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
<Skeleton className="h-4 w-28" />
|
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
|
||||||
<Skeleton className="h-5 w-16 rounded-full" />
|
<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>
|
</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 { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
|
||||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||||
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
|
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 { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
|
||||||
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||||
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
|
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
|
||||||
@@ -714,7 +717,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="default"
|
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"}
|
{isJoining ? "Joining..." : "Join to participate"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -854,27 +857,15 @@ export const ChannelPane = React.memo(function ChannelPane({
|
|||||||
panel
|
panel
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
: activeChannel && selectedAgent
|
: openThreadHeadId && activeChannel
|
||||||
? (() => {
|
? (() => {
|
||||||
const panel = (
|
const panel = (
|
||||||
<AgentSessionThreadPanel
|
<MessageThreadPanelSkeleton
|
||||||
agent={selectedAgent}
|
|
||||||
canInterruptTurn={selectedAgent.canInterruptTurn}
|
|
||||||
channel={activeChannel}
|
|
||||||
isWorking={botTypingEntries.some(
|
|
||||||
(entry) =>
|
|
||||||
entry.pubkey.toLowerCase() ===
|
|
||||||
selectedAgent.pubkey.toLowerCase(),
|
|
||||||
)}
|
|
||||||
isSinglePanelView={
|
isSinglePanelView={
|
||||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||||
}
|
}
|
||||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||||
profiles={profiles}
|
onClose={onCloseThread}
|
||||||
onBackToProfile={() =>
|
|
||||||
onOpenProfilePanel(selectedAgent.pubkey)
|
|
||||||
}
|
|
||||||
onClose={onCloseAgentSession}
|
|
||||||
widthPx={threadPanelWidthPx}
|
widthPx={threadPanelWidthPx}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -883,7 +874,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
|||||||
canResetWidth={canResetThreadPanelWidth}
|
canResetWidth={canResetThreadPanelWidth}
|
||||||
onResetWidth={onResetThreadPanelWidth}
|
onResetWidth={onResetThreadPanelWidth}
|
||||||
onResizeStart={onThreadPanelResizeStart}
|
onResizeStart={onThreadPanelResizeStart}
|
||||||
testId="agent-session-thread-panel"
|
testId="message-thread-panel"
|
||||||
widthPx={threadPanelWidthPx}
|
widthPx={threadPanelWidthPx}
|
||||||
>
|
>
|
||||||
{panel}
|
{panel}
|
||||||
@@ -892,19 +883,27 @@ export const ChannelPane = React.memo(function ChannelPane({
|
|||||||
panel
|
panel
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
: profilePanelPubkey
|
: activeChannel && selectedAgent
|
||||||
? (() => {
|
? (() => {
|
||||||
const panel = (
|
const panel = (
|
||||||
<UserProfilePanel
|
<AgentSessionThreadPanel
|
||||||
currentPubkey={currentPubkey}
|
agent={selectedAgent}
|
||||||
|
canInterruptTurn={selectedAgent.canInterruptTurn}
|
||||||
|
channel={activeChannel}
|
||||||
|
isWorking={botTypingEntries.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.pubkey.toLowerCase() ===
|
||||||
|
selectedAgent.pubkey.toLowerCase(),
|
||||||
|
)}
|
||||||
isSinglePanelView={
|
isSinglePanelView={
|
||||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||||
}
|
}
|
||||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||||
onClose={onCloseProfilePanel}
|
profiles={profiles}
|
||||||
onOpenDm={onOpenDm}
|
onBackToProfile={() =>
|
||||||
pubkey={profilePanelPubkey}
|
onOpenProfilePanel(selectedAgent.pubkey)
|
||||||
splitPaneClamp
|
}
|
||||||
|
onClose={onCloseAgentSession}
|
||||||
widthPx={threadPanelWidthPx}
|
widthPx={threadPanelWidthPx}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -913,7 +912,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
|||||||
canResetWidth={canResetThreadPanelWidth}
|
canResetWidth={canResetThreadPanelWidth}
|
||||||
onResetWidth={onResetThreadPanelWidth}
|
onResetWidth={onResetThreadPanelWidth}
|
||||||
onResizeStart={onThreadPanelResizeStart}
|
onResizeStart={onThreadPanelResizeStart}
|
||||||
testId="user-profile-panel"
|
testId="agent-session-thread-panel"
|
||||||
widthPx={threadPanelWidthPx}
|
widthPx={threadPanelWidthPx}
|
||||||
>
|
>
|
||||||
{panel}
|
{panel}
|
||||||
@@ -922,7 +921,37 @@ export const ChannelPane = React.memo(function ChannelPane({
|
|||||||
panel
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -429,11 +429,13 @@ export function ChannelScreen({
|
|||||||
setThreadReplyTargetId,
|
setThreadReplyTargetId,
|
||||||
setThreadScrollTargetId,
|
setThreadScrollTargetId,
|
||||||
});
|
});
|
||||||
|
const hasTimelineData = messagesQuery.data !== undefined;
|
||||||
const isTimelineLoading =
|
const isTimelineLoading =
|
||||||
activeChannel !== null &&
|
activeChannel !== null &&
|
||||||
activeChannel.channelType !== "forum" &&
|
activeChannel.channelType !== "forum" &&
|
||||||
(messagesQuery.isPending ||
|
!hasTimelineData &&
|
||||||
(messagesQuery.isFetching && resolvedMessages.length === 0));
|
messagesQuery.isPending;
|
||||||
|
const shouldShowInitialChannelLoading = isTimelineLoading;
|
||||||
const resetComposerTargets = React.useCallback(
|
const resetComposerTargets = React.useCallback(
|
||||||
(_channelId: string | null) => {
|
(_channelId: string | null) => {
|
||||||
setOpenThreadHeadId(null);
|
setOpenThreadHeadId(null);
|
||||||
@@ -549,7 +551,9 @@ export function ChannelScreen({
|
|||||||
ref={channelContentRef}
|
ref={channelContentRef}
|
||||||
>
|
>
|
||||||
{activeChannel ? (
|
{activeChannel ? (
|
||||||
activeChannel.channelType === "forum" ? (
|
shouldShowInitialChannelLoading ? (
|
||||||
|
<ViewLoadingFallback includeHeader kind="channel" />
|
||||||
|
) : activeChannel.channelType === "forum" ? (
|
||||||
<>
|
<>
|
||||||
{channelHeader}
|
{channelHeader}
|
||||||
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
|
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
|
||||||
@@ -564,7 +568,9 @@ export function ChannelScreen({
|
|||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
|
<React.Suspense
|
||||||
|
fallback={<ViewLoadingFallback includeHeader kind="channel" />}
|
||||||
|
>
|
||||||
<ChannelPane
|
<ChannelPane
|
||||||
activeChannel={activeChannel}
|
activeChannel={activeChannel}
|
||||||
agentPubkeys={agentPubkeys}
|
agentPubkeys={agentPubkeys}
|
||||||
|
|||||||
@@ -2,28 +2,112 @@ import { Skeleton } from "@/shared/ui/skeleton";
|
|||||||
|
|
||||||
export function HomeLoadingState() {
|
export function HomeLoadingState() {
|
||||||
return (
|
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="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="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">
|
<div className="px-5 py-1 pt-14">
|
||||||
<Skeleton className="h-4 w-20" />
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||||
<Skeleton className="mt-2 h-4 w-28" />
|
<div className="flex min-w-0 items-center gap-[6px]">
|
||||||
<Skeleton className="mt-4 h-10 rounded-md" />
|
<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>
|
||||||
<div className="space-y-3 px-4 py-4">
|
|
||||||
{["a", "b", "c", "d"].map((row) => (
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<Skeleton className="h-20 rounded-md" key={row} />
|
{["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>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden bg-background/60">
|
<div className="relative flex min-h-0 flex-col overflow-hidden bg-background/60">
|
||||||
<div className="border-b border-border/70 px-5 pb-4 pt-14">
|
<div className="px-5 py-1 pr-3 pt-14">
|
||||||
<Skeleton className="h-5 w-48" />
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||||
<Skeleton className="mt-3 h-8 w-72" />
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const MessageRow = React.memo(
|
|||||||
channelId = null,
|
channelId = null,
|
||||||
highlighted = false,
|
highlighted = false,
|
||||||
hoverBackground = true,
|
hoverBackground = true,
|
||||||
|
actionBarPlacement = "floating",
|
||||||
isFollowingThread,
|
isFollowingThread,
|
||||||
layoutVariant = "default",
|
layoutVariant = "default",
|
||||||
message,
|
message,
|
||||||
@@ -53,6 +54,7 @@ export const MessageRow = React.memo(
|
|||||||
channelId?: string | null;
|
channelId?: string | null;
|
||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
hoverBackground?: boolean;
|
hoverBackground?: boolean;
|
||||||
|
actionBarPlacement?: "floating" | "inside";
|
||||||
isFollowingThread?: boolean;
|
isFollowingThread?: boolean;
|
||||||
layoutVariant?: "default" | "thread-reply";
|
layoutVariant?: "default" | "thread-reply";
|
||||||
message: TimelineMessage;
|
message: TimelineMessage;
|
||||||
@@ -255,7 +257,14 @@ export const MessageRow = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const actionBarNode = (
|
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
|
<MessageActionBar
|
||||||
channelId={channelId}
|
channelId={channelId}
|
||||||
isFollowingThread={isFollowingThread}
|
isFollowingThread={isFollowingThread}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
PANEL_OVERLAY_CLASS,
|
PANEL_OVERLAY_CLASS,
|
||||||
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
|
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
|
||||||
} from "@/shared/ui/OverlayPanelBackdrop";
|
} from "@/shared/ui/OverlayPanelBackdrop";
|
||||||
|
import { Skeleton } from "@/shared/ui/skeleton";
|
||||||
import { MessageComposer } from "./MessageComposer";
|
import { MessageComposer } from "./MessageComposer";
|
||||||
import { MessageRow } from "./MessageRow";
|
import { MessageRow } from "./MessageRow";
|
||||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||||
@@ -80,6 +81,13 @@ type MessageThreadPanelProps = {
|
|||||||
onUnfollowThread?: () => void;
|
onUnfollowThread?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MessageThreadPanelSkeletonProps = {
|
||||||
|
isSinglePanelView?: boolean;
|
||||||
|
layout?: "standalone" | "split";
|
||||||
|
onClose: () => void;
|
||||||
|
widthPx: number;
|
||||||
|
};
|
||||||
|
|
||||||
function canManageMessage(
|
function canManageMessage(
|
||||||
message: TimelineMessage,
|
message: TimelineMessage,
|
||||||
currentPubkey: string | undefined,
|
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({
|
export function MessageThreadPanel({
|
||||||
agentPubkeys,
|
agentPubkeys,
|
||||||
channel,
|
channel,
|
||||||
@@ -190,6 +351,7 @@ export function MessageThreadPanel({
|
|||||||
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
|
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
|
||||||
<div className="rounded-2xl">
|
<div className="rounded-2xl">
|
||||||
<MessageRow
|
<MessageRow
|
||||||
|
actionBarPlacement="inside"
|
||||||
agentPubkeys={agentPubkeys}
|
agentPubkeys={agentPubkeys}
|
||||||
channelId={channelId}
|
channelId={channelId}
|
||||||
isFollowingThread={isFollowingThread}
|
isFollowingThread={isFollowingThread}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import { cn } from "@/shared/lib/cn";
|
|||||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Spinner } from "@/shared/ui/spinner";
|
import { Spinner } from "@/shared/ui/spinner";
|
||||||
|
import { SkeletonReveal } from "@/shared/ui/skeleton";
|
||||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||||
import { TimelineSkeleton } from "./TimelineSkeleton";
|
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
|
||||||
import { TimelineMessageList } from "./TimelineMessageList";
|
import { TimelineMessageList } from "./TimelineMessageList";
|
||||||
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
||||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||||
@@ -192,6 +193,11 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
|||||||
directMessageIntro === null &&
|
directMessageIntro === null &&
|
||||||
channelIntro === null;
|
channelIntro === null;
|
||||||
const showMessageList = !isLoading && messages.length > 0;
|
const showMessageList = !isLoading && messages.length > 0;
|
||||||
|
const timelineSkeletonRows = useTimelineSkeletonRows({
|
||||||
|
channelId,
|
||||||
|
isLoading,
|
||||||
|
messages,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
@@ -218,175 +224,187 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
|||||||
|
|
||||||
{isFetchingOlder ? (
|
{isFetchingOlder ? (
|
||||||
<div className="flex justify-center py-2">
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isLoading ? <TimelineSkeleton /> : null}
|
<SkeletonReveal
|
||||||
|
className={cn(
|
||||||
{showDirectMessageIntro ? (
|
"min-h-[18rem]",
|
||||||
<div
|
(showIntro || showGenericEmpty) && "min-h-full",
|
||||||
className="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
|
showMessageList && !showIntro && "mt-auto",
|
||||||
data-testid="message-dm-intro"
|
)}
|
||||||
>
|
contentClassName={cn(
|
||||||
<ProfileAvatar
|
"flex flex-col gap-2",
|
||||||
avatarUrl={directMessageIntro.avatarUrl}
|
(showIntro || showGenericEmpty) && "min-h-full",
|
||||||
className="h-[60px] w-[60px] text-base"
|
)}
|
||||||
iconClassName="h-6 w-6"
|
loading={isLoading}
|
||||||
label={directMessageIntro.displayName}
|
skeleton={<TimelineSkeleton rows={timelineSkeletonRows} />}
|
||||||
testId="message-dm-intro-avatar"
|
>
|
||||||
/>
|
{showDirectMessageIntro ? (
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
|
className="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
|
||||||
data-testid="message-channel-intro-icon"
|
data-testid="message-dm-intro"
|
||||||
>
|
>
|
||||||
{channelIntro.icon ?? (
|
<ProfileAvatar
|
||||||
<Hash aria-hidden className="h-7 w-7" />
|
avatarUrl={directMessageIntro.avatarUrl}
|
||||||
)}
|
className="h-[60px] w-[60px] text-base"
|
||||||
</div>
|
iconClassName="h-6 w-6"
|
||||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
label={directMessageIntro.displayName}
|
||||||
#{channelIntro.channelName}
|
testId="message-dm-intro-avatar"
|
||||||
</p>
|
/>
|
||||||
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
|
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||||
This is the beginning of the{" "}
|
{directMessageIntro.displayName}
|
||||||
<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>
|
</p>
|
||||||
) : null}
|
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
|
||||||
{channelIntro.actions?.length ? (
|
This is the beginning of your direct message with{" "}
|
||||||
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
|
<span className="font-medium text-foreground">
|
||||||
{channelIntro.actions.map((action) => {
|
{directMessageIntro.displayName}
|
||||||
const hasDescription = Boolean(action.description);
|
</span>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
return (
|
{showChannelIntro ? (
|
||||||
<button
|
<div
|
||||||
className={cn(
|
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
|
||||||
"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",
|
data-testid="message-channel-intro"
|
||||||
hasDescription
|
>
|
||||||
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
|
<div
|
||||||
: "h-28 w-64 flex-col rounded-xl p-4",
|
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"
|
||||||
data-testid={action.testId}
|
>
|
||||||
key={action.label}
|
{channelIntro.icon ?? (
|
||||||
onClick={action.onClick}
|
<Hash aria-hidden className="h-7 w-7" />
|
||||||
type="button"
|
)}
|
||||||
>
|
</div>
|
||||||
<span
|
<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(
|
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
|
hasDescription
|
||||||
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
|
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
|
||||||
: "h-10 w-10 [&_svg]:h-5 [&_svg]:w-5",
|
: "h-28 w-64 flex-col rounded-xl p-4",
|
||||||
)}
|
)}
|
||||||
data-testid={
|
data-testid={action.testId}
|
||||||
action.testId
|
key={action.label}
|
||||||
? `${action.testId}-icon`
|
onClick={action.onClick}
|
||||||
: undefined
|
type="button"
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{action.icon}
|
|
||||||
</span>
|
|
||||||
<span className="mt-auto min-w-0">
|
|
||||||
<span
|
<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={
|
data-testid={
|
||||||
action.testId
|
action.testId
|
||||||
? `${action.testId}-title`
|
? `${action.testId}-icon`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.icon}
|
||||||
</span>
|
</span>
|
||||||
{action.description ? (
|
<span className="mt-auto min-w-0">
|
||||||
<span
|
<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={
|
data-testid={
|
||||||
action.testId
|
action.testId
|
||||||
? `${action.testId}-description`
|
? `${action.testId}-title`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{action.description}
|
{action.label}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
{action.description ? (
|
||||||
</span>
|
<span
|
||||||
</button>
|
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
|
||||||
);
|
data-testid={
|
||||||
})}
|
action.testId
|
||||||
</div>
|
? `${action.testId}-description`
|
||||||
) : null}
|
: undefined
|
||||||
</div>
|
}
|
||||||
) : null}
|
>
|
||||||
|
{action.description}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showGenericEmpty ? (
|
{showGenericEmpty ? (
|
||||||
<div
|
<div
|
||||||
className="mt-auto rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
|
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"
|
data-testid="message-empty"
|
||||||
>
|
>
|
||||||
<p className="text-base font-semibold tracking-tight">
|
<p className="text-base font-semibold tracking-tight">
|
||||||
{emptyTitle}
|
{emptyTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{emptyDescription}
|
{emptyDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showMessageList ? (
|
{showMessageList ? (
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col gap-2", !showIntro && "mt-auto")}
|
className={cn("flex flex-col gap-2", !showIntro && "mt-auto")}
|
||||||
>
|
>
|
||||||
<TimelineMessageList
|
<TimelineMessageList
|
||||||
agentPubkeys={agentPubkeys}
|
agentPubkeys={agentPubkeys}
|
||||||
channelId={channelId}
|
channelId={channelId}
|
||||||
channelName={channelName}
|
channelName={channelName}
|
||||||
channelType={channelType}
|
channelType={channelType}
|
||||||
currentPubkey={currentPubkey}
|
currentPubkey={currentPubkey}
|
||||||
followThreadById={followThreadById}
|
followThreadById={followThreadById}
|
||||||
highlightedMessageId={highlightedMessageId}
|
highlightedMessageId={highlightedMessageId}
|
||||||
isFollowingThreadById={isFollowingThreadById}
|
isFollowingThreadById={isFollowingThreadById}
|
||||||
messageFooters={messageFooters}
|
messageFooters={messageFooters}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onMarkUnread={onMarkUnread}
|
onMarkUnread={onMarkUnread}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
isSendingVideoReviewComment={isSendingVideoReviewComment}
|
isSendingVideoReviewComment={isSendingVideoReviewComment}
|
||||||
onSendVideoReviewComment={onSendVideoReviewComment}
|
onSendVideoReviewComment={onSendVideoReviewComment}
|
||||||
onToggleReaction={onToggleReaction}
|
onToggleReaction={onToggleReaction}
|
||||||
personaLookup={personaLookup}
|
personaLookup={personaLookup}
|
||||||
profiles={profiles}
|
profiles={profiles}
|
||||||
searchActiveMessageId={searchActiveMessageId}
|
searchActiveMessageId={searchActiveMessageId}
|
||||||
searchMatchingMessageIds={searchMatchingMessageIds}
|
searchMatchingMessageIds={searchMatchingMessageIds}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
unfollowThreadById={unfollowThreadById}
|
unfollowThreadById={unfollowThreadById}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</SkeletonReveal>
|
||||||
|
|
||||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||||
</div>
|
</div>
|
||||||
@@ -400,7 +418,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Button
|
<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"
|
data-testid="message-scroll-to-latest"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
scrollToBottom("smooth");
|
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";
|
import { Skeleton } from "@/shared/ui/skeleton";
|
||||||
|
|
||||||
export function TimelineSkeleton() {
|
const TIMELINE_SKELETON_CACHE_PREFIX = "buzz-timeline-skeleton-shape.v1";
|
||||||
const skeletonRows = ["first", "second", "third", "fourth"];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{skeletonRows.map((row, index) => (
|
{skeletonRows.map((row) => (
|
||||||
<div className="flex gap-2.5" key={row}>
|
<article
|
||||||
<Skeleton className="h-9 w-9 shrink-0 rounded-xl" />
|
className="relative flex items-start gap-2.5 rounded-2xl px-3 py-2"
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
key={row.key}
|
||||||
<div className="flex items-baseline gap-2">
|
>
|
||||||
<Skeleton className="h-3.5 w-28" />
|
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
|
||||||
<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={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>
|
</div>
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className={index % 2 === 0 ? "h-4 w-4/5" : "h-4 w-2/3"} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LoaderCircle, Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "@/features/search/ui/SearchResultItem";
|
} from "@/features/search/ui/SearchResultItem";
|
||||||
import type { Channel, SearchHit } from "@/shared/api/types";
|
import type { Channel, SearchHit } from "@/shared/api/types";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
import { Skeleton } from "@/shared/ui/skeleton";
|
||||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||||
|
|
||||||
type TopbarSearchProps = {
|
type TopbarSearchProps = {
|
||||||
@@ -76,6 +77,62 @@ function formatRelativeTime(unixSeconds: number) {
|
|||||||
}).format(new Date(unixSeconds * 1_000));
|
}).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({
|
export function TopbarSearch({
|
||||||
channels,
|
channels,
|
||||||
className,
|
className,
|
||||||
@@ -156,11 +213,13 @@ export function TopbarSearch({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative", className)} ref={rootRef}>
|
<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">
|
<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-3.5 w-3.5 shrink-0" />
|
<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
|
<input
|
||||||
aria-label="Search everything"
|
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"
|
data-testid="open-search"
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -199,6 +258,7 @@ export function TopbarSearch({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Search everything"
|
placeholder="Search everything"
|
||||||
|
spellCheck={false}
|
||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
<kbd className="shrink-0 text-[10px] text-muted-foreground/70">
|
<kbd className="shrink-0 text-[10px] text-muted-foreground/70">
|
||||||
@@ -208,6 +268,7 @@ export function TopbarSearch({
|
|||||||
|
|
||||||
{showSuggestions ? (
|
{showSuggestions ? (
|
||||||
<div
|
<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"
|
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"
|
data-testid="search-results"
|
||||||
>
|
>
|
||||||
@@ -216,10 +277,7 @@ export function TopbarSearch({
|
|||||||
<p>Type at least two characters for live suggestions.</p>
|
<p>Type at least two characters for live suggestions.</p>
|
||||||
</div>
|
</div>
|
||||||
) : searchQuery.isLoading && results.length === 0 ? (
|
) : searchQuery.isLoading && results.length === 0 ? (
|
||||||
<div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground">
|
<SearchResultsSkeleton />
|
||||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
Searching...
|
|
||||||
</div>
|
|
||||||
) : searchQuery.error instanceof Error && results.length === 0 ? (
|
) : searchQuery.error instanceof Error && results.length === 0 ? (
|
||||||
<p className="px-3 py-3 text-xs text-destructive">
|
<p className="px-3 py-3 text-xs text-destructive">
|
||||||
{searchQuery.error.message}
|
{searchQuery.error.message}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ import {
|
|||||||
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
|
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
|
||||||
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
|
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
|
||||||
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
|
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 { SECTION_ACTION_VISIBILITY_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles";
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
@@ -56,16 +60,12 @@ import {
|
|||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupAction,
|
SidebarGroupAction,
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuBadge,
|
SidebarMenuBadge,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSkeleton,
|
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "@/shared/ui/sidebar";
|
} from "@/shared/ui/sidebar";
|
||||||
|
|
||||||
@@ -222,7 +222,6 @@ export function AppSidebar({
|
|||||||
onStarChannel,
|
onStarChannel,
|
||||||
onUnstarChannel,
|
onUnstarChannel,
|
||||||
}: AppSidebarProps) {
|
}: AppSidebarProps) {
|
||||||
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
|
|
||||||
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
|
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
|
||||||
const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal;
|
const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal;
|
||||||
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
|
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
|
||||||
@@ -375,6 +374,14 @@ export function AppSidebar({
|
|||||||
fallbackDisplayName,
|
fallbackDisplayName,
|
||||||
profileDisplayName: profile?.displayName,
|
profileDisplayName: profile?.displayName,
|
||||||
});
|
});
|
||||||
|
const sidebarLoadingShape = useSidebarLoadingShape({
|
||||||
|
activeWorkspaceId: activeWorkspace?.id,
|
||||||
|
currentPubkey,
|
||||||
|
directMessages,
|
||||||
|
dmChannelLabels,
|
||||||
|
isLoading,
|
||||||
|
streamChannels,
|
||||||
|
});
|
||||||
const shouldLoadAgentCount = useDeferredLoad({
|
const shouldLoadAgentCount = useDeferredLoad({
|
||||||
immediate: selectedView === "agents",
|
immediate: selectedView === "agents",
|
||||||
timeoutMs: 250,
|
timeoutMs: 250,
|
||||||
@@ -528,16 +535,7 @@ export function AppSidebar({
|
|||||||
) : null}
|
) : null}
|
||||||
<SidebarContent className="pb-32" ref={scrollRef}>
|
<SidebarContent className="pb-32" ref={scrollRef}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<SidebarGroup>
|
<SidebarLoadingContent shape={sidebarLoadingShape} />
|
||||||
<SidebarGroupLabel>Channels</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu data-testid="sidebar-loading">
|
|
||||||
{skeletonRows.map((row) => (
|
|
||||||
<SidebarMenuSkeleton key={row} showIcon />
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isLoading ? (
|
{!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 { Card } from "@/shared/ui/card";
|
||||||
import { useSidebar } from "@/shared/ui/sidebar";
|
|
||||||
import { Skeleton } from "@/shared/ui/skeleton";
|
import { Skeleton } from "@/shared/ui/skeleton";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||||
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
|
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
|
||||||
|
import { TopChromeBackdrop } from "@/shared/ui/TopChromeBackdrop";
|
||||||
|
|
||||||
type ViewLoadingFallbackKind =
|
type ViewLoadingFallbackKind =
|
||||||
| "agents"
|
| "agents"
|
||||||
@@ -19,26 +22,18 @@ type ViewLoadingFallbackProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function LoadingHeaderSkeleton() {
|
function LoadingHeaderSkeleton() {
|
||||||
const { state: sidebarState } = useSidebar();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopChromeInsetHeader data-tauri-drag-region>
|
<TopChromeInsetHeader data-tauri-drag-region>
|
||||||
<header
|
<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">
|
||||||
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]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-center gap-[6px]">
|
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||||
<Skeleton className="h-3.5 w-3.5 rounded-xs" />
|
<Skeleton className="h-4 w-4 shrink-0 rounded-sm" />
|
||||||
<Skeleton className="h-4 w-28 max-w-[50vw]" />
|
<Skeleton className="h-4 w-28 max-w-[50vw]" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="mt-2 h-4 w-full max-w-2xl" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
<div className="hidden shrink-0 items-center gap-1 sm:flex">
|
||||||
<Skeleton className="h-6 w-6 rounded-lg" />
|
<Skeleton className="h-8 w-16 rounded-lg" />
|
||||||
<Skeleton className="h-6 w-6 rounded-lg" />
|
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</TopChromeInsetHeader>
|
</TopChromeInsetHeader>
|
||||||
@@ -48,93 +43,232 @@ function LoadingHeaderSkeleton() {
|
|||||||
function MessageRowsSkeleton() {
|
function MessageRowsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{["first", "second", "third", "fourth", "fifth"].map((row, index) => (
|
{["first", "second", "third", "fourth"].map((row, index) => (
|
||||||
<div className="flex gap-3" key={row}>
|
<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" />
|
<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="-mt-1 min-w-0 flex-1">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
|
||||||
<Skeleton className="h-3.5 w-28" />
|
<Skeleton className="h-[15px] w-28" />
|
||||||
<Skeleton className="h-3 w-12" />
|
<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>
|
</div>
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className={index % 2 === 0 ? "h-4 w-4/5" : "h-4 w-2/3"} />
|
|
||||||
</div>
|
</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() {
|
function AgentsLoadingBody() {
|
||||||
return (
|
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="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="space-y-6">
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||||
<section className="space-y-4">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<AgentsLibrarySkeleton />
|
||||||
<div className="space-y-2">
|
<AgentTeamsSkeleton />
|
||||||
<Skeleton className="h-5 w-28" />
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -181,8 +315,8 @@ function CardListLoadingBody() {
|
|||||||
|
|
||||||
function ChannelLoadingBody({ hasHeader = false }: { hasHeader?: boolean }) {
|
function ChannelLoadingBody({ hasHeader = false }: { hasHeader?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
<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 pt-1 sm:px-6">
|
<div className="flex-1 overflow-y-auto px-4 pb-32 pt-1 sm:px-6">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-col gap-4",
|
"flex w-full flex-col gap-4",
|
||||||
@@ -195,13 +329,25 @@ function ChannelLoadingBody({ hasHeader = false }: { hasHeader?: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 bg-background px-4 py-4 sm:px-6">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
|
||||||
<div className="w-full space-y-3">
|
<div className="pointer-events-auto">
|
||||||
<Skeleton className="h-10 w-full rounded-2xl" />
|
<div className="relative z-10 shrink-0 bg-transparent px-4 pb-2 pt-0">
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
<Skeleton className="h-8 w-20 rounded-lg" />
|
aria-hidden="true"
|
||||||
<Skeleton className="h-8 w-16 rounded-lg" />
|
className="absolute inset-x-0 bottom-0 h-5 bg-background"
|
||||||
<Skeleton className="ml-auto h-8 w-24 rounded-lg" />
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,18 +396,37 @@ export function ViewLoadingFallback({
|
|||||||
includeHeader = false,
|
includeHeader = false,
|
||||||
kind,
|
kind,
|
||||||
}: ViewLoadingFallbackProps) {
|
}: ViewLoadingFallbackProps) {
|
||||||
|
const { setTopbarSearchLoading } = useAppShell();
|
||||||
|
const shouldShowChannelHeader =
|
||||||
|
includeHeader && (kind === "channel" || kind === "forum");
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!includeHeader) return;
|
||||||
|
|
||||||
|
setTopbarSearchLoading(true);
|
||||||
|
return () => {
|
||||||
|
setTopbarSearchLoading(false);
|
||||||
|
};
|
||||||
|
}, [includeHeader, setTopbarSearchLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
{includeHeader ? <LoadingHeaderSkeleton /> : null}
|
{shouldShowChannelHeader ? (
|
||||||
|
<LoadingHeaderSkeleton />
|
||||||
|
) : includeHeader ? (
|
||||||
|
<TopChromeBackdrop />
|
||||||
|
) : null}
|
||||||
{kind === "agents" ? <AgentsLoadingBody /> : null}
|
{kind === "agents" ? <AgentsLoadingBody /> : null}
|
||||||
{kind === "workflows" ? <CardListLoadingBody /> : null}
|
{kind === "workflows" ? <CardListLoadingBody /> : null}
|
||||||
{kind === "projects" ? <CardListLoadingBody /> : null}
|
{kind === "projects" ? <CardListLoadingBody /> : null}
|
||||||
{kind === "channel" ? (
|
{kind === "channel" ? (
|
||||||
<ChannelLoadingBody hasHeader={includeHeader} />
|
<ChannelLoadingBody hasHeader={shouldShowChannelHeader} />
|
||||||
|
) : null}
|
||||||
|
{kind === "forum" ? (
|
||||||
|
<ForumLoadingBody hasHeader={shouldShowChannelHeader} />
|
||||||
) : null}
|
) : null}
|
||||||
{kind === "forum" ? <ForumLoadingBody hasHeader={includeHeader} /> : null}
|
|
||||||
{kind === "pulse" ? (
|
{kind === "pulse" ? (
|
||||||
<ChannelLoadingBody hasHeader={includeHeader} />
|
<ChannelLoadingBody hasHeader={shouldShowChannelHeader} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user