mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fix responsive layout breakpoints for small window sizes (#426)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user