@@ -40,8 +50,35 @@ export function DeleteConfirm({
{t("deleteConfirm.title", { title })}
- {t("deleteConfirm.description")}
+ {hasAutomations
+ ? t("deleteConfirm.automationsDescription")
+ : t("deleteConfirm.description")}
+ {hasAutomations ? (
+
+ {visibleAutomations.map((job) => (
+
+
+ {job.name || job.id}
+
+
+
+ {formatAutomationSchedule(job, t, locale)}
+
+ ·
+ {formatAutomationNextRun(job, t, locale)}
+
+
+ ))}
+ {hiddenCount > 0 ? (
+
+ {t("deleteConfirm.moreAutomations", {
+ count: hiddenCount,
+ })}
+
+ ) : null}
+
+ ) : null}
- {t("deleteConfirm.confirm")}
+ {hasAutomations
+ ? t("deleteConfirm.confirmWithAutomations")
+ : t("deleteConfirm.confirm")}
);
}
+
+function formatAutomationSchedule(
+ job: SessionAutomationJob,
+ t: TFunction,
+ locale: string,
+): string {
+ if (job.schedule.kind === "at" && job.schedule.at_ms) {
+ return t("deleteConfirm.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) });
+ }
+ if (job.schedule.kind === "every" && job.schedule.every_ms) {
+ return t("deleteConfirm.schedule.every", {
+ duration: formatDuration(job.schedule.every_ms, locale),
+ });
+ }
+ if (job.schedule.kind === "cron" && job.schedule.expr) {
+ return job.schedule.tz
+ ? t("deleteConfirm.schedule.cronWithTz", {
+ expr: job.schedule.expr,
+ tz: job.schedule.tz,
+ })
+ : t("deleteConfirm.schedule.cron", { expr: job.schedule.expr });
+ }
+ return t("deleteConfirm.schedule.unknown");
+}
+
+function formatAutomationNextRun(
+ job: SessionAutomationJob,
+ t: TFunction,
+ locale: string,
+): string {
+ if (!job.enabled) return t("deleteConfirm.next.disabled");
+ const next = job.state.next_run_at_ms;
+ if (!next) return t("deleteConfirm.next.none");
+ return t("deleteConfirm.next.label", { time: fmtDateTime(next, locale) });
+}
+
+function formatDuration(ms: number, locale: string): string {
+ const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
+ ["day", 86_400_000],
+ ["hour", 3_600_000],
+ ["minute", 60_000],
+ ["second", 1000],
+ ];
+ for (const [unit, size] of units) {
+ if (ms >= size && ms % size === 0) {
+ return new Intl.NumberFormat(locale, {
+ style: "unit",
+ unit,
+ unitDisplay: "long",
+ maximumFractionDigits: 0,
+ }).format(ms / size);
+ }
+ }
+ return new Intl.NumberFormat(locale, {
+ style: "unit",
+ unit: "minute",
+ unitDisplay: "long",
+ maximumFractionDigits: 1,
+ }).format(ms / 60_000);
+}
diff --git a/webui/src/components/thread/SessionInfoPopover.tsx b/webui/src/components/thread/SessionInfoPopover.tsx
index 31df132bb..d69755dc2 100644
--- a/webui/src/components/thread/SessionInfoPopover.tsx
+++ b/webui/src/components/thread/SessionInfoPopover.tsx
@@ -176,6 +176,9 @@ function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
if (!job.enabled) {
return { label: t("thread.sessionInfo.next.disabled"), title: "" };
}
+ if (job.state.pending) {
+ return { label: t("thread.sessionInfo.next.pending"), title: "" };
+ }
const next = job.state.next_run_at_ms;
if (!next) {
return { label: t("thread.sessionInfo.next.none"), title: "" };
diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts
index e53377bab..bac44ad00 100644
--- a/webui/src/hooks/useNanobotStream.ts
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -8,6 +8,7 @@ import {
normalizeToolProgressEvents,
toolTraceLinesFromEvents,
} from "@/lib/tool-traces";
+import { hasPendingAgentActivity } from "@/lib/activity-timeline";
import type { StreamError } from "@/lib/nanobot-client";
import type {
InboundEvent,
@@ -450,12 +451,8 @@ export function useNanobotStream(
} {
const { client } = useClient();
const [messages, setMessages] = useState
(initialMessages);
- /** If the last loaded message is a trace row (e.g. "Using 2 tools"),
- * the model was still processing when the page loaded — keep the
- * loading spinner alive so the user sees the model is active. */
- const initialStreaming = initialMessages.length > 0
- ? initialMessages[initialMessages.length - 1].kind === "trace"
- : false;
+ /** If history ends in unfinished agent activity, keep the loading spinner alive. */
+ const initialStreaming = hasPendingAgentActivity(initialMessages);
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
const [runStartedAt, setRunStartedAt] = useState(null);
@@ -694,9 +691,7 @@ export function useNanobotStream(
useEffect(() => {
setMessages(initialMessages);
setIsStreaming(
- (initialMessages.length > 0
- ? initialMessages[initialMessages.length - 1].kind === "trace"
- : false) || hasPendingToolCalls,
+ hasPendingAgentActivity(initialMessages) || hasPendingToolCalls,
);
setStreamError(null);
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts
index ab2aed727..f1faea403 100644
--- a/webui/src/hooks/useSessions.ts
+++ b/webui/src/hooks/useSessions.ts
@@ -5,15 +5,24 @@ import i18n from "@/i18n";
import {
ApiError,
deleteSession as apiDeleteSession,
+ fetchSessionAutomations,
fetchWebuiThread,
listSessions,
} from "@/lib/api";
+import { hasPendingAgentActivity } from "@/lib/activity-timeline";
import { deriveTitle } from "@/lib/format";
-import type { ChatSummary, UIMessage, WorkspaceScopePayload } from "@/lib/types";
+import type {
+ ChatSummary,
+ SessionAutomationJob,
+ SessionDeleteResult,
+ UIMessage,
+ WorkspaceScopePayload,
+} from "@/lib/types";
const EMPTY_MESSAGES: UIMessage[] = [];
const INITIAL_HISTORY_PAGE_LIMIT = 160;
const OLDER_HISTORY_PAGE_LIMIT = 120;
+const CHAT_CREATE_TIMEOUT_MS = 60_000;
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
return messages.map((m, idx) => ({
@@ -23,6 +32,16 @@ function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
}));
}
+function hasPendingToolCallsFromThread(
+ body: Awaited>,
+ messages: UIMessage[],
+): boolean {
+ if (typeof body?.has_pending_tool_calls === "boolean") {
+ return body.has_pending_tool_calls;
+ }
+ return hasPendingAgentActivity(messages);
+}
+
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
export function useSessions(): {
sessions: ChatSummary[];
@@ -31,7 +50,11 @@ export function useSessions(): {
refresh: () => Promise;
createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise;
forkChat: (sourceChatId: string, beforeUserIndex: number, title?: string) => Promise;
- deleteChat: (key: string) => Promise;
+ deleteChat: (
+ key: string,
+ options?: { deleteAutomations?: boolean },
+ ) => Promise;
+ getSessionAutomations: (key: string) => Promise;
} {
const { client, token } = useClient();
const [sessions, setSessions] = useState([]);
@@ -78,7 +101,7 @@ export function useSessions(): {
}, [client, refresh]);
const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise => {
- const chatId = await client.newChat(5_000, workspaceScope);
+ const chatId = await client.newChat(CHAT_CREATE_TIMEOUT_MS, workspaceScope);
const key = `websocket:${chatId}`;
optimisticKeysRef.current.add(key);
// Optimistic insert; a subsequent refresh will replace it with the
@@ -104,7 +127,12 @@ export function useSessions(): {
beforeUserIndex: number,
title?: string,
): Promise => {
- const chatId = await client.forkChat(sourceChatId, beforeUserIndex, title);
+ const chatId = await client.forkChat(
+ sourceChatId,
+ beforeUserIndex,
+ title,
+ CHAT_CREATE_TIMEOUT_MS,
+ );
const key = `websocket:${chatId}`;
optimisticKeysRef.current.add(key);
setSessions((prev) => [
@@ -124,15 +152,31 @@ export function useSessions(): {
}, [client]);
const deleteChat = useCallback(
- async (key: string) => {
- await apiDeleteSession(tokenRef.current, key);
+ async (key: string, options?: { deleteAutomations?: boolean }) => {
+ const result = await apiDeleteSession(tokenRef.current, key, options);
+ if (!result.deleted) return result;
optimisticKeysRef.current.delete(key);
setSessions((prev) => prev.filter((s) => s.key !== key));
+ return result;
},
[],
);
- return { sessions, loading, error, refresh, createChat, forkChat, deleteChat };
+ const getSessionAutomations = useCallback(async (key: string) => {
+ const result = await fetchSessionAutomations(tokenRef.current, key);
+ return result.jobs;
+ }, []);
+
+ return {
+ sessions,
+ loading,
+ error,
+ refresh,
+ createChat,
+ forkChat,
+ deleteChat,
+ getSessionAutomations,
+ };
}
/** Lazy-load a session's on-disk messages the first time the UI displays it. */
@@ -241,8 +285,7 @@ export function useSessionHistory(key: string | null): {
return;
}
const ui = persistedMessagesToUi(body.messages);
- const last = ui[ui.length - 1];
- const hasPending = last?.kind === "trace";
+ const hasPending = hasPendingToolCallsFromThread(body, ui);
const forkBoundary = typeof body.fork_boundary_message_count === "number"
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
: null;
@@ -326,13 +369,12 @@ export function useSessionHistory(key: string | null): {
? null
: prev.forkBoundaryMessageCount + older.length;
const nextMessages = [...older, ...prev.messages];
- const last = nextMessages[nextMessages.length - 1];
return {
...prev,
messages: nextMessages,
loadingOlder: false,
error: null,
- hasPendingToolCalls: last?.kind === "trace",
+ hasPendingToolCalls: hasPendingAgentActivity(nextMessages),
forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary,
beforeCursor: body.page?.before_cursor ?? null,
hasMoreBefore: body.page?.has_more_before === true,
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index 605e66f01..8020d3485 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -551,7 +551,22 @@
"title": "Delete this chat?",
"description": "This action cannot be undone.",
"cancel": "Cancel",
- "confirm": "Delete"
+ "confirm": "Delete",
+ "automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
+ "moreAutomations": "+ {{count}} more",
+ "confirmWithAutomations": "Delete chat and automations",
+ "schedule": {
+ "at": "{{time}}",
+ "every": "Every {{duration}}",
+ "cron": "Cron {{expr}}",
+ "cronWithTz": "Cron {{expr}} · {{tz}}",
+ "unknown": "Custom schedule"
+ },
+ "next": {
+ "label": "Next: {{time}}",
+ "disabled": "Paused",
+ "none": "No next run"
+ }
},
"connection": {
"idle": "Idle",
@@ -648,6 +663,7 @@
},
"next": {
"label": "{{time}}",
+ "pending": "Runs shortly",
"disabled": "Paused",
"none": "No next run"
}
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index 165940215..46a6c3ab0 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -551,7 +551,22 @@
"title": "¿Eliminar este chat?",
"description": "Esta acción no se puede deshacer.",
"cancel": "Cancelar",
- "confirm": "Eliminar"
+ "confirm": "Eliminar",
+ "automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
+ "moreAutomations": "+ {{count}} más",
+ "confirmWithAutomations": "Eliminar chat y automatizaciones",
+ "schedule": {
+ "at": "{{time}}",
+ "every": "Cada {{duration}}",
+ "cron": "Cron {{expr}}",
+ "cronWithTz": "Cron {{expr}} · {{tz}}",
+ "unknown": "Programación personalizada"
+ },
+ "next": {
+ "label": "Siguiente: {{time}}",
+ "disabled": "Pausada",
+ "none": "Sin próxima ejecución"
+ }
},
"connection": {
"idle": "Inactivo",
@@ -648,6 +663,7 @@
},
"next": {
"label": "Siguiente {{time}}",
+ "pending": "Se ejecutará pronto",
"disabled": "En pausa",
"none": "Sin próxima ejecución"
}
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index 2f87ec4b9..61182951c 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -551,7 +551,22 @@
"title": "Supprimer cette discussion ?",
"description": "Cette action est irréversible.",
"cancel": "Annuler",
- "confirm": "Supprimer"
+ "confirm": "Supprimer",
+ "automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
+ "moreAutomations": "+ {{count}} autres",
+ "confirmWithAutomations": "Supprimer la discussion et les automatisations",
+ "schedule": {
+ "at": "{{time}}",
+ "every": "Tous les {{duration}}",
+ "cron": "Cron {{expr}}",
+ "cronWithTz": "Cron {{expr}} · {{tz}}",
+ "unknown": "Planification personnalisée"
+ },
+ "next": {
+ "label": "Prochaine exécution : {{time}}",
+ "disabled": "En pause",
+ "none": "Aucune prochaine exécution"
+ }
},
"connection": {
"idle": "Inactif",
@@ -648,6 +663,7 @@
},
"next": {
"label": "Prochaine {{time}}",
+ "pending": "Exécution imminente",
"disabled": "En pause",
"none": "Aucune prochaine exécution"
}
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index 72770eaea..12b8f1af3 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -551,7 +551,22 @@
"title": "Hapus obrolan ini?",
"description": "Tindakan ini tidak dapat dibatalkan.",
"cancel": "Batal",
- "confirm": "Hapus"
+ "confirm": "Hapus",
+ "automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
+ "moreAutomations": "+ {{count}} lagi",
+ "confirmWithAutomations": "Hapus obrolan dan automasi",
+ "schedule": {
+ "at": "{{time}}",
+ "every": "Setiap {{duration}}",
+ "cron": "Cron {{expr}}",
+ "cronWithTz": "Cron {{expr}} · {{tz}}",
+ "unknown": "Jadwal khusus"
+ },
+ "next": {
+ "label": "Berikutnya: {{time}}",
+ "disabled": "Dijeda",
+ "none": "Tidak ada jadwal berikutnya"
+ }
},
"connection": {
"idle": "Idle",
@@ -648,6 +663,7 @@
},
"next": {
"label": "Berikutnya {{time}}",
+ "pending": "Segera berjalan",
"disabled": "Dijeda",
"none": "Tidak ada jadwal berikutnya"
}
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index fef2bf225..1c2a19628 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -551,7 +551,22 @@
"title": "このチャットを削除しますか?",
"description": "この操作は元に戻せません。",
"cancel": "キャンセル",
- "confirm": "削除"
+ "confirm": "削除",
+ "automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
+ "moreAutomations": "他 {{count}} 件",
+ "confirmWithAutomations": "チャットと自動タスクを削除",
+ "schedule": {
+ "at": "{{time}}",
+ "every": "{{duration}} ごと",
+ "cron": "Cron {{expr}}",
+ "cronWithTz": "Cron {{expr}} · {{tz}}",
+ "unknown": "カスタムスケジュール"
+ },
+ "next": {
+ "label": "次回: {{time}}",
+ "disabled": "一時停止中",
+ "none": "次回実行なし"
+ }
},
"connection": {
"idle": "待機中",
@@ -648,6 +663,7 @@
},
"next": {
"label": "次回 {{time}}",
+ "pending": "まもなく実行",
"disabled": "一時停止",
"none": "次回実行なし"
}
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index dcc0b2172..e59c78cb8 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -551,7 +551,22 @@
"title": "이 채팅을 삭제할까요?",
"description": "이 작업은 되돌릴 수 없습니다.",
"cancel": "취소",
- "confirm": "삭제"
+ "confirm": "삭제",
+ "automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
+ "moreAutomations": "+ {{count}}개 더",
+ "confirmWithAutomations": "채팅과 자동화 삭제",
+ "schedule": {
+ "at": "{{time}}",
+ "every": "{{duration}}마다",
+ "cron": "Cron {{expr}}",
+ "cronWithTz": "Cron {{expr}} · {{tz}}",
+ "unknown": "사용자 지정 일정"
+ },
+ "next": {
+ "label": "다음: {{time}}",
+ "disabled": "일시 중지됨",
+ "none": "다음 실행 없음"
+ }
},
"connection": {
"idle": "대기 중",
@@ -648,6 +663,7 @@
},
"next": {
"label": "다음 {{time}}",
+ "pending": "곧 실행됨",
"disabled": "일시 중지됨",
"none": "다음 실행 없음"
}
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index ee2e8463c..22faba4e6 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -551,7 +551,22 @@
"title": "Xóa cuộc trò chuyện này?",
"description": "Không thể hoàn tác thao tác này.",
"cancel": "Hủy",
- "confirm": "Xóa"
+ "confirm": "Xóa",
+ "automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
+ "moreAutomations": "+ {{count}} mục nữa",
+ "confirmWithAutomations": "Xóa trò chuyện và tự động hóa",
+ "schedule": {
+ "at": "{{time}}",
+ "every": "Mỗi {{duration}}",
+ "cron": "Cron {{expr}}",
+ "cronWithTz": "Cron {{expr}} · {{tz}}",
+ "unknown": "Lịch tùy chỉnh"
+ },
+ "next": {
+ "label": "Tiếp theo: {{time}}",
+ "disabled": "Đã tạm dừng",
+ "none": "Không có lần chạy tiếp theo"
+ }
},
"connection": {
"idle": "Rảnh",
@@ -648,6 +663,7 @@
},
"next": {
"label": "Tiếp theo {{time}}",
+ "pending": "Sắp chạy",
"disabled": "Đã tạm dừng",
"none": "Không có lần chạy tiếp theo"
}
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index 29aa44baa..3ec1c3ac4 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -551,7 +551,22 @@
"title": "删除这个对话?",
"description": "此操作无法撤销。",
"cancel": "取消",
- "confirm": "删除"
+ "confirm": "删除",
+ "automationsDescription": "这个对话有关联的自动任务。删除对话也会删除这些自动任务。",
+ "moreAutomations": "另有 {{count}} 个",
+ "confirmWithAutomations": "删除对话和自动任务",
+ "schedule": {
+ "at": "{{time}}",
+ "every": "每 {{duration}}",
+ "cron": "Cron {{expr}}",
+ "cronWithTz": "Cron {{expr}} · {{tz}}",
+ "unknown": "自定义计划"
+ },
+ "next": {
+ "label": "下次:{{time}}",
+ "disabled": "已暂停",
+ "none": "没有下次运行"
+ }
},
"connection": {
"idle": "空闲",
@@ -648,6 +663,7 @@
},
"next": {
"label": "下次 {{time}}",
+ "pending": "即将执行",
"disabled": "已暂停",
"none": "没有下次运行"
}
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index b5b382fac..70f2a6242 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -551,7 +551,22 @@
"title": "刪除這個對話?",
"description": "此操作無法復原。",
"cancel": "取消",
- "confirm": "刪除"
+ "confirm": "刪除",
+ "automationsDescription": "這個對話有關聯的自動任務。刪除對話也會刪除這些自動任務。",
+ "moreAutomations": "另有 {{count}} 個",
+ "confirmWithAutomations": "刪除對話和自動任務",
+ "schedule": {
+ "at": "{{time}}",
+ "every": "每 {{duration}}",
+ "cron": "Cron {{expr}}",
+ "cronWithTz": "Cron {{expr}} · {{tz}}",
+ "unknown": "自訂計畫"
+ },
+ "next": {
+ "label": "下次:{{time}}",
+ "disabled": "已暫停",
+ "none": "沒有下次執行"
+ }
},
"connection": {
"idle": "閒置",
@@ -648,6 +663,7 @@
},
"next": {
"label": "下次 {{time}}",
+ "pending": "即將執行",
"disabled": "已暫停",
"none": "沒有下次執行"
}
diff --git a/webui/src/lib/activity-timeline.ts b/webui/src/lib/activity-timeline.ts
index 9f2b049b6..f1f912cef 100644
--- a/webui/src/lib/activity-timeline.ts
+++ b/webui/src/lib/activity-timeline.ts
@@ -52,6 +52,38 @@ export function isAgentActivityMember(message: UIMessage): boolean {
return isReasoningOnlyAssistant(message) || message.kind === "trace";
}
+export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
+ if (messages.length === 0) return false;
+ const last = messages[messages.length - 1];
+ if (!isAgentActivityMember(last)) return false;
+
+ let trailingStart = messages.length - 1;
+ while (
+ trailingStart > 0
+ && isAgentActivityMember(messages[trailingStart - 1])
+ ) {
+ trailingStart -= 1;
+ }
+
+ const trailing = messages.slice(trailingStart);
+ if (trailing.some((message) => message.isStreaming || message.reasoningStreaming)) {
+ return true;
+ }
+
+ const previous = messages[trailingStart - 1];
+ if (!previous || previous.role !== "assistant" || isAgentActivityMember(previous)) {
+ return true;
+ }
+
+ const trailingTurnIds = new Set(
+ trailing
+ .map((message) => message.turnId)
+ .filter((turnId): turnId is string => typeof turnId === "string" && turnId.length > 0),
+ );
+ if (!previous.turnId) return trailingTurnIds.size > 0;
+ return trailingTurnIds.size > 0 && !trailingTurnIds.has(previous.turnId);
+}
+
export function normalizeActivityTimeline(
messages: UIMessage[],
options: NormalizeActivityTimelineOptions = {},
diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts
index 9b8aa2551..5a6fee9cd 100644
--- a/webui/src/lib/api.ts
+++ b/webui/src/lib/api.ts
@@ -9,6 +9,7 @@ import type {
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
ProviderSettingsUpdate,
+ SessionDeleteResult,
SessionAutomationsPayload,
SettingsPayload,
SettingsUpdate,
@@ -211,13 +212,18 @@ export async function fetchSkillDetail(
export async function deleteSession(
token: string,
key: string,
+ optionsOrBase?: { deleteAutomations?: boolean } | string,
base: string = "",
-): Promise {
- const body = await request<{ deleted: boolean }>(
- `${base}/api/sessions/${encodeURIComponent(key)}/delete`,
+): Promise {
+ const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase;
+ const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base;
+ const query = new URLSearchParams();
+ if (options?.deleteAutomations) query.set("delete_automations", "true");
+ const suffix = query.toString() ? `?${query}` : "";
+ return request(
+ `${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`,
token,
);
- return body.deleted;
}
export async function fetchSettings(
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index 3365b83f4..aa7461f3e 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -113,11 +113,18 @@ export interface SessionAutomationJob {
state: {
next_run_at_ms?: number | null;
last_status?: "ok" | "error" | "skipped" | string | null;
+ pending?: boolean;
};
}
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
+export interface SessionDeleteResult {
+ deleted: boolean;
+ blocked_by_automations?: boolean;
+ automations?: SessionAutomationJob[];
+}
+
export interface SkillSummary {
name: string;
description: string;
@@ -875,6 +882,7 @@ export interface WebuiThreadPersistedPayload {
savedAt?: string;
messages: UIMessage[];
fork_boundary_message_count?: number;
+ has_pending_tool_calls?: boolean;
page?: WebuiThreadPagePayload;
workspace_scope?: WorkspaceScopePayload;
}
diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts
index f4c5972f2..71a5b8648 100644
--- a/webui/src/tests/api.test.ts
+++ b/webui/src/tests/api.test.ts
@@ -131,6 +131,17 @@ describe("webui API helpers", () => {
);
});
+ it("passes the automation cascade flag when deleting a session", async () => {
+ await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true });
+
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/sessions/websocket%3Achat-1/delete?delete_automations=true",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer tok" },
+ }),
+ );
+ });
+
it("serializes settings updates as a narrow query string", async () => {
await updateSettings("tok", {
modelPreset: "default",
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index 3fa3e8124..d83d09965 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -1,12 +1,14 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { ChatSummary } from "@/lib/types";
+import i18n from "@/i18n";
+import type { ChatSummary, SessionAutomationJob } from "@/lib/types";
const connectSpy = vi.fn();
const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
+const getSessionAutomationsSpy = vi.fn<(key: string) => Promise>();
const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
@@ -146,9 +148,12 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
refresh: refreshSpy,
createChat: createChatSpy,
forkChat: async () => "fork-chat",
- deleteChat: async (key: string) => {
- await deleteChatSpy(key);
+ getSessionAutomations: getSessionAutomationsSpy,
+ deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
+ if (options === undefined) await deleteChatSpy(key);
+ else await deleteChatSpy(key, options);
setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key));
+ return { deleted: true };
},
};
},
@@ -210,13 +215,15 @@ import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import App from "@/App";
describe("App layout", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ await i18n.changeLanguage("en");
mockSessions = [];
connectSpy.mockClear();
updateUrlSpy.mockClear();
refreshSpy.mockReset();
createChatSpy.mockClear();
deleteChatSpy.mockReset();
+ getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset();
attachSpy.mockReset();
runStatusHandlers.clear();
@@ -433,6 +440,74 @@ describe("App layout", () => {
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
+ it("shows localized bound automations in the first delete confirmation", async () => {
+ mockSessions = [
+ {
+ key: "websocket:chat-a",
+ channel: "websocket",
+ chatId: "chat-a",
+ createdAt: "2026-04-16T10:00:00Z",
+ updatedAt: "2026-04-16T10:00:00Z",
+ preview: "First chat",
+ },
+ {
+ key: "websocket:chat-b",
+ channel: "websocket",
+ chatId: "chat-b",
+ createdAt: "2026-04-16T11:00:00Z",
+ updatedAt: "2026-04-16T11:00:00Z",
+ preview: "Second chat",
+ },
+ ];
+ getSessionAutomationsSpy.mockResolvedValue([
+ {
+ id: "job-1",
+ name: "Daily repo check",
+ enabled: true,
+ schedule: { kind: "every", every_ms: 86_400_000 },
+ payload: { message: "Check the repo" },
+ state: { next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0) },
+ },
+ ]);
+ await i18n.changeLanguage("zh-CN");
+
+ render();
+
+ await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ const sidebar = screen.getByRole("navigation", { name: "侧边栏导航" });
+ await waitFor(() =>
+ expect(
+ within(sidebar).getByRole("button", { name: /^First chat$/ }),
+ ).toBeInTheDocument(),
+ );
+
+ fireEvent.pointerDown(screen.getByLabelText(/First chat.*会话操作/), {
+ button: 0,
+ });
+ fireEvent.click(await screen.findByRole("menuitem", { name: "删除" }));
+
+ await waitFor(() =>
+ expect(screen.getByText("Daily repo check")).toBeInTheDocument(),
+ );
+ expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a");
+ expect(
+ screen.getByText("这个对话有关联的自动任务。删除对话也会删除这些自动任务。"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText("This chat has scheduled automations. Deleting it will also delete them."),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "删除对话和自动任务" }));
+
+ await waitFor(() =>
+ expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a", {
+ deleteAutomations: true,
+ }),
+ );
+ expect(deleteChatSpy).toHaveBeenCalledTimes(1);
+ expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument();
+ }, 15_000);
+
it("keeps the mobile session action menu inside the sidebar sheet", async () => {
mockSessions = [
{
diff --git a/webui/src/tests/session-info-popover.test.tsx b/webui/src/tests/session-info-popover.test.tsx
index 15da6986d..34fbc2e78 100644
--- a/webui/src/tests/session-info-popover.test.tsx
+++ b/webui/src/tests/session-info-popover.test.tsx
@@ -5,14 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { setAppLanguage } from "@/i18n";
-function automationJob(nextRunAt = Date.now() + 3_600_000) {
+function automationJob(
+ nextRunAt = Date.now() + 3_600_000,
+ state: Record = {},
+) {
return {
id: "job-1",
name: "Morning check",
enabled: true,
schedule: { kind: "every", every_ms: 3_600_000 },
payload: { message: "Check the project status" },
- state: { next_run_at_ms: nextRunAt },
+ state: { next_run_at_ms: nextRunAt, ...state },
};
}
@@ -27,7 +30,8 @@ function automationsResponse(jobs: unknown[]) {
}
describe("SessionInfoPopover", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ await setAppLanguage("en");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(automationsResponse([automationJob()])),
@@ -86,6 +90,29 @@ describe("SessionInfoPopover", () => {
expect(screen.queryByText("Automations")).not.toBeInTheDocument();
});
+ it("shows a short pending label for deferred automations", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ automationsResponse([automationJob(Date.now() - 1000, { pending: true })]),
+ ),
+ );
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Session details" }));
+
+ expect(await screen.findByText("Runs shortly")).toBeInTheDocument();
+ expect(screen.queryByText(/ago/i)).not.toBeInTheDocument();
+ });
+
it("refreshes while open so completed one-shot automations disappear", async () => {
vi.stubGlobal(
"fetch",
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index dcec94df5..b983fc193 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -180,6 +180,36 @@ describe("useNanobotStream", () => {
});
});
+ it("does not start streaming from completed trailing activity after an answer", () => {
+ const fake = fakeClient();
+ const initialMessages = [
+ {
+ id: "a1",
+ role: "assistant" as const,
+ content: "Cron test",
+ turnId: "cron:run",
+ createdAt: Date.now(),
+ },
+ {
+ id: "t1",
+ role: "tool" as const,
+ kind: "trace" as const,
+ content: "message({})",
+ traces: ["message({})"],
+ turnId: "cron:run",
+ createdAt: Date.now(),
+ },
+ ];
+
+ const { result } = renderHook(
+ () => useNanobotStream("chat-cron-done", initialMessages),
+ { wrapper: wrap(fake.client) },
+ );
+
+ expect(result.current.messages.at(-1)?.kind).toBe("trace");
+ expect(result.current.isStreaming).toBe(false);
+ });
+
it("drops pending stream work when switching chats", async () => {
const fake = fakeClient();
const { result, rerender } = renderHook(
diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx
index a606b249a..148bf4628 100644
--- a/webui/src/tests/useSessions.test.tsx
+++ b/webui/src/tests/useSessions.test.tsx
@@ -103,7 +103,7 @@ describe("useSessions", () => {
preview: "Beta",
},
]);
- vi.mocked(api.deleteSession).mockResolvedValue(true);
+ vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
const { result } = renderHook(() => useSessions(), {
wrapper: wrap(fakeClient()),
@@ -115,10 +115,42 @@ describe("useSessions", () => {
await result.current.deleteChat("websocket:chat-a");
});
- expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a");
+ expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined);
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
});
+ it("keeps a session when delete is blocked by bound automations", async () => {
+ vi.mocked(api.listSessions).mockResolvedValue([
+ {
+ key: "websocket:chat-a",
+ channel: "websocket",
+ chatId: "chat-a",
+ createdAt: "2026-04-16T10:00:00Z",
+ updatedAt: "2026-04-16T10:00:00Z",
+ preview: "Alpha",
+ },
+ ]);
+ vi.mocked(api.deleteSession).mockResolvedValue({
+ deleted: false,
+ blocked_by_automations: true,
+ automations: [],
+ });
+
+ const { result } = renderHook(() => useSessions(), {
+ wrapper: wrap(fakeClient()),
+ });
+
+ await waitFor(() => expect(result.current.sessions).toHaveLength(1));
+
+ let deleteResult: Awaited> | undefined;
+ await act(async () => {
+ deleteResult = await result.current.deleteChat("websocket:chat-a");
+ });
+
+ expect(deleteResult?.blocked_by_automations).toBe(true);
+ expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-a"]);
+ });
+
it("refreshes sessions when the websocket reports a session update", async () => {
vi.mocked(api.listSessions)
.mockResolvedValueOnce([
@@ -187,7 +219,7 @@ describe("useSessions", () => {
await result.current.createChat();
});
- expect(client.newChat).toHaveBeenCalledWith(5000, undefined);
+ expect(client.newChat).toHaveBeenCalledWith(60_000, undefined);
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-new"]);
await act(async () => {
@@ -226,7 +258,7 @@ describe("useSessions", () => {
await result.current.createChat(workspaceScope);
});
- expect(client.newChat).toHaveBeenCalledWith(5000, workspaceScope);
+ expect(client.newChat).toHaveBeenCalledWith(60_000, workspaceScope);
expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope);
});
@@ -384,6 +416,40 @@ describe("useSessions", () => {
expect(result.current.hasPendingToolCalls).toBe(true);
});
+ it("uses the server pending flag for completed tails that still end with trace rows", async () => {
+ vi.mocked(api.fetchWebuiThread).mockResolvedValue({
+ schemaVersion: 3,
+ has_pending_tool_calls: false,
+ messages: [
+ {
+ id: "a1",
+ role: "assistant",
+ content: "Cron test",
+ turnId: "cron:run",
+ createdAt: 1,
+ },
+ {
+ id: "t1",
+ role: "tool",
+ kind: "trace",
+ content: "message({})",
+ traces: ["message({})"],
+ turnId: "cron:run",
+ createdAt: 2,
+ },
+ ],
+ });
+
+ const { result } = renderHook(() => useSessionHistory("websocket:chat-cron-done"), {
+ wrapper: wrap(fakeClient()),
+ });
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.messages.at(-1)?.kind).toBe("trace");
+ expect(result.current.hasPendingToolCalls).toBe(false);
+ });
+
it("does not flag transcript as pending when last row is not a trace", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,