mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-11 04:02:45 -10:00
* fix(web-shell): close managed host integration gaps from #15928 Follow-up to #15928. Each change below was reproduced against the merged head before fixing, and every new assertion was checked to fail without its fix. The host entry required a Java base URL even when a provider was injected, so the documented mount only compiled inside test files and a JavaScript host that dropped the provider silently addressed /undefined/api/... . The base URL is now optional and only the Java path constructs a client; with neither input the entry renders the unavailable notice. Returning to an unconfirmed request's owner was the only navigation that bypassed the shared open path: it dropped the owning workspace selection and carried the previous conversation's error into the newly opened one. Pending records now remember their workspace and the redirect uses the shared path, so the owner keeps its workspace even after the catalog stops listing it. An unconfirmed create still returns to the new-conversation page. The archived-status callback was forwarded by identity into an effect that emits a fresh object, so an inline host callback re-rendered without bound. It is routed through a ref like the other host callbacks. Host navigation slots sit outside the shell container, where the compiled stylesheet matches nothing. The entry now wraps each portaled list in the portal scope and syncs theme class, language and custom properties. The managed empty state centered every in-flow child, floating the session header mid-pane and pushing content out of scroll reach once it grew taller than the pane; it also stayed set while a transcript was loading or a first send was in flight. Centering moved to auto margins on the content wrapper and the marker now requires an idle, non-loading conversation. The welcome header no longer stacks with a failure banner. Host mode renders no session list, so both focus fallbacks became no-ops and dropped focus to the document body, and the pane measurement never re-ran after the waiting state. Focus falls back to the page wrapper and measurement re-runs when the host supplies a Session ID. Shared style release was not idempotent: disposing one mount twice stripped the stylesheet a sibling still used. Both release paths are now guarded. The managed-host build mode ran in no gate and wrote inside the directory the app build empties. It is part of the package build and writes to dist-managed-host, staying out of the published exports and files. Weak assertions from the original change were strengthened: the injected provider test now spies on Java construction, the host-scope teardown keeps the Session ID constant, the shadow-style test exercises the constructable-sheet path, and the pending-row exclusion names the real copy. New coverage pins the host pending key isolation, the notice reset on a host switch, and the empty failed conversation. Co-authored-by: Qwen-Coder <[email protected]> * fix(web-shell): render the unavailable notice and gate the pending workspace fallback Address the two blocking review items on the #15928 managed-host follow-up: - The mount docs/README promise an unavailable notice when neither a provider nor a baseUrl is supplied, but the shell rendered an empty wrapper and the test only asserted the Java provider was not called. Render the notice via the existing managed.unavailable i18n key and strengthen the test to assert the notice text (red without the render, green with it). - The 'open the original conversation' handler fell back to the on-screen summary's workspace, which in the cross-conversation case is a foreign workspace. Gate that fallback on pending.sessionId === sessionId so the owner is never bound to the wrong workspace when its own workspace is unknown. The third review item (managed-host bundle in npm run build) is a maintainer call on build cost and is left unchanged pending that decision. ManagedAgentWebShell 18/18, ManagedSessionsPage 173/173; eslint exit 0, prettier clean. * fix(web-shell): theme only our own host slot wrappers and cover centering in a browser Two review items on this PR remained aftera61bfc04. The slot sync identified its wrapper as the target's first element child. That is wrong whenever the host slot already holds host chrome, and it is also wrong when both slots name the same element. A probe against the reviewed head showed the real consequence is worse than "fails silently": it wrote lang, the dark class and every custom property onto the host's own node (host chrome lang fr -> zh-CN) while leaving the actual list wrapper unstyled. The wrappers now register themselves through a ref, so the sync only ever touches nodes this entry rendered, and disconnected wrappers are dropped. Both cases are pinned by tests that fail on the first-child lookup. The centering claim had no layout evidence: jsdom performs no layout, so the component test can only pin data-managed-empty, and the visuals bot reported that no scenario renders this surface. Added a browser regression with its own host-slot fixture that measures the pane at a cramped viewport. With the previous justify-content rule the session header sits at -51px, above the scroll origin and permanently unreachable; with auto margins it sits at 0 and stays in the viewport. The test fails on the old rule and passes on the new one. The build-wiring cost stays unchanged pending the maintainer decision. For that decision, measured locally on this machine: the managed-host pass takes about 13-14s wall clock against about 9s for the default lib pass and about 3s for the transcript pass, and emits 38 MB. ManagedAgentWebShell 20/20, the five affected suites 212/212, the new browser regression 1/1; eslint exit 0, prettier clean. Co-authored-by: Qwen-Coder <[email protected]> * fix(web-shell): keep the host nav slot from blocking session-list scroll R1-4: the portal-scope/theme wrapper added around the host navigation slot generates a block box, so in a bounded flex-column host sidebar it becomes the flex item and its default min-height:auto pins the nav to content height — the nav's overflow-y-auto never engages and the list is clipped. Make the wrapper display:contents: it stays in the DOM (portal-scope attributes and synced theme variables still apply and inherit) but generates no box, so the nav remains the host's flex item and scrolls within the bounded slot. Witness: browser A/B on this head — bounded 300px flex slot, 40 sessions: navClientHeight 300 / navScrollHeight 1516 (scrollable), last row reachable; the slot test asserts display:contents (red without it). Theme-sync suite 20/20. * ci(web-shell): gate the managed host bundle in its own lane, not every build Resolves the maintainer-call item on this PR. The managed-host mode externalizes nothing, so riding `npm run build` charged every local build and every fresh-clone `prepare` with a full bundle of React, xterm, mermaid, echarts, shiki and codemirror — measured locally at about 13-14s and 38 MB — for an artifact that stays out of `exports`/`files` and has no in-repo consumer. The pass bought rot detection and nothing else. Rot detection now lives in a dedicated workflow that runs the mode on pull requests and post-merge pushes touching the entry, its build config or the lockfile. It installs with QWEN_SKIP_PREPARE because the entry needs no sibling workspace dist/, so the lane costs one bundling pass rather than a monorepo rebuild first. It runs on an ephemeral hosted runner with a read-only token and no secrets, and produces no artifact. A green exit code alone would not catch the rot, so the lane asserts a non-empty entry containing the mount symbol and the scoped empty-state CSS, and checks the output stays outside the directory the app build empties and out of the published exports/files. The local entry point is now `npm run build:managed-host`. A workflow meta-test pins both halves of the split: putting the mode back into the default build fails it, and so does dropping the build config from the lane's path filter. Both were confirmed by mutation. The .ci-budget entry is added for the new PR-stage workflow. Verified locally: actionlint and yamllint clean, CI budget holds, the new meta-test 9/9, package-scripts and ci-platform-lanes 134 passed, the all-workflow expression-length assertion passes with the new file, the five affected web-shell suites 212/212, and `npm run build:managed-host` succeeds with the lane's verify assertions holding. Co-authored-by: Qwen-Coder <[email protected]> * ci(web-shell): build core before the managed host bundle The managed host entry imports one compiled core module, and the package `exports` map resolves it through `dist/src/`, which `QWEN_SKIP_PREPARE=1` never produces. The lane failed to resolve it before Rollup had anything to bundle. Build that single package instead of running the full `prepare`, and record the new workflow in the size baseline so the ratchet tracks it. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw0ykbnlbb * ci(web-shell): build the SDK dist the managed host entry imports The managed-host lane installs with QWEN_SKIP_PREPARE=1, so no workspace dist exists except the ones the lane builds explicitly. managed-host mode externalizes nothing (`vite.lib.config.ts` returns false), and the entry's module graph reaches a runtime value import of `@qwen-code/sdk/daemon`, whose exports map points into `packages/sdk-typescript/dist/`. Building only core therefore left the bundle step red on this head: [vite]: Rollup failed to resolve import "@qwen-code/sdk/daemon" from packages/web-shell/client/components/messages/tools/ToolDetailsContext.tsx Build `@qwen-code/sdk` after core (its `tsconfig.reference.json` only references `../core`, so the existing step already satisfies it) and pin the new step in the lane's workflow test. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw19a4jbbr * ci(web-shell): build acp-bridge before the SDK in the managed host lane The lane installs with QWEN_SKIP_PREPARE=1 and built only core, then the SDK. The SDK's daemon sources import `@qwen-code/acp-bridge/<subpath>`, which resolves through that package's `dist/` exports map, and `tsconfig.reference.json` references core alone, so `tsc --build` never produces acp-bridge: the lane died on 94 TS2307/TS2339 errors before it bundled anything. Build it between core and the SDK, the order live-host-release.yml already uses. Reproduced both arms locally: without acp-bridge/dist the SDK build fails with the same 94 errors as run 41732515178; with it, the build exits 0. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw1bfabvbu * fix(web-shell): close the managed host R1 criticals R1-2: the identity-switch branch dropped an explicit host sessionId when the provider arrived after mount, because `selection.storageKey` started as undefined. Guard on a previous identity existing; a real storageKey change still drops the carried id. R1-1: a wrapper that mounted in a commit the sync effect did not watch kept no theme at all. The ref callback now themes the element it just saw, and the effect discovers our own wrappers by their portal-scope marker instead of a registry that dropped a detached wrapper for good. `style` joins the effect deps so a host token change re-copies without a theme flip. R1-3: the page-wrapper focus fallback parked focus where the discard latch did not recognise it. The latch now accepts the node the discard parked on instead of the page wrapper wholesale. Tests: late-mount + style-only token arms and the late-provider sessionId arm in ManagedAgentWebShell.test.tsx (22/22 green). Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw1bfabvbu * fix(web-shell): keep the managed empty marker after a failed pending restore A failed pending restore leaves `busy` set until the user discards, so the new `!busy` term dropped `data-managed-empty` in a state the base marker still covered, losing the centring and overflow rule this PR adds. The shell root ref is now a stable callback so a wrapper mounting in a commit the sync effect does not watch is still themed. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw1jzxglc6 * fix(web-shell): drop a carried session id across a provider-less identity switch Closes the R4 Critical and three review items that made this PR's own guarantees unverifiable. R4-1: an identity switch routed through a provider-less render handed the previous tenant's session to the next one. The provider-less render wrote `storageKey: undefined`, so the next real provider compared against undefined and kept the carried id: A -> none -> C mounted C's page on A's `s1`. The direct A -> C transition dropped it, so this was the one path around the drop-on-scope-change rule the design doc requires. The selection now retains the last real identity while no provider is mounted and compares against it. Both the branch condition and the drop predicate are gated on a provider being present — the review's suggested shape gated only the predicate, which I confirmed loops with "Too many re-renders" because the retained key keeps the condition permanently true. R2-3: the lane's scoped-CSS assertion could not fail. `data-managed-empty` is a JSX prop name that survives minification as an object key, so a stopped injection guard still passed it. It now keys on the injection preamble (`__qwenWebShellCss`, the per-entry `managed-host` guard) and the portal scope selector. Witness: renaming the entry in the plugin's guard produced a build that exits 0 with zero styles — the old grep passed, the new step fails. R3-3: the meta-test's no-secrets guard only matched dot access, so `secrets['TOKEN']` kept it green. It now covers bracket form too, still without matching the prose in the security comment. R3-1: the test named for building every workspace dist pinned only the SDK step. Core and acp-bridge are equally load-bearing, so all three are pinned along with their required order. Deleting the core step now reds it. R1-9: the README example imported the pre-move `./managed-host/index.js`, which the current flat output no longer produces. The stale comment claiming the entry needs no sibling workspace dist is corrected: the lane builds core, acp-bridge and the SDK precisely because it resolves through them. Verified: the five affected web-shell suites 216/216, the workflow meta-test 10/10, actionlint and yamllint clean, CI budget holds, eslint exit 0, prettier clean, and the lane's verify step run verbatim against a freshly built bundle. Co-authored-by: Qwen-Coder <[email protected]> * fix(ci): make the managed-host bundle assertions able to fail R2-3: the portal wrapper ships in its own chunk, and every occurrence of `data-web-shell-portal-root` in the entry sits inside the injected stylesheet, so the entry grep passed on a bundle whose wrapper was deleted. Search the whole output for the quoted JSX prop instead, which the scoped CSS cannot produce. R5-1: a job-level `permissions:` block replaces the workflow default, so pin the job's effective scope rather than the workflow-level block. R5-2: match any expression that reads `secrets`; `${{ toJSON(secrets) }}` is as much a read as `secrets.TOKEN`. The workflow's own `${{ github.* }}` expressions are the control against over-broadening the guard. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw29pur5d6 * fix(ci): trigger the managed-host lane on every input it builds from The lane's `paths` filters listed the entry, its manifest, two build configs, the lockfile and the workflow itself — none of the workspace sources the non-externalized bundle is built from. A PR touching `packages/core/src/**`, `packages/acp-bridge/src/**` or `packages/sdk-typescript/src/**` reported this lane `skipped`, so a break inside the bundle surfaced only when a host loaded it. `packages/web-shell/vite.config.ts` was missing the same way even though `vite.lib.config.ts` imports `WEB_SHELL_BUILD_TARGET` from it and uses it as the bundle's `target`. Both triggers now list those four inputs. The meta-test also pinned the list with `arrayContaining`, which let two of the six entries be deleted with the suite green; it now pins the exact set, so extending or dropping an entry is a reviewed change rather than a silent one. Addresses review thread R2-2 on #16200 and the same gap reported as the only finding by the deep verification onb84f2339. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw420o4bfo * fix(web-shell): keep the managed-host bundle out of lint and clean its outDir The managed-host pass writes `dist-managed-host/`, which `packages/**/dist/**` does not match, so a local build followed by `npm run lint` linted the 1.25 MB bundle as source. Ignore the directory and let the managed-host pass empty its own outDir so rebuilds stop accumulating previous hashed chunks. Also drop the unreachable same-session workspace fallback in the pending-owner button (`pendingElsewhere` implies `pending.sessionId !== sessionId`), and restore the broad pending-row guard in the host dom test. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw4plfohgl * fix(web-shell): close managed-host correctness gaps from review round 8 Four review findings describe defects in behavior this PR introduced; each lands with a regression test confirmed red without its fix. - readPending now validates the new workspaceId field like its siblings, so a corrupt stored record cannot hand a non-string to the host's workspace selection (R1-7). - The managed page wrapper is a non-interactive focus parking spot; the composer's global keydown gate now treats a marked focus region like body focus, restoring type-anywhere after a click inside the pane (R1-11). - A host that swaps its archived-status handler now receives the current status: the shell replays it when the replaced callback was stable, while churning inline callbacks never trigger a replay, preserving the bounded render behavior the frozen forwarded identity provides (R1-10). - Slot token sync removes custom properties the shell root no longer defines, so a withdrawn host token cannot stay pinned inline above the host's cascade (R2-6). Deferred to #16471 with recorded reasoning: mount-specific unavailable copy (R1-5), real-page render-prop and busy-marker test pinning (R1-13, R1-14), and a mutation trigger for host class-driven token changes (R3-2). * fix(web-shell): read a parked focus region from the container root The focus-region arm added for the managed page wrapper read document.activeElement, which a shadow-root mount retargets to the host, so clicking the pane parked focus on the wrapper while the gate still dropped every printable keystroke. Read the active element from the container's own root and keep the document.body arm untouched. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw58vvp6hc * fix(web-shell): scope a parked focus region to its own shell Two managed shells can share one shadow root and park focus on sibling `data-web-shell-focus-region` wrappers. The global keydown gate accepted the attribute alone, so shell A captured a keystroke aimed at shell B and inserted it into the wrong conversation's editor before the session submitted it. Require the parked region to contain this composer before treating it as body focus, and reshape both fixtures so the wrapper is an ancestor of the composer as in the real managed DOM, with a regression witness for the sibling case. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw5d677mhi * fix(web-shell): drop the unreachable provider-less identity arm Main's `baseUrl` guard makes a provider-less render unreachable, so the sessionId-carry-over arm that passed through one has no producer left to pin. Prettier also collapses the i18n import. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw69blwmis * fix(web-shell): read the parked focus node from its own root (C2) In shadow-DOM portal mode document.activeElement retargets to the shadow host, so the page's parked-focus comparisons to body/sessions nav/discard node were always false and the composer latch treated a click inside the pane as a real move. Resolve the active element from the page wrapper's own root, as App and the other focus users already do. * test(web-shell): pin the real page as the composer focus region (C1) The attribute plus tabIndex pair is what useComposerCore keys type-anywhere off; the existing assertions all build their own region. Render the real page and pin both; deleting either side reds this test. * fix(web-shell): watch the workspace manifests in the managed-host lane (C4) The lane builds core, acp-bridge and the SDK from source, so a change to those workspace manifests can break the bundle while the filter reports skipped. Add the three package.json entries to both triggers and to the exact-set pin. * chore(web-shell): raise the managed-host lane size baseline (C3) Measured 7051 bytes for web-shell-managed-host.yml after C4; the baseline still recorded 5672, so the size gate would fail on the next run. * test(web-shell): pin the archived slot branch and the baseUrl guard (C5, R4-1) C5: the single shared target hid the archived branch — deleting it still left one wrapper inside the element. Use two distinct targets and assert each gets exactly one themed wrapper. R4-1: patrol removed the identity arm that passed through a provider-less render because the baseUrl guard makes that render unreachable. Pin the guard itself: a provider-less render must show the config fallback and commit no page render, and the next provider must still render. Relaxing the guard (or defaulting baseUrl) reds this test, so a future refactor cannot silently bring the arm back. * fix(web-shell): treat an unfocused shadow root as unclaimed in the discard latch The discard focus latch read the parked node from the page wrapper's own root, so a shadow-root mount whose discard dropped focus to the outer document body produced ShadowRoot.activeElement === null. Every `ours` operand was false, the latch was dropped, and the composer that just got the draft back was never focused. Treat an unfocused shadow root as unclaimed, matching the body-focus operand. The latch still dies when the user moves on (ManagedSessionsPage.test.tsx pins that). Pinned by a new shadow-root regression: the page mounts into host.attachShadow({mode:'open'}) with a host-side navigationTarget, a pending record is discarded, and shadow.activeElement must be the composer. The test is red without the fix and green with it. Addresses review thread R13-1 on #16200. Co-authored-by: Qwen-Coder <[email protected]> * fix(web-shell): resolve host-page focus before judging the discard latch unclaimed ShadowRoot.activeElement is null both when nothing inside the root is focused and when focus sits on a host-page element outside the root, so the shadow-aware read alone collapsed the two states and let the discard latch steal focus back from a user who had demonstrably moved on. Fall through to document.activeElement when the root reports null so the latch sees the real focus holder, and pin the host-toolbar case with a shadow-root regression test. Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: yiliang114 <[email protected]>
426 lines
17 KiB
TypeScript
426 lines
17 KiB
TypeScript
import './styles/globals.css';
|
|
import shellStyles from './App.module.css';
|
|
import {
|
|
useCallback,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ComponentProps,
|
|
type CSSProperties,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { BrandProvider, type WebShellBrand } from './brandContext';
|
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
|
import type { ManagedArchivedSessionsStatus } from './components/managed/ManagedArchivedSessions';
|
|
import { ManagedSessionsPage } from './components/managed/ManagedSessionsPage';
|
|
import {
|
|
createJavaManagedAgentProvider,
|
|
type JavaManagedAgentProviderOptions,
|
|
} from './components/managed/java-managed-agent-provider';
|
|
import type { ManagedAgentProvider } from './components/managed/managed-agent-provider';
|
|
import { RootErrorFallback } from './components/RootErrorFallback';
|
|
import { WebShellCustomizationProvider } from './customization';
|
|
import { I18nProvider, normalizeLanguage, type WebShellLanguage } from './i18n';
|
|
import { WebShellPortalRootContext } from './portalRoot';
|
|
import {
|
|
ThemeProvider,
|
|
WebShellThemeId,
|
|
type WebShellTheme,
|
|
} from './themeContext';
|
|
import { CompactModeContext, TodoContextsProvider } from './WebShellContexts';
|
|
|
|
/**
|
|
* 会话列表 portal 到宿主导航槽位所需的 props,直接从 ManagedSessionsPage 的
|
|
* props 推导,避免在入口层维护第二份类型。
|
|
*/
|
|
type ManagedSessionsNavigationProps = Pick<
|
|
ComponentProps<typeof ManagedSessionsPage>,
|
|
| 'navigationTarget'
|
|
| 'archivedNavigationTarget'
|
|
| 'onArchivedSessionsStatusChange'
|
|
| 'navigationActive'
|
|
| 'navigationSearch'
|
|
>;
|
|
|
|
/**
|
|
* 宿主插槽在 shell 容器之外,编译后的 CSS 只匹配 shell/portal 作用域,
|
|
* 因此每个插槽都套一层带 portal 作用域标记的容器,主题变量由 shell 根同步。
|
|
*/
|
|
function HostNavigationSlot({
|
|
target,
|
|
slotRef,
|
|
children,
|
|
}: {
|
|
target: HTMLElement;
|
|
slotRef: (element: HTMLDivElement | null) => void;
|
|
children: ReactNode;
|
|
}) {
|
|
return createPortal(
|
|
// display:contents keeps the portal-scope/theme wrapper in the DOM (so the
|
|
// scope attributes and synced variables still apply) without generating a
|
|
// box: otherwise this wrapper becomes the host sidebar's flex item and its
|
|
// default min-height:auto blocks the nav's overflow-y-auto from scrolling.
|
|
<div
|
|
ref={slotRef}
|
|
data-web-shell-portal-root
|
|
data-web-shell-shadcn
|
|
style={{ display: 'contents' }}
|
|
>
|
|
{children}
|
|
</div>,
|
|
target,
|
|
);
|
|
}
|
|
|
|
/** Change productScope with tenant/actor identity so saved selections and output caches are discarded. */
|
|
export interface ManagedAgentWebShellProps
|
|
extends Omit<JavaManagedAgentProviderOptions, 'baseUrl'>,
|
|
ManagedSessionsNavigationProps {
|
|
managedAgentProvider?: ManagedAgentProvider;
|
|
/**
|
|
* Backend base URL for the built-in Java provider. Required unless
|
|
* `managedAgentProvider` is supplied; a missing value fails loudly instead
|
|
* of resolving requests against `<origin>/undefined`.
|
|
*/
|
|
baseUrl?: string;
|
|
/** Only open the existing Session explicitly selected by the host. */
|
|
hostSessionOnly?: boolean;
|
|
sessionId?: string;
|
|
onSessionChange?: (sessionId: string | undefined) => void;
|
|
language?: WebShellLanguage;
|
|
theme?: WebShellTheme;
|
|
brand?: WebShellBrand;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
function shellErrorFallback(language: WebShellLanguage) {
|
|
return function ShellErrorFallback(error: Error, reset: () => void) {
|
|
return (
|
|
<RootErrorFallback error={error} onRetry={reset} language={language} />
|
|
);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Managed-only 产品入口,不创建 daemon workspace/session context。
|
|
*
|
|
* The provider (and its `baseUrl` guard) resolves one level below this
|
|
* boundary: an error boundary cannot catch a throw from the component that
|
|
* renders it, and a host mounting before its config resolves would otherwise
|
|
* get a blank container instead of the shell's own fallback.
|
|
*/
|
|
export function ManagedAgentWebShell(props: ManagedAgentWebShellProps) {
|
|
return (
|
|
<ErrorBoundary
|
|
label="managed-agent-web-shell-config"
|
|
resetKeys={[props.baseUrl, props.managedAgentProvider]}
|
|
fallback={shellErrorFallback(normalizeLanguage(props.language))}
|
|
>
|
|
<ManagedAgentWebShellRoot {...props} />
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
function ManagedAgentWebShellRoot(props: ManagedAgentWebShellProps) {
|
|
const {
|
|
sessionId,
|
|
managedAgentProvider,
|
|
hostSessionOnly = false,
|
|
onSessionChange,
|
|
language,
|
|
theme = WebShellThemeId.Dark,
|
|
brand = {},
|
|
className,
|
|
style,
|
|
baseUrl,
|
|
credentials,
|
|
fetch: fetchImpl,
|
|
getHeaders,
|
|
agentId,
|
|
productScope,
|
|
enableWorkspaceBinding,
|
|
modelPresets,
|
|
saveArtifact,
|
|
navigationTarget,
|
|
archivedNavigationTarget,
|
|
onArchivedSessionsStatusChange,
|
|
navigationActive,
|
|
navigationSearch,
|
|
} = props;
|
|
const resolvedLanguage = normalizeLanguage(language);
|
|
// Function props may be inline closures: route them through a ref so the
|
|
// provider (and its caches, fetches and SSE) is rebuilt only when a real
|
|
// connection input changes, not on every parent render. Assigned in a
|
|
// layout effect: a render React discards (a suspended or interrupted
|
|
// transition) must not repoint the committed tree's callbacks, and a
|
|
// passive effect would come too late — the layout phase of a commit
|
|
// finishes before any passive effect, so a child effect of that same
|
|
// commit still reads the fresh callbacks.
|
|
const archivedStatusRef = useRef(onArchivedSessionsStatusChange);
|
|
const stableArchivedCallbackRef = useRef(onArchivedSessionsStatusChange);
|
|
const lastArchivedStatusRef = useRef<
|
|
ManagedArchivedSessionsStatus | undefined
|
|
>(undefined);
|
|
const handleArchivedStatusChange = useRef<
|
|
NonNullable<
|
|
ManagedSessionsNavigationProps['onArchivedSessionsStatusChange']
|
|
>
|
|
>((status) => {
|
|
lastArchivedStatusRef.current = status;
|
|
archivedStatusRef.current?.(status);
|
|
});
|
|
const callbacksRef = useRef({ fetchImpl, getHeaders, saveArtifact });
|
|
useLayoutEffect(() => {
|
|
callbacksRef.current = { fetchImpl, getHeaders, saveArtifact };
|
|
const previous = archivedStatusRef.current;
|
|
const changed = previous !== onArchivedSessionsStatusChange;
|
|
// The child's reporting effect keys on the forwarded identity, which is
|
|
// frozen, so a swapped handler would never see the already-current
|
|
// status. Re-emit it — but only when the replaced callback was stable
|
|
// (the initial one, or one that survived an unchanged commit). Inline
|
|
// closures change identity every render and never qualify, so this
|
|
// cannot rebuild the unbounded render loop the frozen identity prevents.
|
|
if (
|
|
changed &&
|
|
previous !== undefined &&
|
|
previous === stableArchivedCallbackRef.current &&
|
|
onArchivedSessionsStatusChange &&
|
|
lastArchivedStatusRef.current
|
|
) {
|
|
onArchivedSessionsStatusChange(lastArchivedStatusRef.current);
|
|
}
|
|
if (!changed) stableArchivedCallbackRef.current = previous;
|
|
archivedStatusRef.current = onArchivedSessionsStatusChange;
|
|
});
|
|
const hasFetch = fetchImpl !== undefined;
|
|
const hasGetHeaders = getHeaders !== undefined;
|
|
const hasSaveArtifact = saveArtifact !== undefined;
|
|
const provider = useMemo(() => {
|
|
if (managedAgentProvider) return managedAgentProvider;
|
|
// `''` is a supported same-origin base (`new URL('', origin)` resolves to
|
|
// the page origin), so only a missing prop is a configuration error. A
|
|
// plain-JS host can hand over `null` for an unresolved config value, which
|
|
// would otherwise resolve every request against `<origin>/null`.
|
|
if (baseUrl == null)
|
|
throw new Error(
|
|
'ManagedAgentWebShell requires `baseUrl` when no `managedAgentProvider` is supplied.',
|
|
);
|
|
return createJavaManagedAgentProvider({
|
|
baseUrl,
|
|
credentials,
|
|
fetch: !hasFetch
|
|
? undefined
|
|
: (...args) => (callbacksRef.current.fetchImpl ?? fetch)(...args),
|
|
getHeaders: !hasGetHeaders
|
|
? undefined
|
|
: () => callbacksRef.current.getHeaders?.() ?? {},
|
|
agentId,
|
|
productScope,
|
|
enableWorkspaceBinding,
|
|
modelPresets,
|
|
saveArtifact: !hasSaveArtifact
|
|
? undefined
|
|
: (artifact, options) =>
|
|
callbacksRef.current.saveArtifact?.(artifact, options) ??
|
|
Promise.resolve(),
|
|
});
|
|
}, [
|
|
managedAgentProvider,
|
|
baseUrl,
|
|
credentials,
|
|
agentId,
|
|
productScope,
|
|
enableWorkspaceBinding,
|
|
modelPresets,
|
|
hasFetch,
|
|
hasGetHeaders,
|
|
hasSaveArtifact,
|
|
]);
|
|
const [selection, setSelection] = useState(() => ({
|
|
storageKey: provider.storageKey,
|
|
hostSessionOnly,
|
|
externalSessionId: sessionId,
|
|
selectedSessionId: sessionId,
|
|
}));
|
|
// With both sessionId and onSessionChange the host owns the selection:
|
|
// onSelectSession suppresses the internal write, but the echoed value
|
|
// still passes through the same state machine — otherwise a host can never
|
|
// return to "no selection" and a scope switch forwards the carried-over id.
|
|
const controlled =
|
|
hostSessionOnly ||
|
|
(sessionId !== undefined && onSessionChange !== undefined);
|
|
let selectedSessionId: string | undefined;
|
|
if (
|
|
selection.storageKey !== provider.storageKey ||
|
|
selection.hostSessionOnly !== hostSessionOnly ||
|
|
selection.externalSessionId !== sessionId
|
|
) {
|
|
// Adjust during render (React's adjusting-state-when-props-change
|
|
// pattern): an external switch is visible on this commit — an effect
|
|
// would paint one stale frame first. Only an id carried over from the
|
|
// previous identity is dropped, never an explicit new selection.
|
|
selectedSessionId =
|
|
selection.storageKey !== provider.storageKey &&
|
|
sessionId === selection.externalSessionId
|
|
? undefined
|
|
: sessionId;
|
|
setSelection({
|
|
storageKey: provider.storageKey,
|
|
hostSessionOnly,
|
|
externalSessionId: sessionId,
|
|
selectedSessionId,
|
|
});
|
|
} else {
|
|
selectedSessionId = selection.selectedSessionId;
|
|
}
|
|
const [portalRoot, setPortalRoot] = useState<HTMLDivElement | null>(null);
|
|
const shellRootRef = useRef<HTMLDivElement | null>(null);
|
|
// Stable, or React detaches the inline callback with null in every commit's
|
|
// mutation phase and `syncSlot` below sees no root.
|
|
const setShellRoot = useCallback((element: HTMLDivElement | null) => {
|
|
shellRootRef.current = element;
|
|
setPortalRoot(element);
|
|
}, []);
|
|
// A wrapper can mount in a commit where none of the sync effect's deps
|
|
// changed (the provider arrives later, hostSessionOnly flips, the provider
|
|
// identity remounts the page), so the ref callback themes the element it
|
|
// just saw instead of relying on the effect to notice the mount.
|
|
const slotTokenNames = useMemo(
|
|
() => new WeakMap<HTMLElement, Set<string>>(),
|
|
[],
|
|
);
|
|
const syncSlot = useCallback(
|
|
(slot: HTMLElement) => {
|
|
const root = shellRootRef.current;
|
|
if (!root) return;
|
|
const computed = getComputedStyle(root);
|
|
slot.classList.toggle('dark', theme === WebShellThemeId.Dark);
|
|
slot.lang = resolvedLanguage;
|
|
const applied = slotTokenNames.get(slot) ?? new Set<string>();
|
|
const current = new Set<string>();
|
|
for (let index = 0; index < computed.length; index += 1) {
|
|
const name = computed[index];
|
|
if (!name.startsWith('--')) continue;
|
|
current.add(name);
|
|
slot.style.setProperty(name, computed.getPropertyValue(name));
|
|
}
|
|
// A token the root no longer defines must not stay pinned here: an
|
|
// inline declaration on our own wrapper outranks the host's cascade,
|
|
// so a withdrawn host token would otherwise outlive its source.
|
|
for (const name of applied)
|
|
if (!current.has(name)) slot.style.removeProperty(name);
|
|
slotTokenNames.set(slot, current);
|
|
},
|
|
[theme, resolvedLanguage, slotTokenNames],
|
|
);
|
|
const registerHostSlot = useCallback(
|
|
(element: HTMLDivElement | null) => {
|
|
if (element) syncSlot(element);
|
|
},
|
|
[syncSlot],
|
|
);
|
|
// Our own wrappers are the only nodes carrying the portal-scope marker, so
|
|
// the host slot's own chrome is never themed and a wrapper that outlives a
|
|
// detach is picked up again instead of being dropped from a registry.
|
|
const syncHostSlots = useCallback(() => {
|
|
for (const target of [navigationTarget, archivedNavigationTarget]) {
|
|
if (!target) continue;
|
|
for (const slot of target.querySelectorAll<HTMLElement>(
|
|
'[data-web-shell-portal-root]',
|
|
))
|
|
syncSlot(slot);
|
|
}
|
|
}, [syncSlot, navigationTarget, archivedNavigationTarget]);
|
|
useLayoutEffect(syncHostSlots, [syncHostSlots, style, portalRoot]);
|
|
const emptyMap = useMemo(() => new Map(), []);
|
|
|
|
return (
|
|
<ErrorBoundary
|
|
label="managed-agent-web-shell-root"
|
|
resetKeys={[provider.storageKey, selectedSessionId]}
|
|
fallback={shellErrorFallback(resolvedLanguage)}
|
|
>
|
|
<ThemeProvider value={theme}>
|
|
<BrandProvider value={brand}>
|
|
<I18nProvider language={resolvedLanguage}>
|
|
<WebShellPortalRootContext.Provider value={portalRoot}>
|
|
<WebShellCustomizationProvider value={{}}>
|
|
<TodoContextsProvider timeline={emptyMap} details={emptyMap}>
|
|
<CompactModeContext.Provider value={false}>
|
|
<div
|
|
ref={setShellRoot}
|
|
className={`${shellStyles.app} ${shellStyles.managedHome} ${theme === WebShellThemeId.Dark ? `${shellStyles.themeDark} dark` : shellStyles.themeLight} ${className ?? ''}`}
|
|
style={style}
|
|
data-web-shell-root
|
|
data-web-shell-shadcn
|
|
lang={resolvedLanguage}
|
|
>
|
|
{provider && (
|
|
<ManagedSessionsPage
|
|
key={provider.storageKey}
|
|
renderNavigation={
|
|
navigationTarget
|
|
? (navigation) => (
|
|
<HostNavigationSlot
|
|
target={navigationTarget}
|
|
slotRef={registerHostSlot}
|
|
>
|
|
{navigation}
|
|
</HostNavigationSlot>
|
|
)
|
|
: undefined
|
|
}
|
|
renderArchivedNavigation={(archived) =>
|
|
archivedNavigationTarget ? (
|
|
<HostNavigationSlot
|
|
target={archivedNavigationTarget}
|
|
slotRef={registerHostSlot}
|
|
>
|
|
{archived}
|
|
</HostNavigationSlot>
|
|
) : null
|
|
}
|
|
sessionId={selectedSessionId}
|
|
onSelectSession={(next) => {
|
|
// A host without an echo channel still owns new
|
|
// selections, but a clear (delete/archive of the
|
|
// current session) must not evaporate with it.
|
|
if (
|
|
!controlled ||
|
|
(next === undefined &&
|
|
onSessionChange === undefined)
|
|
)
|
|
setSelection((current) => ({
|
|
...current,
|
|
selectedSessionId: next,
|
|
}));
|
|
onSessionChange?.(next);
|
|
}}
|
|
hostSessionOnly={hostSessionOnly}
|
|
managedAgentProvider={provider}
|
|
navigationTarget={navigationTarget}
|
|
archivedNavigationTarget={archivedNavigationTarget}
|
|
onArchivedSessionsStatusChange={
|
|
onArchivedSessionsStatusChange
|
|
? handleArchivedStatusChange.current
|
|
: undefined
|
|
}
|
|
navigationActive={navigationActive}
|
|
navigationSearch={navigationSearch}
|
|
/>
|
|
)}
|
|
</div>
|
|
</CompactModeContext.Provider>
|
|
</TodoContextsProvider>
|
|
</WebShellCustomizationProvider>
|
|
</WebShellPortalRootContext.Provider>
|
|
</I18nProvider>
|
|
</BrandProvider>
|
|
</ThemeProvider>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|