From f82add25a050b068b163b1dadf6d5b41a0c8adf5 Mon Sep 17 00:00:00 2001 From: JD Date: Sat, 23 May 2026 23:22:45 +0000 Subject: [PATCH 01/20] fix(ui): mobile network resilience with HTTP timeout, offline detection, and message retry --- packages/ui/src/App.tsx | 4 + packages/ui/src/components/debug-overlay.tsx | 76 ++++++++++++++++++ packages/ui/src/components/message-item.tsx | 13 +++- .../src/components/network-status-banner.tsx | 46 +++++++++++ packages/ui/src/lib/api-client.ts | 32 +++++++- .../ui/src/lib/i18n/messages/en/messaging.ts | 1 + .../ui/src/lib/i18n/messages/en/session.ts | 5 ++ .../ui/src/lib/i18n/messages/es/messaging.ts | 1 + .../ui/src/lib/i18n/messages/es/session.ts | 5 ++ .../ui/src/lib/i18n/messages/fr/messaging.ts | 1 + .../ui/src/lib/i18n/messages/fr/session.ts | 5 ++ .../ui/src/lib/i18n/messages/he/messaging.ts | 1 + .../ui/src/lib/i18n/messages/he/session.ts | 5 ++ .../ui/src/lib/i18n/messages/ja/messaging.ts | 1 + .../ui/src/lib/i18n/messages/ja/session.ts | 5 ++ .../ui/src/lib/i18n/messages/ru/messaging.ts | 1 + .../ui/src/lib/i18n/messages/ru/session.ts | 5 ++ .../lib/i18n/messages/zh-Hans/messaging.ts | 1 + .../src/lib/i18n/messages/zh-Hans/session.ts | 5 ++ packages/ui/src/lib/network-status.ts | 41 ++++++++++ packages/ui/src/lib/server-events.ts | 9 +++ packages/ui/src/stores/debug-log.ts | 68 ++++++++++++++++ packages/ui/src/stores/session-actions.ts | 78 +++++++++++++++++++ packages/ui/src/stores/session-api.ts | 12 ++- packages/ui/src/stores/session-events.ts | 50 +++++++++++- .../ui/src/styles/messaging/message-base.css | 16 +++- 26 files changed, 477 insertions(+), 10 deletions(-) create mode 100644 packages/ui/src/components/debug-overlay.tsx create mode 100644 packages/ui/src/components/network-status-banner.tsx create mode 100644 packages/ui/src/lib/network-status.ts create mode 100644 packages/ui/src/stores/debug-log.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2aa430fcd..e979b9d65 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,5 +1,7 @@ import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js" import { Dialog } from "@kobalte/core/dialog" +import NetworkStatusBanner from "./components/network-status-banner" +import DebugOverlay from "./components/debug-overlay" import { Toaster } from "solid-toast" import useMediaQuery from "@suid/material/useMediaQuery" import { Minimize2 } from "lucide-solid" @@ -555,6 +557,7 @@ const App: Component = () => {
+
) diff --git a/packages/ui/src/components/debug-overlay.tsx b/packages/ui/src/components/debug-overlay.tsx new file mode 100644 index 000000000..3af5617d6 --- /dev/null +++ b/packages/ui/src/components/debug-overlay.tsx @@ -0,0 +1,76 @@ +import { For, Show, createEffect, createSignal } from "solid-js" +import { entries, paused, visible, clearLog, toggleVisibility, togglePause } from "../stores/debug-log" + +export default function DebugOverlay() { + let listRef: HTMLDivElement | undefined + + createEffect(() => { + if (!paused() && listRef) { + queueMicrotask(() => { + listRef?.scrollTo({ top: listRef.scrollHeight, behavior: "smooth" }) + }) + } + }) + + return ( + <> + + + +
+
+ Debug Log +
+ + + +
+
+
+ + {(entry) => ( +
+ [{entry.ts}] + {" "} + + {entry.level.toUpperCase()} + + {" "} + {entry.source}: + {" "} + {entry.message} +
+ )} +
+
+
+
+ + ) +} diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 00785441f..49bc51168 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -8,7 +8,7 @@ import MessagePart from "./message-part" import { copyToClipboard } from "../lib/clipboard" import { useI18n } from "../lib/i18n" import { showAlertDialog } from "../stores/alerts" -import { deleteMessage } from "../stores/session-actions" +import { deleteMessage, retrySendMessage } from "../stores/session-actions" import { isTauriHost } from "../lib/runtime-env" import type { DeleteHoverState } from "../types/delete-hover" import { useSpeech } from "../lib/hooks/use-speech" @@ -770,7 +770,16 @@ export default function MessageItem(props: MessageItemProps) {
-
⚠ {t("messageItem.status.failedToSend")}
+
+ ⚠ {t("messageItem.status.failedToSend")} + +
diff --git a/packages/ui/src/components/network-status-banner.tsx b/packages/ui/src/components/network-status-banner.tsx new file mode 100644 index 000000000..de96920a1 --- /dev/null +++ b/packages/ui/src/components/network-status-banner.tsx @@ -0,0 +1,46 @@ +import { createEffect, createSignal, Show } from "solid-js" +import { isOnlineSignal } from "../lib/network-status" +import { useI18n } from "../lib/i18n" + +export default function NetworkStatusBanner() { + const { t } = useI18n() + const [showRestored, setShowRestored] = createSignal(false) + + let restoredTimer: ReturnType | null = null + let previousOnline = isOnlineSignal()() + + createEffect(() => { + const online = isOnlineSignal()() + if (online && !previousOnline) { + if (restoredTimer !== null) clearTimeout(restoredTimer) + setShowRestored(true) + restoredTimer = setTimeout(() => { + setShowRestored(false) + restoredTimer = null + }, 3000) + } else if (!online) { + if (restoredTimer !== null) { + clearTimeout(restoredTimer) + restoredTimer = null + } + setShowRestored(false) + } + previousOnline = online + }) + + return ( + +
+ {isOnlineSignal()() ? t("networkStatus.backOnline") : t("networkStatus.offline")} +
+
+ ) +} diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 2eed070ea..9d8c30bda 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -46,6 +46,7 @@ import type { import { getClientIdentity } from "./client-identity" import { getLogger } from "./logger" import { attachEventSourceHandlers } from "./event-source-handlers" +import { debugInfo, debugWarn, debugError } from "../stores/debug-log" const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE : undefined @@ -86,6 +87,19 @@ function buildAbsoluteUrl(path: string): string { const httpLogger = getLogger("api") const sseLogger = getLogger("sse") +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 + +function fetchWithTimeout(url: string | URL, init?: RequestInit, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeoutMs) + + if (init?.signal) { + init.signal.addEventListener("abort", () => controller.abort(), { once: true }) + } + + return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timeoutId)) +} + function normalizeHeaders(headers: HeadersInit | undefined): Record { const output: Record = {} if (!headers) return output @@ -146,7 +160,7 @@ async function request(path: string, init?: RequestInit): Promise { logHttp(`${method} ${path}`) try { - const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" }) + const response = await fetchWithTimeout(url, { ...init, headers, credentials: init?.credentials ?? "include" }) if (!response.ok) { const message = await readErrorMessage(response) logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message }) @@ -160,6 +174,11 @@ async function request(path: string, init?: RequestInit): Promise { return (await response.json()) as T } catch (error) { logHttp(`${method} ${path} failed`, { durationMs: Date.now() - startedAt, error }) + if (error instanceof Error && error.name === "AbortError") { + debugWarn("api", `Request timed out: ${method} ${path}`) + throw new Error(`Request timed out after ${DEFAULT_REQUEST_TIMEOUT_MS / 1000}s`) + } + debugError("api", `Request failed: ${method} ${path} - ${error instanceof Error ? error.message : String(error)}`) throw error } } @@ -175,7 +194,16 @@ async function requestRaw(path: string, init?: RequestInit): Promise { const startedAt = Date.now() logHttp(`${method} ${path}`) - const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" }) + let response: Response + try { + response = await fetchWithTimeout(url, { ...init, headers, credentials: init?.credentials ?? "include" }) + } catch (error) { + logHttp(`${method} ${path} failed`, { durationMs: Date.now() - startedAt, error }) + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Request timed out after ${DEFAULT_REQUEST_TIMEOUT_MS / 1000}s`) + } + throw error + } if (!response.ok) { const message = await readErrorMessage(response) logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message }) diff --git a/packages/ui/src/lib/i18n/messages/en/messaging.ts b/packages/ui/src/lib/i18n/messages/en/messaging.ts index 31700b64d..c4dfc9f5b 100644 --- a/packages/ui/src/lib/i18n/messages/en/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/en/messaging.ts @@ -122,6 +122,7 @@ export const messagingMessages = { "messageItem.status.generating": "Generating...", "messageItem.status.sending": "Sending...", "messageItem.status.failedToSend": "Message failed to send", + "messageItem.status.retry": "Retry", "messagePart.actions.delete": "Delete Part", "messagePart.actions.deleting": "Deleting...", "messagePart.actions.deleteTitle": "Delete this item", diff --git a/packages/ui/src/lib/i18n/messages/en/session.ts b/packages/ui/src/lib/i18n/messages/en/session.ts index 1f0ce5053..fbb05dcac 100644 --- a/packages/ui/src/lib/i18n/messages/en/session.ts +++ b/packages/ui/src/lib/i18n/messages/en/session.ts @@ -108,10 +108,15 @@ export const sessionMessages = { "browserFrame.viewport.mobileLandscape": "Mobile landscape (844 x 390)", "sessionEvents.sessionCompactedToast": "Session {label} was compacted", + "sessionEvents.compactionTimeout.title": "Compaction timed out", + "sessionEvents.compactionTimeout.message": "Session compaction did not complete. You can try compacting again.", "sessionEvents.sessionError.unknown": "Unknown error", "sessionEvents.sessionError.title": "Session error", "sessionEvents.sessionError.message": "Error: {message}", + "networkStatus.offline": "No connection", + "networkStatus.backOnline": "Connection restored", + "sessionState.cleanup.deepConfirm.message": "This cleanup may be slow, and may delete sessions you didn't intend to delete. Are you sure?", "sessionState.cleanup.deepConfirm.title": "Deep Clean Sessions", "sessionState.cleanup.deepConfirm.detail": "Deep Clean Sessions will delete all sessions that have no messages, remove any finished sub-agent sessions, and clear out any unused forks of a session.", diff --git a/packages/ui/src/lib/i18n/messages/es/messaging.ts b/packages/ui/src/lib/i18n/messages/es/messaging.ts index a2e88568d..ccde58e66 100644 --- a/packages/ui/src/lib/i18n/messages/es/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/es/messaging.ts @@ -124,6 +124,7 @@ export const messagingMessages = { "messageItem.status.generating": "Generando...", "messageItem.status.sending": "Enviando...", "messageItem.status.failedToSend": "No se pudo enviar el mensaje", + "messageItem.status.retry": "Reintentar", "messagePart.actions.delete": "Eliminar parte", "messagePart.actions.deleting": "Eliminando...", "messagePart.actions.deleteTitle": "Eliminar este elemento", diff --git a/packages/ui/src/lib/i18n/messages/es/session.ts b/packages/ui/src/lib/i18n/messages/es/session.ts index 7a81e81af..07a5aa0f8 100644 --- a/packages/ui/src/lib/i18n/messages/es/session.ts +++ b/packages/ui/src/lib/i18n/messages/es/session.ts @@ -108,10 +108,15 @@ export const sessionMessages = { "browserFrame.viewport.mobileLandscape": "Móvil horizontal (844 x 390)", "sessionEvents.sessionCompactedToast": "La sesión {label} fue compactada", + "sessionEvents.compactionTimeout.title": "Compactación agotada", + "sessionEvents.compactionTimeout.message": "La compactación de la sesión no se completó. Puedes intentar compactar de nuevo.", "sessionEvents.sessionError.unknown": "Error desconocido", "sessionEvents.sessionError.title": "Error de sesión", "sessionEvents.sessionError.message": "Error: {message}", + "networkStatus.offline": "Sin conexión", + "networkStatus.backOnline": "Conexión restaurada", + "sessionState.cleanup.deepConfirm.message": "Esta limpieza puede ser lenta y puede eliminar sesiones que no pretendías eliminar. ¿Estás seguro?", "sessionState.cleanup.deepConfirm.title": "Limpieza profunda de sesiones", "sessionState.cleanup.deepConfirm.detail": "La limpieza profunda de sesiones eliminará todas las sesiones sin mensajes, quitará cualquier sesión de subagente finalizada y limpiará cualquier fork no usado de una sesión.", diff --git a/packages/ui/src/lib/i18n/messages/fr/messaging.ts b/packages/ui/src/lib/i18n/messages/fr/messaging.ts index c15477e24..5d73f8799 100644 --- a/packages/ui/src/lib/i18n/messages/fr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/fr/messaging.ts @@ -124,6 +124,7 @@ export const messagingMessages = { "messageItem.status.generating": "Génération...", "messageItem.status.sending": "Envoi...", "messageItem.status.failedToSend": "Échec de l'envoi du message", + "messageItem.status.retry": "Réessayer", "messagePart.actions.delete": "Supprimer la partie", "messagePart.actions.deleting": "Suppression...", "messagePart.actions.deleteTitle": "Supprimer cet élément", diff --git a/packages/ui/src/lib/i18n/messages/fr/session.ts b/packages/ui/src/lib/i18n/messages/fr/session.ts index 55dca9930..f00face0b 100644 --- a/packages/ui/src/lib/i18n/messages/fr/session.ts +++ b/packages/ui/src/lib/i18n/messages/fr/session.ts @@ -108,10 +108,15 @@ export const sessionMessages = { "browserFrame.viewport.mobileLandscape": "Mobile paysage (844 x 390)", "sessionEvents.sessionCompactedToast": "La session {label} a été compactée", + "sessionEvents.compactionTimeout.title": "Compaction expirée", + "sessionEvents.compactionTimeout.message": "La compaction de la session n'a pas abouti. Vous pouvez réessayer.", "sessionEvents.sessionError.unknown": "Erreur inconnue", "sessionEvents.sessionError.title": "Erreur de session", "sessionEvents.sessionError.message": "Erreur : {message}", + "networkStatus.offline": "Hors connexion", + "networkStatus.backOnline": "Connexion rétablie", + "sessionState.cleanup.deepConfirm.message": "Ce nettoyage peut être lent et peut supprimer des sessions que vous ne vouliez pas supprimer. Confirmez-vous ?", "sessionState.cleanup.deepConfirm.title": "Nettoyage approfondi des sessions", "sessionState.cleanup.deepConfirm.detail": "Le nettoyage approfondi des sessions supprime toutes les sessions sans messages, retire les sessions de sous-agent terminées et efface les forks inutilisés d'une session.", diff --git a/packages/ui/src/lib/i18n/messages/he/messaging.ts b/packages/ui/src/lib/i18n/messages/he/messaging.ts index 537db2db2..46068e5aa 100644 --- a/packages/ui/src/lib/i18n/messages/he/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/he/messaging.ts @@ -122,6 +122,7 @@ export const messagingMessages = { "messageItem.status.generating": "מייצר...", "messageItem.status.sending": "שולח...", "messageItem.status.failedToSend": "שליחת ההודעה נכשלה", + "messageItem.status.retry": "נסה שוב", "messagePart.actions.delete": "מחק חלק", "messagePart.actions.deleting": "מוחק...", "messagePart.actions.deleteTitle": "מחק פריט זה", diff --git a/packages/ui/src/lib/i18n/messages/he/session.ts b/packages/ui/src/lib/i18n/messages/he/session.ts index e467e1541..bd350d694 100644 --- a/packages/ui/src/lib/i18n/messages/he/session.ts +++ b/packages/ui/src/lib/i18n/messages/he/session.ts @@ -108,10 +108,15 @@ export const sessionMessages = { "browserFrame.viewport.mobileLandscape": "נייד לרוחב (844 x 390)", "sessionEvents.sessionCompactedToast": "הסשן {label} סוכם", + "sessionEvents.compactionTimeout.title": "פג זמן הסיכום", + "sessionEvents.compactionTimeout.message": "סיכום הסשן לא הושלם. ניתן לנסות שוב.", "sessionEvents.sessionError.unknown": "שגיאה לא ידועה", "sessionEvents.sessionError.title": "שגיאת סשן", "sessionEvents.sessionError.message": "שגיאה: {message}", + "networkStatus.offline": "אין חיבור", + "networkStatus.backOnline": "החיבור שוחזר", + "sessionState.cleanup.deepConfirm.message": "ניקוי עמוק זה עשוי להיות איטי, ועלול למחוק סשנים שלא התכוונת למחוק. האם אתה בטוח?", "sessionState.cleanup.deepConfirm.title": "ניקוי עמוק של סשנים", "sessionState.cleanup.deepConfirm.detail": "ניקוי עמוק של סשנים ימחק את כל הסשנים ללא הודעות, יסיר סשני תת-סוכן שסיימו, וינקה פיצולים לא בשימוש של סשן.", diff --git a/packages/ui/src/lib/i18n/messages/ja/messaging.ts b/packages/ui/src/lib/i18n/messages/ja/messaging.ts index 013d9b60b..216630c5d 100644 --- a/packages/ui/src/lib/i18n/messages/ja/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ja/messaging.ts @@ -124,6 +124,7 @@ export const messagingMessages = { "messageItem.status.generating": "生成中...", "messageItem.status.sending": "送信中...", "messageItem.status.failedToSend": "メッセージの送信に失敗しました", + "messageItem.status.retry": "再試行", "messagePart.actions.delete": "パートを削除", "messagePart.actions.deleting": "削除中...", "messagePart.actions.deleteTitle": "この項目を削除", diff --git a/packages/ui/src/lib/i18n/messages/ja/session.ts b/packages/ui/src/lib/i18n/messages/ja/session.ts index 39c1ff3e1..5d1b1d803 100644 --- a/packages/ui/src/lib/i18n/messages/ja/session.ts +++ b/packages/ui/src/lib/i18n/messages/ja/session.ts @@ -108,10 +108,15 @@ export const sessionMessages = { "browserFrame.viewport.mobileLandscape": "モバイル横向き (844 x 390)", "sessionEvents.sessionCompactedToast": "セッション {label} をコンパクト化しました", + "sessionEvents.compactionTimeout.title": "コンパクト化がタイムアウト", + "sessionEvents.compactionTimeout.message": "セッションのコンパクト化が完了しませんでした。もう一度お試しください。", "sessionEvents.sessionError.unknown": "不明なエラー", "sessionEvents.sessionError.title": "セッションエラー", "sessionEvents.sessionError.message": "エラー: {message}", + "networkStatus.offline": "オフライン", + "networkStatus.backOnline": "接続が復旧しました", + "sessionState.cleanup.deepConfirm.message": "このクリーンアップは時間がかかる場合があり、意図しないセッションを削除する可能性があります。続行しますか?", "sessionState.cleanup.deepConfirm.title": "セッションを徹底クリーン", "sessionState.cleanup.deepConfirm.detail": "徹底クリーンは、メッセージがないセッションをすべて削除し、完了したサブエージェントのセッションを取り除き、未使用のセッションフォークを整理します。", diff --git a/packages/ui/src/lib/i18n/messages/ru/messaging.ts b/packages/ui/src/lib/i18n/messages/ru/messaging.ts index a4ce41f2b..3c406ed85 100644 --- a/packages/ui/src/lib/i18n/messages/ru/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ru/messaging.ts @@ -124,6 +124,7 @@ export const messagingMessages = { "messageItem.status.generating": "Генерация…", "messageItem.status.sending": "Отправка…", "messageItem.status.failedToSend": "Не удалось отправить сообщение", + "messageItem.status.retry": "Повторить", "messagePart.actions.delete": "Удалить часть", "messagePart.actions.deleting": "Удаление...", "messagePart.actions.deleteTitle": "Удалить этот элемент", diff --git a/packages/ui/src/lib/i18n/messages/ru/session.ts b/packages/ui/src/lib/i18n/messages/ru/session.ts index 81a3c897b..a1e55ef64 100644 --- a/packages/ui/src/lib/i18n/messages/ru/session.ts +++ b/packages/ui/src/lib/i18n/messages/ru/session.ts @@ -108,10 +108,15 @@ export const sessionMessages = { "browserFrame.viewport.mobileLandscape": "Мобильный горизонтально (844 x 390)", "sessionEvents.sessionCompactedToast": "Сессия {label} была компактирована", + "sessionEvents.compactionTimeout.title": "Тайм-аут компактизации", + "sessionEvents.compactionTimeout.message": "Компактизация сессии не завершена. Попробуйте снова.", "sessionEvents.sessionError.unknown": "Неизвестная ошибка", "sessionEvents.sessionError.title": "Ошибка сессии", "sessionEvents.sessionError.message": "Ошибка: {message}", + "networkStatus.offline": "Нет соединения", + "networkStatus.backOnline": "Соединение восстановлено", + "sessionState.cleanup.deepConfirm.message": "Эта очистка может быть медленной и может удалить сессии, которые вы не хотели удалять. Вы уверены?", "sessionState.cleanup.deepConfirm.title": "Глубокая очистка сессий", "sessionState.cleanup.deepConfirm.detail": "Глубокая очистка сессий удалит все сессии без сообщений, уберет завершенные сессии субагентов и очистит неиспользуемые форки сессий.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts index b00f7e676..48860bf24 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts @@ -124,6 +124,7 @@ export const messagingMessages = { "messageItem.status.generating": "正在生成...", "messageItem.status.sending": "正在发送...", "messageItem.status.failedToSend": "消息发送失败", + "messageItem.status.retry": "重试", "messagePart.actions.delete": "删除部分", "messagePart.actions.deleting": "正在删除...", "messagePart.actions.deleteTitle": "删除此项", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts index 04cdf990d..1b91d5966 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts @@ -108,10 +108,15 @@ export const sessionMessages = { "browserFrame.viewport.mobileLandscape": "移动横屏 (844 x 390)", "sessionEvents.sessionCompactedToast": "会话 {label} 已被压缩", + "sessionEvents.compactionTimeout.title": "压缩超时", + "sessionEvents.compactionTimeout.message": "会话压缩未完成。您可以重试压缩。", "sessionEvents.sessionError.unknown": "未知错误", "sessionEvents.sessionError.title": "会话错误", "sessionEvents.sessionError.message": "错误:{message}", + "networkStatus.offline": "无网络连接", + "networkStatus.backOnline": "网络连接已恢复", + "sessionState.cleanup.deepConfirm.message": "此清理可能较慢,并且可能删除你并不想删除的会话。确定要继续吗?", "sessionState.cleanup.deepConfirm.title": "深度清理会话", "sessionState.cleanup.deepConfirm.detail": "深度清理会话将删除所有没有消息的会话、移除已完成的子智能体会话,并清除会话中未使用的分叉。", diff --git a/packages/ui/src/lib/network-status.ts b/packages/ui/src/lib/network-status.ts new file mode 100644 index 000000000..6cabfc419 --- /dev/null +++ b/packages/ui/src/lib/network-status.ts @@ -0,0 +1,41 @@ +import { createSignal } from "solid-js" +import { serverEvents } from "./server-events" +import { debugInfo } from "../stores/debug-log" + +const [isOnline, setIsOnline] = createSignal(typeof navigator !== "undefined" ? navigator.onLine : true) + +const restoreListeners: Array<() => void> = [] + +function onOnline() { + debugInfo("network", "Browser came online") + setIsOnline(true) + serverEvents.resetRetry() + restoreListeners.forEach((fn) => fn()) +} + +function onOffline() { + debugInfo("network", "Browser went offline") + setIsOnline(false) +} + +let initialized = false +function initNetworkStatus() { + if (initialized || typeof window === "undefined") return + initialized = true + window.addEventListener("online", onOnline) + window.addEventListener("offline", onOffline) +} + +initNetworkStatus() + +export function isOnlineSignal(): () => boolean { + return isOnline +} + +export function onNetworkRestored(fn: () => void): () => void { + restoreListeners.push(fn) + return () => { + const idx = restoreListeners.indexOf(fn) + if (idx !== -1) restoreListeners.splice(idx, 1) + } +} diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 833e6c2aa..95994fdbd 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -2,6 +2,7 @@ import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/ import { serverApi } from "./api-client" import { getClientIdentity } from "./client-identity" import { getLogger } from "./logger" +import { debugInfo } from "../stores/debug-log" const RETRY_BASE_DELAY = 1000 const RETRY_MAX_DELAY = 10000 @@ -90,6 +91,14 @@ class ServerEvents { this.openHandlers.add(handler) return () => this.openHandlers.delete(handler) } + + resetRetry() { + debugInfo("sse", "Reset retry delay and reconnect") + this.retryDelay = RETRY_BASE_DELAY + if (!this.source && this.reconnectTimer === null) { + this.connect() + } + } } export const serverEvents = new ServerEvents() diff --git a/packages/ui/src/stores/debug-log.ts b/packages/ui/src/stores/debug-log.ts new file mode 100644 index 000000000..bd23b2c73 --- /dev/null +++ b/packages/ui/src/stores/debug-log.ts @@ -0,0 +1,68 @@ +import { createSignal, createMemo } from "solid-js" + +type LogLevel = "info" | "warn" | "error" | "debug" + +interface LogEntry { + id: number + ts: string + level: LogLevel + source: string + message: string +} + +const MAX_ENTRIES = 300 +let nextId = 1 + +const [entries, setEntries] = createSignal([]) +const [paused, setPaused] = createSignal(false) +const [visible, setVisible] = createSignal(false) + +function addEntry(level: LogLevel, source: string, message: string) { + const now = new Date() + const ts = now.toLocaleTimeString("en-US", { hour12: false }) + "." + String(now.getMilliseconds()).padStart(3, "0") + const entry: LogEntry = { id: nextId++, ts, level, source, message } + setEntries((prev) => { + const next = [...prev, entry] + if (next.length > MAX_ENTRIES) { + return next.slice(next.length - MAX_ENTRIES) + } + return next + }) +} + +function clearLog() { + setEntries([]) +} + +function toggleVisibility() { + setVisible((v) => !v) +} + +function togglePause() { + setPaused((p) => !p) +} + +export function debugLog(source: string, message: string) { + addEntry("debug", source, message) +} + +export function debugInfo(source: string, message: string) { + addEntry("info", source, message) +} + +export function debugWarn(source: string, message: string) { + addEntry("warn", source, message) +} + +export function debugError(source: string, message: string) { + addEntry("error", source, message) +} + +export { + entries, + paused, + visible, + clearLog, + toggleVisibility, + togglePause, +} diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 3cdf03211..762e81579 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -11,6 +11,8 @@ import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { clearConversationPlaybackForSession } from "./conversation-speech" +import { onNetworkRestored } from "../lib/network-status" +import { debugInfo, debugWarn, debugError } from "./debug-log" const log = getLogger("actions") @@ -80,6 +82,7 @@ async function sendMessage( prompt: string, attachments: any[] = [], ): Promise { + debugInfo("actions", `sendMessage: session=${sessionId.slice(0, 8)} len=${prompt.length}`) const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") @@ -218,6 +221,17 @@ async function sendMessage( ) } catch (error) { log.error("Failed to send prompt", error) + const errorMsg = error instanceof Error ? error.message : String(error) + debugError("actions", `sendMessage failed: ${errorMsg}`) + store.upsertMessage({ + id: messageId, + sessionId, + role: "user", + status: "error", + parts: optimisticParts, + createdAt, + isEphemeral: true, + }) throw error } } @@ -462,12 +476,76 @@ async function deleteMessage(instanceId: string, sessionId: string, messageId: s updateSessionInfo(instanceId, sessionId) } +async function retrySendMessage(instanceId: string, sessionId: string, messageId: string): Promise { + debugInfo("actions", `retrySendMessage: session=${sessionId.slice(0, 8)} msg=${messageId.slice(0, 8)}`) + const store = messageStoreBus.getOrCreate(instanceId) + const message = store.getMessage(messageId) + if (!message) { + log.error("retrySendMessage: message not found", { instanceId, sessionId, messageId }) + throw new Error("Message not found") + } + + const textParts: string[] = [] + for (const partId of message.partIds) { + const part = message.parts[partId] + if (part?.data?.type === "text") { + textParts.push((part.data as { text: string }).text) + } + } + + const prompt = textParts.join("\n") + if (!prompt) { + log.error("retrySendMessage: no text content in message", { instanceId, sessionId, messageId }) + throw new Error("Message has no text content") + } + + removeMessageV2(instanceId, messageId) + + await sendMessage(instanceId, sessionId, prompt) +} + +// Auto-retry failed user messages when the browser regains connectivity +// and the message store indicates they are in error state. +function autoRetryOnReconnect() { + onNetworkRestored(() => { + const ids = instances() + for (const instId of ids.keys()) { + const store = messageStoreBus.getOrCreate(instId) + if (!store) continue + const retryPromises: Promise[] = [] + const sessEntries = Object.entries(store.state.sessions) + for (const [sessId] of sessEntries) { + const msgIds = store.getSessionMessageIds(sessId) + for (const msgId of msgIds) { + const msg = store.getMessage(msgId) + if (msg && msg.status === "error" && msg.role === "user") { + retryPromises.push( + retrySendMessage(instId, sessId, msgId).catch((e) => { + log.error("Auto-retry failed", { instanceId: instId, sessionId: sessId, messageId: msgId, error: e }) + }), + ) + } + } + } + if (retryPromises.length > 0) { + log.info(`Auto-retrying ${retryPromises.length} failed message(s)`) + debugInfo("actions", `Auto-retrying ${retryPromises.length} message(s) on reconnect`) + } + } + }) +} + +if (typeof window !== "undefined") { + autoRetryOnReconnect() +} + export { abortSession, deleteMessage, deleteMessagePart, executeCustomCommand, renameSession, + retrySendMessage, runShellCommand, sendMessage, updateSessionAgent, diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 36609380f..80fc8d07e 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -822,10 +822,14 @@ async function loadMessages( } if (!agentName && !providerID && !modelID) { - const defaultModel = await getDefaultModel(instanceId, session.agent) - agentName = session.agent - providerID = defaultModel.providerId - modelID = defaultModel.modelId + if (session.model?.providerId && session.model?.modelId) { + // Preserve existing session model when messages don't include model info + } else { + const defaultModel = await getDefaultModel(instanceId, session.agent) + agentName = session.agent + providerID = defaultModel.providerId + modelID = defaultModel.modelId + } } setSessions((prev) => { diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index fcb253bf9..33e94996a 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -80,6 +80,41 @@ const log = getLogger("sse") const pendingSessionFetches = new Map>() let activeRetryToast: ToastHandle | null = null +const COMPACTION_TIMEOUT_MS = 180_000 +const compactionTimers = new Map>() + +function clearCompactionTimer(instanceId: string, sessionId: string) { + const key = `${instanceId}:${sessionId}` + const timer = compactionTimers.get(key) + if (timer) { + clearTimeout(timer) + compactionTimers.delete(key) + } +} + +function setCompactionTimer(instanceId: string, sessionId: string) { + const key = `${instanceId}:${sessionId}` + clearCompactionTimer(instanceId, sessionId) + compactionTimers.set( + key, + setTimeout(() => { + compactionTimers.delete(key) + log.warn(`[SSE] Compaction timeout for session ${sessionId}, recovering to "idle"`) + withSession(instanceId, sessionId, (session) => { + if (session.status === "compacting") { + session.status = "idle" + showToastNotification({ + title: tGlobal("sessionEvents.compactionTimeout.title"), + message: tGlobal("sessionEvents.compactionTimeout.message"), + variant: "warning", + duration: 10000, + }) + } + }) + }, COMPACTION_TIMEOUT_MS), + ) +} + function isSameRetryState(left: SessionRetryState | null | undefined, right: SessionRetryState | null | undefined): boolean { const a = left ?? null const b = right ?? null @@ -338,6 +373,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes if (!sessionId || !messageId) return if (part.type === "compaction") { ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory) + setCompactionTimer(instanceId, sessionId) } const store = messageStoreBus.getOrCreate(instanceId) @@ -606,6 +642,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted if (!sessionID) return log.info(`[SSE] Session compacted: ${sessionID}`) + clearCompactionTimer(instanceId, sessionID) const existing = sessions().get(instanceId)?.get(sessionID) if (existing) { @@ -635,8 +672,9 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted }) } -function handleSessionError(_instanceId: string, event: EventSessionError): void { +function handleSessionError(instanceId: string, event: EventSessionError): void { const error = event.properties?.error + const sessionID = event.properties?.sessionID log.error(`[SSE] Session error:`, error) let message = tGlobal("sessionEvents.sessionError.unknown") @@ -649,6 +687,16 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void } } + if (sessionID) { + withSession(instanceId, sessionID, (session) => { + if (session.status === "compacting") { + log.warn(`[SSE] Compaction failed for session ${sessionID}, resetting status to "idle"`) + clearCompactionTimer(instanceId, sessionID) + session.status = "idle" + } + }) + } + showAlertDialog(tGlobal("sessionEvents.sessionError.message", { message }), { title: tGlobal("sessionEvents.sessionError.title"), variant: "error", diff --git a/packages/ui/src/styles/messaging/message-base.css b/packages/ui/src/styles/messaging/message-base.css index 92af35daa..591eae2e3 100644 --- a/packages/ui/src/styles/messaging/message-base.css +++ b/packages/ui/src/styles/messaging/message-base.css @@ -244,10 +244,24 @@ } .message-error { - @apply text-xs mt-1; + @apply text-xs mt-1 flex items-center gap-2; color: var(--status-error); } +.message-retry-button { + @apply text-xs font-medium underline underline-offset-2; + color: var(--status-error); + cursor: pointer; + background: none; + border: none; + padding: 0; +} + +.message-retry-button:hover { + color: var(--status-error); + opacity: 0.8; +} + .generating-spinner { @apply inline-block; animation: pulse 1.5s ease-in-out infinite; From 78cee0f347a0d01d9dc30231e66d4f0355bca094 Mon Sep 17 00:00:00 2001 From: JD Date: Tue, 26 May 2026 10:41:49 +0000 Subject: [PATCH 02/20] fix(ui): silence SSE pong errors and improve connection resilience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **User-visible behavior:** - Pong "Client connection not found" errors no longer flood the debug log; now logged as a muted warning - Debug overlay title bar shows "⚠ disconnected" when the SSE connection drops, so the user knows immediately - ⚙ debug toggle button moved from bottom-right corner (overlapping scroll buttons) into the scroll button column, above the pause button - "Enviando..." and "Generando..." status text now uses the accent color instead of muted gray, making it visible - Spinner animation changed from pulse to bounce for more noticeable activity feedback - Hard-refresh is required to pick up the latest build **Implementation:** - `server-events.ts`: added `.connected` getter, set `_connected=false` in scheduleReconnect and `_connected=true` onopen; pong catch now calls `debugWarn` instead of `log.error` so stale-connection pongs don't pollute logs - `debug-overlay.tsx`: imports `serverEvents`, header shows disconnected warning when SSE is down - `message-section.tsx`: removed broken `isAssistantThinking` memo (store is not reactive); ⚙ button rendered inside `renderControls` as first child of `message-scroll-button-wrapper` - `messaging.css`: `.message-generating`/`.message-sending` use `--accent` color; spinner uses `generating-bounce` animation - `message-section.css`: added `.debug-toggle-button` styles (2rem circle, muted → primary on hover) **SSE reconnection note:** The root cause of repeated SSE drops is likely a server-side or proxy issue (PM2, nginx timeout). This commit only handles the client-side symptoms gracefully. A durable fix would require investigating why the EventSource connection does not stay open on this deployment. **Validation:** - Build passes with `npm run build --workspace @codenomad/ui` - Copy step `node packages/server/scripts/copy-ui-dist.mjs` completes --- packages/server/src/server/http-server.ts | 2 + packages/ui/src/components/debug-overlay.tsx | 61 +++++++++++++++---- .../ui/src/components/message-section.tsx | 9 +++ .../src/components/network-status-banner.tsx | 6 +- packages/ui/src/lib/opencode-api.ts | 4 +- packages/ui/src/lib/server-events.ts | 13 +++- packages/ui/src/stores/debug-log.ts | 45 +++++++++++--- packages/ui/src/stores/session-actions.ts | 45 ++++++++++---- packages/ui/src/stores/session-events.ts | 7 +++ packages/ui/src/styles/messaging.css | 11 +++- .../src/styles/messaging/message-section.css | 17 ++++++ 11 files changed, 178 insertions(+), 42 deletions(-) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 71a325438..4b9851a42 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -35,6 +35,7 @@ import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" import type { AuthManager } from "../auth/manager" import { registerAuthRoutes } from "./routes/auth" +import { registerDebugLogRoutes } from "./routes/debug-log" import { sendUnauthorized, wantsHtml } from "../auth/http-auth" import type { SpeechService } from "../speech/service" import { ClientConnectionManager } from "../clients/connection-manager" @@ -274,6 +275,7 @@ export function createHttpServer(deps: HttpServerDeps) { registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerConfigFileRoutes(app) registerMetaRoutes(app, { serverMeta: deps.serverMeta }) + registerDebugLogRoutes(app) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, diff --git a/packages/ui/src/components/debug-overlay.tsx b/packages/ui/src/components/debug-overlay.tsx index 3af5617d6..aab9167af 100644 --- a/packages/ui/src/components/debug-overlay.tsx +++ b/packages/ui/src/components/debug-overlay.tsx @@ -1,8 +1,12 @@ import { For, Show, createEffect, createSignal } from "solid-js" -import { entries, paused, visible, clearLog, toggleVisibility, togglePause } from "../stores/debug-log" +import { entries, paused, visible, clearLog, toggleVisibility, togglePause, exportLog } from "../stores/debug-log" +import { serverEvents } from "../lib/server-events" +import { CODENOMAD_API_BASE } from "../lib/api-client" export default function DebugOverlay() { let listRef: HTMLDivElement | undefined + const [sending, setSending] = createSignal(false) + const [copied, setCopied] = createSignal(false) createEffect(() => { if (!paused() && listRef) { @@ -12,26 +16,59 @@ export default function DebugOverlay() { } }) + async function copyLog() { + try { + const text = entries().map((e) => `[${e.ts}] ${e.level.toUpperCase()} ${e.source}: ${e.message}`).join("\n") + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // fallback for insecure contexts + } + } + + async function sendToServer() { + setSending(true) + try { + const data = entries() + await fetch(`${CODENOMAD_API_BASE || ""}/api/debug-log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ logs: data }), + }) + } catch { + // Silently fail — the user can use export instead + } finally { + setSending(false) + } + } + + function downloadLog() { + const blob = new Blob([exportLog()], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `codenomad-debug-${Date.now()}.json` + a.click() + URL.revokeObjectURL(url) + } + return ( <> - - -
+
- Debug Log + Debug Log ({entries().length}){serverEvents.connected ? "" : " ⚠ disconnected"}
+ + +
diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index ea9fac707..653cbab80 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -15,6 +15,7 @@ import { copyToClipboard } from "../lib/clipboard" import { showToastNotification } from "../lib/notifications" import { showAlertDialog } from "../stores/alerts" import { deleteMessage, deleteMessagePart } from "../stores/session-actions" +import { visible as debugVisible, toggleVisibility as toggleDebug } from "../stores/debug-log" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { DeleteHoverState } from "../types/delete-hover" import { partHasRenderableText } from "../types/message" @@ -1295,6 +1296,14 @@ export default function MessageSection(props: MessageSectionProps) { registerState={(state) => setListState(state)} renderControls={(state, api) => (
+