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
+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 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.',
+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': '此會話受密碼保護。',