feat(workspace): add React shell integration

This commit is contained in:
2026-09-15 23:41:27 +08:00
parent 604fd63e3b
commit d4ae41555c
12 changed files with 267 additions and 8 deletions
+8
View File
@@ -75,6 +75,14 @@ directly remains supported through the legacy compatibility pages and
`service-worker-loader.js` for source-level development and tests; the React
Popup and Prompt Template Library are generated by the WXT build entrypoints.
The generated Workspace page also mounts a React shell for the top toolbar,
provider selection, feature actions, and layout controls. The shell delegates
Provider panels, message transport, Task Runtime, Compare, Session, Prompt,
Template, and Handoff behavior to the existing Workspace runtime through a
small event/DOM compatibility bridge. This keeps the first migration slice
reversible: direct source-checkout loading still uses the legacy toolbar until
the remaining Workspace features have been migrated and browser-validated.
For a local browser smoke against the built artifact, use:
```bash
@@ -0,0 +1,10 @@
import { createRoot } from "react-dom/client";
import { defineUnlistedScript } from "wxt/utils/define-unlisted-script";
import "../shared/provider-catalog.js";
import "../shared/storage-contract.js";
import { WorkspaceShell } from "../ui/workspace/workspace-shell";
export default defineUnlistedScript(() => {
const root = document.getElementById("workspaceReactRoot");
if (root) createRoot(root).render(<WorkspaceShell />);
});
@@ -0,0 +1,35 @@
import type { ProviderDescriptor, ProviderId } from "../../../contracts/provider";
import { Badge } from "../../components/badge";
import { Button } from "../../components/button";
export function ProviderStrip({
providers,
selected,
onToggle
}: {
providers: readonly ProviderDescriptor[];
selected: readonly ProviderId[];
onToggle: (providerId: ProviderId) => void;
}) {
const selectedSet = new Set(selected);
return (
<div className="workspace-provider-strip" aria-label="Workspace 模型选择">
{providers.map((provider) => {
const active = selectedSet.has(provider.id);
return (
<Button
key={provider.id}
size="sm"
variant={active ? "secondary" : "ghost"}
className="workspace-provider-button"
aria-pressed={active}
onClick={() => onToggle(provider.id)}
>
{provider.name}
</Button>
);
})}
<Badge>{selected.length} </Badge>
</div>
);
}
@@ -0,0 +1,22 @@
import { Button } from "../../components/button";
export type WorkspaceAction = "session" | "prompt" | "template" | "compare";
const actions: Array<{ id: WorkspaceAction; label: string }> = [
{ id: "session", label: "Sessions" },
{ id: "prompt", label: "Prompts" },
{ id: "template", label: "Templates" },
{ id: "compare", label: "Compare" }
];
export function WorkspaceActions({ onAction }: { onAction: (action: WorkspaceAction) => void }) {
return (
<div className="workspace-actions" aria-label="Workspace 功能">
{actions.map((action) => (
<Button key={action.id} size="sm" variant="outline" onClick={() => onAction(action.id)}>
{action.label}
</Button>
))}
</div>
);
}
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import type { ProviderDescriptor, ProviderId } from "../../contracts/provider";
import { Button } from "../components/button";
import { ProviderStrip } from "./features/provider-strip";
import { WorkspaceActions, type WorkspaceAction } from "./features/workspace-actions";
const providers = globalThis.AIParallelProviderCatalog;
const storage = globalThis.AIParallelStorageContract.createLocalStorage();
const legacyActionIds: Record<WorkspaceAction, string> = {
session: "sessionBtn",
prompt: "promptLibraryBtn",
template: "templateLibraryBtn",
compare: "compareBtn"
};
const layouts = ["auto", "1", "2", "3"] as const;
type WorkspaceLayout = (typeof layouts)[number];
function isProviderId(value: unknown): value is ProviderId {
return typeof value === "string" && providers.some((provider) => provider.id === value);
}
function readProviderSelection(value: unknown, fallback: readonly ProviderDescriptor[]) {
if (!Array.isArray(value)) return fallback.filter((provider) => provider.default).map((provider) => provider.id);
return value.filter(isProviderId);
}
function emitSelection(providerIds: readonly ProviderId[]) {
window.dispatchEvent(new CustomEvent("ai-parallel:workspace-set-selection", {
detail: { providerIds: [...providerIds] }
}));
}
function triggerLegacyAction(action: WorkspaceAction) {
document.getElementById(legacyActionIds[action])?.click();
}
export function WorkspaceShell() {
const [selected, setSelected] = useState<ProviderId[]>([]);
const [layout, setLayout] = useState<WorkspaceLayout>("auto");
useEffect(() => {
let active = true;
storage.get(["selectedProviders", "workspaceLayout"]).then((data) => {
if (!active) return;
setSelected(readProviderSelection(data.selectedProviders, providers));
setLayout(layouts.includes(data.workspaceLayout as WorkspaceLayout) ? data.workspaceLayout as WorkspaceLayout : "auto");
}).catch(() => {});
const handleWorkspaceState = (event: Event) => {
const detail = (event as CustomEvent<{ selectedProviders?: unknown; workspaceLayout?: unknown }>).detail;
if (detail?.selectedProviders) setSelected(readProviderSelection(detail.selectedProviders, providers));
if (layouts.includes(detail?.workspaceLayout as WorkspaceLayout)) setLayout(detail.workspaceLayout as WorkspaceLayout);
};
window.addEventListener("ai-parallel:workspace-state", handleWorkspaceState);
return () => {
active = false;
window.removeEventListener("ai-parallel:workspace-state", handleWorkspaceState);
};
}, []);
function toggleProvider(providerId: ProviderId) {
const next = selected.includes(providerId)
? selected.filter((id) => id !== providerId)
: [...selected, providerId];
setSelected(next);
emitSelection(next);
}
function changeLayout(next: WorkspaceLayout) {
setLayout(next);
document.querySelector<HTMLButtonElement>(`.layout-switch button[data-layout="${next}"]`)?.click();
}
return (
<div className="workspace-react-toolbar">
<div className="workspace-react-brand">
<span className="workspace-react-mark">AP</span>
<span>AI Parallel</span>
<span className="workspace-react-version">React Shell</span>
</div>
<ProviderStrip providers={providers} selected={selected} onToggle={toggleProvider} />
<WorkspaceActions onAction={triggerLegacyAction} />
<div className="workspace-layout-switch" aria-label="布局">
{layouts.map((value) => (
<Button key={value} size="sm" variant={layout === value ? "secondary" : "ghost"} aria-pressed={layout === value} onClick={() => changeLayout(value)}>
{value === "auto" ? "Auto" : value}
</Button>
))}
</div>
</div>
);
}
@@ -0,0 +1,19 @@
body[data-react-workspace="true"] .workspace-legacy-toolbar { display: none; }
body[data-react-workspace="true"] #workspaceReactRoot { display: block; grid-column: 1 / -1; min-width: 0; }
#workspaceReactRoot { display: none; }
.workspace-react-toolbar { align-items: center; display: grid; gap: 12px; grid-template-columns: auto minmax(0, 1fr) auto auto; min-height: 48px; padding: 0 14px; }
.workspace-react-brand { align-items: center; display: flex; font-size: 13px; font-weight: 700; gap: 8px; white-space: nowrap; }
.workspace-react-mark { align-items: center; background: linear-gradient(135deg, #7c6cff, #a274ff); border-radius: 7px; color: white; display: inline-flex; font-size: 9px; height: 24px; justify-content: center; letter-spacing: -.04em; width: 24px; }
.workspace-react-version { border: 1px solid var(--ui-border); border-radius: 999px; color: var(--ui-muted); font-size: 9px; padding: 3px 6px; text-transform: uppercase; }
.workspace-provider-strip, .workspace-actions, .workspace-layout-switch { align-items: center; display: flex; gap: 5px; min-width: 0; }
.workspace-provider-strip { overflow-x: auto; scrollbar-width: none; }
.workspace-provider-strip::-webkit-scrollbar { display: none; }
.workspace-provider-button { flex: 0 0 auto; white-space: nowrap; }
.workspace-provider-strip .ui-badge { flex: 0 0 auto; }
.workspace-actions { justify-content: flex-end; }
.workspace-layout-switch { background: #0d0f13; border: 1px solid var(--ui-border); border-radius: 8px; gap: 2px; padding: 2px; }
.workspace-layout-switch .ui-button { min-width: 30px; }
@media (max-width: 1100px) {
.workspace-react-toolbar { grid-template-columns: auto minmax(0, 1fr) auto; }
.workspace-actions { grid-column: 2 / -1; grid-row: 2; justify-content: flex-start; }
}
+5 -3
View File
@@ -5,17 +5,19 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>AI Parallel</title>
<link rel="stylesheet" href="workspace.css" />
<link rel="stylesheet" href="../workspace-shell.css" />
</head>
<body>
<div class="app">
<header class="toolbar">
<div class="brand">
<div id="workspaceReactRoot"></div>
<div class="brand workspace-legacy-toolbar">
<span class="brand-mark">AP</span>
<span>AI Parallel</span>
<span class="version">Live Web</span>
</div>
<div class="toolbar-center" id="providerBar"></div>
<div class="toolbar-actions">
<div class="toolbar-center workspace-legacy-toolbar" id="providerBar"></div>
<div class="toolbar-actions workspace-legacy-toolbar">
<button id="sessionBtn" type="button" class="compare-trigger">Sessions</button>
<button id="promptLibraryBtn" type="button" class="compare-trigger">Prompts</button>
<button id="templateLibraryBtn" type="button" class="compare-trigger">Templates</button>
@@ -114,6 +114,12 @@ function updateMeta() {
sendBtn.disabled = selected.size === 0 || !promptInput.value.trim();
}
function notifyWorkspaceShell() {
window.dispatchEvent(new CustomEvent("ai-parallel:workspace-state", {
detail: { selectedProviders: [...selected], workspaceLayout: currentLayout }
}));
}
function setPanelState(providerId, state, { ready } = {}) {
const panel = panels.get(providerId);
if (!panel) return;
@@ -154,6 +160,7 @@ function renderProviderBar() {
});
providerBar.append(button);
}
notifyWorkspaceShell();
}
function postToFrame(providerId, payload) {
@@ -1674,6 +1681,22 @@ insertTemplateBtn.addEventListener("click", () => applyTemplateForm(false).catch
runTemplateBtn.addEventListener("click", () => applyTemplateForm(true).catch((error) => showTemplateFormError(error instanceof Error ? error.message : String(error))));
templateForm.addEventListener("submit", (event) => event.preventDefault());
templateFormDialog.addEventListener("cancel", () => closeTemplateForm());
window.addEventListener("ai-parallel:workspace-set-selection", (event) => {
const detail = event instanceof CustomEvent ? event.detail : null;
if (!Array.isArray(detail?.providerIds)) return;
const providerIds = detail.providerIds.filter((id) => providerById(id));
selected = new Set(providerIds);
for (const providerId of PROVIDERS.map((provider) => provider.id)) {
if (!selected.has(providerId)) responseBundles.delete(providerId);
}
storage.set({ selectedProviders: providerIds }).catch(() => {});
renderProviderBar();
renderPanels();
renderResponses();
updateMeta();
});
compareBtn.addEventListener("click", openCompareDrawer);
closeCompareBtn.addEventListener("click", closeCompareDrawer);
copyMarkdownBtn.addEventListener("click", () => copyText(buildComparisonMarkdown(), "Markdown 已复制"));
@@ -1697,6 +1720,7 @@ document.querySelectorAll(".layout-switch button").forEach((button) => {
panelGrid.dataset.layout = currentLayout;
document.querySelectorAll(".layout-switch button").forEach((item) => item.classList.toggle("active", item === button));
await storage.set({ workspaceLayout: currentLayout });
notifyWorkspaceShell();
});
});
@@ -1724,6 +1748,7 @@ async function init() {
});
autosizeComposer();
updateMeta();
notifyWorkspaceShell();
if (pendingLaunch) runPendingLaunch(pendingLaunch).catch((error) => showError(error instanceof Error ? error.message : String(error)));
setInterval(() => {
for (const id of selected) {
+2
View File
@@ -14,6 +14,8 @@ const files = [
"workspace/workspace.css",
"workspace/workspace.js",
"workspace/context-utils.js",
"workspace-shell.css",
"ui/theme.css",
"shared/agent-execution-controller.js",
"shared/prompt-template-catalog.js",
"shared/prompt-template-utils.js",
+12 -5
View File
@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
@@ -77,20 +77,27 @@ for (const relativePath of referencedFiles) {
}
}
for (const page of ["popup.html", "templates.html"]) {
for (const page of ["popup.html", "templates.html", "workspace/index.html"]) {
const pagePath = resolve(buildRoot, page);
if (!existsSync(pagePath)) throw new Error(`Generated extension page is missing: ${page}`);
const html = readFileSync(pagePath, "utf8");
if (/https?:\/\//i.test(html)) throw new Error(`Generated ${page} contains a remote asset URL`);
for (const [, reference] of html.matchAll(/(?:src|href)="([^"]+)"/g)) {
const relativePath = reference.replace(/^\/+/, "");
if (!relativePath || relativePath.startsWith("#")) continue;
if (!existsSync(resolve(buildRoot, relativePath))) {
if (!reference || reference.startsWith("#")) continue;
const referencedPath = reference.startsWith("/")
? resolve(buildRoot, reference.slice(1))
: resolve(dirname(pagePath), reference);
if (!existsSync(referencedPath)) {
throw new Error(`Generated ${page} references missing asset: ${reference}`);
}
}
}
const generatedWorkspace = readFileSync(resolve(buildRoot, "workspace/index.html"), "utf8");
if (!generatedWorkspace.includes('data-react-workspace="true"') || !generatedWorkspace.includes("../workspace-shell.js")) {
throw new Error("Generated Workspace page is missing the React shell integration");
}
for (const legacyRuntimeFile of ["service-worker.js", "content/frame-bridge.js", "content/providers/core.js"]) {
if (existsSync(resolve(buildRoot, legacyRuntimeFile))) {
throw new Error(`Generated extension still ships a legacy runtime asset: ${legacyRuntimeFile}`);
+21
View File
@@ -47,3 +47,24 @@ test("React template library covers schema validation, import/export, editing, a
assert.match(source, /编辑模板定义/);
assert.match(source, /createRoot\(document\.getElementById\("root"\)!\)/);
});
test("Workspace React shell owns the generated toolbar and delegates legacy runtime actions", () => {
const shell = read("ui", "workspace", "workspace-shell.tsx");
const entrypoint = read("entrypoints", "workspace-shell.tsx");
const workspaceHtml = read("workspace", "index.html");
const legacyWorkspace = read("workspace", "workspace.js");
assert.match(shell, /ProviderStrip/);
assert.match(shell, /WorkspaceActions/);
assert.match(shell, /ai-parallel:workspace-set-selection/);
assert.match(shell, /sessionBtn/);
assert.match(shell, /promptLibraryBtn/);
assert.match(shell, /templateLibraryBtn/);
assert.match(shell, /compareBtn/);
assert.doesNotMatch(shell, /content\/providers/);
assert.match(entrypoint, /defineUnlistedScript/);
assert.match(entrypoint, /workspaceReactRoot/);
assert.match(workspaceHtml, /id="workspaceReactRoot"/);
assert.match(legacyWorkspace, /ai-parallel:workspace-state/);
assert.match(legacyWorkspace, /ai-parallel:workspace-set-selection/);
});
+16
View File
@@ -53,6 +53,22 @@ export default defineConfig({
content_security_policy: legacyManifest.content_security_policy,
}),
hooks: {
"build:publicAssets": (_wxt, files) => {
const workspaceFileIndex = files.findIndex((file) => file.relativeDest === "workspace/index.html");
const workspaceFile = files[workspaceFileIndex];
if (!workspaceFile || !("absoluteSrc" in workspaceFile)) return;
const source = readFileSync(workspaceFile.absoluteSrc, "utf8");
files[workspaceFileIndex] = {
relativeDest: workspaceFile.relativeDest,
contents: source
.replace(
"</head>",
' <link rel="stylesheet" href="../ui/theme.css" />\n</head>'
)
.replace("<body>", '<body data-react-workspace="true">')
.replace("</body>", ' <script src="../workspace-shell.js"></script>\n</body>')
};
},
"build:manifestGenerated": (_wxt, manifest) => {
// Background and content scripts are generated from explicit WXT
// entrypoints. Keep DNR data sourced from the single legacy manifest