diff --git a/s2s-frontend/README.md b/s2s-frontend/README.md index 9a7d498..96cd1ab 100644 --- a/s2s-frontend/README.md +++ b/s2s-frontend/README.md @@ -31,7 +31,7 @@ The Vite child explicitly uses `NODE_ENV=development`, including when a process manager sets `NODE_ENV=production`, so React Fast Refresh remains enabled. Vite serves React and Tailwind with hot updates. It proxies `/api`, -`/v1/realtime` (including WebSockets), OAuth, and backend documentation routes +`/v1/realtime` (including WebSockets), and backend documentation routes to FastAPI. The browser uses one origin on port `8420`, so there is no separate API origin or development CORS setup. The proxy preserves the incoming Host header because FastAPI uses it to construct the speech WebSocket URL. @@ -114,7 +114,7 @@ by Git. The launcher serves source through Vite; it does not use `dist/` or `vite preview`. Tests cover session initialization, response sequencing, cumulative transcripts, -interruptions, queue cancellation, microphone-permission cancellation, tool +interruptions, queue polling, admission deadlines, cancellation, microphone-permission cancellation, tool results, external reasoning and `LOG_ONLY`, late-response cleanup, the idle timeout, settings, RAG, profiles, and sanitized Markdown/export behavior. Launcher tests start temporary local children to check readiness, environment @@ -123,9 +123,9 @@ Vitest handles Node 26's experimental web storage behavior internally. ## Source layout -- `src/components/`: voice controls, chat, dialogs, profile, and account views. -- `src/realtime/`: session lifecycle, observable application state, queue and - budget handling, WebSocket protocol, tools, and external reasoning. +- `src/components/`: voice controls, chat, dialogs, and caller profile views. +- `src/realtime/`: session lifecycle, observable application state, queue + handling, WebSocket protocol, tools, and external reasoning. - `src/audio/`: TypeScript capture/playback worklets, audio graph, PCM codec, and visualizer. Vite emits the worklets as separate JavaScript assets. - `src/lib/`: validated backend contracts, settings, prompt context, profile diff --git a/s2s-frontend/config/backend-proxy.ts b/s2s-frontend/config/backend-proxy.ts index 84f168d..59c22c3 100644 --- a/s2s-frontend/config/backend-proxy.ts +++ b/s2s-frontend/config/backend-proxy.ts @@ -9,7 +9,6 @@ export function createBackendProxy(target: string): Record "/docs": http, "/redoc": http, "/openapi.json": http, - "/oauth": http, "/project": http, "/public/docs.html": http, }; diff --git a/s2s-frontend/src/App.test.tsx b/s2s-frontend/src/App.test.tsx index 7f74b5b..9bfe371 100644 --- a/s2s-frontend/src/App.test.tsx +++ b/s2s-frontend/src/App.test.tsx @@ -20,7 +20,6 @@ beforeEach(() => { const path = new URL(input, window.location.origin).pathname; const data: Record = { "/api/config": bootstrap, - "/api/me": { enabled: false }, "/api/user/phone": { result: { name: "Ana Vargas", contact: { emailAddress: "ana@example.test" } } }, "/api/user/reports": { count: 1, diff --git a/s2s-frontend/src/App.tsx b/s2s-frontend/src/App.tsx index ba5a661..a018102 100644 --- a/s2s-frontend/src/App.tsx +++ b/s2s-frontend/src/App.tsx @@ -1,17 +1,9 @@ import { BookOpen, ChartNoAxesColumn, ChevronRight, Info, PanelLeft, Settings, Wrench, X } from "lucide-react"; import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react"; -import { Account } from "./components/Account"; import { Bubbles } from "./components/Bubbles"; import { Conversation } from "./components/Conversation"; -import { - AboutDialog, - LimitDialog, - ProfileDialog, - RagDialog, - SettingsDialog, - ToolsDialog, -} from "./components/SettingsDialogs"; +import { AboutDialog, ProfileDialog, RagDialog, SettingsDialog, ToolsDialog } from "./components/SettingsDialogs"; import { VoiceStage } from "./components/VoiceStage"; import { useWakeWord } from "./hooks/use-wake-word"; import { initials, profileName } from "./lib/context"; @@ -149,7 +141,6 @@ export default function App() { )} {debug && (
- - {open && ( -
-
{account.username}
-
- {remaining} -
- {!unlimited && ( - - Pasar a PRO - - )} - {account.logoutUrl && ( - - Cerrar sesión - - )} -
- )} -
- ); -} diff --git a/s2s-frontend/src/components/SettingsDialogs.tsx b/s2s-frontend/src/components/SettingsDialogs.tsx index b5588eb..964ca4f 100644 --- a/s2s-frontend/src/components/SettingsDialogs.tsx +++ b/s2s-frontend/src/components/SettingsDialogs.tsx @@ -335,28 +335,3 @@ export function AboutDialog({ onClose }: { onClose: () => void }) { ); } - -export function LimitDialog({ state, onClose }: Omit) { - const anonymous = !state.account?.loggedIn; - return ( - -

- Has agotado el tiempo de conversación disponible hoy. Vuelve mañana para seguir conversando. -

- {anonymous && state.account?.loginUrl ? ( - - Inicia sesión para obtener más tiempo - - ) : state.account?.tier === "free" ? ( - - Pasar a PRO - - ) : null} -
- ); -} diff --git a/s2s-frontend/src/lib/api.ts b/s2s-frontend/src/lib/api.ts index d133ebc..1e7052c 100644 --- a/s2s-frontend/src/lib/api.ts +++ b/s2s-frontend/src/lib/api.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { accountSchema, configSchema, recordSchema, reportsSchema, toolsEnvelopeSchema } from "./contracts"; +import { configSchema, recordSchema, reportsSchema, toolsEnvelopeSchema } from "./contracts"; export class ApiError extends Error { readonly status: number; @@ -40,7 +40,7 @@ export async function optionalRequest(path: string, schema: z.ZodType, sig if (signal.aborted) { throw error; } - // Profile, reports, and account features are optional in generic deployments. + // Profile and reports are optional in generic deployments. return null; } } @@ -48,9 +48,6 @@ export async function optionalRequest(path: string, schema: z.ZodType, sig export function getConfig(signal: AbortSignal) { return request("/api/config", configSchema, { signal }); } -export function getAccount(signal: AbortSignal) { - return optionalRequest("/api/me", accountSchema, signal); -} export async function getProfile(phone: string, signal: AbortSignal) { if (!phone) { return null; @@ -89,8 +86,9 @@ export const searchSchema = z.object({ }); /** Teardown must survive pagehide and must not hold up a new session. */ -export function endReservation(path: "/api/session/end" | "/api/queue/end", body: Record) { - const payload = JSON.stringify(body); +export function cancelQueue(queueId: string) { + const path = "/api/queue/end"; + const payload = JSON.stringify({ queueId }); if ( typeof navigator.sendBeacon === "function" && navigator.sendBeacon(path, new Blob([payload], { type: "application/json" })) diff --git a/s2s-frontend/src/lib/contracts.ts b/s2s-frontend/src/lib/contracts.ts index df8a9ea..916a97f 100644 --- a/s2s-frontend/src/lib/contracts.ts +++ b/s2s-frontend/src/lib/contracts.ts @@ -10,7 +10,6 @@ export const configSchema = z.object({ s2sUrl: z.string().default(""), primaryPrompt: z.string().default(""), reasoningType: z.enum(["PROXY", "OPENAI", "NODE_RED"]).catch("PROXY"), - auth: z.boolean().default(false), injectProfile: z.boolean().default(false), debugPrompt: z.boolean().default(false), startSessionMode: z.string().default(""), @@ -37,20 +36,6 @@ export const reportsSchema = z.object({ }); export type Reports = z.infer; -export const accountSchema = z.object({ - enabled: z.boolean(), - loggedIn: z.boolean().default(false), - auth: z.boolean().default(false), - tier: z.string().default("anon"), - username: z.string().nullish(), - avatar: z.string().nullish(), - loginUrl: z.string().nullish(), - logoutUrl: z.string().nullish(), - remainingSec: z.number().nullish(), - limitSec: z.number().nullish(), -}); -export type AccountInfo = z.infer; - export const toolSchema = z.object({ name: z.string(), description: z.string().default(""), @@ -74,10 +59,6 @@ export const grantSchema = z.object({ session_id: z.string(), connect_url: z.string(), pending_timeout_s: z.number().default(60), - tier: z.string().default("anon"), - limited: z.boolean().default(false), - heartbeatSec: z.number().default(5), - remainingSec: z.number().nullish(), }); export type SessionGrant = z.infer; export const queueSchema = z.object({ diff --git a/s2s-frontend/src/realtime/client.test.ts b/s2s-frontend/src/realtime/client.test.ts index 3023219..aaead41 100644 --- a/s2s-frontend/src/realtime/client.test.ts +++ b/s2s-frontend/src/realtime/client.test.ts @@ -56,8 +56,7 @@ const options = (): ClientOptions => ({ gate: { enabled: false, thresholdDb: -66 }, }); -async function connect(client: RealtimeClient) { - const pending = client.connect(); +async function connect(client: RealtimeClient, pending = client.connect()) { await vi.waitFor(() => { expect(Socket.instances).toHaveLength(1); }); @@ -89,33 +88,145 @@ afterEach(() => { }); describe("realtime session", () => { - it("ends a metered session and releases its reservation when the budget expires", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - session_id: "metered", - connect_url: "ws://localhost/v1/realtime", - limited: true, - heartbeatSec: 5, - }), - ), - ) - .mockResolvedValueOnce(new Response(JSON.stringify({ expired: true }))); + it("connects with a plain load-balancer grant and closes without further HTTP requests", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + session_id: "local-session", + connect_url: "ws://localhost/v1/realtime", + }), + ), + ); vi.stubGlobal("fetch", fetchMock); const client = new RealtimeClient({ ...options(), directUrl: undefined, sessionUrl: "/api/session" }); const error = vi.fn(); client.on("error", error); const socket = await connect(client); - await vi.advanceTimersByTimeAsync(5000); - expect(error).toHaveBeenCalledWith(expect.objectContaining({ code: "limit" })); + await vi.advanceTimersByTimeAsync(60000); + expect(client.status).toBe("listening"); + expect(error).not.toHaveBeenCalled(); + expect(socket.url).toBe("ws://localhost/v1/realtime"); + await client.close(); expect(socket.close).toHaveBeenCalledOnce(); - expect(sendBeacon).toHaveBeenCalledWith("/api/session/end", expect.any(Blob)); + expect(audio.close).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith("/api/session", expect.objectContaining({ method: "POST" })); + expect(sendBeacon).not.toHaveBeenCalled(); expect(client.status).toBe("closed"); expect(vi.getTimerCount()).toBe(0); }); + it.each(["ws://voice.test/v1/realtime", "ws://localhost:8420/v1/realtime"])( + "starts and cleans up the direct or proxied socket at %s", + async (directUrl) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const client = new RealtimeClient({ ...options(), directUrl }); + const socket = await connect(client); + expect(socket.url).toBe(directUrl); + expect(audio.start).toHaveBeenCalledOnce(); + await client.close(); + await client.close(); + expect(socket.close).toHaveBeenCalledExactlyOnceWith(1000, "client closed"); + expect(audio.close).toHaveBeenCalledOnce(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(sendBeacon).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it("polls a queue ticket and joins the granted slot before its deadline", async () => { + const fetchMock = vi + .fn<(path: string, init?: RequestInit) => Promise>() + .mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1", position: 2 }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1", position: 1 }))) + .mockResolvedValueOnce( + new Response(JSON.stringify({ session_id: "s1", connect_url: "ws://voice.test", pending_timeout_s: 10 })), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new RealtimeClient({ ...options(), directUrl: undefined }); + const readyToJoin = vi.fn(); + client.on("readyToJoin", readyToJoin); + const pending = client.connect(); + await vi.waitFor(() => { + expect(client.status).toBe("queued"); + }); + await vi.advanceTimersByTimeAsync(4000); + expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/session", "/api/queue/q1", "/api/queue/q1"]); + expect(client.status).toBe("your-turn"); + expect(readyToJoin).toHaveBeenCalledWith(expect.objectContaining({ expiresSec: 7 })); + expect(Socket.instances).toHaveLength(0); + client.join(); + await connect(client, pending); + await client.close(); + expect(sendBeacon).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("expires an unclaimed join offer after releasing the permission probe's microphone", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" }))) + .mockResolvedValueOnce( + new Response(JSON.stringify({ session_id: "s1", connect_url: "ws://voice.test", pending_timeout_s: 10 })), + ), + ); + const client = new RealtimeClient({ ...options(), directUrl: undefined }); + const rejected = expect(client.connect()).rejects.toMatchObject({ code: "join-expired" }); + await vi.waitFor(() => { + expect(client.status).toBe("queued"); + }); + await vi.advanceTimersByTimeAsync(9000); + await rejected; + expect(stopTrack).toHaveBeenCalledOnce(); + expect(audio.start).not.toHaveBeenCalled(); + expect(Socket.instances).toHaveLength(0); + expect(client.status).toBe("closed"); + expect(sendBeacon).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stops polling when the load balancer expires a queue ticket", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ state: "expired" }), { status: 404 })); + vi.stubGlobal("fetch", fetchMock); + const client = new RealtimeClient({ ...options(), directUrl: undefined }); + const rejected = expect(client.connect()).rejects.toMatchObject({ code: "queue-expired" }); + await vi.waitFor(() => { + expect(client.status).toBe("queued"); + }); + await vi.advanceTimersByTimeAsync(2000); + await rejected; + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(Socket.instances).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("uses a keepalive request to cancel a queue ticket when sendBeacon declines it", async () => { + sendBeacon.mockReturnValueOnce(false); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ state: "queued", queue_id: "q1" }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }))); + vi.stubGlobal("fetch", fetchMock); + const client = new RealtimeClient({ ...options(), directUrl: undefined }); + const rejected = expect(client.connect()).rejects.toMatchObject({ code: "aborted" }); + await vi.waitFor(() => { + expect(client.status).toBe("queued"); + }); + await client.close(); + await rejected; + expect(fetchMock).toHaveBeenLastCalledWith( + "/api/queue/end", + expect.objectContaining({ method: "POST", body: JSON.stringify({ queueId: "q1" }), keepalive: true }), + ); + expect(vi.getTimerCount()).toBe(0); + }); + it("replaces cumulative user transcript deltas instead of duplicating words", async () => { const client = new RealtimeClient(options()); const transcript = vi.fn(); diff --git a/s2s-frontend/src/realtime/client.ts b/s2s-frontend/src/realtime/client.ts index c24d67a..b618ec6 100644 --- a/s2s-frontend/src/realtime/client.ts +++ b/s2s-frontend/src/realtime/client.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { encodePcm } from "../audio/codec"; import { AudioEngine, MIC_CONSTRAINTS } from "../audio/engine"; -import { ApiError, endReservation, post, request } from "../lib/api"; +import { ApiError, cancelQueue, post, request } from "../lib/api"; import { grantSchema, realtimeEventSchema, @@ -41,7 +41,7 @@ export interface ClientOptions { } export class SessionError extends Error { - readonly code: "limit" | "queue-full" | "queue-expired" | "join-expired" | "aborted"; + readonly code: "queue-full" | "queue-expired" | "join-expired" | "aborted"; constructor(code: SessionError["code"], message: string) { super(message); this.code = code; @@ -60,12 +60,10 @@ export class RealtimeClient { private configured = false; private closed = false; private closing: Promise | null = null; - private grant: SessionGrant | null = null; private queueId = ""; private joinResolve: (() => void) | null = null; private joinReject: ((reason: Error) => void) | null = null; private joinTimer: ReturnType | undefined; - private heartbeat: ReturnType | undefined; private incoming: Promise = Promise.resolve(); private activeResponses = new Set(); private activeResponse = ""; @@ -211,7 +209,7 @@ export class RealtimeClient { }); } catch (error) { this.ensureOpen(); - if (error instanceof ApiError && (error.status === 402 || error.status === 404)) { + if (error instanceof ApiError && error.status === 404) { throw this.sessionError(error); } continue; @@ -222,7 +220,6 @@ export class RealtimeClient { } } const grant = grantSchema.parse(result); - this.grant = grant; this.queueId = ""; this.ensureOpen(); if (waited) { @@ -239,33 +236,11 @@ export class RealtimeClient { await joined; } this.emit("grant", grant); - if (grant.limited) { - this.heartbeat = setInterval( - () => { - void post( - "/api/session/heartbeat", - z.object({ expired: z.boolean() }), - { sessionId: grant.session_id }, - this.abort.signal, - ) - .then((result) => { - if (result.expired) { - this.fail(new SessionError("limit", "Se ha agotado tu tiempo de conversación.")); - } - }) - .catch(() => undefined); - }, - Math.max(1, grant.heartbeatSec) * 1000, - ); - } this.setStatus("connecting"); return grant; } private sessionError(error: unknown): unknown { if (error instanceof ApiError) { - if (error.status === 402) { - return new SessionError("limit", "Se ha agotado tu tiempo de conversación."); - } if (error.status === 404) { return new SessionError("queue-expired", "La espera ha caducado. Vuelve a intentarlo."); } @@ -566,7 +541,6 @@ export class RealtimeClient { } this.closed = true; this.abort.abort(); - clearInterval(this.heartbeat); clearTimeout(this.joinTimer); clearTimeout(this.readyTimer); const cancelled = new SessionError("aborted", "La conexión se canceló."); @@ -577,13 +551,9 @@ export class RealtimeClient { this.resolveReady = null; this.rejectReady = null; if (this.queueId) { - endReservation("/api/queue/end", { queueId: this.queueId }); + cancelQueue(this.queueId); this.queueId = ""; } - if (this.grant) { - endReservation("/api/session/end", { sessionId: this.grant.session_id }); - this.grant = null; - } if (this.socket && this.socket.readyState <= WebSocket.OPEN) { this.socket.close(1000, "client closed"); } diff --git a/s2s-frontend/src/realtime/controller.test.ts b/s2s-frontend/src/realtime/controller.test.ts index 2ff2b54..fc8dc1a 100644 --- a/s2s-frontend/src/realtime/controller.test.ts +++ b/s2s-frontend/src/realtime/controller.test.ts @@ -64,7 +64,6 @@ beforeEach(() => { const pathname = new URL(path, "http://localhost").pathname; const responses: Record = { "/api/config": { reasoningType: "NODE_RED", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" }, - "/api/me": { enabled: false }, "/api/user/phone": { result: { name: "Ana" } }, "/api/user/reports": { count: 0 }, }; @@ -88,6 +87,17 @@ async function start() { return client; } describe("voice application lifecycle", () => { + it("loads configuration and caller context without an account request on startup or stop", async () => { + const client = await start(); + await controller.stop(); + expect(client.close).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls.map(([path]) => new URL(path, "http://localhost").pathname)).toEqual([ + "/api/config", + "/api/user/phone", + "/api/user/reports", + ]); + }); + it("keeps LOG_ONLY reasoning on the native voice path and preserves separate profile context", async () => { const client = await start(); client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false }); diff --git a/s2s-frontend/src/realtime/controller.ts b/s2s-frontend/src/realtime/controller.ts index c8e216c..1f99a2c 100644 --- a/s2s-frontend/src/realtime/controller.ts +++ b/s2s-frontend/src/realtime/controller.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import { createAudioContext } from "../audio/engine"; import { errorMessage, - getAccount, getConfig, getProfile, getReports, @@ -15,7 +14,6 @@ import { import { buildInstructions, profileContext, ragContext, speechInstructions, WEB_SEARCH } from "../lib/context"; import { defaultConfig, - type AccountInfo, type ConnectionStatus, type DynamicTool, type Profile, @@ -44,7 +42,6 @@ export interface AppState { settings: Settings; profile: Profile | null; reports: Reports | null; - account: AccountInfo | null; tools: DynamicTool[]; toolsLoading: boolean; toolsError: string; @@ -57,8 +54,6 @@ export interface AppState { rag: string; queuePosition: number; joinDeadline: number; - remainingSec: number | null; - limitReached: boolean; unlock: boolean; } @@ -76,7 +71,6 @@ export class VoiceController { private sessionNumber = 0; private reasoned = new Set(); private autoStarted = false; - private pageSignal: AbortSignal | undefined; constructor(caller = callerFromLocation()) { this.caller = caller; @@ -86,7 +80,6 @@ export class VoiceController { settings: loadSettings(), profile: null, reports: null, - account: null, tools: [], toolsLoading: false, toolsError: "", @@ -99,8 +92,6 @@ export class VoiceController { rag: "", queuePosition: 0, joinDeadline: 0, - remainingSec: null, - limitReached: false, unlock: false, }; } @@ -119,18 +110,16 @@ export class VoiceController { } async initialize(signal: AbortSignal) { - this.pageSignal = signal; try { - const [config, profile, reports, account] = await Promise.all([ + const [config, profile, reports] = await Promise.all([ getConfig(signal), getProfile(this.caller.phoneNumber, signal), getReports(this.caller.phoneNumber, signal), - getAccount(signal), ]); if (signal.aborted) { return; } - this.update({ config, profile, reports, account, loading: false, error: "" }); + this.update({ config, profile, reports, loading: false, error: "" }); if (config.apipilot) { await this.refreshTools(signal, !!profile); } @@ -189,9 +178,6 @@ export class VoiceController { this.update({ rag: rag.trim() }); this.pushSettings(); } - dismissLimit() { - this.update({ limitReached: false }); - } dismissError() { this.update({ error: "" }); } @@ -220,7 +206,6 @@ export class VoiceController { notice: "", status: "connecting", muted: false, - limitReached: false, joinDeadline: 0, }); let context: AudioContext | undefined; @@ -307,8 +292,8 @@ export class VoiceController { client.on("readyToJoin", ({ expiresSec }) => { this.update({ joinDeadline: Date.now() + expiresSec * 1000 }); }), - client.on("grant", ({ remainingSec }) => { - this.update({ remainingSec: remainingSec ?? null, joinDeadline: 0 }); + client.on("grant", () => { + this.update({ joinDeadline: 0 }); }), client.on("error", (error) => { this.reportError(error); @@ -352,7 +337,6 @@ export class VoiceController { this.update({ error: errorMessage(error), status: "error", - limitReached: error instanceof SessionError && error.code === "limit", }); } private transcript(transcript: Transcript, interrupted = false) { @@ -513,19 +497,11 @@ export class VoiceController { muted: false, level: 0, joinDeadline: 0, - remainingSec: null, unlock: false, messages: this.state.messages.map((message) => message.partial ? { ...message, partial: false, interrupted: message.role === "assistant" } : message, ), }); await client?.close(); - if (client && this.state.account?.enabled && this.pageSignal && !this.pageSignal.aborted) { - const signal = this.pageSignal; - const account = await getAccount(signal).catch(() => null); - if (!signal.aborted && account) { - this.update({ account }); - } - } } } diff --git a/s2s-frontend/src/styles/legacy-theme.css b/s2s-frontend/src/styles/legacy-theme.css index 42e147e..6e0baef 100644 --- a/s2s-frontend/src/styles/legacy-theme.css +++ b/s2s-frontend/src/styles/legacy-theme.css @@ -645,18 +645,6 @@ a:hover { width: 18px; height: 18px; } - /* Keep the account control compact on phones: avatar-only chip, shorter pill. */ - .account-chip { - height: 36px; - padding: 0 6px; - } - .account-handle { - display: none; - } - .signin-pill { - height: 36px; - padding: 0 12px; - } /* ── Footer (narrow viewport): reduce tracking to avoid awkward wrapping; * use compact padding and a taller line height. */ .app-footer { @@ -857,236 +845,6 @@ a:hover { height: 20px; } -/* ─── Account (HF login chip + sign-in pill + popover) ─────────────────────── */ -.account { - position: relative; - display: inline-flex; - align-items: center; -} -/* Signed-out: a compact pill that reads as the primary affordance. */ -.signin-pill { - display: inline-flex; - align-items: center; - gap: 7px; - height: 40px; - padding: 0 14px; - border-radius: var(--radius-sm); - border: 1px solid var(--border-strong); - background: var(--bg-elev); - color: var(--text); - font-size: 13px; - font-weight: 600; - text-decoration: none; - transition: - background 0.15s, - border-color 0.15s; -} -.signin-pill svg { - width: 16px; - height: 16px; - flex: none; -} -.signin-pill:hover { - background: var(--bg-elev-2); - border-color: var(--text); -} -/* Narrow viewports: collapse the pill to an icon-only square. */ -@media (max-width: 800px) { - .signin-pill { - gap: 0; - width: 40px; - padding: 0; - justify-content: center; - } - .signin-pill span { - display: none; - } -} -/* Signed-in: avatar + handle chip. */ -.account-chip { - display: inline-flex; - align-items: center; - gap: 8px; - height: 40px; - padding: 0 10px 0 6px; - border-radius: var(--radius-sm); - border: 1px solid var(--border); - background: var(--bg-elev); - color: var(--text-dim); - cursor: pointer; - transition: - background 0.15s, - color 0.15s, - border-color 0.15s; -} -.account-chip:hover { - background: var(--bg-elev-2); - color: var(--text); - border-color: var(--border-strong); -} -.account-avatar { - width: 26px; - height: 26px; - border-radius: 50%; - object-fit: cover; - flex: none; -} -.account-avatar-fallback { - display: inline-flex; - align-items: center; - justify-content: center; - background: var(--bg-elev-2); - color: var(--text); - font-size: 12px; - font-weight: 700; -} -.account-handle { - font-size: 13px; - font-weight: 600; - max-width: 12ch; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.account-pro { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.04em; - padding: 2px 5px; - border-radius: 5px; - background: var(--text); - color: var(--bg); -} -/* Org "Team" badge: same pill as PRO, accent-coloured so it reads as - * unlimited without claiming a paid PRO subscription. */ -.account-team { - background: var(--accent); - color: var(--bg); -} -.account-pop { - position: absolute; - top: calc(100% + 8px); - right: 0; - min-width: 200px; - padding: 6px; - border-radius: var(--radius-md); - border: 1px solid var(--border-strong); - background: var(--bg-elev); - box-shadow: var(--shadow-soft); - z-index: 50; -} -.account-pop[hidden] { - display: none; -} -.account-pop-row { - padding: 8px 10px; -} -.account-pop-name { - font-weight: 600; - font-size: 13px; -} -.account-pop-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding-top: 0; - font-size: 12px; - color: var(--text-dim); -} -.account-tier { - font-weight: 600; - color: var(--text); -} -.account-pop-link { - display: block; - padding: 8px 10px; - border-radius: var(--radius-sm); - color: var(--text-dim); - font-size: 13px; - text-decoration: none; - transition: - background 0.15s, - color 0.15s; -} -.account-pop-link:hover { - background: var(--bg-elev-2); - color: var(--text); -} -.account-signout { - border-top: 1px solid var(--border); - margin-top: 4px; - padding-top: 10px; - border-radius: 0 0 var(--radius-sm) var(--radius-sm); -} - -/* ─── Daily-limit modal ────────────────────────────────────────────────────── */ -.limit-modal { - width: min(400px, 92vw); -} -.limit-card { - position: relative; - align-items: center; - text-align: center; - gap: 14px; - padding: 34px 28px 26px; -} -.limit-close { - position: absolute; - top: 12px; - right: 12px; -} -/* HF smiling face (brand yellow) on a neutral badge — friendly, not a stop sign. - * The logo is the single pop of colour, so the badge itself stays quiet. */ -.limit-badge { - width: 66px; - height: 66px; - border-radius: 50%; - display: grid; - place-items: center; - margin: 2px auto 2px; - background: var(--bg-elev-2); - border: 1px solid var(--border-strong); - box-shadow: 0 0 0 6px rgba(255, 210, 30, 0.06); -} -.limit-badge .hf-logo { - width: 46px; - height: auto; -} -.limit-title { - margin: 0; - font-size: 20px; - font-weight: 700; - letter-spacing: 0; -} -.limit-msg { - color: var(--text-dim); - font-size: 14px; - line-height: 1.55; - margin: 0; - max-width: 30ch; -} -.limit-cta { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - margin-top: 4px; -} -.limit-cta .hf-logo { - width: 20px; - height: auto; - flex: none; -} -.limit-note { - margin: 0; - font-size: 12px; - color: var(--text-faint); -} -#limit-cta[hidden] { - display: none; -} - /* ─── Stage ───────────────────────────────────────────────────────────── */ .stage { @@ -6112,19 +5870,6 @@ html[data-theme="white"] .reports-sidebar-header h2 { color: #0f172a; } -/* ── Limit modal (budget) ───────────────────────────────────────────── */ -html[data-theme="white"] .limit-card { - background: #ffffff; - border: 1px solid rgba(15, 23, 42, 0.08); -} -html[data-theme="white"] .limit-title, -html[data-theme="white"] .limit-msg { - color: #0f172a; -} -html[data-theme="white"] .limit-note { - color: rgba(15, 23, 42, 0.62); -} - /* ── User profile modal ─────────────────────────────────────────────── */ html[data-theme="white"] .user-profile-card { background: #ffffff; diff --git a/s2s-frontend/src/test/backend-proxy.test.ts b/s2s-frontend/src/test/backend-proxy.test.ts index 29ed1b0..a555721 100644 --- a/s2s-frontend/src/test/backend-proxy.test.ts +++ b/s2s-frontend/src/test/backend-proxy.test.ts @@ -8,6 +8,6 @@ describe("development backend proxy", () => { expect(proxy["/api"]).toEqual({ target: "http://127.0.0.1:8421", changeOrigin: false }); expect(proxy["/v1/realtime"]).toEqual({ target: "http://127.0.0.1:8421", changeOrigin: false, ws: true }); expect(proxy["/openapi.json"]).toEqual(proxy["/api"]); - expect(proxy["/oauth"]).toEqual(proxy["/api"]); + expect(proxy["/docs"]).toEqual(proxy["/api"]); }); });