diff --git a/CHANGELOG.md b/CHANGELOG.md index 9688673e..bc8d2c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. - **Keyboard shortcuts learned sequences** (thanks @ChangeHow): shortcuts can now be two steps — press Cmd/Ctrl+S, then a letter: P opens the draft's project picker, G its branch picker, L the session list. A held sequence shows no menu but forgives you: Escape or three quiet seconds cancel it, and typing into a text field never triggers one armed elsewhere. Dropdown menus and pickers now also answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and every tooltip and menu label shows the binding you actually have set, not the default. - **Keyboard shortcuts redesigned.** Defaults now follow one model: single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (K then P/G/L — project picker, branch picker, session list; T timeline, N prompt navigator, I services, H shortcut help, C theme), held Cmd/Ctrl+digit for header session tabs and held Cmd/Ctrl+Option+digit for context panel surfaces. Cmd/Ctrl+B now toggles the sidebar; when it's already open, the session-list shortcut jumps into its search. Rare actions moved into the command palette instead of carrying obscure default bindings, and custom bindings recorded under the old layout are reset once. Shortcuts also stopped requiring an English keyboard layout — bindings follow the physical key on non-Latin layouts (and Option-modified digits on macOS), including when recording custom ones. +- **Session expiry is announced, not discovered.** When the OpenChamber login expires (a browser on the LAN, a paired device, a tunnel), a frosted banner appears under the header within seconds — before anything is clicked — saying the session expired, with a Log in button that opens the usual unlock screen. Work on screen stays visible and interactive; sending is paused until login instead of failing into a toast. A 401 is confirmed against the server first, so an expired model-provider key can't fake a logout, and returning to the app after a long absence re-checks the session once. If a conversation failed to load while logged out, it explains that and reloads itself right after login. - **Permission cards answer to the keyboard:** Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons, and the newest pending card is the one that listens. The auto-accept toggle also got a shortcut (Cmd/Ctrl+K, A). - Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. Cmd/Ctrl+K, R renames the current session right in the header. - Git: Cmd/Ctrl+Enter in the commit message box commits, like every git client. diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 4d30dc00..5c4a5079 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -12,6 +12,7 @@ import { SettingsView } from '@/components/views/SettingsView'; import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; @@ -772,6 +773,23 @@ export function MobileApp({ apis }: MobileAppProps) { }; }, [isNativeMobileApp, handleNativeResume]); + // A confirmed mid-session auth expiry (classified centrally from live 401 + // traffic) runs the same seq-guarded re-probe the resume path uses: it ends + // in needs-login → the native welcome screen with the auth-expired notice. + // The shared web banner never renders on native (the session gate is not + // mounted here), so this is the only surface reacting to the signal. + React.useEffect(() => { + if (!isNativeMobileApp) return; + return useAuthSessionStore.subscribe((store, previous) => { + if (store.state === 'expired' && previous.state !== 'expired') { + handleNativeResume(); + // The probe ladder owns the outcome from here; the shared store goes + // back to 'ok' so a later expiry can signal again. + useAuthSessionStore.getState().markAuthenticated(); + } + }); + }, [isNativeMobileApp, handleNativeResume]); + React.useEffect(() => { registerRuntimeAPIs(apis); return () => registerRuntimeAPIs(null); diff --git a/packages/ui/src/components/auth/AuthExpiredBanner.tsx b/packages/ui/src/components/auth/AuthExpiredBanner.tsx new file mode 100644 index 00000000..986b7484 --- /dev/null +++ b/packages/ui/src/components/auth/AuthExpiredBanner.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; + +/** + * Non-blocking notice that the OpenChamber session expired mid-work. It never + * takes the screen on its own: work stays visible and interactive, and only + * the explicit "Log in" click hands control to the session gate's full login + * flow (password, passkey, desktop shell — all already there). + */ +export const AuthExpiredBanner: React.FC = () => { + const { t } = useI18n(); + const authState = useAuthSessionStore((store) => store.state); + const markReauthenticating = useAuthSessionStore((store) => store.markReauthenticating); + + if (authState !== 'expired') { + return null; + } + + return ( + // Below the header on purpose: the header row can be a window-drag region + // on desktop, where nothing under the cursor is clickable. +
+
+ + {t('sessionAuth.expired.banner')} + +
+
+ ); +}; diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 553804cc..4b855f1e 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -12,6 +12,8 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { installAuthSessionFocusWatch, useAuthSessionStore } from '@/lib/runtime-auth-expiry'; +import { AuthExpiredBanner } from './AuthExpiredBanner'; import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts'; @@ -557,6 +559,27 @@ export const SessionAuthGate: React.FC = ({ } }, [skipAuth, state]); + // Mid-session expiry: the banner asks for a re-login by flipping the shared + // auth store to 'reauthenticating'; the gate answers with its own status + // check, which lands in the full 'locked' flow on a genuine 401. A + // successful login resolves the store back to 'ok'. + const authSessionState = useAuthSessionStore((store) => store.state); + React.useEffect(() => { + if (!skipAuth) installAuthSessionFocusWatch(); + }, [skipAuth]); + React.useEffect(() => { + if (skipAuth) return; + if (authSessionState === 'reauthenticating') { + void checkStatusRef.current?.(); + } + }, [authSessionState, skipAuth]); + React.useEffect(() => { + if (skipAuth) return; + if (state === 'authenticated' && useAuthSessionStore.getState().state !== 'ok') { + useAuthSessionStore.getState().markAuthenticated(); + } + }, [skipAuth, state]); + React.useEffect(() => { if (state === 'locked' && passwordInputRef.current) { passwordInputRef.current.focus(); @@ -983,5 +1006,10 @@ export const SessionAuthGate: React.FC = ({ ); } - return <>{children}; + return ( + <> + {skipAuth ? null : } + {children} + + ); }; diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 0873eee2..d3c71b23 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -18,6 +18,7 @@ import { StatusRowContainer } from './StatusRowContainer'; import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { PromptNavigatorRail } from './components/PromptNavigatorRail'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { useScrollShadow } from '@/components/ui/useScrollShadow'; import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll'; import { useChatTimelineController } from './hooks/useChatTimelineController'; @@ -645,6 +646,8 @@ export const ChatContainer: React.FC = ({ suspendPartUpdatesForMessageId: streamingMessageId, }); const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; + const authSessionExpired = useAuthSessionStore((store) => store.state !== 'ok'); + const wasAuthExpiredRef = React.useRef(false); const sessionMessageLoadState = useSessionMessageLoadState( currentSessionId ?? '', effectiveSessionDirectory, @@ -1170,6 +1173,23 @@ export const ChatContainer: React.FC = ({ void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory); }, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]); + // A load that failed while the session was expired retries itself the + // moment the re-login lands — the error screen should never outlive its + // cause. + React.useEffect(() => { + if (authSessionExpired) { + wasAuthExpiredRef.current = true; + return; + } + if (wasAuthExpiredRef.current) { + wasAuthExpiredRef.current = false; + if (sessionMessageLoadState.status === 'error') { + retrySessionLoad(); + } + } + }, [authSessionExpired, retrySessionLoad, sessionMessageLoadState.status]); + + React.useEffect(() => { if (!active || !currentSessionId) return; if (lastScrolledSessionKeyRef.current === currentSessionKey) return; @@ -1298,10 +1318,20 @@ export const ChatContainer: React.FC = ({

