Fix responsive layout breakpoints for small window sizes (#426)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-29 13:20:01 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent d38c55a120
commit 8d5949dcc4
7 changed files with 387 additions and 310 deletions
@@ -5,9 +5,17 @@ import { toast } from "sonner";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
import { cancelManagedAgentTurn } from "@/shared/api/agentControl";
import type { Channel, ManagedAgent } from "@/shared/api/types";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { useStickToBottom } from "@/shared/hooks/useStickToBottom";
import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import {
OverlayPanelBackdrop,
PANEL_BASE_CLASS,
PANEL_OVERLAY_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
type AgentSessionThreadPanelProps = {
@@ -32,6 +40,9 @@ export function AgentSessionThreadPanel({
widthPx,
}: AgentSessionThreadPanelProps) {
const isLive = agent.status === "running" && Boolean(agent.observerUrl);
const isOverlay = useIsThreadPanelOverlay();
useEscapeKey(onClose, isOverlay);
const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>();
async function handleInterruptTurn() {
@@ -52,99 +63,104 @@ export function AgentSessionThreadPanel({
}
return (
<aside
className="relative hidden h-full shrink-0 flex-col border-l border-border/80 bg-background lg:flex"
data-testid="agent-session-thread-panel"
style={{ width: `${widthPx}px` }}
>
<button
aria-label="Resize agent session panel"
className="group absolute inset-y-0 left-0 z-20 w-3 -translate-x-1/2 cursor-col-resize"
data-testid="agent-session-resize-handle"
onDoubleClick={canResetWidth ? onResetWidth : undefined}
onPointerDown={onResizeStart}
title={
canResetWidth
? "Drag to resize. Double-click to reset width."
: "Drag to resize."
}
type="button"
<>
{isOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(PANEL_BASE_CLASS, isOverlay && PANEL_OVERLAY_CLASS)}
data-testid="agent-session-thread-panel"
style={{ width: `${widthPx}px` }}
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80" />
</button>
<div className="flex items-center gap-3 border-b border-border/70 px-4 py-2.5">
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold tracking-tight">
{agent.name}
</h2>
<div className="flex items-center gap-1.5">
<Activity className="h-3 w-3 text-muted-foreground" />
<p className="truncate text-xs text-muted-foreground">
Agent activity log
</p>
</div>
</div>
{isLive ? (
<Badge className="shrink-0 gap-1" variant="default">
<CircleDot className="h-3 w-3" />
Live
</Badge>
) : (
<Badge className="shrink-0" variant="secondary">
Idle
</Badge>
{!isOverlay && (
<button
aria-label="Resize agent session panel"
className="group absolute inset-y-0 left-0 z-20 w-3 -translate-x-1/2 cursor-col-resize"
data-testid="agent-session-resize-handle"
onDoubleClick={canResetWidth ? onResetWidth : undefined}
onPointerDown={onResizeStart}
title={
canResetWidth
? "Drag to resize. Double-click to reset width."
: "Drag to resize."
}
type="button"
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80" />
</button>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Stop current agent turn"
data-testid="agent-session-stop-turn"
disabled={!isLive || !isWorking}
onClick={() => {
void handleInterruptTurn();
}}
size="sm"
type="button"
variant="outline"
>
<Octagon className="h-3.5 w-3.5" />
Stop
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{isWorking
? "Interrupt the current ACP turn without stopping the agent process."
: "No active turn to interrupt."}
</TooltipContent>
</Tooltip>
<Button
aria-label="Close activity panel"
data-testid="agent-session-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<div
ref={scrollRef}
onScroll={onScroll}
className="min-h-0 flex-1 overflow-y-auto px-3 py-4"
>
<ManagedAgentSessionPanel
agent={agent}
channelId={channel.id}
className="border-0 bg-transparent p-0 shadow-none"
emptyDescription={`Mention ${agent.name} in the channel to see its work here.`}
showHeader={false}
showRaw={false}
/>
</div>
</aside>
<div className="flex items-center gap-3 border-b border-border/70 px-4 py-2.5">
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold tracking-tight">
{agent.name}
</h2>
<div className="flex items-center gap-1.5">
<Activity className="h-3 w-3 text-muted-foreground" />
<p className="truncate text-xs text-muted-foreground">
Agent activity log
</p>
</div>
</div>
{isLive ? (
<Badge className="shrink-0 gap-1" variant="default">
<CircleDot className="h-3 w-3" />
Live
</Badge>
) : (
<Badge className="shrink-0" variant="secondary">
Idle
</Badge>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Stop current agent turn"
data-testid="agent-session-stop-turn"
disabled={!isLive || !isWorking}
onClick={() => {
void handleInterruptTurn();
}}
size="sm"
type="button"
variant="outline"
>
<Octagon className="h-3.5 w-3.5" />
Stop
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{isWorking
? "Interrupt the current ACP turn without stopping the agent process."
: "No active turn to interrupt."}
</TooltipContent>
</Tooltip>
<Button
aria-label="Close activity panel"
data-testid="agent-session-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<div
ref={scrollRef}
onScroll={onScroll}
className="min-h-0 flex-1 overflow-y-auto px-3 py-4"
>
<ManagedAgentSessionPanel
agent={agent}
channelId={channel.id}
className="border-0 bg-transparent p-0 shadow-none"
emptyDescription={`Mention ${agent.name} in the channel to see its work here.`}
showHeader={false}
showRaw={false}
/>
</div>
</aside>
</>
);
}
@@ -5,7 +5,15 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
OverlayPanelBackdrop,
PANEL_BASE_CLASS,
PANEL_OVERLAY_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { MessageComposer } from "./MessageComposer";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
@@ -95,6 +103,9 @@ export function MessageThreadPanel({
widthPx,
}: MessageThreadPanelProps) {
const threadBodyRef = React.useRef<HTMLDivElement>(null);
const isOverlay = useIsThreadPanelOverlay();
useEscapeKey(onClose, isOverlay);
const threadHeadId = threadHead?.id ?? null;
const composerReplyTarget =
@@ -132,170 +143,178 @@ export function MessageThreadPanel({
}
return (
<aside
className="relative hidden h-full shrink-0 flex-col border-l border-border/80 bg-background lg:flex"
data-testid="message-thread-panel"
style={{ width: `${widthPx}px` }}
>
<button
aria-label="Resize thread panel"
className="group absolute inset-y-0 left-0 z-20 w-3 -translate-x-1/2 cursor-col-resize"
data-testid="message-thread-resize-handle"
onDoubleClick={canResetWidth ? onResetWidth : undefined}
onPointerDown={onResizeStart}
title={
canResetWidth
? "Drag to resize. Double-click to reset width."
: "Drag to resize."
}
type="button"
<>
{isOverlay && <OverlayPanelBackdrop onClose={onClose} />}
<aside
className={cn(PANEL_BASE_CLASS, isOverlay && PANEL_OVERLAY_CLASS)}
data-testid="message-thread-panel"
style={{ width: `${widthPx}px` }}
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80" />
</button>
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold tracking-tight">Thread</h2>
</div>
<Button
aria-label="Close thread"
data-testid="message-thread-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto pb-6"
data-testid="message-thread-body"
onScroll={syncScrollState}
ref={threadBodyRef}
>
<div ref={contentRef}>
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
<div className="rounded-2xl">
<MessageRow
activeReplyTargetId={replyTargetId}
layoutVariant="thread-reply"
message={threadHead}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
: undefined
}
onEdit={
onEdit && canManageMessage(threadHead, currentPubkey)
? onEdit
: undefined
}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
</div>
</div>
<div className="px-3 pb-3 pt-1" data-testid="message-thread-replies">
{threadReplies.length > 0 ? (
<div className="space-y-2">
{threadReplies.map((entry, index) => {
const nextDepth =
threadReplies[index + 1]?.message.depth ?? -1;
const isExpanded = nextDepth > entry.message.depth;
return (
<div key={entry.message.id}>
<MessageRow
activeReplyTargetId={replyTargetId}
layoutVariant="thread-reply"
message={entry.message}
onDelete={
onDelete &&
canManageMessage(entry.message, currentPubkey)
? onDelete
: undefined
}
onEdit={
onEdit &&
canManageMessage(entry.message, currentPubkey)
? onEdit
: undefined
}
onReply={onSelectReplyTarget}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{entry.summary && !isExpanded ? (
<MessageThreadSummaryRow
depth={entry.message.depth}
message={entry.message}
onOpenThread={onExpandReplies}
summary={entry.summary}
/>
) : null}
</div>
);
})}
</div>
) : (
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
<p className="text-sm font-medium text-foreground/80">
No replies in this branch yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
Reply in the thread to continue this branch.
</p>
</div>
)}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>
</div>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-16 flex justify-center px-4">
<Button
className="pointer-events-auto rounded-full shadow-lg"
data-testid="thread-scroll-to-latest"
onClick={() => scrollToBottom("smooth")}
size="sm"
{!isOverlay && (
<button
aria-label="Resize thread panel"
className="group absolute inset-y-0 left-0 z-20 w-3 -translate-x-1/2 cursor-col-resize"
data-testid="message-thread-resize-handle"
onDoubleClick={canResetWidth ? onResetWidth : undefined}
onPointerDown={onResizeStart}
title={
canResetWidth
? "Drag to resize. Double-click to reset width."
: "Drag to resize."
}
type="button"
>
<ArrowDown className="h-4 w-4" />
{newMessageCount > 0
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
: "Jump to latest"}
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80" />
</button>
)}
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold tracking-tight">Thread</h2>
</div>
<Button
aria-label="Close thread"
data-testid="message-thread-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
) : null}
<div>
<MessageComposer
channelId={channelId}
channelName={channelName}
disabled={disabled || isSending || !channelId}
draftKey={`thread:${threadHead.id}`}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
onEditSave={onEditSave}
onSend={onSend}
placeholder={`Reply in thread to ${threadHead.author}`}
replyTarget={composerReplyTarget}
typingParentEventId={threadHead.id}
typingRootEventId={threadHead.rootId}
/>
<TypingIndicatorRow
channel={channel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
/>
</div>
</aside>
<div
className="min-h-0 flex-1 overflow-y-auto pb-6"
data-testid="message-thread-body"
onScroll={syncScrollState}
ref={threadBodyRef}
>
<div ref={contentRef}>
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
<div className="rounded-2xl">
<MessageRow
activeReplyTargetId={replyTargetId}
layoutVariant="thread-reply"
message={threadHead}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
: undefined
}
onEdit={
onEdit && canManageMessage(threadHead, currentPubkey)
? onEdit
: undefined
}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
</div>
</div>
<div
className="px-3 pb-3 pt-1"
data-testid="message-thread-replies"
>
{threadReplies.length > 0 ? (
<div className="space-y-2">
{threadReplies.map((entry, index) => {
const nextDepth =
threadReplies[index + 1]?.message.depth ?? -1;
const isExpanded = nextDepth > entry.message.depth;
return (
<div key={entry.message.id}>
<MessageRow
activeReplyTargetId={replyTargetId}
layoutVariant="thread-reply"
message={entry.message}
onDelete={
onDelete &&
canManageMessage(entry.message, currentPubkey)
? onDelete
: undefined
}
onEdit={
onEdit &&
canManageMessage(entry.message, currentPubkey)
? onEdit
: undefined
}
onReply={onSelectReplyTarget}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{entry.summary && !isExpanded ? (
<MessageThreadSummaryRow
depth={entry.message.depth}
message={entry.message}
onOpenThread={onExpandReplies}
summary={entry.summary}
/>
) : null}
</div>
);
})}
</div>
) : (
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
<p className="text-sm font-medium text-foreground/80">
No replies in this branch yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
Reply in the thread to continue this branch.
</p>
</div>
)}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>
</div>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-16 flex justify-center px-4">
<Button
className="pointer-events-auto rounded-full shadow-lg"
data-testid="thread-scroll-to-latest"
onClick={() => scrollToBottom("smooth")}
size="sm"
type="button"
>
<ArrowDown className="h-4 w-4" />
{newMessageCount > 0
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
: "Jump to latest"}
</Button>
</div>
) : null}
<div>
<MessageComposer
channelId={channelId}
channelName={channelName}
disabled={disabled || isSending || !channelId}
draftKey={`thread:${threadHead.id}`}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
onEditSave={onEditSave}
onSend={onSend}
placeholder={`Reply in thread to ${threadHead.author}`}
replyTarget={composerReplyTarget}
typingParentEventId={threadHead.id}
typingRootEventId={threadHead.rootId}
/>
<TypingIndicatorRow
channel={channel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
/>
</div>
</aside>
</>
);
}
@@ -117,7 +117,7 @@ export function SettingsView({
aria-labelledby="settings-title"
aria-modal="true"
className={cn(
"relative mx-auto flex h-[min(600px,calc(100vh-8rem))] w-[calc(100%-4rem)] max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-background shadow-lg motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out",
"relative mx-auto flex h-[min(600px,calc(100vh-2rem))] w-[calc(100%-4rem)] max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-background shadow-lg motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out",
isLoaded ? "opacity-100 scale-100" : "opacity-0 scale-95",
)}
data-testid="settings-view"
@@ -146,10 +146,10 @@ export function SettingsView({
</button>
</header>
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden lg:grid-cols-[220px_minmax(0,1fr)] lg:grid-rows-1">
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden md:grid-cols-[220px_minmax(0,1fr)] md:grid-rows-1">
<aside
className={cn(
"flex flex-col border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out lg:border-b-0 lg:border-r",
"flex flex-col border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out md:border-b-0 md:border-r",
isLoaded
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-2",
@@ -157,7 +157,7 @@ export function SettingsView({
>
<nav
aria-label="Settings sections"
className="flex gap-1 overflow-x-auto px-3 py-3 lg:flex-1 lg:flex-col lg:overflow-y-auto lg:pt-1"
className="flex gap-1 overflow-x-auto px-3 py-3 md:flex-1 md:flex-col md:overflow-y-auto md:pt-1"
>
{settingsSections.map((entry) => (
<SettingsSectionButton
@@ -170,7 +170,7 @@ export function SettingsView({
))}
</nav>
{appVersion ? (
<p className="hidden px-3 pb-3 text-xs text-muted-foreground/60 lg:block">
<p className="hidden px-3 pb-3 text-xs text-muted-foreground/60 md:block">
v{appVersion}
</p>
) : null}
+21 -8
View File
@@ -1,21 +1,34 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
const THREAD_PANEL_OVERLAY_BREAKPOINT = 1024;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
/**
* Returns `true` when the viewport is narrower than `breakpointPx`.
* Uses `matchMedia` for efficient change detection.
*/
export function useMediaBreakpoint(breakpointPx: number): boolean {
const [isBelow, setIsBelow] = React.useState<boolean>(() =>
typeof window !== "undefined" ? window.innerWidth < breakpointPx : false,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const mql = window.matchMedia(`(max-width: ${breakpointPx - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
setIsBelow(window.innerWidth < breakpointPx);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
setIsBelow(window.innerWidth < breakpointPx);
return () => mql.removeEventListener("change", onChange);
}, []);
}, [breakpointPx]);
return !!isMobile;
return isBelow;
}
export function useIsMobile() {
return useMediaBreakpoint(MOBILE_BREAKPOINT);
}
export function useIsThreadPanelOverlay() {
return useMediaBreakpoint(THREAD_PANEL_OVERLAY_BREAKPOINT);
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
/**
* Calls `onEscape` when the Escape key is pressed, unless the event
* was already handled (`defaultPrevented`).
*
* Pass `enabled: false` to skip registering the listener entirely.
*/
export function useEscapeKey(onEscape: () => void, enabled: boolean = true) {
React.useEffect(() => {
if (!enabled) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape" && !event.defaultPrevented) {
event.preventDefault();
onEscape();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [enabled, onEscape]);
}
@@ -0,0 +1,26 @@
/**
* Shared constants and backdrop for thread/agent overlay panels
* that slide in from the right at narrow viewport widths.
*/
/** Base classes for every side panel `<aside>`. */
export const PANEL_BASE_CLASS =
"relative flex h-full shrink-0 flex-col border-l border-border/80 bg-background";
/** Extra classes applied when the panel is rendered as a floating overlay. */
export const PANEL_OVERLAY_CLASS =
"fixed inset-y-0 right-0 z-40 shadow-xl max-w-[calc(100vw-2rem)]";
type OverlayPanelBackdropProps = {
onClose: () => void;
};
export function OverlayPanelBackdrop({ onClose }: OverlayPanelBackdropProps) {
return (
<div
className="fixed inset-0 z-30 bg-black/20"
onClick={onClose}
aria-hidden="true"
/>
);
}
+29 -47
View File
@@ -128,56 +128,38 @@ function AgentsLoadingBody() {
function WorkflowsLoadingBody() {
return (
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-28" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
<Skeleton className="h-9 w-36 rounded-lg" />
</div>
<div className="space-y-2">
{["first", "second", "third", "fourth"].map((card) => (
<Card className="p-4" key={card}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-44" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-full max-w-2xl" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
</div>
<div className="hidden shrink-0 gap-2 sm:flex">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</div>
</Card>
))}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-28" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
<Skeleton className="h-9 w-36 rounded-lg" />
</div>
<div className="hidden w-[400px] shrink-0 border-l border-border/60 bg-background/70 lg:block">
<div className="space-y-4 p-4">
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<Skeleton className="h-40 w-full rounded-xl" />
<div className="space-y-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</div>
</div>
<div className="space-y-2">
{["first", "second", "third", "fourth"].map((card) => (
<Card className="p-4" key={card}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-44" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-full max-w-2xl" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
</div>
<div className="hidden shrink-0 gap-2 sm:flex">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</div>
</Card>
))}
</div>
</div>
);