Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -33829,12 +33829,30 @@ function handleAuthStatusChangeForChatTab(detail) {
publishRealtimeConnectionTelemetry('auth_status_changed');
}

export async function retryConversationReadsAfterConnectionRecovery() {
if (pendingChatOrganisationSwitchId !== null) return;
const organisationGeneration = chatOrganisationGeneration;
await refreshChatSessionTabs();
if (!isChatOrganisationRequestCurrent(organisationGeneration)) return;
// Do not replay a send or replace a live answer. Retry only a failed/missing
// transcript; the composer, attachments and current selection stay in place.
if (!activeHistoryRequest && !getLiveChatRequestForSession(activeChatSessionId)
&& (historyLoadState.degraded || displayedHistorySessionId !== activeChatSessionId)) {
await loadChatHistory({ scrollToBottom: false, preserveScroll: true });
}
}

export function initializeChatTab() {
if (chatTabInitialised) {
console.warn('[chatTab] initializeChatTab called more than once; skipping duplicate initialisation.');
return;
}
chatTabInitialised = true;
document.addEventListener('von:connectionRestored', () => {
void retryConversationReadsAfterConnectionRecovery().catch(error => {
console.warn('[chatTab] Connection recovered; conversation retry failed:', error);
});
});
const turnModelSelect = document.getElementById('turnModelSelect');
if (turnModelSelect) {
turnModelPicker = createTurnModelPicker({
Expand Down
20 changes: 7 additions & 13 deletions src/frontend/web/von_interface/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { escapeHtml } from './markdownUtils.js';
import './suppressTooltips.js';
import { activateTab, loadTabData, setupTabNavigation } from './tabNavigation.js';
import { installOutageView } from '../outage/app.js';
import { fetchVonHealth, healthErrorKind } from '../outage/health.js';
import { preserveOutageDraft } from '../outage/draft.js';
import { evaluateServerHealthState } from './utils/serverHealthState.js';
import { handleSelectConceptByIdDetail } from './utils/selectConceptByIdHandler.js';
Expand Down Expand Up @@ -998,8 +999,8 @@ function startHealthPolling() {
? `cannot reach Von (checked ${lastCheckLabel})`
: 'cannot reach Von';
const baseTitle = lastSuccessLabel
? `Von server is unreachable | Last healthy response ${lastSuccessLabel} ago`
: 'Von server is unreachable';
? `Connection to Von is unconfirmed | Last healthy response ${lastSuccessLabel} ago`
: 'Connection to Von is unconfirmed';
uptimeSpan.title = copyHint ? `${baseTitle} | ${copyHint}` : baseTitle;
if (uptimeContainer) uptimeContainer.classList.add('pid-error');
} else if (serverHealthUiState === 'waiting') {
Expand Down Expand Up @@ -1131,13 +1132,9 @@ function startHealthPolling() {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const res = await fetch('/health', { cache: 'no-store', signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
if (data?.status !== 'healthy' || typeof data.start_time !== 'string') {
throw new Error('Invalid Von health response');
}
let data;
try { data = await fetchVonHealth({ signal: controller.signal }); }
finally { clearTimeout(timeout); }
const recoveringFromOutage = serverHealthUiState === 'down';
lastHealthCheckCompletedAtMs = Date.now();
hasSeenSuccessfulHealthPoll = true;
Expand Down Expand Up @@ -2336,10 +2333,7 @@ function startHealthPolling() {
firstFailureAtMs = Date.now();
}
const nowMs = Date.now();
const isHttpError = typeof e?.message === 'string' && e.message.startsWith('HTTP ');
const errorKind = e?.name === 'AbortError'
? 'timeout'
: (isHttpError ? 'http' : 'network_or_unknown');
const errorKind = healthErrorKind(e);
const errorDetail = typeof e?.message === 'string'
? e.message
: String(e || '');
Expand Down
31 changes: 31 additions & 0 deletions src/frontend/web/von_interface/static/js/test/chatTab.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
jest.mock('../workflowStudioAccess.js', () => ({ canUseWorkflowStudio: jest.fn(() => true) }));
import {
retryConversationReadsAfterConnectionRecovery,
__testOnly_buildThinkingProgressPresentation,
__testOnly_buildThinkingCardProgressViewModel,
__testOnly_buildRetainedThinkingCardRequestFromDebugData,
Expand Down Expand Up @@ -2731,6 +2732,36 @@ describe('loadChatHistory degraded handling', () => {
__testOnly_setActiveChatSession(null, null);
});

test('connection recovery retries failed reads for the same conversation without altering the draft or sending', async () => {
document.body.insertAdjacentHTML('beforeend', '<textarea id="promptInput">Keep this unsent text</textarea><div id="chatSessionTabs"></div>');
let available = false;
global.fetch = jest.fn(async (url) => {
if (!available) throw new TypeError('Failed to fetch');
if (url.startsWith('/von/history/sessions')) return { ok: true, json: async () => ({
sessions: [{ session_id: 'session-1', session_name: 'Session 1', message_count: 1 }],
active_session_id: 'session-1'
}) };
if (url.startsWith('/von/history?')) return { ok: true, json: async () => ({
history: [{ role: 'user', content: 'Recovered conversation' }],
segments_returned: 1, total_segments: 1
}) };
return { ok: true, json: async () => ({}) };
});
expect(await __testOnly_loadChatHistory()).toBe(false);
available = true;
await retryConversationReadsAfterConnectionRecovery();
expect(document.getElementById('scrollableField').textContent).toContain('Recovered conversation');
expect(document.getElementById('promptInput').value).toBe('Keep this unsent text');
expect(fetch.mock.calls.some(([url]) => url.startsWith('/von/history/sessions'))).toBe(true);
const historyReads = fetch.mock.calls.filter(([url]) => url.startsWith('/von/history?'));
expect(historyReads).toHaveLength(2);
expect(historyReads.every(([url]) => new URL(url, 'http://localhost').searchParams.get('session_id') === 'session-1')).toBe(true);
expect(fetch.mock.calls.every(([, options]) => !options?.method || options.method === 'GET')).toBe(true);
// Repeated successful health checks must not replace a successfully loaded transcript.
await retryConversationReadsAfterConnectionRecovery();
expect(fetch.mock.calls.filter(([url]) => url.startsWith('/von/history?'))).toHaveLength(2);
});

test('preserves rendered history and surfaces a banner when the backend reports degraded history', async () => {
__testOnly_setDisplayedHistorySession('session-1');
global.fetch = jest.fn((url) => {
Expand Down
24 changes: 20 additions & 4 deletions src/frontend/web/von_interface/static/outage/app.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import { maintenanceMessage, readMaintenance } from './status.js';
import { connectionMessage } from './health.js';

// The existing health poll owns the decision; this module only presents it.
export function installOutageView() {
const dialog = document.createElement('dialog');
dialog.className = 'von-outage';
dialog.setAttribute('aria-labelledby', 'vonOutageTitle');
dialog.innerHTML = '<section><h1 id="vonOutageTitle">Cannot reach Von</h1><p>Sorry, Von cannot connect right now.</p><p data-connection></p><p data-maintenance role="status"></p><small data-checked></small><p><button type="button" data-retry>Check status / Retry</button><button type="button" data-return>Return to my work</button></p><p>Your open work stays in place. We’ll reconnect automatically. An interrupted message will not be sent again; check its result before resending.</p></section>';
dialog.innerHTML = '<section><h1 id="vonOutageTitle">Connection to Von interrupted</h1><p data-connection></p><p><a data-sign-in href="/von/" target="_blank" rel="noopener noreferrer">Sign in again in a new tab</a></p><p>Complete sign-in with the same account, then return to this tab and choose Check status / Retry. Keep this tab open: your conversation, unsent text and attachments stay here.</p><p data-maintenance role="status"></p><small data-checked></small><p><button type="button" data-retry>Check status / Retry</button><button type="button" data-return>Return to my work</button></p><p>We’ll reconnect automatically. An interrupted message will not be sent again; check its result before resending.</p></section>';
document.body.append(dialog);
let lastFocus = null;
let state = 'waiting';
let dismissed = false;
let statusInFlight = false;
let status = { record: null, cached: true };
let lastChecked = null;
let lastErrorKind = null;
let signInAttempted = false;
const updateText = () => {
dialog.querySelector('[data-connection]').textContent = navigator.onLine === false
? 'Your device reports that it is offline. Check your connection; Von’s status is unknown.'
: 'Cannot reach Von. The cause is unknown.';
dialog.querySelector('[data-connection]').textContent = connectionMessage(lastErrorKind);
dialog.querySelector('[data-maintenance]').textContent = maintenanceMessage(status.record, status);
dialog.querySelector('[data-checked]').textContent = lastChecked ? `Last checked: ${new Date(lastChecked).toLocaleTimeString()}` : 'No check completed yet.';
};
Expand All @@ -35,11 +36,16 @@ export function installOutageView() {
const detail = event.detail || {};
const previous = state;
state = detail.state;
lastErrorKind = detail.lastErrorKind || detail.latestErrorKind || null;
lastChecked = Date.now();
updateText();
if (state === 'healthy') {
dismissed = false;
close();
if (previous === 'down' || signInAttempted) {
signInAttempted = false;
document.dispatchEvent(new CustomEvent('von:connectionRestored'));
}
} else if (state === 'down' && !detail.isThinkingActive && !dismissed && !dialog.open) {
lastFocus = document.activeElement;
dialog.showModal();
Expand All @@ -51,12 +57,22 @@ export function installOutageView() {
void refreshStatus();
});
dialog.querySelector('[data-return]').addEventListener('click', () => { dismissed = true; close(); });
// A normal user-initiated navigation can complete an edge sign-in that fetch
// cannot. Never navigate/reload the working tab or pass draft data to the new one.
dialog.querySelector('[data-sign-in]').addEventListener('click', () => { signInAttempted = true; });
dialog.addEventListener('cancel', event => { event.preventDefault(); dismissed = true; close(); });
window.addEventListener('online', () => {
document.dispatchEvent(new CustomEvent('von:requestHealthCheck'));
if (state === 'down') void refreshStatus();
});
window.addEventListener('offline', updateText);
const checkOnReturn = () => {
if (document.visibilityState !== 'hidden' && (state === 'down' || signInAttempted)) {
document.dispatchEvent(new CustomEvent('von:requestHealthCheck'));
}
};
window.addEventListener('focus', checkOnReturn);
document.addEventListener('visibilitychange', checkOnReturn);
// Fetch before an outage so the last-known attributed record can survive it.
void refreshStatus();
setInterval(() => { if (dialog.open) updateText(); }, 10000);
Expand Down
38 changes: 38 additions & 0 deletions src/frontend/web/von_interface/static/outage/health.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// A failed health request describes this browser's connection, not necessarily
// backend downtime. In particular, Access redirects can be opaque to fetch.
export async function fetchVonHealth({ signal } = {}) {
const response = await fetch('/health', {
cache: 'no-store', credentials: 'same-origin', redirect: 'manual', signal
});
const fail = (kind, message) => { throw Object.assign(new Error(message), { healthErrorKind: kind }); };
if (response.type === 'opaqueredirect' || response.redirected ||
(response.status >= 300 && response.status < 400)) {
fail('redirect', 'Health request was redirected');
}
if (response.status === 401 || response.status === 403) {
fail('access', `HTTP ${response.status}`);
}
if (!response.ok) fail('http', `HTTP ${response.status}`);
if (!response.headers.get('content-type')?.includes('application/json')) {
fail('unexpected_response', 'Expected a Von health response');
}
let data;
try { data = await response.json(); }
catch { fail('unexpected_response', 'Invalid Von health response'); }
if (data?.status !== 'healthy' || typeof data.start_time !== 'string') {
fail('unexpected_response', 'Invalid Von health response');
}
return data;
}

export function healthErrorKind(error) {
return error?.healthErrorKind || (error?.name === 'AbortError' ? 'timeout' : 'network_or_unknown');
}

export function connectionMessage(kind, online = navigator.onLine) {
if (online === false) return 'Your device reports that it is offline. Check your connection; Von’s status is unknown.';
if (kind === 'redirect') return 'The connection was redirected. You may need to sign in again; this does not confirm a Von outage.';
if (kind === 'access') return 'Access was refused. Try signing in again. If access is still refused, check your account’s access to Von.';
if (kind === 'http') return 'Von or its gateway returned an error. We’ll keep checking the connection.';
return 'The connection could not be checked. Your sign-in may have expired, or there may be a network or server problem.';
}
2 changes: 1 addition & 1 deletion src/frontend/web/von_interface/static/outage/offline.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<!doctype html>
<html lang="en-NZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"><title>Von — reconnecting</title><link rel="stylesheet" href="/static/outage/outage.css"></head>
<body class="von-outage"><main><h1>Cannot reach Von</h1><p>Sorry, Von cannot connect right now.</p><p id="connection">The cause is unknown. We’ll keep checking.</p><p id="maintenance" role="status">Return time unknown.</p><small id="checked">No check completed yet.</small><button id="retry" type="button">Check status / Retry</button><p>This page reconnects automatically. It will never resend a message. If a previous send was interrupted, check the conversation before sending again.</p></main><script type="module" src="/static/outage/offline.js"></script></body></html>
<body class="von-outage"><main><h1>Connection to Von interrupted</h1><p id="connection">Your sign-in may have expired, or there may be a network or server problem. We’ll keep checking.</p><p><a href="/von/" target="_blank" rel="noopener noreferrer">Sign in again in a new tab</a></p><p>Complete sign-in with the same account, then return to this tab and choose Check status / Retry.</p><p id="maintenance" role="status">Return time unknown.</p><small id="checked">No check completed yet.</small><button id="retry" type="button">Check status / Retry</button><p>This page reconnects automatically. It will never resend a message. If a previous send was interrupted, check the conversation before sending again.</p></main><script type="module" src="/static/outage/offline.js"></script></body></html>
13 changes: 7 additions & 6 deletions src/frontend/web/von_interface/static/outage/offline.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { maintenanceMessage, readMaintenance } from './status.js';
import { fetchVonHealth, healthErrorKind, connectionMessage } from './health.js';
let inFlight = false;
let failures = 0;
let timer;
let lastErrorKind = null;
async function check() {
if (inFlight) return;
inFlight = true;
Expand All @@ -14,8 +16,8 @@ async function check() {
document.getElementById('maintenance').textContent = maintenanceMessage(value.record, value);
});
try {
const response = await fetch('/health', { cache: 'no-store', redirect: 'error', signal: controller.signal });
const health = response.ok ? await response.json() : null;
const health = await fetchVonHealth({ signal: controller.signal });
lastErrorKind = null;
const seeds = health?.runtime_authority?.startup_seed_materialisations;
// Explicitly skipped startup work (including AgentTest) is not a pending
// readiness failure. Still require healthy transport and the real app below.
Expand All @@ -31,14 +33,12 @@ async function check() {
return;
}
}
} catch { /* A proxy error or device-offline event does not identify the server-side cause. */ }
} catch (error) { lastErrorKind = healthErrorKind(error); }
finally {
clearTimeout(timeout);
await status;
document.getElementById('checked').textContent = `Last checked: ${new Date().toLocaleTimeString()}`;
document.getElementById('connection').textContent = navigator.onLine === false
? 'Your device reports that it is offline. Check your connection; Von’s status is unknown.'
: 'Cannot reach Von. The cause is unknown. We’ll keep checking.';
document.getElementById('connection').textContent = connectionMessage(lastErrorKind);
button.disabled = false;
inFlight = false;
failures++;
Expand All @@ -47,4 +47,5 @@ async function check() {
}
document.getElementById('retry').addEventListener('click', check);
window.addEventListener('online', check);
window.addEventListener('focus', check);
void check();
6 changes: 3 additions & 3 deletions src/frontend/web/von_interface/static/outage/outage.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Cache only this public, dependency-free outage shell. Never cache application HTML,
// API responses, navigation history, health payloads, credentials or uploaded files.
const CACHE = 'von-outage-v1';
// Install the complete dependency bundle separately. A failed upgrade must not
// overwrite the active shell with imports its old worker cannot serve offline.
const CACHE = 'von-outage-v1-sign-in';
const STATIC_ROOT = '/static/';
const ASSETS = ['/static/outage/offline.html', '/static/outage/offline.js', '/static/outage/status.js', '/static/outage/outage.css'];
const ASSETS = ['/static/outage/offline.html', '/static/outage/offline.js', '/static/outage/status.js', '/static/outage/outage.css', '/static/outage/health.js'];
self.addEventListener('install', event => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE);
Expand All @@ -24,7 +26,7 @@ self.addEventListener('activate', event => {
event.waitUntil((async () => {
await self.clients.claim();
for (const name of await caches.keys()) {
if (name.startsWith('von-outage-v1-') && name !== CACHE) await caches.delete(name);
if ((name === 'von-outage-v1' || name.startsWith('von-outage-v1-')) && name !== CACHE) await caches.delete(name);
}
})());
});
Expand Down
Loading
Loading