fix: prioritize v02 workspace first screen

This commit is contained in:
Codex Agent
2026-06-05 15:55:18 +08:00
parent 00bccbb61a
commit 775c986a72
8 changed files with 291 additions and 37 deletions
+25 -13
View File
@@ -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">
+4 -4
View File
@@ -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 }),
+71 -17
View File
@@ -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) {