feat(auth): detect session expiry live and offer re-login in place

Every response already funnels through runtimeFetch, so a classifier there
spots 401s, confirms them against /auth/session (a proxied provider 401
must not read as a logout), and flips a small auth-session store. The web
and hosted surfaces show a frosted banner under the header whose Log in
button hands off to the session gate's existing unlock flow; sends are
paused while expired, the session-load error screen explains the auth case
and retries itself after login, and returning to a long-idle window
revalidates once via visibility/focus. Native mobile feeds the same signal
into its connection re-probe instead of showing the banner; VS Code is
exempt.
This commit is contained in:
Bohdan Triapitsyn
2026-08-26 18:22:04 +03:00
parent 5612849bd7
commit f7a006dc6a
19 changed files with 322 additions and 16 deletions
+18
View File
@@ -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);
@@ -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.
<div
className="pointer-events-none fixed inset-x-0 z-[200] flex justify-center px-4"
style={{ top: 'calc(var(--oc-header-height, 56px) + 8px)' }}
>
<div
role="alert"
className="oc-glass-popover oc-glass-floating pointer-events-auto flex items-center gap-3 rounded-lg px-3 py-2"
>
<Icon name="lock" className="size-4 flex-shrink-0" style={{ color: 'var(--status-error)' }} />
<span className="typography-ui-label text-foreground">{t('sessionAuth.expired.banner')}</span>
<Button size="xs" variant="outline" onClick={markReauthenticating} className="normal-case">
{t('sessionAuth.expired.loginAction')}
</Button>
</div>
</div>
);
};
@@ -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<SessionAuthGateProps> = ({
}
}, [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<SessionAuthGateProps> = ({
);
}
return <>{children}</>;
return (
<>
{skipAuth ? null : <AuthExpiredBanner />}
{children}
</>
);
};
@@ -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<ChatContainerProps> = ({
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<ChatContainerProps> = ({
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<ChatContainerProps> = ({
<Icon name="error-warning" className="size-4" />
</div>
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
<p className="typography-meta mt-1 text-muted-foreground">
{authSessionExpired
? t('chat.container.sessionLoadError.authDescription')
: t('chat.container.sessionLoadError.description')}
</p>
{authSessionExpired ? (
<Button variant="outline" size="sm" className="mt-4" onClick={() => useAuthSessionStore.getState().markReauthenticating()}>
{t('sessionAuth.expired.loginAction')}
</Button>
) : (
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
)}
</div>
</div>
);
@@ -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<ChatInputProps> = ({
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;
+5 -1
View File
@@ -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',
+5 -1
View File
@@ -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.',
+5 -1
View File
@@ -2140,7 +2140,8 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
"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.",
+5 -1
View File
@@ -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 dactualiser 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.',
+5 -1
View File
@@ -2158,7 +2158,8 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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': 'このセッションはパスワードで保護されています。',
+5 -1
View File
@@ -2164,7 +2164,8 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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': '이 세션은 비밀번호로 보호됩니다.',
+5 -1
View File
@@ -853,7 +853,8 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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',
+5 -1
View File
@@ -2140,7 +2140,8 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
"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.",
+5 -1
View File
@@ -2140,7 +2140,8 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
"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": "Ця сесія захищена паролем.",
+5 -1
View File
@@ -2128,7 +2128,8 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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': '此会话受密码保护。',
+5 -1
View File
@@ -2132,7 +2132,8 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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': '此會話受密碼保護。',
+126
View File
@@ -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<AuthSessionStore>((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<void> => {
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);
};
+9
View File
@@ -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;