fix(webui): polish automation layout and session updates

This commit is contained in:
chengyongru
2026-06-15 17:30:06 +08:00
parent d7e73609d3
commit cbb4c0bad2
17 changed files with 340 additions and 149 deletions
+16 -2
View File
@@ -846,6 +846,19 @@ class WebSocketChannel(BaseChannel):
self.logger.exception("send failed{}", label) self.logger.exception("send failed{}", label)
raise raise
def _all_subscribed_connections(self) -> list[Any]:
"""Return every live WebUI connection that is subscribed to at least one chat."""
seen: set[int] = set()
conns: list[Any] = []
for subscribers in self._subs.values():
for connection in subscribers:
marker = id(connection)
if marker in seen:
continue
seen.add(marker)
conns.append(connection)
return conns
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
if msg.metadata.get("_runtime_model_updated"): if msg.metadata.get("_runtime_model_updated"):
await self.send_runtime_model_updated( await self.send_runtime_model_updated(
@@ -896,6 +909,7 @@ class WebSocketChannel(BaseChannel):
goal_state=gs_blob, goal_state=gs_blob,
metadata=msg.metadata, metadata=msg.metadata,
) )
await self.send_session_updated(msg.chat_id, scope="thread")
return return
if msg.metadata.get("_session_updated"): if msg.metadata.get("_session_updated"):
if conns: if conns:
@@ -1146,8 +1160,8 @@ class WebSocketChannel(BaseChannel):
await self._safe_send_to(connection, raw, label=" goal_status ") await self._safe_send_to(connection, raw, label=" goal_status ")
async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None: async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None:
"""Notify clients that session metadata changed outside the main turn.""" """Notify WebUI clients that a session row should refresh."""
conns = list(self._subs.get(chat_id, ())) conns = self._all_subscribed_connections()
if not conns: if not conns:
return return
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id} body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
+40
View File
@@ -119,6 +119,46 @@ async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Re
) )
@pytest.mark.asyncio
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
class Conn:
remote_address = None
def __init__(self) -> None:
self.sent: list[str] = []
async def send(self, raw: str) -> None:
self.sent.append(raw)
channel = _ch(bus)
active_conn = Conn()
other_conn = Conn()
channel._attach(active_conn, "chat-a")
channel._attach(other_conn, "chat-b")
assert sorted(channel._subs) == ["chat-a", "chat-b"]
assert sum(len(conns) for conns in channel._subs.values()) == 2
assert {id(conn) for conn in channel._all_subscribed_connections()} == {
id(active_conn),
id(other_conn),
}
await channel.send_session_updated("chat-a", scope="thread")
active_events = [json.loads(raw)["event"] for raw in active_conn.sent]
other_events = [json.loads(raw)["event"] for raw in other_conn.sent]
assert (active_events, other_events) == (
["session_updated"],
["session_updated"],
)
payload = json.loads(other_conn.sent[0])
assert payload == {
"event": "session_updated",
"chat_id": "chat-a",
"scope": "thread",
}
async def _recv_ws_event(client: Any, event: str) -> dict[str, Any]: async def _recv_ws_event(client: Any, event: str) -> dict[str, Any]:
"""Receive until a specific websocket event appears.""" """Receive until a specific websocket event appears."""
for _ in range(10): for _ in range(10):
+34 -18
View File
@@ -65,7 +65,8 @@ type BootState =
}; };
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1"; const SESSION_UPDATES_STORAGE_KEY = "nanobot-webui.sidebar.session-updates.v1";
const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt"; const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272; const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56; const SIDEBAR_RAIL_WIDTH = 56;
@@ -258,10 +259,12 @@ function readSidebarOpen(): boolean {
} }
} }
function readCompletedRunChatIds(): Set<string> { function readSessionUpdateChatIds(): Set<string> {
if (typeof window === "undefined") return new Set(); if (typeof window === "undefined") return new Set();
try { try {
const raw = window.localStorage.getItem(COMPLETED_RUNS_STORAGE_KEY); const raw =
window.localStorage.getItem(SESSION_UPDATES_STORAGE_KEY)
?? window.localStorage.getItem(LEGACY_COMPLETED_RUNS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : []; const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return new Set(); if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((item): item is string => typeof item === "string")); return new Set(parsed.filter((item): item is string => typeof item === "string"));
@@ -270,10 +273,10 @@ function readCompletedRunChatIds(): Set<string> {
} }
} }
function writeCompletedRunChatIds(chatIds: Set<string>): void { function writeSessionUpdateChatIds(chatIds: Set<string>): void {
try { try {
window.localStorage.setItem( window.localStorage.setItem(
COMPLETED_RUNS_STORAGE_KEY, SESSION_UPDATES_STORAGE_KEY,
JSON.stringify(Array.from(chatIds)), JSON.stringify(Array.from(chatIds)),
); );
} catch { } catch {
@@ -573,7 +576,7 @@ function Shell({
const [restartToast, setRestartToast] = useState<string | null>(null); const [restartToast, setRestartToast] = useState<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false); const [isRestarting, setIsRestarting] = useState(false);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set()); const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds); const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null); const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
const skills = useSkills(token); const skills = useSkills(token);
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null); const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
@@ -641,20 +644,20 @@ function Shell({
}, [hostSidebarOpen]); }, [hostSidebarOpen]);
useEffect(() => { useEffect(() => {
writeCompletedRunChatIds(completedChatIds); writeSessionUpdateChatIds(updatedChatIds);
}, [completedChatIds]); }, [updatedChatIds]);
const activeSession = useMemo<ChatSummary | null>(() => { const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null; if (!activeKey) return null;
return sessions.find((s) => s.key === activeKey) ?? null; return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]); }, [sessions, activeKey]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const completedChatIdList = useMemo(() => Array.from(completedChatIds), [completedChatIds]); const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null; const activeChatId = activeSession?.chatId ?? null;
useEffect(() => { useEffect(() => {
activeChatIdRef.current = activeChatId; activeChatIdRef.current = activeChatId;
if (!activeChatId) return; if (!activeChatId) return;
setCompletedChatIds((current) => { setUpdatedChatIds((current) => {
if (!current.has(activeChatId)) return current; if (!current.has(activeChatId)) return current;
const next = new Set(current); const next = new Set(current);
next.delete(activeChatId); next.delete(activeChatId);
@@ -694,7 +697,7 @@ function Shell({
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
const knownChatIds = new Set(sessions.map((session) => session.chatId)); const knownChatIds = new Set(sessions.map((session) => session.chatId));
setCompletedChatIds((current) => { setUpdatedChatIds((current) => {
const next = new Set( const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)), Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
); );
@@ -722,12 +725,25 @@ function Shell({
}, [activeKey, loading, navigate, sessions]); }, [activeKey, loading, navigate, sessions]);
useEffect(() => { useEffect(() => {
return client.onSessionUpdate((_chatId, _scope, workspaceScope) => { return client.onSessionUpdate((chatId, scope, workspaceScope) => {
if (scope === "thread") {
setUpdatedChatIds((current) => {
const next = new Set(current);
if (activeChatIdRef.current === chatId) {
next.delete(chatId);
} else {
next.add(chatId);
}
return next.size === current.size && next.has(chatId) === current.has(chatId)
? current
: next;
});
}
if (!workspaceScope) return; if (!workspaceScope) return;
const next = normalizeWorkspaceScope(workspaceScope); const next = normalizeWorkspaceScope(workspaceScope);
setWorkspaceOverrides((current) => ({ setWorkspaceOverrides((current) => ({
...current, ...current,
[_chatId]: next, [chatId]: next,
})); }));
setDraftWorkspaceScope(next); setDraftWorkspaceScope(next);
setWorkspaceError(null); setWorkspaceError(null);
@@ -764,7 +780,7 @@ function Shell({
runningChatIdsRef.current = next; runningChatIdsRef.current = next;
return next; return next;
}); });
setCompletedChatIds((current) => { setUpdatedChatIds((current) => {
let changed = false; let changed = false;
const next = new Set(current); const next = new Set(current);
for (const chatId of activeRunIds) { for (const chatId of activeRunIds) {
@@ -961,7 +977,7 @@ function Shell({
const selected = sessions.find((session) => session.key === key); const selected = sessions.find((session) => session.key === key);
const selectedChatId = selected?.chatId; const selectedChatId = selected?.chatId;
if (selectedChatId) { if (selectedChatId) {
setCompletedChatIds((current) => { setUpdatedChatIds((current) => {
if (!current.has(selectedChatId)) return current; if (!current.has(selectedChatId)) return current;
const next = new Set(current); const next = new Set(current);
next.delete(selectedChatId); next.delete(selectedChatId);
@@ -1232,7 +1248,7 @@ function Shell({
nextRunning.add(chatId); nextRunning.add(chatId);
runningChatIdsRef.current = nextRunning; runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning); setRunningChatIds(nextRunning);
setCompletedChatIds((current) => { setUpdatedChatIds((current) => {
if (!current.has(chatId)) return current; if (!current.has(chatId)) return current;
const next = new Set(current); const next = new Set(current);
next.delete(chatId); next.delete(chatId);
@@ -1246,7 +1262,7 @@ function Shell({
nextRunning.delete(chatId); nextRunning.delete(chatId);
runningChatIdsRef.current = nextRunning; runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning); setRunningChatIds(nextRunning);
setCompletedChatIds((current) => { setUpdatedChatIds((current) => {
const next = new Set(current); const next = new Set(current);
if (activeChatIdRef.current === chatId) { if (activeChatIdRef.current === chatId) {
next.delete(chatId); next.delete(chatId);
@@ -1393,7 +1409,7 @@ function Shell({
projectNameOverrides: sidebarState.project_name_overrides, projectNameOverrides: sidebarState.project_name_overrides,
collapsedGroups: sidebarState.collapsed_groups, collapsedGroups: sidebarState.collapsed_groups,
runningChatIds: runningChatIdList, runningChatIds: runningChatIdList,
completedChatIds: completedChatIdList, updatedChatIds: updatedChatIdList,
viewState: sidebarState.view, viewState: sidebarState.view,
showArchived: sidebarState.view.show_archived, showArchived: sidebarState.view.show_archived,
archivedCount: sidebarState.archived_keys.length, archivedCount: sidebarState.archived_keys.length,
+9 -9
View File
@@ -60,7 +60,7 @@ interface ChatListProps {
projectNameOverrides?: Record<string, string>; projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>; collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[]; runningChatIds?: string[];
completedChatIds?: string[]; updatedChatIds?: string[];
density?: SidebarDensity; density?: SidebarDensity;
showPreviews?: boolean; showPreviews?: boolean;
showTimestamps?: boolean; showTimestamps?: boolean;
@@ -89,7 +89,7 @@ export const ChatList = memo(function ChatList({
projectNameOverrides = {}, projectNameOverrides = {},
collapsedGroups = {}, collapsedGroups = {},
runningChatIds = [], runningChatIds = [],
completedChatIds = [], updatedChatIds = [],
density = "comfortable", density = "comfortable",
showPreviews = false, showPreviews = false,
showTimestamps = false, showTimestamps = false,
@@ -175,7 +175,7 @@ export const ChatList = memo(function ChatList({
const pinned = new Set(pinnedKeys); const pinned = new Set(pinnedKeys);
const archived = new Set(archivedKeys); const archived = new Set(archivedKeys);
const running = new Set(runningChatIds); const running = new Set(runningChatIds);
const completed = new Set(completedChatIds); const updated = new Set(updatedChatIds);
const compact = density === "compact"; const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project"); const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
@@ -245,8 +245,8 @@ export const ChatList = memo(function ChatList({
const projectMode = group.kind === "project"; const projectMode = group.kind === "project";
const activityState = running.has(s.chatId) const activityState = running.has(s.chatId)
? "running" ? "running"
: completed.has(s.chatId) && !active : updated.has(s.chatId) && !active
? "complete" ? "updated"
: null; : null;
return ( return (
<li key={s.key} className="min-w-0"> <li key={s.key} className="min-w-0">
@@ -525,7 +525,7 @@ function ChatsFoldFooter({
function SessionActivityIndicator({ function SessionActivityIndicator({
state, state,
}: { }: {
state: "running" | "complete" | null; state: "running" | "updated" | null;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -542,15 +542,15 @@ function SessionActivityIndicator({
); );
} }
if (state === "complete") { if (state === "updated") {
const label = t("chat.activity.complete"); const label = t("chat.activity.updated");
return ( return (
<span <span
aria-label={label} aria-label={label}
title={label} title={label}
className="grid h-4 w-4 shrink-0 place-items-center" className="grid h-4 w-4 shrink-0 place-items-center"
> >
<span className="h-2 w-2 rounded-full bg-blue-500 dark:bg-blue-400" /> <span className="h-2 w-2 rounded-full bg-[#ff8a3d] shadow-[0_0_0_2px_rgba(255,138,61,0.16)]" />
</span> </span>
); );
} }
+2 -2
View File
@@ -51,7 +51,7 @@ interface SidebarProps {
projectNameOverrides?: Record<string, string>; projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>; collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[]; runningChatIds?: string[];
completedChatIds?: string[]; updatedChatIds?: string[];
viewState?: SidebarViewState; viewState?: SidebarViewState;
showArchived?: boolean; showArchived?: boolean;
archivedCount?: number; archivedCount?: number;
@@ -210,7 +210,7 @@ export function Sidebar(props: SidebarProps) {
projectNameOverrides={props.projectNameOverrides} projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups} collapsedGroups={props.collapsedGroups}
runningChatIds={props.runningChatIds} runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds} updatedChatIds={props.updatedChatIds}
density={props.viewState?.density} density={props.viewState?.density}
showPreviews={props.viewState?.show_previews} showPreviews={props.viewState?.show_previews}
showTimestamps={props.viewState?.show_timestamps} showTimestamps={props.viewState?.show_timestamps}
+123 -96
View File
@@ -1715,7 +1715,10 @@ export function SettingsView({
<main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]"> <main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<div <div
className={cn( className={cn(
"mx-auto w-full max-w-[920px] px-5 py-8 sm:px-8 lg:py-12", "mx-auto w-full px-5 py-8 sm:px-8 lg:py-12",
activeSection === "automations"
? "max-w-[1220px] 2xl:max-w-[1320px]"
: "max-w-[920px]",
hostChromeInset && "pt-[4.25rem] sm:pt-[4.25rem] lg:pt-[4.75rem]", hostChromeInset && "pt-[4.25rem] sm:pt-[4.25rem] lg:pt-[4.75rem]",
)} )}
> >
@@ -3470,10 +3473,10 @@ function AutomationsSettings({
}, [filtered, selectedJobId]); }, [filtered, selectedJobId]);
return ( return (
<div className="space-y-5"> <div className="space-y-4">
<section className="rounded-[22px] border border-border/45 bg-card/80 px-3 py-3 shadow-[0_18px_50px_rgba(15,23,42,0.045)] backdrop-blur-xl sm:px-4"> <section className="rounded-[24px] border border-border/45 bg-card/80 p-3 shadow-[0_22px_70px_rgba(15,23,42,0.055)] backdrop-blur-xl">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between"> <div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="flex min-w-0 flex-wrap gap-1 rounded-[16px] bg-muted/55 p-1"> <div className="flex min-w-0 flex-wrap gap-1 rounded-[16px] bg-muted/50 p-1">
{summaryOptions.map((option) => ( {summaryOptions.map((option) => (
<button <button
key={option.value} key={option.value}
@@ -3485,7 +3488,7 @@ function AutomationsSettings({
)} )}
> >
<span>{option.label}</span> <span>{option.label}</span>
<span className="min-w-5 rounded-full bg-muted px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground"> <span className="min-w-5 rounded-full bg-background/75 px-1.5 py-0.5 text-center text-[11px] tabular-nums text-muted-foreground">
{option.count} {option.count}
</span> </span>
</button> </button>
@@ -3493,20 +3496,20 @@ function AutomationsSettings({
</div> </div>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-end"> <div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<div className="relative min-w-0 sm:w-[22rem]"> <div className="relative min-w-0 sm:w-[24rem]">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" /> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
<Input <Input
value={query} value={query}
onChange={(event) => onQueryChange(event.target.value)} onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.automations.search", "Search automation, message, session, or cron expression")} placeholder={tx("settings.automations.search", "Search automation, message, session, or cron expression")}
className="h-9 rounded-full bg-background/85 pl-9 text-[13px]" className="h-9 rounded-[13px] border-border/45 bg-background/85 pl-9 text-[13px] shadow-sm"
/> />
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-full border border-border/45 bg-background/85 px-3 text-[12px] font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/70 hover:text-foreground" className="inline-flex h-9 items-center justify-center gap-1.5 rounded-[13px] border border-border/45 bg-background/85 px-3 text-[12px] font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/70 hover:text-foreground"
> >
<ArrowUpDown className="h-3.5 w-3.5" aria-hidden /> <ArrowUpDown className="h-3.5 w-3.5" aria-hidden />
<span>{sortLabel[sort]}</span> <span>{sortLabel[sort]}</span>
@@ -3533,55 +3536,64 @@ function AutomationsSettings({
</div> </div>
) : null} ) : null}
<section> {loading && !payload ? (
<SettingsSectionTitle>{tx("settings.automations.queue", "Queue")}</SettingsSectionTitle> <div className="flex h-44 items-center justify-center rounded-[24px] border border-border/45 bg-card/80 text-[13px] text-muted-foreground shadow-[0_22px_70px_rgba(15,23,42,0.055)]">
{loading && !payload ? ( <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
<div className="flex h-40 items-center justify-center rounded-[22px] border border-border/45 bg-card/78 text-[13px] text-muted-foreground"> {tx("settings.automations.loading", "Loading automations...")}
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> </div>
{tx("settings.automations.loading", "Loading automations...")} ) : filtered.length && selectedJob ? (
</div> <section className="grid items-start gap-4 lg:grid-cols-[minmax(18rem,22rem)_minmax(0,1fr)] xl:grid-cols-[minmax(20rem,24rem)_minmax(0,1fr)]">
) : filtered.length && selectedJob ? ( <aside className="overflow-hidden rounded-[24px] border border-border/45 bg-card/80 shadow-[0_22px_70px_rgba(15,23,42,0.055)] backdrop-blur-xl">
<div className="grid gap-3 xl:grid-cols-[minmax(18rem,23rem)_minmax(0,1fr)]"> <div className="flex items-center justify-between gap-3 border-b border-border/35 px-4 py-3">
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/78 p-1.5 shadow-[0_18px_55px_rgba(15,23,42,0.045)] backdrop-blur-xl"> <h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
<div className="space-y-1" role="list" aria-label={tx("settings.automations.queue", "Queue")}> {tx("settings.automations.queue", "Queue")}
{filtered.map((job) => ( </h2>
<AutomationListItem <span className="rounded-full bg-muted/70 px-2 py-0.5 text-[11px] text-muted-foreground tabular-nums">
key={job.id} {filtered.length}
job={job} </span>
locale={locale}
selected={job.id === selectedJob.id}
onSelect={() => setSelectedJobId(job.id)}
/>
))}
</div>
</div> </div>
<AutomationDetailPanel <div
job={selectedJob} className="max-h-[26rem] space-y-1 overflow-y-auto p-2 lg:max-h-[calc(100dvh-17rem)]"
locale={locale} role="list"
actionKey={actionKey} aria-label={tx("settings.automations.queue", "Queue")}
onAction={onAction} >
onRequestEdit={onRequestEdit} {filtered.map((job) => (
onRequestDelete={onRequestDelete} <AutomationListItem
/> key={job.id}
</div> job={job}
) : ( locale={locale}
<div className="rounded-[22px] border border-border/45 bg-card/78 px-5 py-10 text-center text-[13px] text-muted-foreground"> selected={job.id === selectedJob.id}
<div> onSelect={() => setSelectedJobId(job.id)}
{jobs.length />
? tx("settings.automations.noMatches", "No automations match this view.") ))}
: tx("settings.automations.empty", "No automations yet.")}
</div> </div>
{!jobs.length ? ( </aside>
<div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5"> <AutomationDetailPanel
{tx( job={selectedJob}
"settings.automations.emptyHint", locale={locale}
"Create one from the chat or channel where it should run so nanobot keeps the right context.", actionKey={actionKey}
)} onAction={onAction}
</div> onRequestEdit={onRequestEdit}
) : null} onRequestDelete={onRequestDelete}
/>
</section>
) : (
<div className="rounded-[24px] border border-border/45 bg-card/80 px-5 py-12 text-center text-[13px] text-muted-foreground shadow-[0_22px_70px_rgba(15,23,42,0.055)]">
<div>
{jobs.length
? tx("settings.automations.noMatches", "No automations match this view.")
: tx("settings.automations.empty", "No automations yet.")}
</div> </div>
)} {!jobs.length ? (
</section> <div className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
{tx(
"settings.automations.emptyHint",
"Create one from the chat or channel where it should run so nanobot keeps the right context.",
)}
</div>
) : null}
</div>
)}
</div> </div>
); );
} }
@@ -3613,14 +3625,14 @@ function AutomationListItem({
aria-pressed={selected} aria-pressed={selected}
onClick={onSelect} onClick={onSelect}
className={cn( className={cn(
"group grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-[18px] px-3 py-3 text-left transition-colors", "group grid w-full grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-[18px] px-3 py-3.5 text-left transition-colors",
selected selected
? "bg-background text-foreground shadow-sm ring-1 ring-border/45" ? "bg-background text-foreground shadow-sm ring-1 ring-border/45"
: "text-muted-foreground hover:bg-background/60 hover:text-foreground", : "text-muted-foreground hover:bg-background/55 hover:text-foreground",
)} )}
> >
<span className="min-w-0"> <span className="min-w-0">
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2.5">
<span <span
className={cn("h-2 w-2 shrink-0 rounded-full", automationStatusDotClass(job))} className={cn("h-2 w-2 shrink-0 rounded-full", automationStatusDotClass(job))}
aria-hidden aria-hidden
@@ -3629,10 +3641,10 @@ function AutomationListItem({
{job.name || job.id} {job.name || job.id}
</span> </span>
</span> </span>
<span className="mt-1 line-clamp-2 text-[12px] leading-5 text-muted-foreground"> <span className="mt-1.5 line-clamp-2 text-[12px] leading-5 text-muted-foreground">
{job.payload.message || tx("settings.automations.systemTask", "System-managed automation")} {job.payload.message || tx("settings.automations.systemTask", "System-managed automation")}
</span> </span>
<span className="mt-2 flex min-w-0 items-center gap-2 text-[11.5px] leading-none text-muted-foreground"> <span className="mt-2.5 flex min-w-0 items-center gap-2 text-[11.5px] leading-none text-muted-foreground">
<span className="truncate" title={formatAutomationNextTitle(job, locale, tx)}> <span className="truncate" title={formatAutomationNextTitle(job, locale, tx)}>
{nextRun} {nextRun}
</span> </span>
@@ -3691,14 +3703,16 @@ function AutomationDetailPanel({
const needsRecreation = automationNeedsRecreation(job); const needsRecreation = automationNeedsRecreation(job);
const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null; const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null;
const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null; const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null;
const message = job.payload.message || tx("settings.automations.systemTask", "System-managed automation");
const schedule = formatAutomationSchedule(job, locale, tx);
return ( return (
<article className="min-w-0 overflow-hidden rounded-[24px] border border-border/45 bg-card/82 shadow-[0_24px_70px_rgba(15,23,42,0.06)] backdrop-blur-xl"> <article className="min-w-0 overflow-hidden rounded-[24px] border border-border/45 bg-card/90 shadow-[0_24px_80px_rgba(15,23,42,0.065)] backdrop-blur-xl">
<div className="border-b border-border/35 px-4 py-4 sm:px-5"> <div className="border-b border-border/35 px-4 py-4 sm:px-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate text-[17px] font-medium leading-7 text-foreground"> <h3 className="min-w-0 truncate text-[18px] font-medium leading-7 text-foreground">
{job.name || job.id} {job.name || job.id}
</h3> </h3>
<StatusPill tone={status.tone}>{status.label}</StatusPill> <StatusPill tone={status.tone}>{status.label}</StatusPill>
@@ -3706,8 +3720,8 @@ function AutomationDetailPanel({
<StatusPill>{tx("settings.automations.oneShot", "One-time")}</StatusPill> <StatusPill>{tx("settings.automations.oneShot", "One-time")}</StatusPill>
) : null} ) : null}
</div> </div>
<p className="mt-1 max-w-[62rem] text-[13px] leading-6 text-muted-foreground"> <p className="mt-1 truncate text-[12.5px] leading-5 text-muted-foreground">
{job.payload.message || tx("settings.automations.systemTask", "System-managed automation")} {schedule} · {origin}
</p> </p>
</div> </div>
<AutomationActionGroup <AutomationActionGroup
@@ -3720,15 +3734,18 @@ function AutomationDetailPanel({
</div> </div>
</div> </div>
<div className="grid gap-4 p-4 2xl:grid-cols-[minmax(0,1fr)_14rem]"> <div className="grid min-w-0 xl:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-w-0 space-y-4"> <div className="min-w-0 space-y-4 p-4 sm:p-5">
<div className="grid gap-2 md:grid-cols-2"> <section className="rounded-[20px] border border-border/35 bg-background/60 px-4 py-3.5">
<AutomationDetail <div className="text-[11px] font-medium leading-none text-muted-foreground/75">
label={tx("settings.automations.labels.schedule", "Schedule")} {tx("settings.automations.fields.message", "Message")}
title={formatAutomationSchedule(job, locale, tx)} </div>
> <div className="mt-3 max-h-64 overflow-y-auto whitespace-pre-wrap break-words text-[13px] leading-6 text-foreground/85">
{formatAutomationSchedule(job, locale, tx)} {message}
</AutomationDetail> </div>
</section>
<div className="grid gap-3 md:grid-cols-3">
<AutomationDetail <AutomationDetail
label={tx("settings.automations.labels.next", "Next")} label={tx("settings.automations.labels.next", "Next")}
title={formatAutomationNextTitle(job, locale, tx)} title={formatAutomationNextTitle(job, locale, tx)}
@@ -3764,7 +3781,7 @@ function AutomationDetailPanel({
</div> </div>
{needsRecreation ? ( {needsRecreation ? (
<div className="mt-3 rounded-[14px] border border-amber-500/20 bg-amber-500/8 px-3 py-2 text-[12px] leading-5 text-amber-800 dark:text-amber-200"> <div className="rounded-[16px] border border-amber-500/20 bg-amber-500/8 px-3 py-2 text-[12px] leading-5 text-amber-800 dark:text-amber-200">
{tx( {tx(
"settings.automations.legacyWarning", "settings.automations.legacyWarning",
"This older automation is missing its target chat. Recreate it from the chat or channel where it should run.", "This older automation is missing its target chat. Recreate it from the chat or channel where it should run.",
@@ -3773,7 +3790,7 @@ function AutomationDetailPanel({
) : null} ) : null}
{job.state.last_error ? ( {job.state.last_error ? (
<div className="mt-3 rounded-[14px] bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive"> <div className="rounded-[16px] border border-destructive/20 bg-destructive/8 px-3 py-2 text-[12px] leading-5 text-destructive">
{job.state.last_error} {job.state.last_error}
</div> </div>
) : null} ) : null}
@@ -3781,28 +3798,38 @@ function AutomationDetailPanel({
<AutomationRunHistory history={history} locale={locale} tx={tx} /> <AutomationRunHistory history={history} locale={locale} tx={tx} />
</div> </div>
<aside className="rounded-[18px] bg-muted/32 p-3 text-[12px] text-muted-foreground"> <aside className="border-t border-border/35 bg-muted/20 p-4 text-[12px] text-muted-foreground sm:p-5 xl:border-l xl:border-t-0">
<div className="space-y-3"> <div className="space-y-4">
{created ? ( <AutomationDetail
<div> label={tx("settings.automations.labels.schedule", "Schedule")}
<div className="text-[11px] leading-none text-muted-foreground/75"> title={schedule}
{tx("settings.automations.labels.created", "Created")} >
{schedule}
</AutomationDetail>
<div className="rounded-[18px] bg-background/55 p-3">
<div className="space-y-3">
{created ? (
<div>
<div className="text-[11px] leading-none text-muted-foreground/75">
{tx("settings.automations.labels.created", "Created")}
</div>
<div className="mt-1.5 text-[12.5px] leading-5 text-foreground/80">{created}</div>
</div>
) : null}
{updated ? (
<div>
<div className="text-[11px] leading-none text-muted-foreground/75">
{tx("settings.automations.labels.updated", "Updated")}
</div>
<div className="mt-1.5 text-[12.5px] leading-5 text-foreground/80">{updated}</div>
</div>
) : null}
<div>
<div className="text-[11px] leading-none text-muted-foreground/75">ID</div>
<div className="mt-1.5 break-all font-mono text-[11.5px] leading-5 text-foreground/70">
{job.id}
</div>
</div> </div>
<div className="mt-1.5 text-[12.5px] leading-5 text-foreground/80">{created}</div>
</div>
) : null}
{updated ? (
<div>
<div className="text-[11px] leading-none text-muted-foreground/75">
{tx("settings.automations.labels.updated", "Updated")}
</div>
<div className="mt-1.5 text-[12.5px] leading-5 text-foreground/80">{updated}</div>
</div>
) : null}
<div>
<div className="text-[11px] leading-none text-muted-foreground/75">ID</div>
<div className="mt-1.5 break-all font-mono text-[11.5px] leading-5 text-foreground/70">
{job.id}
</div> </div>
</div> </div>
</div> </div>
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "Start a new chat in {{project}}", "newInProject": "Start a new chat in {{project}}",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished",
"updated": "New activity"
}, },
"pin": "Pin", "pin": "Pin",
"unpin": "Unpin", "unpin": "Unpin",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "Iniciar un chat nuevo en {{project}}", "newInProject": "Iniciar un chat nuevo en {{project}}",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished",
"updated": "New activity"
}, },
"pin": "Pin", "pin": "Pin",
"unpin": "Unpin", "unpin": "Unpin",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "Démarrer une nouvelle discussion dans {{project}}", "newInProject": "Démarrer une nouvelle discussion dans {{project}}",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished",
"updated": "New activity"
}, },
"pin": "Pin", "pin": "Pin",
"unpin": "Unpin", "unpin": "Unpin",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "Mulai obrolan baru di {{project}}", "newInProject": "Mulai obrolan baru di {{project}}",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished",
"updated": "New activity"
}, },
"pin": "Pin", "pin": "Pin",
"unpin": "Unpin", "unpin": "Unpin",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "「{{project}}」で新しいチャットを開始", "newInProject": "「{{project}}」で新しいチャットを開始",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished",
"updated": "New activity"
}, },
"pin": "Pin", "pin": "Pin",
"unpin": "Unpin", "unpin": "Unpin",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "{{project}}에서 새 채팅 시작", "newInProject": "{{project}}에서 새 채팅 시작",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished",
"updated": "New activity"
}, },
"pin": "Pin", "pin": "Pin",
"unpin": "Unpin", "unpin": "Unpin",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "Bắt đầu cuộc trò chuyện mới trong {{project}}", "newInProject": "Bắt đầu cuộc trò chuyện mới trong {{project}}",
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished" "complete": "Agent finished",
"updated": "New activity"
}, },
"pin": "Pin", "pin": "Pin",
"unpin": "Unpin", "unpin": "Unpin",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "在 {{project}} 中开始新对话", "newInProject": "在 {{project}} 中开始新对话",
"activity": { "activity": {
"running": "Agent 正在运行", "running": "Agent 正在运行",
"complete": "Agent 已完成" "complete": "Agent 已完成",
"updated": "有新内容"
}, },
"pin": "置顶", "pin": "置顶",
"unpin": "取消置顶", "unpin": "取消置顶",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "在 {{project}} 中開始新對話", "newInProject": "在 {{project}} 中開始新對話",
"activity": { "activity": {
"running": "Agent 正在執行", "running": "Agent 正在執行",
"complete": "Agent 已完成" "complete": "Agent 已完成",
"updated": "有新內容"
}, },
"pin": "置頂", "pin": "置頂",
"unpin": "取消置頂", "unpin": "取消置頂",
+55 -8
View File
@@ -13,6 +13,7 @@ const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn(); const updateUrlSpy = vi.fn();
const attachSpy = vi.fn(); const attachSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>(); const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
let mockSessions: ChatSummary[] = []; let mockSessions: ChatSummary[] = [];
const HERO_GREETING_PATTERN = const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/; /What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
@@ -194,7 +195,10 @@ vi.mock("@/lib/nanobot-client", () => {
onRuntimeModelUpdate = () => () => {}; onRuntimeModelUpdate = () => () => {};
onError = () => () => {}; onError = () => () => {};
onChat = () => () => {}; onChat = () => () => {};
onSessionUpdate = () => () => {}; onSessionUpdate = (handler: (chatId: string, scope?: string) => void) => {
sessionUpdateHandlers.add(handler);
return () => sessionUpdateHandlers.delete(handler);
};
onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => { onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
runStatusHandlers.add(handler); runStatusHandlers.add(handler);
return () => runStatusHandlers.delete(handler); return () => runStatusHandlers.delete(handler);
@@ -227,10 +231,12 @@ describe("App layout", () => {
toggleThemeSpy.mockReset(); toggleThemeSpy.mockReset();
attachSpy.mockReset(); attachSpy.mockReset();
runStatusHandlers.clear(); runStatusHandlers.clear();
sessionUpdateHandlers.clear();
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
setNavigatorPlatform("Linux x86_64"); setNavigatorPlatform("Linux x86_64");
localStorage.removeItem("nanobot-webui.sidebar"); localStorage.removeItem("nanobot-webui.sidebar");
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1"); localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({ vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok", token: "tok",
ws_path: "/", ws_path: "/",
@@ -1012,15 +1018,15 @@ describe("App layout", () => {
for (const handler of runStatusHandlers) handler("chat-a", null); for (const handler of runStatusHandlers) handler("chat-a", null);
}); });
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument(); expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument(); expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
await act(async () => { await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ })); fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
}); });
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument(); expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
}); });
it("does not show a completed dot later when the active session finishes", async () => { it("does not show an updated dot later when the active session finishes", async () => {
mockSessions = [ mockSessions = [
{ {
key: "websocket:chat-a", key: "websocket:chat-a",
@@ -1064,12 +1070,53 @@ describe("App layout", () => {
for (const handler of runStatusHandlers) handler("chat-a", null); for (const handler of runStatusHandlers) handler("chat-a", null);
}); });
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument(); expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument(); expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
await act(async () => { await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ })); fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
}); });
expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument(); expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
});
it("marks inactive sessions when a thread update arrives", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Open chat",
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Scheduled update target",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Open chat$/ }));
});
act(() => {
for (const handler of sessionUpdateHandlers) handler("chat-b", "thread");
});
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Scheduled update target$/ }));
});
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument();
}); });
it("restores sidebar run indicators after a page reload", async () => { it("restores sidebar run indicators after a page reload", async () => {
@@ -1093,7 +1140,7 @@ describe("App layout", () => {
}, },
]; ];
localStorage.setItem( localStorage.setItem(
"nanobot-webui.sidebar.completed-runs.v1", "nanobot-webui.sidebar.session-updates.v1",
JSON.stringify(["chat-b"]), JSON.stringify(["chat-b"]),
); );
@@ -1104,7 +1151,7 @@ describe("App layout", () => {
await waitFor(() => await waitFor(() =>
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(), expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(),
); );
expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument(); expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument();
expect(attachSpy).toHaveBeenCalledWith("chat-a"); expect(attachSpy).toHaveBeenCalledWith("chat-a");
}); });
+43 -5
View File
@@ -18,6 +18,44 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
} }
describe("ChatList", () => { describe("ChatList", () => {
it("orders chats by latest session activity by default", () => {
const sessions = [
session({
chatId: "older",
title: "Older chat",
updatedAt: "2026-05-21T10:00:00Z",
}),
session({
chatId: "newest",
title: "Newest chat",
updatedAt: "2026-05-21T12:00:00Z",
}),
session({
chatId: "middle",
title: "Middle chat",
updatedAt: "2026-05-21T11:00:00Z",
}),
];
render(
<ChatList
sessions={sessions}
activeKey={null}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const chatsSection = screen.getAllByRole("region")[0];
const text = chatsSection.textContent ?? "";
expect(text.indexOf("Newest chat")).toBeLessThan(text.indexOf("Middle chat"));
expect(text.indexOf("Middle chat")).toBeLessThan(text.indexOf("Older chat"));
});
it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => { it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => {
const sessions = [ const sessions = [
session({ session({
@@ -179,7 +217,7 @@ describe("ChatList", () => {
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos"); expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
}); });
it("hides the completed dot for the active chat", () => { it("hides the updated dot for the active chat", () => {
const sessions = [ const sessions = [
session({ session({
chatId: "active", chatId: "active",
@@ -200,13 +238,13 @@ describe("ChatList", () => {
onTogglePin={vi.fn()} onTogglePin={vi.fn()}
onRequestRename={vi.fn()} onRequestRename={vi.fn()}
onToggleArchive={vi.fn()} onToggleArchive={vi.fn()}
completedChatIds={["active", "done"]} updatedChatIds={["active", "done"]}
/>, />,
); );
const finished = screen.getAllByLabelText("Agent finished"); const updated = screen.getAllByLabelText("New activity");
expect(finished).toHaveLength(1); expect(updated).toHaveLength(1);
expect(finished[0].firstElementChild).toHaveClass("h-2", "w-2"); expect(updated[0].firstElementChild).toHaveClass("h-2", "w-2");
}); });
it("folds long default workspace chats and can show all", () => { it("folds long default workspace chats and can show all", () => {