Feat v0.5.2 chat surface (#32)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m28s
CI/CD / test-sqlite (push) Successful in 2m42s
CI/CD / build-and-deploy (push) Successful in 1m8s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #32.
This commit is contained in:
2026-03-30 15:25:33 +00:00
committed by xcaliber
parent 7155aaf663
commit 6931b125a4
13 changed files with 1513 additions and 23 deletions

View File

@@ -17,13 +17,20 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
const selectableItems = items.filter(i => !i.divider && !i.disabled);
// Position relative to anchor
// When the surface uses CSS transform: scale(), getBoundingClientRect()
// returns scaled coordinates but position:fixed uses viewport coordinates.
// Dividing by the scale factor converts back to true viewport pixels.
useEffect(() => {
if (!open || !anchor) return;
const rect = typeof anchor.getBoundingClientRect === 'function'
const scale = window.sw?.shell?.getScale?.() || 1;
const raw = typeof anchor.getBoundingClientRect === 'function'
? anchor.getBoundingClientRect()
: anchor;
const vw = window.innerWidth;
const vh = window.innerHeight;
const rect = scale !== 1
? { top: raw.top / scale, bottom: raw.bottom / scale, left: raw.left / scale, right: raw.right / scale }
: raw;
const vw = window.innerWidth / scale;
const vh = window.innerHeight / scale;
let top, left;
const [vDir, hDir] = direction.split('-');

View File

@@ -209,13 +209,26 @@ export function createAuth() {
// Suppress _on401Failure redirect during boot — boot handles 401 itself.
_booting = true;
// Defensive timeout: boot MUST complete within 8s.
// If stale tokens cause a network hang or unexpected deadlock,
// clear tokens and let the login page render.
let timedOut = false;
const bootTimeout = setTimeout(() => {
timedOut = true;
console.warn('[sw.auth] Boot timed out — clearing stale tokens');
_clearTokens();
_booting = false;
}, 8000);
// Fetch permissions (validates the access token)
try {
await _fetchPermissions();
} catch (e) {
if (timedOut) return;
if (e.status === 401) {
// Token expired — try refresh
const ok = await auth.refresh();
if (timedOut) return;
if (ok) {
try { await _fetchPermissions(); }
catch (_) { _clearTokens(); }
@@ -226,6 +239,7 @@ export function createAuth() {
console.warn('[sw.auth] Boot: permissions fetch failed (keeping tokens):', e.message);
}
} finally {
clearTimeout(bootTimeout);
_booting = false;
}

View File

@@ -50,6 +50,12 @@ async function _buildError(resp, method, path) {
*/
export function createRestClient(getToken, onRefresh, on401Failure) {
// Auth endpoints must never trigger the 401→refresh→retry loop.
// Doing so causes a deadlock: refresh() calls POST /auth/refresh via
// _request, which 401s and calls onRefresh(), which returns the same
// unresolved refresh promise — both await each other forever.
const _authPaths = ['/api/v1/auth/login', '/api/v1/auth/refresh', '/api/v1/auth/register'];
async function _request(path, method, body, opts) {
const _opts = opts || {};
const headers = { 'Content-Type': 'application/json' };
@@ -62,8 +68,9 @@ export function createRestClient(getToken, onRefresh, on401Failure) {
let resp = await fetch(BASE + path, fetchOpts);
// 401 — try refresh once
if (resp.status === 401) {
// 401 — try refresh once (skip for auth endpoints to prevent deadlock)
const isAuthPath = _authPaths.some(p => path === p || path.startsWith(p + '?'));
if (resp.status === 401 && !isAuthPath && !_opts.skipRefresh) {
const refreshed = await onRefresh();
if (refreshed) {
// Retry with new token