From 2674ff4fba9e8b37ab4c53aaffc71499d8e5aec0 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 16:34:20 +0800 Subject: [PATCH 1/6] Add basic Environment Template management view --- .../EnvironmentTemplatesView.css | 37 +++++ .../EnvironmentTemplatesView.test.tsx | 58 +++++++ .../EnvironmentTemplatesView.tsx | 143 ++++++++++++++++++ .../environment-templates/TemplateForm.tsx | 39 +++++ .../template-editor.test.ts | 85 +++++++++++ .../environment-templates/template-editor.ts | 62 ++++++++ 6 files changed, 424 insertions(+) create mode 100644 apps/web/src/features/environment-templates/EnvironmentTemplatesView.css create mode 100644 apps/web/src/features/environment-templates/EnvironmentTemplatesView.test.tsx create mode 100644 apps/web/src/features/environment-templates/EnvironmentTemplatesView.tsx create mode 100644 apps/web/src/features/environment-templates/TemplateForm.tsx create mode 100644 apps/web/src/features/environment-templates/template-editor.test.ts create mode 100644 apps/web/src/features/environment-templates/template-editor.ts diff --git a/apps/web/src/features/environment-templates/EnvironmentTemplatesView.css b/apps/web/src/features/environment-templates/EnvironmentTemplatesView.css new file mode 100644 index 000000000..d1a5f235f --- /dev/null +++ b/apps/web/src/features/environment-templates/EnvironmentTemplatesView.css @@ -0,0 +1,37 @@ +.templates-page .page-header { align-items: center; flex-wrap: wrap; gap: 12px; } +.templates-page .page-header p, .templates-boundary { color: var(--fg-muted); font-size: 12px; line-height: 1.6; } +.templates-content { padding: 0 24px 24px; } +.templates-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin: 24px 0 14px; } +.templates-toolbar > span { color: var(--fg-muted); font-size: 12px; } +.templates-search { display: flex; align-items: center; gap: 8px; width: 360px; max-width: 100%; color: var(--fg-muted); } +.templates-search input, .template-form input, .template-form select { min-width: 0; width: 100%; border: 1px solid var(--line-strong); background: var(--surface); color: var(--fg); border-radius: 5px; padding: 9px 10px; font: inherit; } +.templates-list { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } +.template-row { display: flex; align-items: center; gap: 24px; padding: 18px; background: var(--surface); } +.template-row + .template-row { border-top: 1px solid var(--line); } +.template-identity { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; } +.template-identity h2 { margin: 0; font-size: 14px; font-weight: 550; overflow-wrap: anywhere; } +.template-identity code, .template-delete code { overflow-wrap: anywhere; font-size: 11px; } +.template-identity small { color: var(--fg-muted); font-size: 11px; } +.template-summary { display: flex; gap: 24px; margin: 0; font-size: 12px; } +.template-summary dt { color: var(--fg-muted); font-size: 11px; margin-bottom: 6px; } +.template-summary dd { margin: 0; } +.template-row-actions, .template-form-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.template-form-actions { justify-content: flex-end; margin-top: 12px; } +.templates-empty { padding: 36px 16px; border: 1px dashed var(--line-strong); border-radius: 8px; text-align: center; } +.templates-empty h2 { font-size: 15px; font-weight: 550; } +.templates-empty p, .template-form p, .template-delete p { color: var(--fg-muted); font-size: 12px; line-height: 1.6; } +.template-form { display: flex; flex-direction: column; gap: 10px; } +.template-form label { font-size: 12px; font-weight: 550; } +.template-form label span { color: var(--fg-muted); font-weight: 400; margin-left: 6px; } +.template-form p { margin: 0; } +.template-delete .button.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: color-mix(in srgb, var(--danger) 7%, var(--surface)); } +.template-delete .button.danger:hover:not(:disabled) { background: color-mix(in srgb, var(--danger) 13%, var(--surface)); } +.template-form :is(input, select):focus-visible, .templates-search input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +@media (max-width: 760px) { + .templates-content { padding: 0 16px 20px; } + .template-row { flex-wrap: wrap; gap: 16px; } + .template-identity { flex-basis: 100%; } + .template-summary { flex: 1; } + .templates-page .page-actions { flex-wrap: wrap; } + .templates-page .button, .template-form-actions .button { min-height: 40px; } +} diff --git a/apps/web/src/features/environment-templates/EnvironmentTemplatesView.test.tsx b/apps/web/src/features/environment-templates/EnvironmentTemplatesView.test.tsx new file mode 100644 index 000000000..ea7b654b3 --- /dev/null +++ b/apps/web/src/features/environment-templates/EnvironmentTemplatesView.test.tsx @@ -0,0 +1,58 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import type { EnvironmentTemplate } from "@agents-core-web/agents-client"; +import { EnvironmentTemplatesView, type EnvironmentTemplatesViewProps } from "./EnvironmentTemplatesView"; +import { TemplateForm } from "./TemplateForm"; + +const template: EnvironmentTemplate = { + id: "eb4fa61b-6c45-4b4c-a33b-6d9d1f05fd21", object: "agent.environment.template", name: "Restricted", + network: { access: "disabled", allowed_domains: [] }, capability_directories: [], + packages: { npm: [], python: [], system: [] }, files: [], plugins: [], skills: [], created_at: 1, updated_at: 2, +}; +function render(catalog: EnvironmentTemplatesViewProps["catalog"]) { + const operations = { + createEnvironmentTemplate: vi.fn(), updateEnvironmentTemplate: vi.fn(), deleteEnvironmentTemplate: vi.fn(), + }; + const html = renderToStaticMarkup( undefined} onConfigureConnection={() => undefined} />); + expect(operations.createEnvironmentTemplate).not.toHaveBeenCalled(); + expect(operations.updateEnvironmentTemplate).not.toHaveBeenCalled(); + expect(operations.deleteEnvironmentTemplate).not.toHaveBeenCalled(); + return html; +} + +describe("Environment Templates view", () => { + it("distinguishes unread, unsupported, failed and empty catalogs", () => { + expect(render(null)).toContain("Loading Environment Templates"); + expect(render({ state: "unsupported" })).toContain("does not expose the Template resource"); + const failed = render({ state: "failed", message: "secret-token" }); + expect(failed).toContain("could not be loaded"); + expect(failed).not.toContain("No Environment Templates yet"); + expect(failed).not.toContain("secret-token"); + expect(render({ state: "ready", templates: [] })).toContain("No Environment Templates yet"); + }); + + it("shows safe configuration and actions without execution readiness claims", () => { + const html = render({ state: "ready", templates: [template] }); + expect(html).toContain("Environment Templates"); + expect(html).toContain("Filter Templates"); + expect(html).toContain("Edit Restricted"); + expect(html).toContain("Delete Restricted"); + expect(html).toContain(template.id); + expect(html).toContain("does not start a Runtime"); + expect(html).not.toContain("connected"); + }); + + it("does not render unknown fields or private configuration", () => { + const value = { ...template, env: { KEY: "secret-canary" }, setup_commands: ["private-command"] }; + const html = render({ state: "ready", templates: [value] }); + expect(html).not.toContain("secret-canary"); + expect(html).not.toContain("private-command"); + }); + + it("provides explicit labels and blocks an unchanged edit", () => { + const html = renderToStaticMarkup( undefined} onCancel={() => undefined} />); + expect(html).toContain("Network access"); + expect(html).toContain("Existing Sessions keep their configuration"); + expect(html).toContain('type="submit" disabled=""'); + }); +}); diff --git a/apps/web/src/features/environment-templates/EnvironmentTemplatesView.tsx b/apps/web/src/features/environment-templates/EnvironmentTemplatesView.tsx new file mode 100644 index 000000000..1c474f536 --- /dev/null +++ b/apps/web/src/features/environment-templates/EnvironmentTemplatesView.tsx @@ -0,0 +1,143 @@ +import { Plus, RefreshCw, Search } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import type { AgentCore, EnvironmentTemplate } from "@agents-core-web/agents-client"; +import { Modal } from "../../components/Modal"; +import { ErrorState } from "../../components/ErrorState"; +import type { EnvironmentTemplateCatalog } from "../sessions/environment/environment-templates"; +import { TemplateForm } from "./TemplateForm"; +import { createTemplateWriteScope, templateName, templatePatch, type TemplateDraft } from "./template-editor"; +import "./EnvironmentTemplatesView.css"; + +export interface EnvironmentTemplatesViewProps { + catalog: EnvironmentTemplateCatalog | null; + operations: Pick; + onRefresh: () => Promise; + onConfigureConnection: () => void; +} + +type Dialog = { kind: "create" } | { kind: "edit" | "delete"; template: EnvironmentTemplate } | null; + +function timestamp(seconds: number) { + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(seconds * 1_000)); +} + +export function EnvironmentTemplatesView({ catalog, operations, onRefresh, onConfigureConnection }: EnvironmentTemplatesViewProps) { + const [query, setQuery] = useState(""); + const [dialog, setDialog] = useState(null); + const [busy, setBusy] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + const [needsRefresh, setNeedsRefresh] = useState(false); + const scope = useRef(createTemplateWriteScope()); + const mounted = useRef(true); + const refreshPending = useRef(false); + const writePending = useRef(false); + const failedCatalog = useRef(null); + + useEffect(() => { + mounted.current = true; + scope.current = createTemplateWriteScope(); + return () => { mounted.current = false; scope.current.dispose(); }; + }, []); + + useEffect(() => { + if (needsRefresh && catalog?.state === "ready" && catalog !== failedCatalog.current) setNeedsRefresh(false); + }, [catalog, needsRefresh]); + + const ready = catalog?.state === "ready"; + const blocked = busy || refreshing || needsRefresh || !ready; + const templates = ready ? catalog.templates : []; + const needle = query.trim().toLocaleLowerCase(); + const visible = templates.filter((template) => [templateName(template), template.id, template.network.access].some((value) => value.toLocaleLowerCase().includes(needle))); + + const refresh = async () => { + if (refreshPending.current || writePending.current) return; + refreshPending.current = true; + setRefreshing(true); + try { await onRefresh(); } + catch { if (mounted.current) setError("The catalog could not be refreshed. Check the connection and try again."); } + finally { refreshPending.current = false; if (mounted.current) setRefreshing(false); } + }; + + const write = async (request: (signal: AbortSignal) => Promise, success: string) => { + if (blocked || writePending.current) return; + writePending.current = true; + setBusy(true); setError(null); setNotice(null); + const result = await scope.current.run(request, onRefresh); + writePending.current = false; + if (!mounted.current || result.kind === "ignored") return; + setBusy(false); + setDialog(null); + if (result.kind === "failed") { + failedCatalog.current = catalog; + setNeedsRefresh(true); + setError(result.message); + } else { + setNotice(success); + if (result.kind === "saved-refresh-failed") { + failedCatalog.current = catalog; + setNeedsRefresh(true); + setError("The change was saved, but the catalog refresh failed. Refresh before making another change."); + } + } + }; + + const save = (draft: TemplateDraft) => { + if (dialog?.kind === "create") { + void write((signal) => operations.createEnvironmentTemplate({ name: draft.name.trim() || null, network: { access: draft.access } }, { signal }), "Template created. No Runtime was allocated."); + } else if (dialog?.kind === "edit") { + const template = dialog.template; + const patch = templatePatch(template, draft); + if (Object.keys(patch).length) void write((signal) => operations.updateEnvironmentTemplate(template.id, patch, { signal }), "Template updated. Existing Sessions keep their configuration."); + } + }; + const close = () => { if (!busy) setDialog(null); }; + + return ( +
+
+

Environment Templates

Reusable configuration for managed Sessions.

+
+ + +
+
+
+

Manage names and network access for basic Templates. Select a saved Template when starting a Session. Saving configuration does not start a Runtime.

+ {notice ?

{notice}

: null} + {error ?

{error}

: null} + {catalog === null ?

Loading Environment Templates…

: catalog.state === "unsupported" ? ( + Configure connection} /> + ) : catalog.state === "failed" ? ( + Configure connection} /> + ) : ( + <> +
+ + {visible.length} of {templates.length} Templates +
+ {templates.length === 0 ?

No Environment Templates yet

Create a reusable configuration, then select it when starting a Session.

: visible.length === 0 ?

No matching Templates

: ( +
+ {visible.map((template) => ( +
+

{templateName(template)}

{template.id}Updated {timestamp(template.updated_at)}
+
Network
{template.network.access === "enabled" ? "Enabled" : "Disabled"}
Allowed domains
{template.network.allowed_domains.length}
+
+
+ ))} +
+ )} + + )} +
+ + {dialog?.kind === "delete" ?
+

Delete {templateName(dialog.template)}?

{dialog.template.id} +

New Sessions will no longer be able to select this Template. Existing Sessions keep their configuration. This action cannot be undone.

+
+
: dialog ? : null} +
+
+ ); +} diff --git a/apps/web/src/features/environment-templates/TemplateForm.tsx b/apps/web/src/features/environment-templates/TemplateForm.tsx new file mode 100644 index 000000000..9421e65b2 --- /dev/null +++ b/apps/web/src/features/environment-templates/TemplateForm.tsx @@ -0,0 +1,39 @@ +import { useId, useState, type FormEvent } from "react"; +import type { EnvironmentTemplate } from "@agents-core-web/agents-client"; +import { templatePatch, type TemplateDraft } from "./template-editor"; + +export function TemplateForm({ template, busy, onSave, onCancel }: { + template?: EnvironmentTemplate; + busy: boolean; + onSave: (draft: TemplateDraft) => void; + onCancel: () => void; +}) { + const id = useId(); + const [draft, setDraft] = useState({ name: template?.name ?? "", access: template?.network.access ?? "enabled" }); + const tooLong = [...draft.name.trim()].length > 256; + const networkEditable = !template || template.network.allowed_domains.length === 0; + const unchanged = template && !tooLong && Object.keys(templatePatch(template, draft)).length === 0; + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!busy && !tooLong && !unchanged) onSave(draft); + }; + return ( +
+ + setDraft({ ...draft, name: event.target.value })} autoComplete="off" placeholder="e.g. Restricted network" /> + {tooLong ? : null} + + + {!networkEditable ?

This Web cannot edit this network policy without replacing its allowed domains.

: null} +

