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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user