refactor(blocks): refine the responsive application shell

Present chat threads in a drawer on mobile and a popover on larger screens, with shared responsive triggers and automatic close after selection. Align header action icon sizing and tighten navigation spacing for the updated shell.
This commit is contained in:
Maofeng
2026-08-08 21:14:04 +08:00
parent 5fced3817c
commit 2c05cfa86d
12 changed files with 123 additions and 36 deletions
@@ -0,0 +1,3 @@
import { createDrawerHandle } from "@workspace/ui/components/drawer"
export const chatDrawerHandle = createDrawerHandle()
@@ -0,0 +1,12 @@
import { DrawerTrigger } from "@workspace/ui/components/drawer"
import React from "react"
import { chatDrawerHandle } from "./chat-drawer-handle"
type ChatDrawerTriggerProps = Omit<
React.ComponentProps<typeof DrawerTrigger>,
"handle"
>
export function ChatDrawerTrigger(props: ChatDrawerTriggerProps) {
return <DrawerTrigger {...props} handle={chatDrawerHandle} />
}
@@ -0,0 +1,39 @@
import { useMessage } from "@workspace/i18n"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@workspace/ui/components/drawer"
import { ChatPopoverProps } from "./chat-popover"
import { chatMessages } from "./messages"
import { ItemGroup } from "@workspace/ui/components/item"
import { ChatThreadItem } from "./chat-thread-item"
import { chatDrawerHandle } from "./chat-drawer-handle"
export function ChatDrawer({ threads, title }: ChatPopoverProps) {
const defaultTitle = useMessage(chatMessages.title)
return (
<Drawer handle={chatDrawerHandle}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>
{title ?? defaultTitle}({threads.length})
</DrawerTitle>
<DrawerDescription className="sr-only" />
</DrawerHeader>
<ItemGroup className="no-scrollbar scroll-fade gap-0! overflow-y-auto p-4">
{threads.map((thread) => (
<ChatThreadItem
key={thread.name}
thread={thread}
onClick={() => chatDrawerHandle.close()}
/>
))}
</ItemGroup>
</DrawerContent>
</Drawer>
)
}
@@ -34,11 +34,15 @@ export function ChatPopover({ threads, title }: ChatPopoverProps) {
<PopoverTitle className="text-lg"> <PopoverTitle className="text-lg">
{title ?? defaultTitle}({threads.length}) {title ?? defaultTitle}({threads.length})
</PopoverTitle> </PopoverTitle>
<PopoverDescription /> <PopoverDescription className="sr-only" />
</PopoverHeader> </PopoverHeader>
<ItemGroup className="no-scrollbar scroll-fade gap-0! overflow-y-auto"> <ItemGroup className="no-scrollbar scroll-fade gap-0! overflow-y-auto">
{threads.map((thread) => ( {threads.map((thread) => (
<ChatThreadItem key={thread.name} thread={thread} /> <ChatThreadItem
key={thread.name}
thread={thread}
onClick={() => chatPopoverHandle.close()}
/>
))} ))}
</ItemGroup> </ItemGroup>
</PopoverContent> </PopoverContent>
@@ -0,0 +1,25 @@
import { useIsMobile } from "@workspace/ui/hooks/use-breakpoint"
import { ChatDrawerTrigger } from "./chat-drawer-trigger"
import { ChatPopoverTrigger } from "./chat-popover-trigger"
import { ComponentRenderFn, HTMLProps } from "@base-ui/react"
type State = {
disabled: boolean
open: boolean
}
type ChatThreadTriggerProps = {
className?: string
render?: React.ReactElement | ComponentRenderFn<HTMLProps, State> | undefined
}
export function ChatThreadTrigger({
className,
render,
}: ChatThreadTriggerProps) {
return useIsMobile() ? (
<ChatDrawerTrigger className={className} render={render} />
) : (
<ChatPopoverTrigger className={className} render={render} />
)
}
@@ -0,0 +1,13 @@
import { useIsMobile } from "@workspace/ui/hooks/use-breakpoint"
import { ChatThread } from "./types"
import { ChatDrawer } from "./chat-drawer"
import { ChatPopover } from "./chat-popover"
export interface ChatThreadsProps {
threads: readonly ChatThread[]
title?: string
}
export function ChatThreads(props: ChatThreadsProps) {
return useIsMobile() ? <ChatDrawer {...props} /> : <ChatPopover {...props} />
}
@@ -1,4 +1,5 @@
import * as React from "react" import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
import { import {
BellIcon, BellIcon,
Icon, Icon,
@@ -9,9 +10,9 @@ import { Button } from "@workspace/ui/components/button"
import { Kbd } from "@workspace/ui/components/kbd" import { Kbd } from "@workspace/ui/components/kbd"
import { useIsTablet } from "@workspace/ui/hooks/use-breakpoint" import { useIsTablet } from "@workspace/ui/hooks/use-breakpoint"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react" import { Separator } from "@workspace/ui/components/separator"
import { ChatPopover, ChatPopoverTrigger, type ChatThread } from "../chats" import { type ChatThread } from "../chats"
import { import {
MobileNavigationSheet, MobileNavigationSheet,
MobileNavigationSheetTrigger, MobileNavigationSheetTrigger,
@@ -31,8 +32,9 @@ import {
import { useSidebarState } from "./sidebar-state" import { useSidebarState } from "./sidebar-state"
import { UserMenu, UserMenuTrigger } from "./user-menu" import { UserMenu, UserMenuTrigger } from "./user-menu"
import { AppBreadcrumb } from "./app-breadcrumb" import { AppBreadcrumb } from "./app-breadcrumb"
import { Separator } from "@workspace/ui/components/separator"
import { AppQueryIndicator } from "./app-query-indicator" import { AppQueryIndicator } from "./app-query-indicator"
import { ChatThreadTrigger } from "../chats/chat-thread-trigger"
import { ChatThreads } from "../chats/chat-threads"
export interface AppLayoutProps { export interface AppLayoutProps {
chatThreads: readonly ChatThread[] chatThreads: readonly ChatThread[]
@@ -54,7 +56,7 @@ export function AppLayout({
<MobileNavigationSheet groups={navigationGroups} /> <MobileNavigationSheet groups={navigationGroups} />
<UserMenu /> <UserMenu />
<NotificationCenterSheet {...notifications} /> <NotificationCenterSheet {...notifications} />
<ChatPopover threads={chatThreads} /> <ChatThreads threads={chatThreads} />
<div className="isolate min-h-svh w-svw"> <div className="isolate min-h-svh w-svw">
<AppHeader actions={headerActions} /> <AppHeader actions={headerActions} />
<div className="relative z-1 flex min-h-svh w-svw items-start"> <div className="relative z-1 flex min-h-svh w-svw items-start">
@@ -90,10 +92,7 @@ function AppHeader({ actions }: { actions?: React.ReactNode }) {
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stopColor="var(--palette-primary-dark)"></stop> <stop stopColor="var(--palette-primary-dark)"></stop>
<stop <stop offset="1" stopColor="var(--palette-primary-main)" />
offset="1"
stopColor="var(--palette-primary-main)"
></stop>
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="_r_5g_-2" id="_r_5g_-2"
@@ -103,11 +102,8 @@ function AppHeader({ actions }: { actions?: React.ReactNode }) {
y2="384" y2="384"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stopColor="var(--palette-primary-light)"></stop> <stop stopColor="var(--palette-primary-light)" />
<stop <stop offset="1" stopColor="var(--palette-primary-main)" />
offset="1"
stopColor="var(--palette-primary-main)"
></stop>
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="_r_5g_-3" id="_r_5g_-3"
@@ -118,28 +114,25 @@ function AppHeader({ actions }: { actions?: React.ReactNode }) {
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stopColor="var(--palette-primary-light)"></stop> <stop stopColor="var(--palette-primary-light)"></stop>
<stop <stop offset="1" stopColor="var(--palette-primary-main)" />
offset="1"
stopColor="var(--palette-primary-main)"
></stop>
</linearGradient> </linearGradient>
</defs> </defs>
<path <path
fill="url(#_r_5g_-1)" fill="url(#_r_5g_-1)"
d="M86.352 246.358C137.511 214.183 161.836 245.017 183.168 285.573C165.515 317.716 153.837 337.331 148.132 344.418C137.373 357.788 125.636 367.911 111.202 373.752C80.856 388.014 43.132 388.681 14 371.048L86.352 246.358Z" d="M86.352 246.358C137.511 214.183 161.836 245.017 183.168 285.573C165.515 317.716 153.837 337.331 148.132 344.418C137.373 357.788 125.636 367.911 111.202 373.752C80.856 388.014 43.132 388.681 14 371.048L86.352 246.358Z"
></path> />
<path <path
fill="url(#_r_5g_-2)" fill="url(#_r_5g_-2)"
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
d="M444.31 229.726C398.04 148.77 350.21 72.498 295.267 184.382C287.751 198.766 282.272 226.719 270 226.719V226.577C257.728 226.577 252.251 198.624 244.735 184.24C189.79 72.356 141.96 148.628 95.689 229.584C92.207 235.69 88.862 241.516 86 246.58C192.038 179.453 183.11 382.247 270 383.858V384C356.891 382.389 347.962 179.595 454 246.72C451.139 241.658 447.794 235.832 444.31 229.726Z" d="M444.31 229.726C398.04 148.77 350.21 72.498 295.267 184.382C287.751 198.766 282.272 226.719 270 226.719V226.577C257.728 226.577 252.251 198.624 244.735 184.24C189.79 72.356 141.96 148.628 95.689 229.584C92.207 235.69 88.862 241.516 86 246.58C192.038 179.453 183.11 382.247 270 383.858V384C356.891 382.389 347.962 179.595 454 246.72C451.139 241.658 447.794 235.832 444.31 229.726Z"
></path> />
<path <path
fill="url(#_r_5g_-3)" fill="url(#_r_5g_-3)"
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
d="M450 384C476.509 384 498 362.509 498 336C498 309.491 476.509 288 450 288C423.491 288 402 309.491 402 336C402 362.509 423.491 384 450 384Z" d="M450 384C476.509 384 498 362.509 498 336C498 309.491 476.509 288 450 288C423.491 288 402 309.491 402 336C402 362.509 423.491 384 450 384Z"
></path> />
</svg> </svg>
</span> </span>
</div> </div>
@@ -148,8 +141,8 @@ function AppHeader({ actions }: { actions?: React.ReactNode }) {
</div> </div>
<div className="flex h-full flex-1 items-center gap-2 px-10 mobile:px-6"> <div className="flex h-full flex-1 items-center gap-2 px-10 mobile:px-6">
<MobileNavigationSheetTrigger /> <MobileNavigationSheetTrigger />
<div className="flex flex-1 items-center"> <div className="flex flex-1 items-center gap-2">
<AppQueryIndicator className="-ml-2.5" /> <AppQueryIndicator />
<Separator <Separator
orientation="vertical" orientation="vertical"
className="ms-1.5 me-4 h-3 shrink-0 self-center! last:hidden" className="ms-1.5 me-4 h-3 shrink-0 self-center! last:hidden"
@@ -162,7 +155,7 @@ function AppHeader({ actions }: { actions?: React.ReactNode }) {
<NotificationCenterTrigger <NotificationCenterTrigger
render={<HeaderActionButton icon={BellIcon} showIndicator />} render={<HeaderActionButton icon={BellIcon} showIndicator />}
/> />
<ChatPopoverTrigger <ChatThreadTrigger
render={ render={
<HeaderActionButton <HeaderActionButton
icon={MessageMultiple01Icon} icon={MessageMultiple01Icon}
@@ -210,7 +203,7 @@ function GlobalSearchTrigger() {
<Icon <Icon
data={Search01Icon} data={Search01Icon}
className={cn( className={cn(
"pointer-event-none size-6 transition-all not-mobile:hidden", "pointer-event-none size-5 transition-all not-mobile:hidden",
headerActionIconClassName headerActionIconClassName
)} )}
/> />
@@ -218,9 +211,7 @@ function GlobalSearchTrigger() {
className="text-muted-foreground mobile:hidden" className="text-muted-foreground mobile:hidden"
strokeWidth={2.5} strokeWidth={2.5}
/> />
<Kbd className="rounded-[4px] bg-white text-black shadow-xs mobile:hidden"> <Kbd className="bg-white text-black shadow-xs mobile:hidden">K</Kbd>
K
</Kbd>
</HeaderActionButton> </HeaderActionButton>
) )
} }
@@ -34,7 +34,7 @@ export function AppQueryIndicator({ className }: { className?: string }) {
render={ render={
<HeaderActionButton <HeaderActionButton
aria-label={refreshLabel} aria-label={refreshLabel}
className={cn("[&_svg]:size-4", className)} className={cn("[&_svg]:size-4.5", className)}
disabled={!canRefresh} disabled={!canRefresh}
icon={ReloadIcon} icon={ReloadIcon}
onClick={refresh} onClick={refresh}
@@ -20,7 +20,7 @@ export function HeaderActionButton({
return ( return (
<Button variant="ghost" size="icon-lg" className={cn(className)} {...props}> <Button variant="ghost" size="icon-lg" className={cn(className)} {...props}>
{icon ? ( {icon ? (
<Icon data={icon} className={cn(headerActionIconClassName)} /> <Icon data={icon} className={cn("size-5", headerActionIconClassName)} />
) : ( ) : (
children children
)} )}
@@ -52,7 +52,7 @@ export function MobileNavigationSheetTrigger({
<Icon <Icon
data={Menu02Icon} data={Menu02Icon}
strokeWidth={2.25} strokeWidth={2.25}
className="size-7 [&_path]:transition-all group-hover/button:[&_path]:not-first:not-last:opacity-40 [&_path]:first:opacity-40 group-hover/button:[&_path]:first:translate-x-1 group-hover/button:[&_path]:first:opacity-100 [&_path]:last:opacity-60 group-hover/button:[&_path]:last:translate-x-2 group-hover/button:[&_path]:last:opacity-100" className="size-5 [&_path]:transition-all group-hover/button:[&_path]:not-first:not-last:opacity-40 [&_path]:first:opacity-40 group-hover/button:[&_path]:first:translate-x-1 group-hover/button:[&_path]:first:opacity-100 [&_path]:last:opacity-60 group-hover/button:[&_path]:last:translate-x-2 group-hover/button:[&_path]:last:opacity-100"
/> />
</SheetTrigger> </SheetTrigger>
) )
@@ -25,10 +25,10 @@ export function InlineNestedNavigationList({
const hasNestedItems = items.some((item) => !!item.items?.length) const hasNestedItems = items.some((item) => !!item.items?.length)
const listElement = ( const listElement = (
<div className={cn("w-full pb-1 pl-6", className)}> <div className={cn("w-full pl-6", className)}>
<ul <ul
className={cn( className={cn(
"relative space-y-1 py-1 pl-2.75", "space-y relative py-px pl-2.75",
"before:absolute before:top-0 before:bottom-7 before:left-0", "before:absolute before:top-0 before:bottom-7 before:left-0",
"before:-translate-x-px", "before:-translate-x-px",
"before:w-0.5", "before:w-0.5",
@@ -164,7 +164,7 @@ export function PrimaryNavigation({
<Accordion.Root <Accordion.Root
className={cn( className={cn(
"w-74 space-y-7 p-4 tablet:w-22 not-mobile:collapsed:w-22", "w-74 space-y-7 p-4 tablet:w-22 not-mobile:collapsed:w-22",
"tablet:space-y-1 tablet:p-2 not-mobile:collapsed:space-y-1 not-mobile:collapsed:p-2", "tablet:space-y tablet:p-2 not-mobile:collapsed:space-y-1 not-mobile:collapsed:p-2",
className className
)} )}
keepMounted keepMounted
@@ -175,7 +175,7 @@ export function PrimaryNavigation({
<div className="mx-3 mb-2 truncate text-xs whitespace-nowrap tablet:hidden not-mobile:collapsed:hidden"> <div className="mx-3 mb-2 truncate text-xs whitespace-nowrap tablet:hidden not-mobile:collapsed:hidden">
<NavigationLabel label={group.label} /> <NavigationLabel label={group.label} />
</div> </div>
<div className="tablet:space-y-1 not-mobile:collapsed:space-y-1"> <div className="tablet:space-y not-mobile:collapsed:space-y-1">
{group.items.map((item) => ( {group.items.map((item) => (
<PrimaryNavigationItem <PrimaryNavigationItem
key={item.id} key={item.id}