From cbb4c0bad2f58ee8fbba9d1f418f25744ca95d7a Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 15 Jun 2026 17:30:06 +0800 Subject: [PATCH] fix(webui): polish automation layout and session updates --- nanobot/channels/websocket.py | 18 +- tests/channels/test_websocket_channel.py | 40 ++++ webui/src/App.tsx | 52 +++-- webui/src/components/ChatList.tsx | 18 +- webui/src/components/Sidebar.tsx | 4 +- .../src/components/settings/SettingsView.tsx | 219 ++++++++++-------- webui/src/i18n/locales/en/common.json | 3 +- webui/src/i18n/locales/es/common.json | 3 +- webui/src/i18n/locales/fr/common.json | 3 +- webui/src/i18n/locales/id/common.json | 3 +- webui/src/i18n/locales/ja/common.json | 3 +- webui/src/i18n/locales/ko/common.json | 3 +- webui/src/i18n/locales/vi/common.json | 3 +- webui/src/i18n/locales/zh-CN/common.json | 3 +- webui/src/i18n/locales/zh-TW/common.json | 3 +- webui/src/tests/app-layout.test.tsx | 63 ++++- webui/src/tests/chat-list.test.tsx | 48 +++- 17 files changed, 340 insertions(+), 149 deletions(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 3c18d8e9..1a048db8 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -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} diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index b695665c..777c30d7 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -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): diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 493f59da..fae72c8c 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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 { +function readSessionUpdateChatIds(): Set { 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 { } } -function writeCompletedRunChatIds(chatIds: Set): void { +function writeSessionUpdateChatIds(chatIds: Set): 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(null); const [isRestarting, setIsRestarting] = useState(false); const [runningChatIds, setRunningChatIds] = useState>(() => new Set()); - const [completedChatIds, setCompletedChatIds] = useState>(readCompletedRunChatIds); + const [updatedChatIds, setUpdatedChatIds] = useState>(readSessionUpdateChatIds); const [workspaces, setWorkspaces] = useState(null); const skills = useSkills(token); const [settingsSnapshot, setSettingsSnapshot] = useState(null); @@ -641,20 +644,20 @@ function Shell({ }, [hostSidebarOpen]); useEffect(() => { - writeCompletedRunChatIds(completedChatIds); - }, [completedChatIds]); + writeSessionUpdateChatIds(updatedChatIds); + }, [updatedChatIds]); const activeSession = useMemo(() => { 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, diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index ccc44a45..889e1a10 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -60,7 +60,7 @@ interface ChatListProps { projectNameOverrides?: Record; collapsedGroups?: Record; 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 (
  • @@ -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 ( - + ); } diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index b86ee57b..48725dce 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -51,7 +51,7 @@ interface SidebarProps { projectNameOverrides?: Record; collapsedGroups?: Record; 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} diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 57f4fc4b..04b8e5aa 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -1715,7 +1715,10 @@ export function SettingsView({
    @@ -3470,10 +3473,10 @@ function AutomationsSettings({ }, [filtered, selectedJobId]); return ( -
    -
    +
    +
    -
    +
    {summaryOptions.map((option) => ( @@ -3493,20 +3496,20 @@ function AutomationsSettings({
    -
    +
    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" />
    ) : null} -
    - {tx("settings.automations.queue", "Queue")} - {loading && !payload ? ( -
    - - {tx("settings.automations.loading", "Loading automations...")} -
    - ) : filtered.length && selectedJob ? ( -
    -
    -
    - {filtered.map((job) => ( - setSelectedJobId(job.id)} - /> - ))} -
    + {loading && !payload ? ( +
    + + {tx("settings.automations.loading", "Loading automations...")} +
    + ) : filtered.length && selectedJob ? ( +
    +
    - ) : ( -
    -
    - {jobs.length - ? tx("settings.automations.noMatches", "No automations match this view.") - : tx("settings.automations.empty", "No automations yet.")} +
    + {filtered.map((job) => ( + setSelectedJobId(job.id)} + /> + ))}
    - {!jobs.length ? ( -
    - {tx( - "settings.automations.emptyHint", - "Create one from the chat or channel where it should run so nanobot keeps the right context.", - )} -
    - ) : null} + + +
    + ) : ( +
    +
    + {jobs.length + ? tx("settings.automations.noMatches", "No automations match this view.") + : tx("settings.automations.empty", "No automations yet.")}
    - )} -
    + {!jobs.length ? ( +
    + {tx( + "settings.automations.emptyHint", + "Create one from the chat or channel where it should run so nanobot keeps the right context.", + )} +
    + ) : null} +
    + )}
    ); } @@ -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", )} > - + - + {job.payload.message || tx("settings.automations.systemTask", "System-managed automation")} - + {nextRun} @@ -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 ( -
    +
    -
    +
    -

    +

    {job.name || job.id}

    {status.label} @@ -3706,8 +3720,8 @@ function AutomationDetailPanel({ {tx("settings.automations.oneShot", "One-time")} ) : null}
    -

    - {job.payload.message || tx("settings.automations.systemTask", "System-managed automation")} +

    + {schedule} · {origin}

    -
    -
    -
    - - {formatAutomationSchedule(job, locale, tx)} - +
    +
    +
    +
    + {tx("settings.automations.fields.message", "Message")} +
    +
    + {message} +
    +
    + +
    {needsRecreation ? ( -
    +
    {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 ? ( -
    +
    {job.state.last_error}
    ) : null} @@ -3781,28 +3798,38 @@ function AutomationDetailPanel({
    -