Improve data agent workspace UI
This commit is contained in:
@@ -3,22 +3,22 @@ import {
|
||||
type ExportedMessageRepository,
|
||||
type ThreadMessage,
|
||||
} from "@assistant-ui/core";
|
||||
import {
|
||||
AuiIf,
|
||||
ThreadListItemMorePrimitive,
|
||||
ThreadListItemPrimitive,
|
||||
ThreadListPrimitive,
|
||||
} from "@assistant-ui/react";
|
||||
import { ThreadListPrimitive } from "@assistant-ui/react";
|
||||
import type { UIMessage } from "ai";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
HistoryIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useClawSessionReplay } from "@/lib/claw-session-replay";
|
||||
|
||||
type ClawSession = {
|
||||
@@ -59,14 +59,6 @@ export const ThreadList: FC = () => {
|
||||
return (
|
||||
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex flex-col gap-1">
|
||||
<ThreadListNew />
|
||||
<AuiIf condition={({ threads }) => threads.isLoading}>
|
||||
<ThreadListSkeleton />
|
||||
</AuiIf>
|
||||
<AuiIf condition={({ threads }) => !threads.isLoading}>
|
||||
<ThreadListPrimitive.Items>
|
||||
{() => <ThreadListItem />}
|
||||
</ThreadListPrimitive.Items>
|
||||
</AuiIf>
|
||||
<ClawSessionList />
|
||||
</ThreadListPrimitive.Root>
|
||||
);
|
||||
@@ -86,162 +78,235 @@ const ThreadListNew: FC = () => {
|
||||
className="aui-thread-list-new h-9 w-full justify-start gap-2 rounded-lg px-3 text-sm hover:bg-muted data-active:bg-muted"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
New Thread
|
||||
New Task
|
||||
</Button>
|
||||
</ThreadListPrimitive.New>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadListSkeleton: FC = () => {
|
||||
const skeletonKeys = ["one", "two", "three", "four", "five"];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{skeletonKeys.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
role="status"
|
||||
aria-label="Loading threads"
|
||||
className="aui-thread-list-skeleton-wrapper flex h-9 items-center px-3"
|
||||
>
|
||||
<Skeleton className="aui-thread-list-skeleton h-4 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadListItem: FC = () => {
|
||||
return (
|
||||
<ThreadListItemPrimitive.Root className="aui-thread-list-item group flex h-9 items-center gap-2 rounded-lg transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none data-active:bg-muted">
|
||||
<ThreadListItemPrimitive.Trigger className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm">
|
||||
<ThreadListItemPrimitive.Title fallback="New Chat" />
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
<ThreadListItemMore />
|
||||
</ThreadListItemPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadListItemMore: FC = () => {
|
||||
return (
|
||||
<ThreadListItemMorePrimitive.Root>
|
||||
<ThreadListItemMorePrimitive.Trigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="aui-thread-list-item-more mr-2 size-7 p-0 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:bg-accent data-[state=open]:opacity-100 group-data-active:opacity-100"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More options</span>
|
||||
</Button>
|
||||
</ThreadListItemMorePrimitive.Trigger>
|
||||
<ThreadListItemMorePrimitive.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="aui-thread-list-item-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
>
|
||||
<ThreadListItemPrimitive.Archive asChild>
|
||||
<ThreadListItemMorePrimitive.Item className="aui-thread-list-item-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
||||
<ArchiveIcon className="size-4" />
|
||||
Archive
|
||||
</ThreadListItemMorePrimitive.Item>
|
||||
</ThreadListItemPrimitive.Archive>
|
||||
</ThreadListItemMorePrimitive.Content>
|
||||
</ThreadListItemMorePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ClawSessionList: FC = () => {
|
||||
const { replaySession } = useClawSessionReplay();
|
||||
const [sessions, setSessions] = useState<ClawSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [openMenuSessionId, setOpenMenuSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSessionId(window.localStorage.getItem("claw.activeSessionId"));
|
||||
const clearActiveSession = () => setActiveSessionId(null);
|
||||
const refreshSessions = () => {
|
||||
fetch("/api/claw/sessions")
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((payload) => {
|
||||
if (Array.isArray(payload))
|
||||
setSessions(dedupeSessions(payload).slice(0, 8));
|
||||
})
|
||||
.catch(() => setSessions([]));
|
||||
};
|
||||
const clearActiveSession = () => {
|
||||
setActiveSessionId(null);
|
||||
refreshSessions();
|
||||
};
|
||||
window.addEventListener("claw-active-session-cleared", clearActiveSession);
|
||||
fetch("/api/claw/sessions")
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((payload) => {
|
||||
if (Array.isArray(payload))
|
||||
setSessions(dedupeSessions(payload).slice(0, 8));
|
||||
})
|
||||
.catch(() => setSessions([]));
|
||||
window.addEventListener("claw-sessions-changed", refreshSessions);
|
||||
window.addEventListener("focus", refreshSessions);
|
||||
refreshSessions();
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"claw-active-session-cleared",
|
||||
clearActiveSession,
|
||||
);
|
||||
window.removeEventListener("claw-sessions-changed", refreshSessions);
|
||||
window.removeEventListener("focus", refreshSessions);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingSessionId) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
});
|
||||
}, [renamingSessionId]);
|
||||
|
||||
if (!sessions.length) return null;
|
||||
|
||||
const saveTitle = async (sessionId: string, fallbackTitle: string) => {
|
||||
const normalized = draftTitle.trim();
|
||||
if (!normalized || normalized === fallbackTitle) {
|
||||
setRenamingSessionId(null);
|
||||
setDraftTitle("");
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ title: normalized }),
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const payload = (await response.json()) as { title?: string };
|
||||
const nextTitle = payload.title || normalized;
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.session_id === sessionId ? { ...item, preview: nextTitle } : item,
|
||||
),
|
||||
);
|
||||
setRenamingSessionId(null);
|
||||
setDraftTitle("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-3">
|
||||
<div className="flex items-center gap-2 font-medium text-muted-foreground text-xs">
|
||||
<HistoryIcon className="size-3.5" />
|
||||
Claw 历史会话
|
||||
</div>
|
||||
{activeSessionId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground text-xs hover:text-foreground"
|
||||
onClick={() => {
|
||||
window.localStorage.removeItem("claw.activeSessionId");
|
||||
setActiveSessionId(null);
|
||||
}}
|
||||
<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}
|
||||
>
|
||||
取消续接
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{sessions.map((session) => {
|
||||
const active = activeSessionId === session.session_id;
|
||||
return (
|
||||
<button
|
||||
key={`claw-session-${session.session_id}-${session.modified_at}`}
|
||||
type="button"
|
||||
className="rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted data-[active=true]:bg-muted"
|
||||
data-active={active}
|
||||
onClick={async () => {
|
||||
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;
|
||||
replaySession(
|
||||
{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,
|
||||
toReplayRepository(payload, session.session_id),
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
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;
|
||||
replaySession(
|
||||
session.session_id,
|
||||
toReplayRepository(payload, session.session_id),
|
||||
);
|
||||
} 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,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="block truncate">
|
||||
{session.preview || session.session_id}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-muted-foreground text-xs">
|
||||
{active ? "下一条消息将续接此会话 · " : ""}
|
||||
{loadingSessionId === session.session_id ? "回放中 · " : ""}
|
||||
{session.turns} 轮 · {session.tool_calls} 次工具
|
||||
</span>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user