diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index c9e45985c2..dc89ed8f30 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -78,11 +78,7 @@ assert.equal((i18n.match(/"header\.managerOutputTokenBudget"/g) ?? []).length, 2 for (const view of ["Chat", "Tasks", "Files"]) { assert.match(header, new RegExp(`\"${view.toLowerCase()}\"|>${view}<`), `Goal header exposes ${view}`); } -assert.match( - page, - /function selectGoal\(goalId: string \| null\)[\s\S]*?setSelectedGoalTab\("tasks"\)/, - "Selecting a Goal opens its Tasks view first", -); +// Task-first entry is exercised with reload/history in workspace-view-recovery. assert.match(model, /onOpenGoalView\??:/, "Goal detail can switch the center workspace view"); for (const label of ["执行中", "已安排", "等待条件", "可继续"]) { assert.match(model + drawer + page, new RegExp(label), `Session and Run status language includes ${label}`); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 64e9966242..70404a0592 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -475,7 +475,8 @@ export type PersonalWorkspaceCallbacks = { settings?: LoopXModeSettings) => void; onSelectAgent?: (agentId: string) => void; onSelectChannel?: (channel: WorkspaceChannel) => void; - onSelectGoal?: (goalId: string | null) => void; + onSelectGoal?: (goalId: string | null, view: WorkspaceGoalTab) => void; + onSelectView?: (view: WorkspaceGoalTab) => void; onOpenNotificationSettings?: (goalId?: string) => void; onFetchNotificationTargets?: () => Promise>; onSetupGoalChannel?: (options: { execute: boolean; goalId: string; target: string }) => Promise<{ ok: boolean; blocker?: string; public_summary?: string; status?: string }>; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index ae53146737..7147185fd0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -757,7 +757,7 @@ export function PersonalWorkspacePage({ agents = [{ agentId: "codex", available: true, capability: "代码与项目执行", label: "Codex" }], callbacks = {}, goalArchiveLoadState = { error: null, phase: "ready" }, - initialManagerChatOpen = false, + selectedView: controlledView, managerChannelBinding, managerRuntime, model, @@ -776,7 +776,7 @@ export function PersonalWorkspacePage({ agents?: WorkspaceAgentOption[]; callbacks?: PersonalWorkspaceCallbacks; goalArchiveLoadState?: WorkspaceGoalArchiveLoadState; - initialManagerChatOpen?: boolean; + selectedView?: WorkspaceGoalTab; managerChannelBinding?: ManagerChannelBinding | null; managerRuntime?: ManagerRuntimeSessionReadback | null; model: WorkspaceModel; @@ -794,8 +794,13 @@ export function PersonalWorkspacePage({ const [taskInspectorExpanded, setTaskInspectorExpanded] = useState(false); const [activeSessionRun, setActiveSessionRun] = useState(null); const [proposals, setProposals] = useState>({}); - const [selectedGoalTab, setSelectedGoalTab] = useState("chat"); - const [managerChatOpen, setManagerChatOpen] = useState(initialManagerChatOpen); + const [localView, setLocalView] = useState(controlledGoalId ? "chat" : "overview"); + const selectedGoalTab = controlledView ?? localView; + const managerChatOpen = selectedGoalTab === "chat"; + function setSelectedGoalTab(view: WorkspaceGoalTab) { + setLocalView(view); + callbacks.onSelectView?.(view); + } const [managerConversationReceiptVisible, setManagerConversationReceiptVisible] = useState(false); const [goalConversationReceiptVisible, setGoalConversationReceiptVisible] = useState(false); const [drafts, setDrafts] = useState>(() => { @@ -1523,8 +1528,8 @@ export function PersonalWorkspacePage({ const drawerCallbacks: PersonalWorkspaceCallbacks = { ...callbacks, onOpenRunSession: async (run) => { - if (run.goalId !== selectedGoalId) selectGoal(run.goalId); - setSelectedGoalTab("chat"); + if (run.goalId !== selectedGoalId) selectGoal(run.goalId, "chat"); + else setSelectedGoalTab("chat"); await callbacks.onOpenRunSession?.(run); setActiveSessionRun(run); setSelection(null); @@ -1539,8 +1544,8 @@ export function PersonalWorkspacePage({ setSelection(null); }, onOpenOutput: (output) => { - if (output.goalId !== selectedGoalId) selectGoal(output.goalId); - setSelectedGoalTab("files"); + if (output.goalId !== selectedGoalId) selectGoal(output.goalId, "files"); + else setSelectedGoalTab("files"); callbacks.onOpenOutput?.(output); }, onApplyProposal: applyProposal, @@ -1619,16 +1624,15 @@ export function PersonalWorkspacePage({ onOpenOutput: drawerCallbacks.onOpenOutput, } : drawerCallbacks; - function selectGoal(goalId: string | null) { + function selectGoal(goalId: string | null, view: WorkspaceGoalTab = goalId ? "tasks" : "overview") { setLocalGoalId(goalId); - setManagerChatOpen(false); setManagerConversationReceiptVisible(false); setGoalConversationReceiptVisible(false); setActiveSessionRun(null); setSelection(null); - setSelectedGoalTab("tasks"); + setLocalView(view); setMobileSidebarOpen(false); - callbacks.onSelectGoal?.(goalId); + callbacks.onSelectGoal?.(goalId, view); } function selectAgent(agentId: string) { @@ -1836,7 +1840,7 @@ export function PersonalWorkspacePage({ onOpenNavigation={() => setMobileSidebarOpen(true)} onOpenManagerChat={() => { setManagerConversationReceiptVisible(false); - setManagerChatOpen(true); + setSelectedGoalTab("chat"); }} onSelectGoalTab={(tab) => { if (tab === "chat") openGoalConversation(); @@ -1844,7 +1848,7 @@ export function PersonalWorkspacePage({ }} onSelectAgent={selectAgent} onReturnManagerHome={() => { - setManagerChatOpen(false); + setSelectedGoalTab("overview"); setManagerConversationReceiptVisible(false); window.requestAnimationFrame(() => channelScrollRef.current?.scrollTo({ behavior: "smooth", top: 0 })); }} @@ -1956,7 +1960,7 @@ export function PersonalWorkspacePage({ onInterruptTurn={!readOnly && callbacks.onInterruptConversationTurn ? (turnId) => callbacks.onInterruptConversationTurn!("manager", turnId) : undefined} - onOpenGoalEvidence={(goalId) => { selectGoal(goalId); openGoalConversation(); }} /> + onOpenGoalEvidence={(goalId) => { selectGoal(goalId, "chat"); }} /> )}
@@ -1984,7 +1988,7 @@ export function PersonalWorkspacePage({ onClose={() => setManagerConversationReceiptVisible(false)} onOpenConversation={() => { setManagerConversationReceiptVisible(false); - setManagerChatOpen(true); + setSelectedGoalTab("chat"); }} /> ) : null} {selectedGoal && selectedGoalTab !== "chat" && goalConversationReceiptVisible && goalMessages.length ? ( diff --git a/apps/presentation/dashboard/src/router.tsx b/apps/presentation/dashboard/src/router.tsx index 35b57ca6ce..df64c85e1b 100644 --- a/apps/presentation/dashboard/src/router.tsx +++ b/apps/presentation/dashboard/src/router.tsx @@ -17,7 +17,7 @@ import { AnswerReportPage } from "./features/personal-workspace/answer-report-pa const searchSchema = z.object({ goalId: z.string().optional().default(""), statusUrl: z.string().optional().default(""), - view: z.literal("conversation").optional(), + view: z.enum(["conversation", "overview", "tasks", "files"]).optional(), reportSessionId: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/).optional(), reportMessageId: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/).optional(), }); diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index fb41de5ba3..303d8562fc 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -101,6 +101,7 @@ import { type WorkspaceAgentOption, type WorkspaceAttention, type WorkspaceGoal, + type WorkspaceGoalTab, type WorkspaceGoalUsage, type WorkspaceHomeLane, type WorkspaceImageAttachment, @@ -1245,7 +1246,8 @@ function buildPersonalHomeModel( } function PersonalGoalHome({ goalArchiveLoadState, - initialManagerChatOpen, + selectedView, + onSelectView, isLoading, onGoalActivationStateChange, onGoalDeleted, @@ -1262,11 +1264,12 @@ function PersonalGoalHome({ toggleTheme, }: { goalArchiveLoadState: WorkspaceGoalArchiveLoadState; - initialManagerChatOpen: boolean; + selectedView?: WorkspaceGoalTab; + onSelectView: (view: WorkspaceGoalTab) => void; isLoading: boolean; onGoalActivationStateChange: (goalId: string, activationState: "active" | "stopped") => void; onGoalDeleted: (goalId: string) => void; - onSelectGoal: (goalId: string) => void; + onSelectGoal: (goalId: string, view?: WorkspaceGoalTab) => void; onReconcileStatus: (options?: { invalidateGoalIds?: string[] }) => void | Promise; onRefresh: (scope?: WorkspaceReadScope) => void | Promise; onRetryGoalArchive: () => void | Promise; @@ -2619,18 +2622,8 @@ function PersonalGoalHome({ setAgentMenuOpen(false); } - function openManagerChat() { - onSelectGoal(""); - setMobilePanel("chat"); - } - - function openGoalList() { - onSelectGoal(""); - setMobilePanel("goals"); - } - function openGoalChat(goalId: string) { - onSelectGoal(goalId); + onSelectGoal(goalId, "chat"); setMobilePanel("chat"); } @@ -2966,7 +2959,6 @@ function PersonalGoalHome({ return { ...current, [targetContextId]: [...messages, { id, sourceMessageId: `steer:${ingressId}`, sourceTurnId: turnId, lines: [], role: "user", text: message }] }; }); }, - onOpenGoal: openGoalChat, onOpenRunSession: async (run) => { if (!run.sessionId) return; const sessionId = run.sessionId; @@ -2975,7 +2967,6 @@ function PersonalGoalHome({ ...current, [sessionId]: snapshot, })); - openGoalChat(run.goalId); setMessagesByContext((current) => ({ ...current, [run.goalId]: snapshot.messages.map((message) => ({ @@ -2999,7 +2990,6 @@ function PersonalGoalHome({ turnId: snapshot.session.active_turn_id ?? undefined, }); }, - onOpenOutput: (output) => openGoalChat(output.goalId), ...(goalSubagentConfigurationEnabled ? { onPreviewGoalSubagentConfiguration: async (request) => { const preview = await previewGoalSubagentConfiguration(request); @@ -3085,14 +3075,15 @@ function PersonalGoalHome({ }, onRetryResumeRun: retryManagerSession, onSelectAgent: chooseAgent, - onSelectGoal: (goalId) => goalId ? openGoalChat(goalId) : openManagerChat(), + onSelectGoal: (goalId, view) => { onSelectGoal(goalId ?? "", view); setMobilePanel("chat"); }, + onSelectView, onSendMessage: async (message, agentId, goalId, attachments) => sendManagerQuestion(message, { agentId, goalId, attachments }), onPrepareLoopX: (agentId, goalId) => prepareGoalConversation(goalId, agentId), onStartLoopX: (operation, agentId, goalId, settings) => { void sendManagerQuestion(operation === "start" ? "开启 LoopX 模式,持续推进当前 Goal。" : "恢复 LoopX 模式。", {agentId, goalId, loopxMode: {operation, settings}}); }, onStartNewRunSession: startNewManagerSession, }} goalArchiveLoadState={goalArchiveLoadState} - initialManagerChatOpen={initialManagerChatOpen} + selectedView={selectedView} managerChannelBinding={managerChannelBinding} managerRuntime={managerRuntime} conversationSessionId={runtimeBindings[contextId]?.sessionId} @@ -3303,12 +3294,16 @@ export function DashboardPage() { setPayload(nextPayload); setSource(nextSource); setStatusUrl(url); - await navigate({ - search: (current) => ({ - ...current, - statusUrl: url, - }), - }); + // Loading the current source on reload must not add a duplicate history + // entry: Back should return to the user's previous workspace view. + if (search.statusUrl !== url) { + await navigate({ + search: (current) => ({ + ...current, + statusUrl: url, + }), + }); + } if (!statusRequestIsCurrent(statusRequestFenceRef.current, request)) return false; statusRequestFenceRef.current.requestedUrl = null; setRequestedStatusUrl(null); @@ -3549,11 +3544,12 @@ export function DashboardPage() { } }, [search.goalId, isLoading, progress, source]); - function selectGoal(goalId: string) { + function selectGoal(goalId: string, view?: WorkspaceGoalTab) { void navigate({ search: (current) => ({ ...current, goalId, + view: view === "chat" ? "conversation" : view, }), }); } @@ -3574,7 +3570,10 @@ export function DashboardPage() { return ( { + void navigate({ search: current => ({ ...current, view: view === "chat" ? "conversation" : view }) }); + }} isLoading={isLoading} onGoalActivationStateChange={(goalId, activationState) => { statusRequestFenceRef.current.projectionRevision += 1; diff --git a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md index 9232d7eaf5..85b37f7756 100644 --- a/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md +++ b/docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md @@ -463,6 +463,8 @@ Artifact continuity is part of semantic continuity. Reuse the artifact owner to Long-running work must remain legible in the existing conversation: what is being attempted, who owns the next step, what is actually blocking, and what conclusion is owed. Provide expandable tool/artifact activity and current semantic context in the frontend; Lark gets a concise equivalent and actionable results. Distinguish a queued worker, unavailable host, permission refusal, website login and undelivered answer. Do not expose raw protocol envelopes or claim fine-grained tool activity on adapters that cannot supply it. +The App must also preserve the user's chosen work view across reload and browser history. Manager conversation and Goal overview/tasks/conversation/artifacts use the existing typed workspace route; source loading must not insert duplicate history entries. Preserve the source and Goal identity, retain an unsent draft under its existing owner, and never start or replay a Turn merely to restore navigation. Fresh-entry defaults remain distinct from restoring an explicit view. Packaged navigation recovery qualifies this presentation boundary only; actual receiver adoption, steering and owed-result return retain their independent acceptance requirements. + Reuse existing capability instructions, context hooks, memory and scheduling owners. Reusable methods may inform planning and handoff, with their source/version visible; remembered lessons do not replace accepted task state or current verification. Stable recurring work may use the existing schedule/event path after its task and replay behavior are understood. This RFC does not add a routine engine, compulsory method learning, a new hook family or business-specific automation. Parallel work does not imply isolated execution resources. Reuse runtime serialization/leases for shared browser screens or mutable workspaces, separately from Core work ownership. Separate screens, agent names or conversation tabs are not permission boundaries. If a runtime lacks the required coordination, serialize the affected operation and expose the wait; unrelated evidence work can continue. M1 reports actual resource behavior rather than assuming a shared cloud-computer design. diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 9857b803c0..e028eff755 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -644,6 +644,12 @@ callback; neither may appear as work completed or a second user-visible reply. This uses the R3 collaboration owner and current managed Turn wake instead of a separate steward scheduler. +For App continuity in GQ07–GQ09, qualify chosen Manager/Goal view restoration +through reload and browser Back/Forward using the existing typed route and +draft owner. Keep source/Goal identity and prove navigation causes no work +mutation or Turn replay. This bounded presentation qualification does not +close R3's receiver-adoption, steering or original-route return acceptance. + R3 entry qualification also covers ordinary human messages in an explicitly configured steward group without a mention. The shared TS admission rule owns trigger semantics; Lark supplies provider identity and the existing inbox owns diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 859fcb97a5..23d073babd 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -15,6 +15,7 @@ import { chatRecoveryScenario } from "./personal-workspace-browser/chat-recovery import { composerSessionAdmissionScenario } from "./personal-workspace-browser/composer-session-admission.mjs"; import { attachedHostFollowUpScenario } from "./personal-workspace-browser/attached-host-follow-up.mjs"; import { conversationReturnContinuityScenario } from "./personal-workspace-browser/conversation-return-continuity.mjs"; +import { workspaceViewRecoveryScenario } from "./personal-workspace-browser/workspace-view-recovery.mjs"; import { conversationHistoryRecoveryScenario } from "./personal-workspace-browser/conversation-history-recovery.mjs"; import { executionChipScenario } from "./personal-workspace-browser/execution-chip.mjs"; import { @@ -55,6 +56,7 @@ import { conversationStartupScenario } from "./personal-workspace-browser/conver const scenarioCatalog = [conversationStartupScenario,goalDraftScenario, capabilityScopeScenario, stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, composerSessionAdmissionScenario, attachedHostFollowUpScenario, conversationReturnContinuityScenario, conversationHistoryRecoveryScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario, larkCliMissingScenario, executionServiceOfflineScenario]; scenarioCatalog.push(confirmedOperationsScenario); +scenarioCatalog.push(workspaceViewRecoveryScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/workspace-view-recovery.mjs b/examples/personal-workspace-browser/workspace-view-recovery.mjs new file mode 100644 index 0000000000..8e2a1e9fd4 --- /dev/null +++ b/examples/personal-workspace-browser/workspace-view-recovery.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +export const workspaceViewRecoveryScenario = { + id: "workspace-view-recovery", + async run({ browser, collectCoverage, url }) { + const context = await openWorkspacePage(browser, url, { collectCoverage }); + const { page, api } = context; + const managerTabs = page.getByRole("navigation", { name: "管家视图" }); + const goalTabs = page.getByRole("navigation", { name: "Goal 视图" }); + const active = async (tabs, name) => { + const button = tabs.getByRole("button", { name, exact: true }); + try { await button.and(page.locator('[aria-current="page"]')).waitFor({ state: "visible", timeout: 6000 }); } + catch (error) { throw new Error(`View ${name} not restored at ${page.url()}; ${await tabs.innerText()}`, { cause: error }); } + return button.getAttribute("aria-current"); + }; + try { + assert.equal(await active(managerTabs, "总览"), "page", "A fresh entry keeps the existing overview"); + await managerTabs.getByRole("button", { name: "对话", exact: true }).click(); + const draft = "接着刚才的研究,先只看微软。"; + await page.getByLabel("向 LoopX 发送消息").fill(draft); + await page.reload({ waitUntil: "networkidle" }); + assert.equal(await active(managerTabs, "对话"), "page", "Reload must retain the chosen conversation"); + assert.equal(await page.getByLabel("向 LoopX 发送消息").inputValue(), draft); + await page.screenshot({ path: resolve(outputDir, "workspace-view-recovery-chat.png") }); + const conversationUrl = page.url(); + await managerTabs.getByRole("button", { name: "总览", exact: true }).click(); + await page.waitForURL(value => value.href !== conversationUrl); + await page.goBack({ waitUntil: "networkidle" }); + assert.equal(await active(managerTabs, "对话"), "page", "Back returns to the conversation"); + await page.goForward({ waitUntil: "networkidle" }); + assert.equal(await active(managerTabs, "总览"), "page", "Forward restores the overview"); + + await page.locator(".personal-goal-link").filter({ hasText: "Product Release" }).click(); + assert.equal(await active(goalTabs, "任务"), "page", "Selecting a Goal keeps the existing task-first entry"); + await goalTabs.getByRole("button", { name: "成果", exact: true }).click(); + await page.reload({ waitUntil: "networkidle" }); + assert.equal(await active(goalTabs, "成果"), "page", "The same route owner restores Goal views"); + await goalTabs.getByRole("button", { name: "对话", exact: true }).click(); + await page.reload({ waitUntil: "networkidle" }); + assert.equal(await active(goalTabs, "对话"), "page"); + await page.goBack({ waitUntil: "networkidle" }); + assert.equal(await active(goalTabs, "成果"), "page", "Back restores the Goal's prior view"); + assert.equal(new URL(page.url()).searchParams.get("goalId"), "product-release"); + assert.equal(new URL(page.url()).searchParams.get("statusUrl"), "/status.json"); + assert.equal(api.turnRequests.length, 0, "Navigation and reload never start or replay model work"); + assert.equal(api.actionApplies.length, 0, "Navigation does not change owning Goal state"); + await page.setViewportSize({ width: 390, height: 844 }); + await goalTabs.getByRole("button", { name: "对话", exact: true }).focus(); + await page.keyboard.press("Enter"); + await page.reload({ waitUntil: "networkidle" }); + assert.equal(await active(goalTabs, "对话"), "page", "Narrow keyboard navigation also survives reload"); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), false); + await page.screenshot({ path: resolve(outputDir, "workspace-view-recovery-narrow.png") }); + // A route without an explicit view retains its established default, + // even after navigating back from a different controlled view. + const freshUrl = new URL(url); + await page.goto(freshUrl.href, { waitUntil: "networkidle" }); + await managerTabs.getByRole("button", { name: "对话", exact: true }).click(); + await page.goBack({ waitUntil: "networkidle" }); + assert.equal(await active(managerTabs, "总览"), "page"); + assert.equal(api.turnRequests.length, 0); + assert.equal(api.actionApplies.length, 0); + return { coverageEntries: context.coverageEntries, note: "Chosen manager/Goal views and composer draft survive reload and browser history; no Turn or Goal write is issued." }; + } finally { await context.close(); } + }, +}; diff --git a/examples/status-source-switch-browser-smoke.mjs b/examples/status-source-switch-browser-smoke.mjs index 12f9154e30..d454868df1 100644 --- a/examples/status-source-switch-browser-smoke.mjs +++ b/examples/status-source-switch-browser-smoke.mjs @@ -73,7 +73,7 @@ async function main() { await waitForHttp(appUrl); browser = await launchBrowser(chromium); for (const storageFailure of ["getter", "methods"]) { - const isolated = await browser.newPage({ viewport: { width: 1512, height: 982 } }); + const isolated = await browser.newPage({ locale: "zh-CN", viewport: { width: 1512, height: 982 } }); const errors = []; isolated.on("pageerror", (error) => errors.push(error.message)); await isolated.addInitScript((failure) => { @@ -112,7 +112,7 @@ async function main() { if (errors.length) throw new Error(`${storageFailure}: uncaught page errors: ${errors.join("; ")}`); await isolated.close(); } - const page = await browser.newPage({ viewport: { width: 1512, height: 982 } }); + const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1512, height: 982 } }); const state = { ensureGates: new Map(), ensureStartedByHost: new Map(),