Fix sidebar session list responsiveness

This commit is contained in:
wuyang6
2026-05-11 20:53:08 +08:00
parent c4427bbd07
commit 47f08dba04
5 changed files with 153 additions and 145 deletions
+6 -1
View File
@@ -14,7 +14,11 @@ import {
} from "@/components/assistant-ui/activity-panel";
import { Thread } from "@/components/assistant-ui/thread";
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
import { ClawAccountGate, useClawAccount } from "./claw-account-gate";
@@ -85,6 +89,7 @@ export const Assistant = () => {
<ActivityProvider>
<SidebarProvider>
<div className="flex h-dvh w-full">
<SidebarTrigger className="fixed top-3 left-3 z-40 size-9 rounded-lg bg-background/90 shadow-sm ring-1 ring-border backdrop-blur sm:hidden" />
<ThreadListSidebar
account={account}
onLogout={() => setAccount(null)}
@@ -95,7 +95,7 @@ type ClawStoredToolCall = {
export const ThreadList: FC = () => {
return (
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex flex-col gap-1">
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1">
<ThreadListNew />
<ClawSessionList />
</ThreadListPrimitive.Root>
@@ -146,8 +146,7 @@ const ClawSessionList: FC = () => {
fetch("/api/claw/sessions")
.then((res) => (res.ok ? res.json() : []))
.then((payload) => {
if (Array.isArray(payload))
setSessions(dedupeSessions(payload).slice(0, 8));
if (Array.isArray(payload)) setSessions(dedupeSessions(payload));
})
.catch(() => setSessions([]));
};
@@ -204,154 +203,158 @@ const ClawSessionList: FC = () => {
};
return (
<div className="flex flex-col gap-1">
{sessions.map((session) => {
const active = activeSessionId === session.session_id;
const loading = loadingSessionId === session.session_id;
return (
<div
key={`claw-session-${session.session_id}-${session.modified_at}`}
className="aui-thread-list-item group relative flex h-9 items-center gap-2 rounded-lg transition-colors hover:bg-muted data-[active=true]:bg-muted"
data-active={active}
>
{renamingSessionId === session.session_id ? (
<div className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center px-3">
<input
ref={renameInputRef}
type="text"
className="h-7 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
onBlur={() => {
saveTitle(
session.session_id,
session.preview || session.session_id,
);
}}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
setRenamingSessionId(null);
setDraftTitle("");
}
}}
/>
</div>
) : (
<button
type="button"
className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm"
onClick={async () => {
setOpenMenuSessionId(null);
window.localStorage.setItem(
"claw.activeSessionId",
session.session_id,
);
setActiveSessionId(session.session_id);
setLoadingSessionId(session.session_id);
try {
const response = await fetch(
`/api/claw/sessions/${session.session_id}`,
);
if (!response.ok) return;
const payload =
(await response.json()) as ClawStoredSession;
const runStatus = await fetchLatestRunStatus(
session.session_id,
);
replaySession(
session.session_id,
toReplayRepository(
payload,
session.session_id,
runStatus,
),
);
} finally {
setLoadingSessionId(null);
}
}}
>
<span className="truncate">
{loading
? "回放中..."
: session.preview || session.session_id}
</span>
</button>
)}
<button
type="button"
className="aui-thread-list-item-more mr-2 flex size-7 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 data-[busy=true]:opacity-100 data-[open=true]:bg-accent data-[open=true]:opacity-100"
data-busy={deletingSessionId === session.session_id}
data-open={openMenuSessionId === session.session_id}
aria-expanded={openMenuSessionId === session.session_id}
aria-label="打开历史会话菜单"
title="更多选项"
onClick={() => {
setOpenMenuSessionId((current) =>
current === session.session_id ? null : session.session_id,
);
}}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="flex flex-col gap-1">
{sessions.map((session) => {
const active = activeSessionId === session.session_id;
const loading = loadingSessionId === session.session_id;
return (
<div
key={`claw-session-${session.session_id}-${session.modified_at}`}
className="aui-thread-list-item group relative flex h-9 items-center gap-2 rounded-lg transition-colors hover:bg-muted data-[active=true]:bg-muted"
data-active={active}
>
<MoreHorizontalIcon className="size-4" />
</button>
{openMenuSessionId === session.session_id ? (
<div className="aui-thread-list-item-more-content absolute top-8 right-1 z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md">
{renamingSessionId === session.session_id ? (
<div className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center px-3">
<input
ref={renameInputRef}
type="text"
className="h-7 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
onBlur={() => {
saveTitle(
session.session_id,
session.preview || session.session_id,
);
}}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
setRenamingSessionId(null);
setDraftTitle("");
}
}}
/>
</div>
) : (
<button
type="button"
className="aui-thread-list-item-more-item flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus:bg-accent"
disabled={renamingSessionId === session.session_id}
onClick={() => {
setRenamingSessionId(session.session_id);
setDraftTitle(session.preview || session.session_id);
setOpenMenuSessionId(null);
}}
>
<PencilIcon className="size-4" />
</button>
<button
type="button"
className="aui-thread-list-item-more-item flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-destructive outline-none hover:bg-accent focus:bg-accent"
disabled={deletingSessionId === session.session_id}
className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm"
onClick={async () => {
setDeletingSessionId(session.session_id);
setOpenMenuSessionId(null);
window.localStorage.setItem(
"claw.activeSessionId",
session.session_id,
);
setActiveSessionId(session.session_id);
setLoadingSessionId(session.session_id);
try {
const response = await fetch(
`/api/claw/sessions/${session.session_id}`,
{ method: "DELETE" },
);
if (!response.ok) return;
setSessions((current) =>
current.filter(
(item) => item.session_id !== session.session_id,
const payload =
(await response.json()) as ClawStoredSession;
const runStatus = await fetchLatestRunStatus(
session.session_id,
);
replaySession(
session.session_id,
toReplayRepository(
payload,
session.session_id,
runStatus,
),
);
setOpenMenuSessionId(null);
if (activeSessionId === session.session_id) {
window.localStorage.removeItem("claw.activeSessionId");
setActiveSessionId(null);
window.dispatchEvent(
new Event("claw-active-session-cleared"),
);
}
} finally {
setDeletingSessionId(null);
setLoadingSessionId(null);
}
}}
>
<Trash2Icon className="size-4" />
<span className="truncate">
{loading
? "回放中..."
: session.preview || session.session_id}
</span>
</button>
</div>
) : null}
</div>
);
})}
)}
<button
type="button"
className="aui-thread-list-item-more mr-2 flex size-7 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 data-[busy=true]:opacity-100 data-[open=true]:bg-accent data-[open=true]:opacity-100"
data-busy={deletingSessionId === session.session_id}
data-open={openMenuSessionId === session.session_id}
aria-expanded={openMenuSessionId === session.session_id}
aria-label="打开历史会话菜单"
title="更多选项"
onClick={() => {
setOpenMenuSessionId((current) =>
current === session.session_id ? null : session.session_id,
);
}}
>
<MoreHorizontalIcon className="size-4" />
</button>
{openMenuSessionId === session.session_id ? (
<div className="aui-thread-list-item-more-content absolute top-8 right-1 z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md">
<button
type="button"
className="aui-thread-list-item-more-item flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus:bg-accent"
disabled={renamingSessionId === session.session_id}
onClick={() => {
setRenamingSessionId(session.session_id);
setDraftTitle(session.preview || session.session_id);
setOpenMenuSessionId(null);
}}
>
<PencilIcon className="size-4" />
</button>
<button
type="button"
className="aui-thread-list-item-more-item flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-destructive outline-none hover:bg-accent focus:bg-accent"
disabled={deletingSessionId === session.session_id}
onClick={async () => {
setDeletingSessionId(session.session_id);
try {
const response = await fetch(
`/api/claw/sessions/${session.session_id}`,
{ method: "DELETE" },
);
if (!response.ok) return;
setSessions((current) =>
current.filter(
(item) => item.session_id !== session.session_id,
),
);
setOpenMenuSessionId(null);
if (activeSessionId === session.session_id) {
window.localStorage.removeItem(
"claw.activeSessionId",
);
setActiveSessionId(null);
window.dispatchEvent(
new Event("claw-active-session-cleared"),
);
}
} finally {
setDeletingSessionId(null);
}
}}
>
<Trash2Icon className="size-4" />
</button>
</div>
) : null}
</div>
);
})}
</div>
</div>
);
};
@@ -117,7 +117,7 @@ export function ThreadListSidebar({
<SidebarTrigger className="size-8 shrink-0" />
</div>
</SidebarHeader>
<SidebarContent className="aui-sidebar-content px-2">
<SidebarContent className="aui-sidebar-content overflow-hidden px-2">
<ThreadList />
</SidebarContent>
<SidebarRail />
@@ -341,7 +341,7 @@ function useSidebarSessions(open: boolean) {
.then((res) => (res.ok ? res.json() : []))
.then((payload) => {
if (cancelled || !Array.isArray(payload)) return;
setSessions(dedupeSidebarSessions(payload).slice(0, 12));
setSessions(dedupeSidebarSessions(payload));
})
.catch(() => {
if (!cancelled) setSessions([]);
+6 -6
View File
@@ -205,7 +205,7 @@ function Sidebar({
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
className="group peer hidden text-sidebar-foreground sm:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
@@ -227,7 +227,7 @@ function Sidebar({
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear sm:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -308,7 +308,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
"sm:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 sm:peer-data-[variant=inset]:m-2 sm:peer-data-[variant=inset]:ml-0 sm:peer-data-[variant=inset]:rounded-xl sm:peer-data-[variant=inset]:shadow-sm",
className,
)}
{...props}
@@ -426,7 +426,7 @@ function SidebarGroupAction({
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"after:absolute after:-inset-2 sm:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
@@ -561,13 +561,13 @@ function SidebarMenuAction({
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"after:absolute after:-inset-2 sm:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground sm:opacity-0",
className,
)}
{...props}
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
const MOBILE_BREAKPOINT = 640;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(