fix(webui): polish automation layout and session updates
This commit is contained in:
@@ -846,6 +846,19 @@ class WebSocketChannel(BaseChannel):
|
||||
self.logger.exception("send failed{}", label)
|
||||
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:
|
||||
if msg.metadata.get("_runtime_model_updated"):
|
||||
await self.send_runtime_model_updated(
|
||||
@@ -896,6 +909,7 @@ class WebSocketChannel(BaseChannel):
|
||||
goal_state=gs_blob,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
await self.send_session_updated(msg.chat_id, scope="thread")
|
||||
return
|
||||
if msg.metadata.get("_session_updated"):
|
||||
if conns:
|
||||
@@ -1146,8 +1160,8 @@ class WebSocketChannel(BaseChannel):
|
||||
await self._safe_send_to(connection, raw, label=" goal_status ")
|
||||
|
||||
async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None:
|
||||
"""Notify clients that session metadata changed outside the main turn."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
"""Notify WebUI clients that a session row should refresh."""
|
||||
conns = self._all_subscribed_connections()
|
||||
if not conns:
|
||||
return
|
||||
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
|
||||
|
||||
@@ -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]:
|
||||
"""Receive until a specific websocket event appears."""
|
||||
for _ in range(10):
|
||||
|
||||
+34
-18
@@ -65,7 +65,8 @@ type BootState =
|
||||
};
|
||||
|
||||
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 SIDEBAR_WIDTH = 272;
|
||||
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();
|
||||
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) : [];
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
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 {
|
||||
window.localStorage.setItem(
|
||||
COMPLETED_RUNS_STORAGE_KEY,
|
||||
SESSION_UPDATES_STORAGE_KEY,
|
||||
JSON.stringify(Array.from(chatIds)),
|
||||
);
|
||||
} catch {
|
||||
@@ -573,7 +576,7 @@ function Shell({
|
||||
const [restartToast, setRestartToast] = useState<string | null>(null);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
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 skills = useSkills(token);
|
||||
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
|
||||
@@ -641,20 +644,20 @@ function Shell({
|
||||
}, [hostSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
writeCompletedRunChatIds(completedChatIds);
|
||||
}, [completedChatIds]);
|
||||
writeSessionUpdateChatIds(updatedChatIds);
|
||||
}, [updatedChatIds]);
|
||||
|
||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeKey) return null;
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
}, [sessions, activeKey]);
|
||||
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;
|
||||
useEffect(() => {
|
||||
activeChatIdRef.current = activeChatId;
|
||||
if (!activeChatId) return;
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
if (!current.has(activeChatId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(activeChatId);
|
||||
@@ -694,7 +697,7 @@ function Shell({
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const knownChatIds = new Set(sessions.map((session) => session.chatId));
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
const next = new Set(
|
||||
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
|
||||
);
|
||||
@@ -722,12 +725,25 @@ function Shell({
|
||||
}, [activeKey, loading, navigate, sessions]);
|
||||
|
||||
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;
|
||||
const next = normalizeWorkspaceScope(workspaceScope);
|
||||
setWorkspaceOverrides((current) => ({
|
||||
...current,
|
||||
[_chatId]: next,
|
||||
[chatId]: next,
|
||||
}));
|
||||
setDraftWorkspaceScope(next);
|
||||
setWorkspaceError(null);
|
||||
@@ -764,7 +780,7 @@ function Shell({
|
||||
runningChatIdsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
let changed = false;
|
||||
const next = new Set(current);
|
||||
for (const chatId of activeRunIds) {
|
||||
@@ -961,7 +977,7 @@ function Shell({
|
||||
const selected = sessions.find((session) => session.key === key);
|
||||
const selectedChatId = selected?.chatId;
|
||||
if (selectedChatId) {
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
if (!current.has(selectedChatId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(selectedChatId);
|
||||
@@ -1232,7 +1248,7 @@ function Shell({
|
||||
nextRunning.add(chatId);
|
||||
runningChatIdsRef.current = nextRunning;
|
||||
setRunningChatIds(nextRunning);
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
if (!current.has(chatId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(chatId);
|
||||
@@ -1246,7 +1262,7 @@ function Shell({
|
||||
nextRunning.delete(chatId);
|
||||
runningChatIdsRef.current = nextRunning;
|
||||
setRunningChatIds(nextRunning);
|
||||
setCompletedChatIds((current) => {
|
||||
setUpdatedChatIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (activeChatIdRef.current === chatId) {
|
||||
next.delete(chatId);
|
||||
@@ -1393,7 +1409,7 @@ function Shell({
|
||||
projectNameOverrides: sidebarState.project_name_overrides,
|
||||
collapsedGroups: sidebarState.collapsed_groups,
|
||||
runningChatIds: runningChatIdList,
|
||||
completedChatIds: completedChatIdList,
|
||||
updatedChatIds: updatedChatIdList,
|
||||
viewState: sidebarState.view,
|
||||
showArchived: sidebarState.view.show_archived,
|
||||
archivedCount: sidebarState.archived_keys.length,
|
||||
|
||||
@@ -60,7 +60,7 @@ interface ChatListProps {
|
||||
projectNameOverrides?: Record<string, string>;
|
||||
collapsedGroups?: Record<string, boolean>;
|
||||
runningChatIds?: string[];
|
||||
completedChatIds?: string[];
|
||||
updatedChatIds?: string[];
|
||||
density?: SidebarDensity;
|
||||
showPreviews?: boolean;
|
||||
showTimestamps?: boolean;
|
||||
@@ -89,7 +89,7 @@ export const ChatList = memo(function ChatList({
|
||||
projectNameOverrides = {},
|
||||
collapsedGroups = {},
|
||||
runningChatIds = [],
|
||||
completedChatIds = [],
|
||||
updatedChatIds = [],
|
||||
density = "comfortable",
|
||||
showPreviews = false,
|
||||
showTimestamps = false,
|
||||
@@ -175,7 +175,7 @@ export const ChatList = memo(function ChatList({
|
||||
const pinned = new Set(pinnedKeys);
|
||||
const archived = new Set(archivedKeys);
|
||||
const running = new Set(runningChatIds);
|
||||
const completed = new Set(completedChatIds);
|
||||
const updated = new Set(updatedChatIds);
|
||||
const compact = density === "compact";
|
||||
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
||||
|
||||
@@ -245,8 +245,8 @@ export const ChatList = memo(function ChatList({
|
||||
const projectMode = group.kind === "project";
|
||||
const activityState = running.has(s.chatId)
|
||||
? "running"
|
||||
: completed.has(s.chatId) && !active
|
||||
? "complete"
|
||||
: updated.has(s.chatId) && !active
|
||||
? "updated"
|
||||
: null;
|
||||
return (
|
||||
<li key={s.key} className="min-w-0">
|
||||
@@ -525,7 +525,7 @@ function ChatsFoldFooter({
|
||||
function SessionActivityIndicator({
|
||||
state,
|
||||
}: {
|
||||
state: "running" | "complete" | null;
|
||||
state: "running" | "updated" | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -542,15 +542,15 @@ function SessionActivityIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "complete") {
|
||||
const label = t("chat.activity.complete");
|
||||
if (state === "updated") {
|
||||
const label = t("chat.activity.updated");
|
||||
return (
|
||||
<span
|
||||
aria-label={label}
|
||||
title={label}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ interface SidebarProps {
|
||||
projectNameOverrides?: Record<string, string>;
|
||||
collapsedGroups?: Record<string, boolean>;
|
||||
runningChatIds?: string[];
|
||||
completedChatIds?: string[];
|
||||
updatedChatIds?: string[];
|
||||
viewState?: SidebarViewState;
|
||||
showArchived?: boolean;
|
||||
archivedCount?: number;
|
||||
@@ -210,7 +210,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
projectNameOverrides={props.projectNameOverrides}
|
||||
collapsedGroups={props.collapsedGroups}
|
||||
runningChatIds={props.runningChatIds}
|
||||
completedChatIds={props.completedChatIds}
|
||||
updatedChatIds={props.updatedChatIds}
|
||||
density={props.viewState?.density}
|
||||
showPreviews={props.viewState?.show_previews}
|
||||
showTimestamps={props.viewState?.show_timestamps}
|
||||
|
||||
@@ -1715,7 +1715,10 @@ export function SettingsView({
|
||||
<main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<div
|
||||
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]",
|
||||
)}
|
||||
>
|
||||
@@ -3470,10 +3473,10 @@ function AutomationsSettings({
|
||||
}, [filtered, selectedJobId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<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">
|
||||
<div className="space-y-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 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) => (
|
||||
<button
|
||||
key={option.value}
|
||||
@@ -3485,7 +3488,7 @@ function AutomationsSettings({
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
</button>
|
||||
@@ -3493,20 +3496,20 @@ function AutomationsSettings({
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
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>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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 />
|
||||
<span>{sortLabel[sort]}</span>
|
||||
@@ -3533,55 +3536,64 @@ function AutomationsSettings({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<SettingsSectionTitle>{tx("settings.automations.queue", "Queue")}</SettingsSectionTitle>
|
||||
{loading && !payload ? (
|
||||
<div className="flex h-40 items-center justify-center rounded-[22px] border border-border/45 bg-card/78 text-[13px] text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
||||
{tx("settings.automations.loading", "Loading automations...")}
|
||||
</div>
|
||||
) : filtered.length && selectedJob ? (
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(18rem,23rem)_minmax(0,1fr)]">
|
||||
<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">
|
||||
<div className="space-y-1" role="list" aria-label={tx("settings.automations.queue", "Queue")}>
|
||||
{filtered.map((job) => (
|
||||
<AutomationListItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
locale={locale}
|
||||
selected={job.id === selectedJob.id}
|
||||
onSelect={() => setSelectedJobId(job.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{loading && !payload ? (
|
||||
<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)]">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
||||
{tx("settings.automations.loading", "Loading automations...")}
|
||||
</div>
|
||||
) : filtered.length && selectedJob ? (
|
||||
<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)]">
|
||||
<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="flex items-center justify-between gap-3 border-b border-border/35 px-4 py-3">
|
||||
<h2 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
|
||||
{tx("settings.automations.queue", "Queue")}
|
||||
</h2>
|
||||
<span className="rounded-full bg-muted/70 px-2 py-0.5 text-[11px] text-muted-foreground tabular-nums">
|
||||
{filtered.length}
|
||||
</span>
|
||||
</div>
|
||||
<AutomationDetailPanel
|
||||
job={selectedJob}
|
||||
locale={locale}
|
||||
actionKey={actionKey}
|
||||
onAction={onAction}
|
||||
onRequestEdit={onRequestEdit}
|
||||
onRequestDelete={onRequestDelete}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[22px] border border-border/45 bg-card/78 px-5 py-10 text-center text-[13px] text-muted-foreground">
|
||||
<div>
|
||||
{jobs.length
|
||||
? tx("settings.automations.noMatches", "No automations match this view.")
|
||||
: tx("settings.automations.empty", "No automations yet.")}
|
||||
<div
|
||||
className="max-h-[26rem] space-y-1 overflow-y-auto p-2 lg:max-h-[calc(100dvh-17rem)]"
|
||||
role="list"
|
||||
aria-label={tx("settings.automations.queue", "Queue")}
|
||||
>
|
||||
{filtered.map((job) => (
|
||||
<AutomationListItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
locale={locale}
|
||||
selected={job.id === selectedJob.id}
|
||||
onSelect={() => setSelectedJobId(job.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!jobs.length ? (
|
||||
<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}
|
||||
</aside>
|
||||
<AutomationDetailPanel
|
||||
job={selectedJob}
|
||||
locale={locale}
|
||||
actionKey={actionKey}
|
||||
onAction={onAction}
|
||||
onRequestEdit={onRequestEdit}
|
||||
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>
|
||||
)}
|
||||
</section>
|
||||
{!jobs.length ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -3613,14 +3625,14 @@ function AutomationListItem({
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
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
|
||||
? "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="flex min-w-0 items-center gap-2">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
className={cn("h-2 w-2 shrink-0 rounded-full", automationStatusDotClass(job))}
|
||||
aria-hidden
|
||||
@@ -3629,10 +3641,10 @@ function AutomationListItem({
|
||||
{job.name || job.id}
|
||||
</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")}
|
||||
</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)}>
|
||||
{nextRun}
|
||||
</span>
|
||||
@@ -3691,14 +3703,16 @@ function AutomationDetailPanel({
|
||||
const needsRecreation = automationNeedsRecreation(job);
|
||||
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 message = job.payload.message || tx("settings.automations.systemTask", "System-managed automation");
|
||||
const schedule = formatAutomationSchedule(job, locale, tx);
|
||||
|
||||
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="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="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}
|
||||
</h3>
|
||||
<StatusPill tone={status.tone}>{status.label}</StatusPill>
|
||||
@@ -3706,8 +3720,8 @@ function AutomationDetailPanel({
|
||||
<StatusPill>{tx("settings.automations.oneShot", "One-time")}</StatusPill>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 max-w-[62rem] text-[13px] leading-6 text-muted-foreground">
|
||||
{job.payload.message || tx("settings.automations.systemTask", "System-managed automation")}
|
||||
<p className="mt-1 truncate text-[12.5px] leading-5 text-muted-foreground">
|
||||
{schedule} · {origin}
|
||||
</p>
|
||||
</div>
|
||||
<AutomationActionGroup
|
||||
@@ -3720,15 +3734,18 @@ function AutomationDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-4 2xl:grid-cols-[minmax(0,1fr)_14rem]">
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<AutomationDetail
|
||||
label={tx("settings.automations.labels.schedule", "Schedule")}
|
||||
title={formatAutomationSchedule(job, locale, tx)}
|
||||
>
|
||||
{formatAutomationSchedule(job, locale, tx)}
|
||||
</AutomationDetail>
|
||||
<div className="grid min-w-0 xl:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="min-w-0 space-y-4 p-4 sm:p-5">
|
||||
<section className="rounded-[20px] border border-border/35 bg-background/60 px-4 py-3.5">
|
||||
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
|
||||
{tx("settings.automations.fields.message", "Message")}
|
||||
</div>
|
||||
<div className="mt-3 max-h-64 overflow-y-auto whitespace-pre-wrap break-words text-[13px] leading-6 text-foreground/85">
|
||||
{message}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<AutomationDetail
|
||||
label={tx("settings.automations.labels.next", "Next")}
|
||||
title={formatAutomationNextTitle(job, locale, tx)}
|
||||
@@ -3764,7 +3781,7 @@ function AutomationDetailPanel({
|
||||
</div>
|
||||
|
||||
{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(
|
||||
"settings.automations.legacyWarning",
|
||||
"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}
|
||||
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -3781,28 +3798,38 @@ function AutomationDetailPanel({
|
||||
<AutomationRunHistory history={history} locale={locale} tx={tx} />
|
||||
</div>
|
||||
|
||||
<aside className="rounded-[18px] bg-muted/32 p-3 text-[12px] text-muted-foreground">
|
||||
<div className="space-y-3">
|
||||
{created ? (
|
||||
<div>
|
||||
<div className="text-[11px] leading-none text-muted-foreground/75">
|
||||
{tx("settings.automations.labels.created", "Created")}
|
||||
<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-4">
|
||||
<AutomationDetail
|
||||
label={tx("settings.automations.labels.schedule", "Schedule")}
|
||||
title={schedule}
|
||||
>
|
||||
{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 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>
|
||||
|
||||
@@ -671,7 +671,8 @@
|
||||
"newInProject": "Start a new chat in {{project}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
|
||||
@@ -671,7 +671,8 @@
|
||||
"newInProject": "Iniciar un chat nuevo en {{project}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
|
||||
@@ -671,7 +671,8 @@
|
||||
"newInProject": "Démarrer une nouvelle discussion dans {{project}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
|
||||
@@ -671,7 +671,8 @@
|
||||
"newInProject": "Mulai obrolan baru di {{project}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
|
||||
@@ -671,7 +671,8 @@
|
||||
"newInProject": "「{{project}}」で新しいチャットを開始",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
|
||||
@@ -671,7 +671,8 @@
|
||||
"newInProject": "{{project}}에서 새 채팅 시작",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
|
||||
@@ -671,7 +671,8 @@
|
||||
"newInProject": "Bắt đầu cuộc trò chuyện mới trong {{project}}",
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished"
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
|
||||
@@ -671,7 +671,8 @@
|
||||
"newInProject": "在 {{project}} 中开始新对话",
|
||||
"activity": {
|
||||
"running": "Agent 正在运行",
|
||||
"complete": "Agent 已完成"
|
||||
"complete": "Agent 已完成",
|
||||
"updated": "有新内容"
|
||||
},
|
||||
"pin": "置顶",
|
||||
"unpin": "取消置顶",
|
||||
|
||||
@@ -671,7 +671,8 @@
|
||||
"newInProject": "在 {{project}} 中開始新對話",
|
||||
"activity": {
|
||||
"running": "Agent 正在執行",
|
||||
"complete": "Agent 已完成"
|
||||
"complete": "Agent 已完成",
|
||||
"updated": "有新內容"
|
||||
},
|
||||
"pin": "置頂",
|
||||
"unpin": "取消置頂",
|
||||
|
||||
@@ -13,6 +13,7 @@ const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
const HERO_GREETING_PATTERN =
|
||||
/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 = () => () => {};
|
||||
onError = () => () => {};
|
||||
onChat = () => () => {};
|
||||
onSessionUpdate = () => () => {};
|
||||
onSessionUpdate = (handler: (chatId: string, scope?: string) => void) => {
|
||||
sessionUpdateHandlers.add(handler);
|
||||
return () => sessionUpdateHandlers.delete(handler);
|
||||
};
|
||||
onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
|
||||
runStatusHandlers.add(handler);
|
||||
return () => runStatusHandlers.delete(handler);
|
||||
@@ -227,10 +231,12 @@ describe("App layout", () => {
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
runStatusHandlers.clear();
|
||||
sessionUpdateHandlers.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
setNavigatorPlatform("Linux x86_64");
|
||||
localStorage.removeItem("nanobot-webui.sidebar");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
ws_path: "/",
|
||||
@@ -1012,15 +1018,15 @@ describe("App layout", () => {
|
||||
for (const handler of runStatusHandlers) handler("chat-a", null);
|
||||
});
|
||||
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 () => {
|
||||
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 = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
@@ -1064,12 +1070,53 @@ describe("App layout", () => {
|
||||
for (const handler of runStatusHandlers) handler("chat-a", null);
|
||||
});
|
||||
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 () => {
|
||||
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 () => {
|
||||
@@ -1093,7 +1140,7 @@ describe("App layout", () => {
|
||||
},
|
||||
];
|
||||
localStorage.setItem(
|
||||
"nanobot-webui.sidebar.completed-runs.v1",
|
||||
"nanobot-webui.sidebar.session-updates.v1",
|
||||
JSON.stringify(["chat-b"]),
|
||||
);
|
||||
|
||||
@@ -1104,7 +1151,7 @@ describe("App layout", () => {
|
||||
await waitFor(() =>
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,44 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const sessions = [
|
||||
session({
|
||||
@@ -179,7 +217,7 @@ describe("ChatList", () => {
|
||||
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 = [
|
||||
session({
|
||||
chatId: "active",
|
||||
@@ -200,13 +238,13 @@ describe("ChatList", () => {
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
completedChatIds={["active", "done"]}
|
||||
updatedChatIds={["active", "done"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const finished = screen.getAllByLabelText("Agent finished");
|
||||
expect(finished).toHaveLength(1);
|
||||
expect(finished[0].firstElementChild).toHaveClass("h-2", "w-2");
|
||||
const updated = screen.getAllByLabelText("New activity");
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(updated[0].firstElementChild).toHaveClass("h-2", "w-2");
|
||||
});
|
||||
|
||||
it("folds long default workspace chats and can show all", () => {
|
||||
|
||||
Reference in New Issue
Block a user