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)
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}
+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]:
"""Receive until a specific websocket event appears."""
for _ in range(10):
+34 -18
View File
@@ -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,
+9 -9
View File
@@ -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>
);
}
+2 -2
View File
@@ -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}
+123 -96
View File
@@ -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>
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "「{{project}}」で新しいチャットを開始",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "{{project}}에서 새 채팅 시작",
"activity": {
"running": "Agent running",
"complete": "Agent finished"
"complete": "Agent finished",
"updated": "New activity"
},
"pin": "Pin",
"unpin": "Unpin",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "在 {{project}} 中开始新对话",
"activity": {
"running": "Agent 正在运行",
"complete": "Agent 已完成"
"complete": "Agent 已完成",
"updated": "有新内容"
},
"pin": "置顶",
"unpin": "取消置顶",
+2 -1
View File
@@ -671,7 +671,8 @@
"newInProject": "在 {{project}} 中開始新對話",
"activity": {
"running": "Agent 正在執行",
"complete": "Agent 已完成"
"complete": "Agent 已完成",
"updated": "有新內容"
},
"pin": "置頂",
"unpin": "取消置頂",
+55 -8
View File
@@ -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");
});
+43 -5
View File
@@ -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", () => {