Applies when creating a new Session with this Template. Existing Sessions keep their configuration.

+
+ + +
+
+ ); +} diff --git a/apps/web/src/features/environment-templates/template-editor.test.ts b/apps/web/src/features/environment-templates/template-editor.test.ts new file mode 100644 index 000000000..0c827609e --- /dev/null +++ b/apps/web/src/features/environment-templates/template-editor.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import type { EnvironmentTemplate } from "@agents-core-web/agents-client"; +import { createTemplateWriteScope, templatePatch } from "./template-editor"; + +const template = { + id: "template-id", name: "Original", network: { access: "enabled", allowed_domains: [] }, +} as unknown as EnvironmentTemplate; + +describe("Template updates", () => { + it("omits unchanged fields and preserves network configuration on a rename", () => { + expect(templatePatch(template, { name: " Renamed ", access: "enabled" })).toEqual({ name: "Renamed" }); + expect(templatePatch(template, { name: "Original", access: "enabled" })).toEqual({}); + expect(templatePatch(template, { name: " ", access: "enabled" })).toEqual({ name: null }); + }); + + it("only updates network access for an empty allowed-domain policy", () => { + expect(templatePatch(template, { name: "Original", access: "disabled" })).toEqual({ network: { access: "disabled" } }); + const restricted = { ...template, network: { access: "enabled", allowed_domains: ["private.example"] } } as unknown as EnvironmentTemplate; + expect(templatePatch(restricted, { name: "Renamed", access: "enabled" })).toEqual({ name: "Renamed" }); + expect(() => templatePatch(restricted, { name: "Original", access: "disabled" })).toThrow("allowed domains must be preserved"); + }); + + it("counts Unicode characters rather than UTF-16 code units", () => { + expect(templatePatch(template, { name: "😀".repeat(256), access: "enabled" }).name).toHaveLength(512); + expect(() => templatePatch(template, { name: "😀".repeat(257), access: "enabled" })).toThrow("256"); + }); +}); + +describe("Template write lifecycle", () => { + it("prevents duplicate writes and refreshes only after the receipt", async () => { + let resolve!: () => void; + const write = vi.fn(() => new Promise((done) => { resolve = done; })); + const refresh = vi.fn(async () => undefined); + const scope = createTemplateWriteScope(); + const result = scope.run(write, refresh); + await expect(scope.run(write, refresh)).resolves.toEqual({ kind: "ignored" }); + expect(write).toHaveBeenCalledOnce(); + expect(refresh).not.toHaveBeenCalled(); + resolve(); + await expect(result).resolves.toEqual({ kind: "saved" }); + expect(refresh).toHaveBeenCalledOnce(); + }); + + it("aborts on disposal and never refreshes after a late mutation receipt", async () => { + let resolve!: () => void; + let signal!: AbortSignal; + const refresh = vi.fn(async () => undefined); + const scope = createTemplateWriteScope(); + const result = scope.run((value) => { signal = value; return new Promise((done) => { resolve = done; }); }, refresh); + scope.dispose(); + expect(signal.aborted).toBe(true); + resolve(); + await expect(result).resolves.toEqual({ kind: "ignored" }); + expect(refresh).not.toHaveBeenCalled(); + }); + + it("discards a refresh result after unmount", async () => { + let resolve!: () => void; + const scope = createTemplateWriteScope(); + const refresh = vi.fn(() => new Promise((done) => { resolve = done; })); + const result = scope.run(async () => undefined, refresh); + await Promise.resolve(); + expect(refresh).toHaveBeenCalledOnce(); + scope.dispose(); + resolve(); + await expect(result).resolves.toEqual({ kind: "ignored" }); + }); + + it("separates a saved write from a failed refresh without replaying it", async () => { + const write = vi.fn(async () => undefined); + const scope = createTemplateWriteScope(); + await expect(scope.run(write, async () => { throw new Error("private"); })).resolves.toEqual({ kind: "saved-refresh-failed" }); + expect(write).toHaveBeenCalledOnce(); + }); + + it("does not replay uncertain writes or expose raw failures", async () => { + const write = vi.fn(async () => { throw new Error("secret-token-and-hostname"); }); + const refresh = vi.fn(async () => undefined); + const result = await createTemplateWriteScope().run(write, refresh); + expect(result.kind).toBe("failed"); + expect(JSON.stringify(result)).not.toContain("secret-token"); + expect(write).toHaveBeenCalledOnce(); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/features/environment-templates/template-editor.ts b/apps/web/src/features/environment-templates/template-editor.ts new file mode 100644 index 000000000..90866f13c --- /dev/null +++ b/apps/web/src/features/environment-templates/template-editor.ts @@ -0,0 +1,62 @@ +import { AgentCoreError, type EnvironmentTemplate, type OpenAIHostedNetworkAccess, type UpdateEnvironmentTemplateInput } from "@agents-core-web/agents-client"; + +export interface TemplateDraft { name: string; access: OpenAIHostedNetworkAccess } + +export function templatePatch(template: EnvironmentTemplate, draft: TemplateDraft): UpdateEnvironmentTemplateInput { + const name = draft.name.trim() || null; + if ([...(name ?? "")].length > 256) throw new Error("A name accepts at most 256 characters."); + const patch: UpdateEnvironmentTemplateInput = {}; + if (name !== template.name) patch.name = name; + if (draft.access !== template.network.access) { + if (template.network.allowed_domains.length !== 0) { + throw new Error("This network policy cannot be edited by this Web. Its allowed domains must be preserved."); + } + patch.network = { access: draft.access }; + } + return patch; +} + +export function templateName(template: EnvironmentTemplate): string { + return template.name?.trim() || "Unnamed Template"; +} + +export function templateFailure(error: unknown): string { + if (error instanceof AgentCoreError) { + if (error.status === 401 || error.status === 403) return "Core rejected this request. Check the connection and access permissions."; + if (error.status === 404) return "This Template is no longer available. Refresh the catalog before trying again."; + if (error.status === 400 || error.status === 422) return "Core rejected the Template configuration. Refresh the catalog and review the fields."; + } + return "The write outcome was not confirmed. Refresh and review Core's catalog before making another change. The request was not replayed."; +} + +export type TemplateWriteResult = + | { kind: "ignored" } + | { kind: "saved" | "saved-refresh-failed" } + | { kind: "failed"; message: string }; + +/** One write at a time; disposal fences both late UI updates and catalog refreshes. */ +export function createTemplateWriteScope() { + let active = true; + let pending = false; + let controller: AbortController | null = null; + return { + dispose() { active = false; controller?.abort(); }, + async run(write: (signal: AbortSignal) => Promise, refresh: () => Promise): Promise { + if (!active || pending) return { kind: "ignored" }; + pending = true; + controller = new AbortController(); + try { + await write(controller.signal); + if (!active) return { kind: "ignored" }; + try { + await refresh(); + return active ? { kind: "saved" } : { kind: "ignored" }; + } catch { + return active ? { kind: "saved-refresh-failed" } : { kind: "ignored" }; + } + } catch (error) { + return active ? { kind: "failed", message: templateFailure(error) } : { kind: "ignored" }; + } finally { pending = false; controller = null; } + }, + }; +} From 8fb4fe877d21f870cad0849007e1a498cd5bc30b Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 16:37:24 +0800 Subject: [PATCH 2/6] Expose basic Templates management in the administrator console --- CONTRIBUTING.md | 8 ++++++++ apps/web/src/App.tsx | 12 ++++++++++++ .../src/components/ProductNavigation.test.tsx | 9 +++++++++ apps/web/src/components/ProductNavigation.tsx | 9 ++++++--- .../EnvironmentTemplatesView.css | 1 + .../EnvironmentTemplatesView.tsx | 7 ++----- docs/web/README.md | 17 +++++++++++++---- docs/web/architecture.md | 9 ++++++++- docs/web/protocol-coverage.md | 4 ++-- 9 files changed, 61 insertions(+), 15 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74087e691..bf87a7b42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,14 @@ independent blind review using only requirements, acceptance criteria, boundarie repository path and comparison baseline. Fix in-scope blockers before delivery. Do not use `codex exec` as a substitute reviewer. +The Core Web is an administrator console for execution and resource operations; +business collaboration remains in Parsar. Environment Template management shares +the Session creation catalog and uses the existing public client operations. Patch +only edited fields, confirm deletion, and never automatically retry an uncertain +write. A Core connection change must discard the previous connection's forms, +pending results and notices. Saving a Template must not allocate a Runtime, call a +model or imply execution readiness. Keep unsupported advanced profiles explicit. + For subsequent alignment and milestone closure batches, the main thread coordinates design, shared interface agreements, file ownership, integration and merge. First reconcile main and the boards, then list remaining mandatory milestone work, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cc0f0482c..4b0e787d6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -34,6 +34,7 @@ import { requestAgentUpdate, } from "./features/agents/agent-actions"; import { DashboardView } from "./features/dashboard/DashboardView"; +import { EnvironmentTemplatesView } from "./features/environment-templates/EnvironmentTemplatesView"; import { SessionsView, type SessionDetailState, @@ -131,6 +132,7 @@ type View = ProductView | "system"; function viewFromLocation(): View { if (typeof window === "undefined") return "dashboard"; const candidate = window.location.hash.slice(1); + if (candidate === "templates" && __AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__) return "templates"; return candidate === "agents" || candidate === "sessions" || candidate === "vaults" || candidate === "system" ? candidate : "dashboard"; @@ -2115,6 +2117,7 @@ export function App() { active={view === "system" ? null : view} onSelect={(nextView) => setView(nextView)} showVaults={vaultSupported === true} + showTemplates={__AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__} />