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)
|
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}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -671,7 +671,8 @@
|
|||||||
"newInProject": "在 {{project}} 中开始新对话",
|
"newInProject": "在 {{project}} 中开始新对话",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent 正在运行",
|
"running": "Agent 正在运行",
|
||||||
"complete": "Agent 已完成"
|
"complete": "Agent 已完成",
|
||||||
|
"updated": "有新内容"
|
||||||
},
|
},
|
||||||
"pin": "置顶",
|
"pin": "置顶",
|
||||||
"unpin": "取消置顶",
|
"unpin": "取消置顶",
|
||||||
|
|||||||
@@ -671,7 +671,8 @@
|
|||||||
"newInProject": "在 {{project}} 中開始新對話",
|
"newInProject": "在 {{project}} 中開始新對話",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent 正在執行",
|
"running": "Agent 正在執行",
|
||||||
"complete": "Agent 已完成"
|
"complete": "Agent 已完成",
|
||||||
|
"updated": "有新內容"
|
||||||
},
|
},
|
||||||
"pin": "置頂",
|
"pin": "置頂",
|
||||||
"unpin": "取消置頂",
|
"unpin": "取消置頂",
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user