Merge pull request #918 from pikasTech/fix/issue912-first-screen-request-priority
fix: prioritize v0.2 workspace first screen
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
const GET_PROXY_PREFIXES = Object.freeze(["/v1/"]);
|
||||
const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session"]);
|
||||
const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session", "/auth/bootstrap"]);
|
||||
const POST_PROXY_ROUTES = new Set([
|
||||
"/auth/login",
|
||||
"/auth/logout",
|
||||
@@ -15,6 +15,7 @@ const POST_PROXY_ROUTES = new Set([
|
||||
]);
|
||||
const PUBLIC_PROXY_ROUTES = new Set([
|
||||
"GET /auth/session",
|
||||
"GET /auth/bootstrap",
|
||||
"POST /auth/login",
|
||||
"POST /auth/logout",
|
||||
"POST /v1/agent/chat",
|
||||
|
||||
@@ -54,6 +54,15 @@ test("cloud web proxies public WebUI performance telemetry to cloud-api", () =>
|
||||
});
|
||||
});
|
||||
|
||||
test("cloud web exposes auth bootstrap as a public auth route", () => {
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/auth/bootstrap"), {
|
||||
proxy: true,
|
||||
authRequired: false,
|
||||
publicRoute: true,
|
||||
routeKey: "GET /auth/bootstrap"
|
||||
});
|
||||
});
|
||||
|
||||
test("cloud web proxies authenticated API key management write routes", () => {
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/api-keys"), {
|
||||
proxy: true,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createServer } from "node:http";
|
||||
import { createServer, request as httpRequest } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { gzip } from "node:zlib";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { isCloudWebSseRoute, proxyCloudApiRequest } from "./cloud-web-proxy.mjs";
|
||||
import { isCloudWebSseRoute, proxyCloudApiRequest, upstreamRequestHeaders } from "./cloud-web-proxy.mjs";
|
||||
import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs";
|
||||
|
||||
const readOnlyRpcMethods = new Set([
|
||||
@@ -15,6 +15,8 @@ const readOnlyRpcMethods = new Set([
|
||||
]);
|
||||
const gzipAsync = promisify(gzip);
|
||||
const GZIP_MIN_BYTES = 1024;
|
||||
const DEFAULT_AUTH_USERNAME = "admin";
|
||||
const DEFAULT_AUTH_PASSWORD = "hwlab2026";
|
||||
|
||||
export async function serveCloudWeb(options) {
|
||||
const server = createCloudWebServer(options);
|
||||
@@ -86,6 +88,10 @@ export function createCloudWebServer({
|
||||
|
||||
export async function handleCloudWebAuth(options) {
|
||||
const { request, response, url } = options;
|
||||
if (url.pathname === "/auth/bootstrap" && request.method === "GET") {
|
||||
await handleAuthBootstrap(options);
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === "/auth/session" && request.method === "GET") {
|
||||
await proxyCloudApi(options);
|
||||
return true;
|
||||
@@ -103,6 +109,101 @@ export async function handleCloudWebAuth(options) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleAuthBootstrap(options) {
|
||||
const { request, response, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson } = options;
|
||||
if (!cloudApiBaseUrl) {
|
||||
sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: "/auth/bootstrap" });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
|
||||
if (session.ok && session.body?.authenticated === true) {
|
||||
sendJsonWithForwardedCookies(response, session.statusCode, session.body, session.headers);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = webAuthConfig();
|
||||
if (config.mode !== "auto") {
|
||||
sendJsonWithForwardedCookies(response, session.statusCode || 200, session.body ?? { authenticated: false }, session.headers);
|
||||
return;
|
||||
}
|
||||
|
||||
const login = await requestCloudApiJson({
|
||||
request,
|
||||
method: "POST",
|
||||
pathname: "/auth/login",
|
||||
body: JSON.stringify({ username: config.username, password: config.password }),
|
||||
cloudApiBaseUrl,
|
||||
cloudApiProxyTimeoutMs
|
||||
});
|
||||
sendJsonWithForwardedCookies(response, login.statusCode || 502, login.body ?? { authenticated: false, error: "auth_bootstrap_failed" }, login.headers);
|
||||
}
|
||||
|
||||
function webAuthConfig() {
|
||||
return {
|
||||
mode: authConfigValue("HWLAB_CLOUD_WEB_AUTH_MODE", "auto"),
|
||||
username: authConfigValue("HWLAB_CLOUD_WEB_AUTH_USERNAME", DEFAULT_AUTH_USERNAME),
|
||||
password: authConfigValue("HWLAB_CLOUD_WEB_AUTH_PASSWORD", DEFAULT_AUTH_PASSWORD)
|
||||
};
|
||||
}
|
||||
|
||||
function authConfigValue(name, fallback) {
|
||||
const value = process.env[name];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function requestCloudApiJson({ request, method, pathname, body = "", cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const target = new URL(pathname, cloudApiBaseUrl);
|
||||
const proxyRequest = { method, headers: upstreamRequestHeaders({ headers: request.headers }, body) };
|
||||
const upstream = httpRequest(target, { method, headers: proxyRequest.headers }, (upstreamResponse) => {
|
||||
const chunks = [];
|
||||
upstreamResponse.on("data", (chunk) => chunks.push(chunk));
|
||||
upstreamResponse.on("end", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const rawBody = Buffer.concat(chunks).toString("utf8");
|
||||
let parsed = null;
|
||||
try { parsed = rawBody ? JSON.parse(rawBody) : null; } catch { parsed = { error: "invalid_json", rawBody: rawBody.slice(0, 200) }; }
|
||||
resolve({ ok: (upstreamResponse.statusCode || 0) >= 200 && (upstreamResponse.statusCode || 0) < 300, statusCode: upstreamResponse.statusCode || 502, body: parsed, headers: upstreamResponse.headers });
|
||||
});
|
||||
upstreamResponse.on("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
|
||||
});
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
upstream.destroy();
|
||||
resolve({ ok: false, statusCode: 504, body: { authenticated: false, error: "auth_bootstrap_timeout" }, headers: {} });
|
||||
}, cloudApiProxyTimeoutMs);
|
||||
upstream.on("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
|
||||
});
|
||||
upstream.on("close", () => clearTimeout(timeout));
|
||||
if (body) upstream.write(body);
|
||||
upstream.end();
|
||||
});
|
||||
}
|
||||
|
||||
function sendJsonWithForwardedCookies(response, statusCode, body, headers = {}) {
|
||||
const payload = JSON.stringify(body);
|
||||
const setCookie = headers["set-cookie"];
|
||||
const responseHeaders = {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(payload)
|
||||
};
|
||||
if (setCookie) responseHeaders["set-cookie"] = setCookie;
|
||||
response.writeHead(statusCode, responseHeaders);
|
||||
response.end(payload);
|
||||
}
|
||||
|
||||
async function readRequestBody(request) {
|
||||
let body = "";
|
||||
for await (const chunk of request) {
|
||||
|
||||
@@ -47,6 +47,82 @@ test("cloud web auth requests are proxied once", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud web auth bootstrap keeps an existing session without auto-login", async () => {
|
||||
const upstreamRequests = [];
|
||||
const upstream = createServer((request, response) => {
|
||||
upstreamRequests.push({ method: request.method, url: request.url, cookie: request.headers.cookie });
|
||||
request.resume();
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ authenticated: true, actor: { username: "alice" }, path: request.url }));
|
||||
});
|
||||
await listen(upstream);
|
||||
|
||||
const cloudWeb = createCloudWebServer({
|
||||
serviceId: "hwlab-cloud-web",
|
||||
cloudApiBaseUrl: serverUrl(upstream),
|
||||
cloudApiProxyTimeoutMs: 1000,
|
||||
healthPayload: () => ({ status: "ok" }),
|
||||
sendJson(response, statusCode, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
|
||||
response.end(payload);
|
||||
}
|
||||
});
|
||||
await listen(cloudWeb);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl(cloudWeb)}/auth/bootstrap`, { headers: { cookie: "hwlab_session=alice" } });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), { authenticated: true, actor: { username: "alice" }, path: "/auth/session" });
|
||||
assert.deepEqual(upstreamRequests, [{ method: "GET", url: "/auth/session", cookie: "hwlab_session=alice" }]);
|
||||
} finally {
|
||||
await close(cloudWeb);
|
||||
await close(upstream);
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud web auth bootstrap auto-logins only after an unauthenticated session", async () => {
|
||||
const upstreamRequests = [];
|
||||
const upstream = createServer(async (request, response) => {
|
||||
let body = "";
|
||||
for await (const chunk of request) body += chunk;
|
||||
upstreamRequests.push({ method: request.method, url: request.url, body });
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json",
|
||||
...(request.url === "/auth/login" ? { "set-cookie": "hwlab_session=session-auto; Path=/; HttpOnly" } : {})
|
||||
});
|
||||
response.end(JSON.stringify(request.url === "/auth/login" ? { authenticated: true, actor: { username: "admin" } } : { authenticated: false }));
|
||||
});
|
||||
await listen(upstream);
|
||||
|
||||
const cloudWeb = createCloudWebServer({
|
||||
serviceId: "hwlab-cloud-web",
|
||||
cloudApiBaseUrl: serverUrl(upstream),
|
||||
cloudApiProxyTimeoutMs: 1000,
|
||||
healthPayload: () => ({ status: "ok" }),
|
||||
sendJson(response, statusCode, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
|
||||
response.end(payload);
|
||||
}
|
||||
});
|
||||
await listen(cloudWeb);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl(cloudWeb)}/auth/bootstrap`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("set-cookie"), "hwlab_session=session-auto; Path=/; HttpOnly");
|
||||
assert.deepEqual(await response.json(), { authenticated: true, actor: { username: "admin" } });
|
||||
assert.deepEqual(upstreamRequests, [
|
||||
{ method: "GET", url: "/auth/session", body: "" },
|
||||
{ method: "POST", url: "/auth/login", body: JSON.stringify({ username: "admin", password: "hwlab2026" }) }
|
||||
]);
|
||||
} finally {
|
||||
await close(cloudWeb);
|
||||
await close(upstream);
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud web proxies Admin Access write routes", async () => {
|
||||
const upstreamRequests = [];
|
||||
const upstream = createServer(async (request, response) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { CSSProperties, ReactElement } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useAuth } from "./hooks/useAuth";
|
||||
import { useSidebarResize } from "./hooks/useSidebarResize";
|
||||
@@ -12,18 +11,19 @@ import { recordDraft } from "./components/command-bar/DraftsList";
|
||||
import { ConversationPanel } from "./components/conversation/ConversationPanel";
|
||||
import { DevicePodSidebar } from "./components/device-pod/DevicePodSidebar";
|
||||
import { ActivityRail } from "./components/layout/ActivityRail";
|
||||
import { AccessView } from "./components/access/AccessView";
|
||||
import { SessionSidebar } from "./components/sessions/SessionSidebar";
|
||||
import { SettingsView } from "./components/settings/SettingsView";
|
||||
import { SkillsView } from "./components/skills/SkillsView";
|
||||
import { GateView } from "./components/gate/GateView";
|
||||
import { PerformanceView } from "./components/performance/PerformanceView";
|
||||
import { WorkbenchBuildSummary } from "./components/workbench/WorkbenchBuildSummary";
|
||||
import { WorkbenchProbe } from "./components/workbench/WorkbenchProbe";
|
||||
import { fetchText } from "./services/api/client";
|
||||
import { installWebPerformanceTelemetry } from "./services/performance/rum";
|
||||
import { firstNonEmptyString } from "./utils";
|
||||
|
||||
const AccessView = lazy(() => import("./components/access/AccessView").then((module) => ({ default: module.AccessView })));
|
||||
const GateView = lazy(() => import("./components/gate/GateView").then((module) => ({ default: module.GateView })));
|
||||
const PerformanceView = lazy(() => import("./components/performance/PerformanceView").then((module) => ({ default: module.PerformanceView })));
|
||||
const SettingsView = lazy(() => import("./components/settings/SettingsView").then((module) => ({ default: module.SettingsView })));
|
||||
const SkillsView = lazy(() => import("./components/skills/SkillsView").then((module) => ({ default: module.SkillsView })));
|
||||
|
||||
const SESSION_SIDEBAR_STORAGE_KEY = "hwlab.workbench.sessionSidebarWidth.v1";
|
||||
const SESSION_SIDEBAR_DEFAULT_WIDTH = 330;
|
||||
const SESSION_SIDEBAR_MIN_WIDTH = 180;
|
||||
@@ -186,12 +186,12 @@ export function App(): ReactElement {
|
||||
) : null}
|
||||
<section className="center-workspace">
|
||||
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} onCopySession={() => copyTextToClipboard(firstNonEmptyString(store.composer.sessionId, store.state.workspace?.selectedAgentSessionId, store.state.workspace?.workspace?.selectedAgentSessionId, store.activeConversationId) ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null}
|
||||
{route === "performance" ? <PerformanceView /> : null}
|
||||
{route === "skills" ? <SkillsView /> : null}
|
||||
{route === "access" ? <AccessView /> : null}
|
||||
{route === "gate" ? <GateView /> : null}
|
||||
{route === "performance" ? <DeferredRouteView><PerformanceView /></DeferredRouteView> : null}
|
||||
{route === "skills" ? <DeferredRouteView><SkillsView /></DeferredRouteView> : null}
|
||||
{route === "access" ? <DeferredRouteView><AccessView /></DeferredRouteView> : null}
|
||||
{route === "gate" ? <DeferredRouteView><GateView /></DeferredRouteView> : null}
|
||||
{route === "help" ? <HelpView help={help} /> : null}
|
||||
{route === "settings" ? <SettingsView providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onLogout={auth.logout} /> : null}
|
||||
{route === "settings" ? <DeferredRouteView><SettingsView providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onLogout={auth.logout} /></DeferredRouteView> : null}
|
||||
{workspaceRoute ? <CommandBar key={store.activeConversationId ?? "default"} disabled={store.composer.disabled} interactionDisabled={workspaceShellPendingAuth} submitMode={store.composer.submitMode} targetTraceId={store.composer.targetTraceId} providerProfile={store.state.providerProfile} onProviderProfile={store.setProviderProfile} onSubmit={submitWithDraftRecord} onClear={store.clearConversation} pickedDraft={pickedDraft} onPickedDraftConsumed={clearPickedDraft} disabledReason={store.composer.disabledReason} onTyping={noteActivity} onPickDraft={pickDraft} /> : null}
|
||||
</section>
|
||||
{workspaceRoute ? (
|
||||
@@ -278,6 +278,18 @@ function AuthLoadingShell(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
function DeferredRouteView({ children }: { children: ReactNode }): ReactElement {
|
||||
return <Suspense fallback={<RouteLoadingView />}>{children}</Suspense>;
|
||||
}
|
||||
|
||||
function RouteLoadingView(): ReactElement {
|
||||
return (
|
||||
<section className="view route-loading-view" id="route-loading" aria-live="polite" aria-busy="true">
|
||||
<div className="workspace-panel route-loading-panel"><div className="panel-title-row"><div><p className="eyebrow">HWLAB</p><h2>正在加载</h2></div><span className="state-tag tone-source">准备中</span></div></div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpView({ help }: { help: string }): ReactElement {
|
||||
return (
|
||||
<section className="view help-view" id="help" data-view="help" aria-labelledby="help-title">
|
||||
|
||||
@@ -23,7 +23,9 @@ export function useAuth(): UseAuthResult {
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
async function run(): Promise<void> {
|
||||
const current = await api.session();
|
||||
const config = window.HWLAB_CLOUD_WEB_CONFIG?.auth;
|
||||
const mode = config?.mode ?? "auto";
|
||||
const current = mode === "auto" ? await api.authBootstrap() : await api.session();
|
||||
if (canceled) return;
|
||||
if (current.ok && current.data?.authenticated === true) {
|
||||
const next = sessionFromPayload(current.data);
|
||||
@@ -31,11 +33,9 @@ export function useAuth(): UseAuthResult {
|
||||
setAuthState("authenticated");
|
||||
return;
|
||||
}
|
||||
const config = window.HWLAB_CLOUD_WEB_CONFIG?.auth;
|
||||
const username = nonEmptyString(config?.username) ?? DEFAULT_USERNAME;
|
||||
const password = nonEmptyString(config?.password) ?? DEFAULT_PASSWORD;
|
||||
const mode = config?.mode ?? "auto";
|
||||
if (mode === "auto") {
|
||||
if (mode === "auto" && current.status === 404) {
|
||||
const autoLogin = await api.login(username, password);
|
||||
if (canceled) return;
|
||||
if (autoLogin.ok && autoLogin.data?.authenticated === true) {
|
||||
|
||||
@@ -205,6 +205,7 @@ function errorMessage(payload: unknown, fallback: string): string {
|
||||
|
||||
export const api = {
|
||||
session: (): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }>> => fetchJson("/auth/session", { timeoutName: "auth session" }),
|
||||
authBootstrap: (): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }>> => fetchJson("/auth/bootstrap", { timeoutName: "auth bootstrap" }),
|
||||
login: (username: string, password: string): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }>> => fetchJson("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
const DEFAULT_DEVICE_POD_ID = "device-pod-71-freq";
|
||||
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
|
||||
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
|
||||
const CONVERSATION_LIST_DELAY_MS = 1_000;
|
||||
const FIRST_SCREEN_LIVE_DELAY_MS = 1_400;
|
||||
const FIRST_SCREEN_EVENT_DELAY_MS = 1_600;
|
||||
const initialState: WorkbenchState = {
|
||||
workspace: null,
|
||||
@@ -104,6 +106,11 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
const selectedDevicePodIdRef = useRef(initialState.selectedDevicePodId);
|
||||
const liveRequestSeqRef = useRef(0);
|
||||
const deferredEventTimerRef = useRef<number | null>(null);
|
||||
const conversationListTimerRef = useRef<number | null>(null);
|
||||
const hydrateInFlightRef = useRef(false);
|
||||
const normalHydrateQueuedRef = useRef(false);
|
||||
const workspaceLoadedRef = useRef(false);
|
||||
const enabledRef = useRef(enabled);
|
||||
const devicePodEventsRef = useRef<{ devicePodId: string; events: LiveSurface["devicePodEvents"] } | null>(null);
|
||||
// Single shared activity clock so submit, trace polling, and user-typing
|
||||
// all reset the same inactivity window. Kept in a ref (not state) so
|
||||
@@ -138,28 +145,65 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
|
||||
const composer = useMemo(() => composerFromState(state, activeConversationId), [activeConversationId, state]);
|
||||
|
||||
useEffect(() => {
|
||||
enabledRef.current = enabled;
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedDevicePodIdRef.current = state.selectedDevicePodId;
|
||||
}, [state.selectedDevicePodId]);
|
||||
|
||||
const hydrate = useCallback(async () => {
|
||||
dispatch({ type: "hydrate:start" });
|
||||
const conversationsPromise = api.conversations(WORKBENCH_PROJECT_ID);
|
||||
const workspaceResult = await api.workspace(WORKBENCH_PROJECT_ID);
|
||||
if (!workspaceResult.ok) {
|
||||
dispatch({ type: "hydrate:error", error: workspaceResult.error ?? "workspace unavailable" });
|
||||
return;
|
||||
}
|
||||
const workspace = workspaceResult.data?.workspace ?? null;
|
||||
dispatch({
|
||||
type: "hydrate:done",
|
||||
workspace,
|
||||
messages: messagesFromWorkspace(workspace)
|
||||
});
|
||||
const conversationsResult = await conversationsPromise;
|
||||
const loadConversations = useCallback(async () => {
|
||||
const conversationsResult = await api.conversations(WORKBENCH_PROJECT_ID);
|
||||
if (conversationsResult.ok) dispatch({ type: "conversation:list", conversations: conversationsResult.data?.conversations ?? [] });
|
||||
}, []);
|
||||
|
||||
const clearConversationListTimer = useCallback(() => {
|
||||
if (conversationListTimerRef.current === null) return;
|
||||
window.clearTimeout(conversationListTimerRef.current);
|
||||
conversationListTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const scheduleConversationList = useCallback((delayMs = CONVERSATION_LIST_DELAY_MS) => {
|
||||
clearConversationListTimer();
|
||||
conversationListTimerRef.current = window.setTimeout(() => {
|
||||
conversationListTimerRef.current = null;
|
||||
void loadConversations();
|
||||
}, delayMs);
|
||||
}, [clearConversationListTimer, loadConversations]);
|
||||
|
||||
const hydrate = useCallback(async () => {
|
||||
if (hydrateInFlightRef.current) {
|
||||
normalHydrateQueuedRef.current = true;
|
||||
return;
|
||||
}
|
||||
hydrateInFlightRef.current = true;
|
||||
dispatch({ type: "hydrate:start" });
|
||||
const workspaceResult = await api.workspace(WORKBENCH_PROJECT_ID);
|
||||
try {
|
||||
if (!workspaceResult.ok) {
|
||||
workspaceLoadedRef.current = false;
|
||||
dispatch({ type: "hydrate:error", error: workspaceResult.error ?? "workspace unavailable" });
|
||||
return;
|
||||
}
|
||||
const workspace = workspaceResult.data?.workspace ?? null;
|
||||
workspaceLoadedRef.current = true;
|
||||
dispatch({
|
||||
type: "hydrate:done",
|
||||
workspace,
|
||||
messages: messagesFromWorkspace(workspace)
|
||||
});
|
||||
if (enabledRef.current) scheduleConversationList();
|
||||
} finally {
|
||||
hydrateInFlightRef.current = false;
|
||||
if (normalHydrateQueuedRef.current) {
|
||||
normalHydrateQueuedRef.current = false;
|
||||
if (enabledRef.current && !workspaceLoadedRef.current) void hydrate();
|
||||
else if (enabledRef.current) scheduleConversationList();
|
||||
}
|
||||
}
|
||||
}, [scheduleConversationList]);
|
||||
|
||||
const refreshLive = useCallback(async (devicePodId?: string) => {
|
||||
const requestSeq = ++liveRequestSeqRef.current;
|
||||
const requestedDevicePodId = devicePodId ?? selectedDevicePodIdRef.current;
|
||||
@@ -201,14 +245,24 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (workspaceLoadedRef.current) {
|
||||
scheduleConversationList();
|
||||
return;
|
||||
}
|
||||
void hydrate();
|
||||
}, [enabled, hydrate]);
|
||||
}, [enabled, hydrate, scheduleConversationList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) return;
|
||||
clearConversationListTimer();
|
||||
}, [enabled, clearConversationListTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void refreshLive();
|
||||
const firstLiveTimer = window.setTimeout(() => void refreshLive(), FIRST_SCREEN_LIVE_DELAY_MS);
|
||||
const timer = window.setInterval(() => void refreshLive(), 30_000);
|
||||
return () => {
|
||||
window.clearTimeout(firstLiveTimer);
|
||||
window.clearInterval(timer);
|
||||
liveRequestSeqRef.current += 1;
|
||||
if (deferredEventTimerRef.current !== null) {
|
||||
|
||||
Reference in New Issue
Block a user