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,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;