{t('chat.container.sessionLoadError.title')}

-

{t('chat.container.sessionLoadError.description')}

- +

+ {authSessionExpired + ? t('chat.container.sessionLoadError.authDescription') + : t('chat.container.sessionLoadError.description')} +

+ {authSessionExpired ? ( + + ) : ( + + )} ); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index f028ecd1..8d375160 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -78,6 +78,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { usePermissionStore } from '@/stores/permissionStore'; import { togglePermissionAutoAccept } from './permissionAutoAccept'; import { useKeybind } from '@/hooks/useKeybind'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { extractGitChangedFiles } from './changedFiles'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; @@ -965,6 +966,14 @@ const ChatInputComponent: React.FC = ({ const queuedMessageId = options?.queuedMessageId; const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; const capturedTarget = messageQueueTarget; + // An expired session cannot deliver anything: keep the prompt in the + // composer and point at the login banner instead of burning the send + // on a guaranteed 401. + if (useAuthSessionStore.getState().state !== 'ok') { + toast.error(t('sessionAuth.expired.sendBlocked')); + return; + } + // Snapshot the draft and current-session identity before the first // async gap so a later sidebar selection cannot reroute the send. const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 64382772..591c055b 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2511,6 +2511,9 @@ export const dict = { 'sessionAuth.error.passkeySignInCanceled': 'Passkey-Anmeldung wurde abgebrochen.', 'sessionAuth.error.enterPasswordForPasskey': 'Geben Sie Ihr Passwort ein, um einen Passkey hinzuzufügen.', 'sessionAuth.locked.tunnelTitle': 'Tunnel-Zugriff erforderlich', + 'sessionAuth.expired.banner': 'Deine Sitzung ist abgelaufen — melde dich an, um fortzufahren.', + 'sessionAuth.expired.loginAction': 'Anmelden', + 'sessionAuth.expired.sendBlocked': 'Sitzung abgelaufen — melde dich an, um Nachrichten zu senden.', 'sessionAuth.locked.unlockTitle': 'OpenChamber entsperren', 'sessionAuth.locked.tunnelDescription': 'Öffnen Sie diesen Tunnel über den Einmal-Verbindungslink aus der Desktop-Anwendung.', 'sessionAuth.locked.passwordDescription': 'Diese Sitzung ist passwortgeschützt.', @@ -3091,7 +3094,8 @@ export const dict = { 'chat.commandAutocomplete.command.scheduleTaskDescription': 'Eine geplante Aufgabe erstellen', 'chat.chatInput.toast.scheduleTaskFailed': 'Aufgabe konnte nicht geplant werden', 'chat.container.sessionLoadError.title': 'Sitzung konnte nicht geladen werden', - 'chat.container.sessionLoadError.description': 'Die Sitzung konnte nicht geladen werden.', + 'chat.container.sessionLoadError.description': 'Die Unterhaltung konnte nicht geladen werden — der Server ist womöglich offline oder nicht erreichbar. Nichts ist verloren; versuche es erneut, sobald er wieder da ist.', + 'chat.container.sessionLoadError.authDescription': 'Deine Sitzung ist abgelaufen, daher hat der Server die Anfrage abgelehnt. Melde dich an, dann wird die Unterhaltung geladen.', 'chat.container.sessionLoadError.retry': 'Erneut versuchen', 'sessions.sidebar.group.empty.loadingSessions': 'Sitzungen werden geladen...', 'sessions.sidebar.group.empty.loadFailed': 'Sitzungen konnten nicht geladen werden', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 169f4c5f..53da59bd 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2162,7 +2162,8 @@ export const dict = { 'chat.btw.promoteAria': 'Keep as a separate session', 'chat.btw.toast.promoteFailed': 'Failed to keep the btw session', 'chat.container.sessionLoadError.title': 'Session could not be loaded', - 'chat.container.sessionLoadError.description': 'Check the connection and try loading this session again.', + 'chat.container.sessionLoadError.description': 'The conversation could not be fetched — the server may be offline or unreachable. Nothing is lost; retry once it is back.', + 'chat.container.sessionLoadError.authDescription': 'Your session expired, so the server refused the request. Log in and the conversation will load.', 'chat.container.sessionLoadError.retry': 'Try again', 'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…', 'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.', @@ -2707,6 +2708,9 @@ export const dict = { 'sessionAuth.error.passkeySignInCanceled': 'Passkey sign-in was canceled.', 'sessionAuth.error.enterPasswordForPasskey': 'Enter your password to add a passkey.', 'sessionAuth.locked.tunnelTitle': 'Tunnel access required', + 'sessionAuth.expired.banner': 'Your session expired — log in to continue.', + 'sessionAuth.expired.loginAction': 'Log in', + 'sessionAuth.expired.sendBlocked': 'Session expired — log in to send messages.', 'sessionAuth.locked.unlockTitle': 'Unlock OpenChamber', 'sessionAuth.locked.tunnelDescription': 'Open this tunnel using the one-time connect link from the desktop app.', 'sessionAuth.locked.passwordDescription': 'This session is password-protected.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 04bbeaa6..9d7f86aa 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2140,7 +2140,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw', "chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.", "chat.container.sessionLoadError.title": "No se pudo cargar la sesión", - "chat.container.sessionLoadError.description": "Comprueba la conexión e intenta cargar esta sesión de nuevo.", + "chat.container.sessionLoadError.description": "No se pudo obtener la conversación: puede que el servidor esté apagado o inaccesible. No se perdió nada; reintenta cuando vuelva.", + "chat.container.sessionLoadError.authDescription": "Tu sesión expiró, por lo que el servidor rechazó la solicitud. Inicia sesión y la conversación se cargará.", "chat.container.sessionLoadError.retry": "Reintentar", "sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…", "sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.", @@ -2673,6 +2674,9 @@ export const dict: Record = { "sessionAuth.error.passkeySignInCanceled": "El inicio de sesión con clave de paso se canceló.", "sessionAuth.error.enterPasswordForPasskey": "Introduce tu contraseña para añadir una clave de paso.", "sessionAuth.locked.tunnelTitle": "Se requiere acceso por túnel", + "sessionAuth.expired.banner": "Tu sesión expiró: inicia sesión para continuar.", + "sessionAuth.expired.loginAction": "Iniciar sesión", + "sessionAuth.expired.sendBlocked": "Sesión expirada: inicia sesión para enviar mensajes.", "sessionAuth.locked.unlockTitle": "Desbloquear OpenChamber", "sessionAuth.locked.tunnelDescription": "Abre este túnel usando el enlace de conexión única desde la aplicación de escritorio.", "sessionAuth.locked.passwordDescription": "Esta sesión está protegida con contraseña.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index b35feb84..09e3b596 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1893,7 +1893,8 @@ export const dict = { 'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw', 'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.', 'chat.container.sessionLoadError.title': 'Impossible de charger la session', - 'chat.container.sessionLoadError.description': 'Vérifiez la connexion et essayez de charger à nouveau cette session.', + 'chat.container.sessionLoadError.description': 'Impossible de récupérer la conversation — le serveur est peut-être hors ligne ou injoignable. Rien n\'est perdu ; réessayez quand il sera de retour.', + 'chat.container.sessionLoadError.authDescription': 'Votre session a expiré, le serveur a donc refusé la requête. Connectez-vous et la conversation se chargera.', 'chat.container.sessionLoadError.retry': 'Réessayer', 'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…', 'sessions.sidebar.group.empty.loadFailed': 'Impossible d’actualiser les sessions.', @@ -2411,6 +2412,9 @@ export const dict = { 'sessionAuth.error.passkeySignInCanceled': 'La connexion par mot de passe a été annulée.', 'sessionAuth.error.enterPasswordForPasskey': 'Entrez votre mot de passe pour ajouter un mot de passe.', 'sessionAuth.locked.tunnelTitle': 'Accès au tunnel requis', + 'sessionAuth.expired.banner': 'Votre session a expiré — connectez-vous pour continuer.', + 'sessionAuth.expired.loginAction': 'Se connecter', + 'sessionAuth.expired.sendBlocked': 'Session expirée — connectez-vous pour envoyer des messages.', 'sessionAuth.locked.unlockTitle': 'Débloquez OpenChamber', 'sessionAuth.locked.tunnelDescription': 'Ouvrez ce tunnel à l\'aide du lien de connexion unique depuis l\'application de bureau.', 'sessionAuth.locked.passwordDescription': 'Cette session est protégée par mot de passe.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 1eeb39e0..0e88eb6d 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2158,7 +2158,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした', 'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。', 'chat.container.sessionLoadError.title': 'セッションを読み込めませんでした', - 'chat.container.sessionLoadError.description': '接続を確認して、このセッションをもう一度読み込んでください。', + 'chat.container.sessionLoadError.description': '会話を取得できませんでした。サーバーが停止中か到達できない可能性があります。データは失われていません。復旧後に再試行してください。', + 'chat.container.sessionLoadError.authDescription': 'セッションの有効期限が切れたため、サーバーがリクエストを拒否しました。ログインすると会話が読み込まれます。', 'chat.container.sessionLoadError.retry': '再試行', 'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…', 'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。', @@ -2706,6 +2707,9 @@ export const dict: Record = { 'sessionAuth.error.passkeySignInCanceled': 'パスキーサインインがキャンセルされました。', 'sessionAuth.error.enterPasswordForPasskey': 'パスキーを追加するためにパスワードを入力してください。', 'sessionAuth.locked.tunnelTitle': 'トンネルアクセスが必要', + 'sessionAuth.expired.banner': 'セッションの有効期限が切れました。続行するにはログインしてください。', + 'sessionAuth.expired.loginAction': 'ログイン', + 'sessionAuth.expired.sendBlocked': 'セッションが切れています。メッセージを送るにはログインしてください。', 'sessionAuth.locked.unlockTitle': 'OpenChamberのロックを解除', 'sessionAuth.locked.tunnelDescription': 'デスクトップアプリのワンタイム接続リンクを使用してこのトンネルを開きます。', 'sessionAuth.locked.passwordDescription': 'このセッションはパスワードで保護されています。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index b2c9192a..ce822479 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2164,7 +2164,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다', 'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.', 'chat.container.sessionLoadError.title': '세션을 불러올 수 없습니다', - 'chat.container.sessionLoadError.description': '연결을 확인한 후 이 세션을 다시 불러오세요.', + 'chat.container.sessionLoadError.description': '대화를 가져오지 못했습니다. 서버가 꺼져 있거나 연결할 수 없는 상태일 수 있습니다. 데이터는 사라지지 않았으니 복구되면 다시 시도하세요.', + 'chat.container.sessionLoadError.authDescription': '세션이 만료되어 서버가 요청을 거부했습니다. 로그인하면 대화가 로드됩니다.', 'chat.container.sessionLoadError.retry': '다시 시도', 'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…', 'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.', @@ -2707,6 +2708,9 @@ export const dict: Record = { 'sessionAuth.error.passkeySignInCanceled': '패스키 로그인이 취소되었습니다.', 'sessionAuth.error.enterPasswordForPasskey': '패스키를 추가하려면 비밀번호를 입력하세요.', 'sessionAuth.locked.tunnelTitle': '터널 접근 필요', + 'sessionAuth.expired.banner': '세션이 만료되었습니다. 계속하려면 로그인하세요.', + 'sessionAuth.expired.loginAction': '로그인', + 'sessionAuth.expired.sendBlocked': '세션이 만료되었습니다. 메시지를 보내려면 로그인하세요.', 'sessionAuth.locked.unlockTitle': 'OpenChamber 잠금 해제', 'sessionAuth.locked.tunnelDescription': '데스크톱 앱의 일회용 연결 링크로 이 터널을 여세요.', 'sessionAuth.locked.passwordDescription': '이 세션은 비밀번호로 보호됩니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 25933ab1..b79dcfd9 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -853,7 +853,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw', 'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.', 'chat.container.sessionLoadError.title': 'Nie udało się wczytać sesji', - 'chat.container.sessionLoadError.description': 'Sprawdź połączenie i spróbuj ponownie wczytać tę sesję.', + 'chat.container.sessionLoadError.description': 'Nie udało się pobrać rozmowy — serwer może być wyłączony lub nieosiągalny. Nic nie przepadło; spróbuj ponownie, gdy wróci.', + 'chat.container.sessionLoadError.authDescription': 'Sesja wygasła, więc serwer odrzucił żądanie. Zaloguj się, a rozmowa się wczyta.', 'chat.container.sessionLoadError.retry': 'Spróbuj ponownie', 'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…', 'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.', @@ -2865,6 +2866,9 @@ export const dict: Record = { 'sessionAuth.locked.passwordDescription': 'Ta sesja jest chroniona hasłem.', 'sessionAuth.locked.tunnelDescription': 'Otwórz ten tunel za pomocą jednorazowego linku połączenia z aplikacji desktopowej.', 'sessionAuth.locked.tunnelTitle': 'Wymagany dostęp przez tunel', + 'sessionAuth.expired.banner': 'Sesja wygasła — zaloguj się, aby kontynuować.', + 'sessionAuth.expired.loginAction': 'Zaloguj się', + 'sessionAuth.expired.sendBlocked': 'Sesja wygasła — zaloguj się, aby wysyłać wiadomości.', 'sessionAuth.locked.unlockTitle': 'Odblokuj OpenChamber', 'sessionAuth.password.placeholder': 'Wpisz hasło', 'sessionAuth.toast.passkeyAdded': 'Dodano klucz dostępu', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 932b3ea3..d66e0070 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2140,7 +2140,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw', "chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.", "chat.container.sessionLoadError.title": "Não foi possível carregar a sessão", - "chat.container.sessionLoadError.description": "Verifique a conexão e tente carregar esta sessão novamente.", + "chat.container.sessionLoadError.description": "Não foi possível buscar a conversa — o servidor pode estar desligado ou inacessível. Nada foi perdido; tente novamente quando ele voltar.", + "chat.container.sessionLoadError.authDescription": "Sua sessão expirou, então o servidor recusou a solicitação. Entre e a conversa será carregada.", "chat.container.sessionLoadError.retry": "Tentar novamente", "sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…", "sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.", @@ -2673,6 +2674,9 @@ export const dict: Record = { "sessionAuth.error.passkeySignInCanceled": "O início de sessão com chave de acesso foi cancelado.", "sessionAuth.error.enterPasswordForPasskey": "Digite sua senha para adicionar uma chave de acesso.", "sessionAuth.locked.tunnelTitle": "É necessário acesso por túnel", + "sessionAuth.expired.banner": "Sua sessão expirou — entre para continuar.", + "sessionAuth.expired.loginAction": "Entrar", + "sessionAuth.expired.sendBlocked": "Sessão expirada — entre para enviar mensagens.", "sessionAuth.locked.unlockTitle": "Desbloquear OpenChamber", "sessionAuth.locked.tunnelDescription": "Abra este túnel usando o link de conexão única do aplicativo desktop.", "sessionAuth.locked.passwordDescription": "Esta sessão está protegida com senha.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 60bbb406..82674b90 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2140,7 +2140,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw', "chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.", "chat.container.sessionLoadError.title": "Не вдалося завантажити сесію", - "chat.container.sessionLoadError.description": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.", + "chat.container.sessionLoadError.description": "Не вдалося отримати розмову — сервер може бути вимкнений або недосяжний. Нічого не втрачено; спробуй знову, коли він повернеться.", + "chat.container.sessionLoadError.authDescription": "Сесія завершилась, тож сервер відхилив запит. Увійди — і розмова завантажиться.", "chat.container.sessionLoadError.retry": "Спробувати знову", "sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…", "sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.", @@ -2673,6 +2674,9 @@ export const dict: Record = { "sessionAuth.error.passkeySignInCanceled": "Вхід за ключем доступу скасовано.", "sessionAuth.error.enterPasswordForPasskey": "Введіть пароль, щоб додати ключ доступу.", "sessionAuth.locked.tunnelTitle": "Потрібен доступ до тунелю", + "sessionAuth.expired.banner": "Сесія завершилась — увійди, щоб продовжити.", + "sessionAuth.expired.loginAction": "Увійти", + "sessionAuth.expired.sendBlocked": "Сесія завершилась — увійди, щоб надсилати повідомлення.", "sessionAuth.locked.unlockTitle": "Розблокувати OpenChamber", "sessionAuth.locked.tunnelDescription": "Відкрийте цей тунель за допомогою одноразового посилання для з’єднання з настільної програми.", "sessionAuth.locked.passwordDescription": "Ця сесія захищена паролем.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2616b032..8121c9c4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2128,7 +2128,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': '保留 btw 会话失败', 'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。', 'chat.container.sessionLoadError.title': '无法加载会话', - 'chat.container.sessionLoadError.description': '请检查连接,然后重新加载此会话。', + 'chat.container.sessionLoadError.description': '无法获取对话——服务器可能已关闭或无法访问。内容没有丢失;等它恢复后重试即可。', + 'chat.container.sessionLoadError.authDescription': '会话已过期,服务器拒绝了请求。登录后对话即会加载。', 'chat.container.sessionLoadError.retry': '重试', 'sessions.sidebar.group.empty.loadingSessions': '正在加载会话…', 'sessions.sidebar.group.empty.loadFailed': '无法刷新会话。', @@ -2673,6 +2674,9 @@ export const dict: Record = { 'sessionAuth.error.passkeySignInCanceled': 'Passkey 登录已取消。', 'sessionAuth.error.enterPasswordForPasskey': '请输入密码以添加 passkey。', 'sessionAuth.locked.tunnelTitle': '需要隧道访问', + 'sessionAuth.expired.banner': '会话已过期——请登录以继续。', + 'sessionAuth.expired.loginAction': '登录', + 'sessionAuth.expired.sendBlocked': '会话已过期——请登录后再发送消息。', 'sessionAuth.locked.unlockTitle': '解锁 OpenChamber', 'sessionAuth.locked.tunnelDescription': '请使用桌面应用提供的一次性连接链接打开该隧道。', 'sessionAuth.locked.passwordDescription': '此会话受密码保护。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 4dc07807..d6039d9f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2132,7 +2132,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗', 'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。', 'chat.container.sessionLoadError.title': '無法載入工作階段', - 'chat.container.sessionLoadError.description': '請檢查連線,然後重新載入此工作階段。', + 'chat.container.sessionLoadError.description': '無法取得對話——伺服器可能已關閉或無法連線。內容沒有遺失;待其恢復後再試即可。', + 'chat.container.sessionLoadError.authDescription': '工作階段已過期,伺服器拒絕了請求。登入後對話即會載入。', 'chat.container.sessionLoadError.retry': '再試一次', 'sessions.sidebar.group.empty.loadingSessions': '正在載入工作階段…', 'sessions.sidebar.group.empty.loadFailed': '無法重新整理工作階段。', @@ -2677,6 +2678,9 @@ export const dict: Record = { 'sessionAuth.error.passkeySignInCanceled': 'Passkey 登入已取消。', 'sessionAuth.error.enterPasswordForPasskey': '請輸入密碼以新增 passkey。', 'sessionAuth.locked.tunnelTitle': '需要 Tunnel 存取', + 'sessionAuth.expired.banner': '工作階段已過期——請登入以繼續。', + 'sessionAuth.expired.loginAction': '登入', + 'sessionAuth.expired.sendBlocked': '工作階段已過期——請登入後再傳送訊息。', 'sessionAuth.locked.unlockTitle': '解鎖 OpenChamber', 'sessionAuth.locked.tunnelDescription': '請使用桌面應用程式提供的一次性連結開啟該 Tunnel。', 'sessionAuth.locked.passwordDescription': '此會話受密碼保護。', diff --git a/packages/ui/src/lib/runtime-auth-expiry.ts b/packages/ui/src/lib/runtime-auth-expiry.ts new file mode 100644 index 00000000..548df47d --- /dev/null +++ b/packages/ui/src/lib/runtime-auth-expiry.ts @@ -0,0 +1,126 @@ +import { create } from 'zustand'; + +// Proactive detection of an expired OpenChamber client session (cookie or +// bearer). There is no polling: every HTTP response already funnels through +// runtimeFetch, and this module only classifies what passes by. A 401 alone +// is NOT proof — OpenCode proxies provider errors through the same routes, so +// a dead Anthropic key also surfaces as 401. Every suspicion is therefore +// confirmed with one debounced GET /auth/session before the state flips. +// +// Consumers: the web/hosted banner (AuthExpiredBanner), the send guard in the +// composer, and the native mobile app, which feeds the signal into its own +// connection orchestration instead of showing the shared banner. + +export type AuthSessionState = 'ok' | 'expired' | 'reauthenticating'; + +interface AuthSessionStore { + state: AuthSessionState; + /** Set only by the confirmed classifier or an explicit auth failure. */ + markExpired: () => void; + markReauthenticating: () => void; + markAuthenticated: () => void; +} + +export const useAuthSessionStore = create((set) => ({ + state: 'ok', + markExpired: () => set((current) => (current.state === 'expired' ? current : { state: 'expired' })), + markReauthenticating: () => set({ state: 'reauthenticating' }), + markAuthenticated: () => set({ state: 'ok' }), +})); + +// One confirm probe per window: parallel 401s from a burst of requests must +// not turn into a probe storm, and a provider-side 401 that keeps repeating +// must not re-probe on every retry. +const CONFIRM_PROBE_MIN_INTERVAL_MS = 15_000; +// Focus revalidation only bothers the server when the tab was away long +// enough for a 12h/7d session to plausibly have died. +const FOCUS_REVALIDATE_MIN_INTERVAL_MS = 5 * 60_000; + +let lastProbeAt = 0; +let probeInFlight = false; + +// Paths where a 401 is part of a normal flow (wrong password on login, a +// pairing redeem, the confirm probe itself) rather than evidence of expiry. +const isExcludedAuthPath = (url: string): boolean => ( + url.includes('/auth/session') || url.includes('/api/client-auth/') +); + +const isClassifiablePath = (url: string): boolean => { + const path = url.startsWith('/') ? url : (() => { + try { + return new URL(url).pathname; + } catch { + return ''; + } + })(); + if (!path.startsWith('/api/') && !path.startsWith('/auth/')) return false; + return !isExcludedAuthPath(path); +}; + +const confirmSessionExpired = async (): Promise => { + if (probeInFlight) return; + probeInFlight = true; + try { + // Deferred import: runtime-fetch classifies through this module, and the + // probe deliberately re-enters it (its /auth/session path is excluded). + const { runtimeFetch } = await import('./runtime-fetch'); + const response = await runtimeFetch('/auth/session', { credentials: 'include' }); + if (response.status === 401) { + useAuthSessionStore.getState().markExpired(); + return; + } + if (response.ok) { + // The suspicious 401 came from deeper in the chain (a provider key, an + // upstream OpenCode instance) — the OpenChamber session is alive. + const { state, markAuthenticated } = useAuthSessionStore.getState(); + if (state === 'expired') markAuthenticated(); + } + } catch { + // Transport failure is connectivity, not authentication; the connection + // status machinery owns that story. + } finally { + probeInFlight = false; + } +}; + +/** + * Called by runtimeFetch for every response. Cheap by design: everything but + * a 401 on a classifiable path returns immediately. + */ +export const observeRuntimeAuthResponse = (url: string, status: number): void => { + if (status !== 401) return; + if (useAuthSessionStore.getState().state === 'expired') return; + if (!isClassifiablePath(url)) return; + const now = Date.now(); + if (now - lastProbeAt < CONFIRM_PROBE_MIN_INTERVAL_MS) return; + lastProbeAt = now; + void confirmSessionExpired(); +}; + +let watchInstalled = false; + +/** + * Revalidates the session when the tab regains visibility after a long + * absence — the "laptop woke up, everything looks alive, first click fails" + * case. One request per wake, nothing periodic. + */ +export const installAuthSessionFocusWatch = (): void => { + // Callers are React effects, so a document always exists here. + if (watchInstalled) return; + watchInstalled = true; + let lastConfirmedAt = Date.now(); + const revalidate = () => { + if (useAuthSessionStore.getState().state !== 'ok') return; + const now = Date.now(); + if (now - lastConfirmedAt < FOCUS_REVALIDATE_MIN_INTERVAL_MS) return; + lastConfirmedAt = now; + lastProbeAt = now; + void confirmSessionExpired(); + }; + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') revalidate(); + }); + // App switches on desktop can refocus the window without a visibility + // change; both signals share one throttle, so a wake costs one request. + window.addEventListener('focus', revalidate); +}; diff --git a/packages/ui/src/lib/runtime-fetch.ts b/packages/ui/src/lib/runtime-fetch.ts index 287d924d..c79fcbbe 100644 --- a/packages/ui/src/lib/runtime-fetch.ts +++ b/packages/ui/src/lib/runtime-fetch.ts @@ -1,6 +1,7 @@ import { getActiveRelayTunnel } from './relay/runtime-tunnel'; import { TUNNEL_PARSE_BASE } from './relay/tunnel-payloads'; import { buildRuntimeAuthHeaders } from './runtime-auth'; +import { observeRuntimeAuthResponse } from './runtime-auth-expiry'; import { getRuntimeUrlResolver, type RuntimeUrlQuery } from './runtime-url'; export interface RuntimeFetchOptions extends RequestInit { @@ -294,6 +295,14 @@ export const runtimeFetch = async (input: string | URL | Request, init: RuntimeF ).toUpperCase(); } + // Session-expiry classification rides on responses that already flow + // through here; only the status is read, never the body. + const rawFetch = doFetch; + doFetch = () => rawFetch().then((response) => { + observeRuntimeAuthResponse(url, response.status); + return response; + }); + // A Request always carries a (possibly default) signal; treat any Request, or // an explicit init.signal, as "has signal" and skip coalescing for safety. const hasSignal = requestInit.signal != null || input instanceof Request;