evOo-local/apps/web/src/pages/shell/bot-panel.tsx
cynarAI 99f868914a
fix(web): recover lazy routes and overlays from a failed module fetch
When the browser aborts a module request while a page loads, the lazy
import rejects and the route throws, leaving a blank page. Chromium does
this whenever a network interface appears or disappears mid-request
(net::ERR_NETWORK_CHANGED), which happens on a laptop that changes
networks and on the CI host every time a container starts or stops. A
Web E2E run hit exactly this: two modules of the onboarding page failed
to load and the suite waited on an empty document.

Lazy routes and overlays now load through a helper that retries the
import once and, when the browser keeps rejecting it from its module
map, reloads the page a single time before surfacing the error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
(cherry picked from commit d5dacb12f14380071ef14ba8845d18c9d39e3fac)
2026-10-02 17:17:24 +00:00

662 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { t } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import type {
AgentSkillCatalogEntry,
Bot,
ComputerMode,
Me,
ModelCatalogEntry,
ModelCredential,
ThinkingLevel,
VoiceInfo,
} from "@rakazo/contracts";
import {
BOT_DESCRIPTION_MAX_LENGTH,
BOT_NAME_MAX_LENGTH,
BOT_TITLE_MAX_LENGTH,
} from "@rakazo/contracts";
import {
Button,
Input,
NativeSelect,
NativeSelectOption,
Switch,
Textarea,
Toggle,
} from "@rakazo/ui-web";
import { X } from "lucide-react";
import { Suspense, useEffect, useId, useRef, useState } from "react";
import { lazyWithRecovery } from "../../lib/lazy-recovery";
import { rpc } from "../../lib/rpc";
import { AvatarStudioPopover } from "./avatar-studio-popover";
const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () =>
import("../ScratchpadSection").then((module) => ({ default: module.ScratchpadSection })),
);
const KnowledgeSection = lazyWithRecovery("KnowledgeSection", () =>
import("../KnowledgeSection").then((module) => ({ default: module.KnowledgeSection })),
);
const fieldLabelClass = "mt-4 block text-ui-body text-muted-foreground";
function ComputerModePicker({
value,
onChange,
teamTestId,
privateTestId,
}: {
value: ComputerMode;
onChange: (value: ComputerMode) => void;
teamTestId?: string;
privateTestId?: string;
}) {
return (
<div className="mt-4">
<div className="text-ui-body text-muted-foreground">
<Trans>Computer</Trans>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{(["team", "dedicated"] as const).map((mode) => (
<Toggle
key={mode}
variant="outline"
pressed={value === mode}
data-testid={mode === "team" ? teamTestId : privateTestId}
onPressedChange={(pressed) => {
if (pressed) onChange(mode);
}}
className="capitalize aria-pressed:border-foreground/40 aria-pressed:text-foreground"
>
{mode === "team" ? <Trans>Team</Trans> : <Trans>Private</Trans>}
</Toggle>
))}
</div>
</div>
);
}
export function CreateBotForm({
onCreate,
onCancel,
}: {
onCreate: (input: {
name: string;
title: string;
description: string;
computerMode: ComputerMode;
}) => Promise<void>;
onCancel: () => void;
}) {
const { t } = useLingui();
const ids = useId();
const [name, setName] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [computerMode, setComputerMode] = useState<ComputerMode>("team");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit() {
if (!name.trim() || submitting) return;
setError(null);
setSubmitting(true);
try {
await onCreate({
name: name.trim(),
title: title.trim(),
description: description.trim(),
computerMode,
});
} catch (err) {
setError(err instanceof Error ? err.message : t`Could not create bot`);
} finally {
setSubmitting(false);
}
}
return (
<div data-testid="create-bot-form">
<div className="mb-4 flex items-center justify-between">
<span className="text-ui-body text-muted-foreground">
<Trans>New bot</Trans>
</span>
<Button variant="ghost" size="icon-sm" aria-label={t`Cancel new bot`} onClick={onCancel}>
<X strokeWidth={1.8} className="size-icon-md" />
</Button>
</div>
{error ? (
<p
role="alert"
data-testid="create-bot-error"
className="mb-3 text-ui-body-sm text-destructive"
>
{error}
</p>
) : null}
<label htmlFor={`${ids}-name`} className="mt-6 block text-ui-body text-muted-foreground">
<Trans>Name</Trans>
<Input
id={`${ids}-name`}
value={name}
maxLength={BOT_NAME_MAX_LENGTH}
onChange={(e) => setName(e.target.value)}
placeholder={t`Name this bot`}
className="mt-2"
/>
</label>
<label htmlFor={`${ids}-title`} className={fieldLabelClass}>
<Trans>Title</Trans>
<Input
id={`${ids}-title`}
value={title}
maxLength={BOT_TITLE_MAX_LENGTH}
onChange={(e) => setTitle(e.target.value)}
placeholder={t`Describe what this bot does`}
className="mt-2"
/>
</label>
<label htmlFor={`${ids}-description`} className={fieldLabelClass}>
<Trans>Description</Trans>
<Textarea
id={`${ids}-description`}
value={description}
maxLength={BOT_DESCRIPTION_MAX_LENGTH}
onChange={(e) => setDescription(e.target.value)}
placeholder={t`What this bot is for`}
rows={4}
className="mt-2"
/>
</label>
<div data-testid="create-bot-computer">
<ComputerModePicker
value={computerMode}
onChange={setComputerMode}
teamTestId="create-bot-team"
privateTestId="create-bot-private"
/>
</div>
<Button
className="mt-5"
disabled={!name.trim() || submitting}
onClick={() => void handleSubmit()}
>
{submitting ? <Trans>Creating…</Trans> : <Trans>Create</Trans>}
</Button>
</div>
);
}
export function BotSettings({
bot,
memoryProviderConfigured,
onSkillsChange,
onSave,
onExport,
onClear,
}: {
bot: Bot;
onSkillsChange: (skills: AgentSkillCatalogEntry[]) => void;
memoryProviderConfigured: boolean;
onSave: (patch: {
name?: string;
title?: string;
description?: string;
instructions?: string;
color?: string;
notifyOnFinish?: boolean;
computerMode: ComputerMode;
memoryScope?: "isolated" | "shared" | null;
autoSpeak?: boolean;
voiceId?: string | null;
modelProvider?: string | null;
modelId?: string | null;
thinkingLevel?: ThinkingLevel | null;
}) => Promise<void>;
onExport: () => Promise<void>;
onClear: () => void;
}) {
const { t } = useLingui();
const [advancedOpened, setAdvancedOpened] = useState(false);
const ids = useId();
const [name, setName] = useState(bot.name);
const [title, setTitle] = useState(bot.title);
const [description, setDescription] = useState(bot.description);
const [color, setColor] = useState(bot.color);
const [notifyOnFinish, setNotifyOnFinish] = useState(bot.notifyOnFinish ?? true);
const [computerMode, setComputerMode] = useState(bot.computerMode);
const [memoryScope, setMemoryScope] = useState(bot.memoryScope);
const [autoSpeak, setAutoSpeak] = useState(bot.autoSpeak);
const [voiceId, setVoiceId] = useState(bot.voiceId ?? "");
const [voices, setVoices] = useState<VoiceInfo[]>([]);
const [modelKey, setModelKey] = useState(
bot.modelProvider && bot.modelId ? modelOptionKey(bot.modelProvider, bot.modelId) : "",
);
const [thinkingLevel, setThinkingLevel] = useState(bot.thinkingLevel ?? "");
const [credentials, setCredentials] = useState<ModelCredential[]>([]);
const [catalog, setCatalog] = useState<ModelCatalogEntry[]>([]);
const [me, setMe] = useState<Me | null>(null);
const [modelMetaReady, setModelMetaReady] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const saveQueueRef = useRef(Promise.resolve());
const executeSaveRef = useRef<
(patchOverrides?: {
name?: string;
title?: string;
description?: string;
color?: string;
notifyOnFinish?: boolean;
}) => Promise<void>
>(async () => undefined);
useEffect(() => {
void rpc.voice
.voices({})
.then(setVoices)
.catch(() => setVoices([]));
void Promise.all([rpc.models.credentials(), rpc.models.list(), rpc.me()])
.then(([nextCredentials, nextCatalog, nextMe]) => {
setCredentials(nextCredentials);
setCatalog(nextCatalog);
setMe(nextMe);
// Only mark ready on success — a failed catalog load must not clear
// an existing thinkingLevel override on save.
setModelMetaReady(true);
})
.catch(() => undefined);
}, []);
const connectedOptions: Array<{
key: string;
provider: string;
modelId: string;
label: string;
}> = [];
const seenOptions = new Set<string>();
for (const credential of credentials) {
const providerModels = catalog.filter(
(entry) => entry.provider === credential.provider && !entry.placeholder,
);
const credentialInCatalog = Boolean(
credential.modelId && providerModels.some((entry) => entry.id === credential.modelId),
);
// Catalog providers expand to every model for that connection. Free-form
// credentials (model id not in the catalog) stay a single connected pair.
const options =
credential.modelId && !credentialInCatalog
? [
{
key: modelOptionKey(credential.provider, credential.modelId),
provider: credential.provider,
modelId: credential.modelId,
label: `${credential.label} · ${credential.modelId}`,
},
]
: providerModels.map((entry) => ({
key: modelOptionKey(entry.provider, entry.id),
provider: entry.provider,
modelId: entry.id,
label: `${entry.providerName ?? entry.provider} · ${entry.label}`,
}));
for (const option of options) {
if (seenOptions.has(option.key)) continue;
seenOptions.add(option.key);
connectedOptions.push(option);
}
}
const effectiveProvider = modelKey
? parseModelOptionKey(modelKey)?.provider
: (me?.defaultProvider ?? null);
const effectiveModelId = modelKey
? parseModelOptionKey(modelKey)?.modelId
: (me?.defaultModel ?? null);
const effectiveEntry =
effectiveProvider && effectiveModelId
? catalog.find(
(entry) => entry.provider === effectiveProvider && entry.id === effectiveModelId,
)
: undefined;
const effectiveCredential = credentials.find(
(entry) => entry.provider === effectiveProvider && entry.modelId === effectiveModelId,
);
const thinkingOptions = (
effectiveCredential?.thinkingLevels ??
effectiveEntry?.thinkingLevels ??
[]
).filter((level) => level !== "off");
const defaultThinkingLevel = effectiveCredential?.thinkingLevel ?? "medium";
async function executeSave(patchOverrides?: {
name?: string;
title?: string;
description?: string;
color?: string;
notifyOnFinish?: boolean;
}) {
const selected = modelKey ? parseModelOptionKey(modelKey) : null;
const nextName = (patchOverrides?.name !== undefined ? patchOverrides.name : name).trim();
const nextTitle = (patchOverrides?.title !== undefined ? patchOverrides.title : title).trim();
const nextDescription = (
patchOverrides?.description !== undefined ? patchOverrides.description : description
).trim();
const nextColor = patchOverrides?.color !== undefined ? patchOverrides.color : color;
const nextNotify =
patchOverrides?.notifyOnFinish !== undefined ? patchOverrides.notifyOnFinish : notifyOnFinish;
if (nextName) setName(nextName);
setTitle(nextTitle);
setDescription(nextDescription);
try {
setSaving(true);
setError(null);
await onSave({
name: nextName || bot.name,
title: nextTitle,
description: nextDescription,
instructions: nextDescription,
color: nextColor,
notifyOnFinish: nextNotify,
computerMode,
memoryScope,
autoSpeak,
voiceId: voiceId || null,
modelProvider: selected?.provider ?? null,
modelId: selected?.modelId ?? null,
...(modelMetaReady
? {
thinkingLevel: thinkingOptions.length
? ((thinkingLevel || null) as ThinkingLevel | null)
: null,
}
: {}),
});
} catch (err) {
setError(err instanceof Error ? err.message : t`Could not save`);
} finally {
setSaving(false);
}
}
executeSaveRef.current = executeSave;
function enqueueSave(patchOverrides?: {
name?: string;
title?: string;
description?: string;
color?: string;
notifyOnFinish?: boolean;
}) {
// Serialize full-object auto-saves so an older in-flight request cannot
// finish after a newer one and clobber fields. Always call through a ref so
// queued work reads the latest field values, not a stale render closure.
saveQueueRef.current = saveQueueRef.current
.catch(() => undefined)
.then(() => executeSaveRef.current(patchOverrides));
return saveQueueRef.current;
}
return (
<div data-testid="bot-settings">
<div className="flex justify-center py-4">
<AvatarStudioPopover
value={color}
identity={bot.id}
status={bot.status}
size={76}
onChange={(newColor) => {
setColor(newColor);
void enqueueSave({ color: newColor });
}}
/>
</div>
<label htmlFor={`${ids}-name`} className="mt-4 block text-ui-body text-muted-foreground/80">
<Trans>Name</Trans>
<Input
id={`${ids}-name`}
value={name}
maxLength={BOT_NAME_MAX_LENGTH}
onChange={(e) => setName(e.target.value)}
onBlur={() => void enqueueSave()}
className="mt-1.5"
/>
</label>
<label htmlFor={`${ids}-title`} className={fieldLabelClass}>
<Trans>Title</Trans>
<Input
id={`${ids}-title`}
value={title}
maxLength={BOT_TITLE_MAX_LENGTH}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => void enqueueSave()}
placeholder={t`e.g. Hivenet Agent, Presales, Timesheets bot`}
className="mt-1.5"
/>
</label>
<label htmlFor={`${ids}-description`} className={fieldLabelClass}>
<Trans>Description</Trans>
<Textarea
id={`${ids}-description`}
value={description}
maxLength={BOT_DESCRIPTION_MAX_LENGTH}
onChange={(e) => setDescription(e.target.value)}
onBlur={() => void enqueueSave()}
rows={3}
className="mt-1.5"
/>
</label>
<div className="mt-6 flex items-center justify-between pt-4 border-t border-border/20">
<div className="space-y-0.5 pe-4">
<div
id={`${ids}-notify-finish-label`}
className="text-ui-body font-medium text-foreground"
>
<Trans>Notifications</Trans>
</div>
<div
id={`${ids}-notify-finish-desc`}
className="text-ui-body-sm text-muted-foreground/70"
>
<Trans>Get notified when this Bot finishes or needs input</Trans>
</div>
</div>
<Switch
id={`${ids}-notify-finish`}
checked={notifyOnFinish}
aria-labelledby={`${ids}-notify-finish-label`}
aria-describedby={`${ids}-notify-finish-desc`}
onCheckedChange={(checked) => {
setNotifyOnFinish(checked);
void enqueueSave({ notifyOnFinish: checked });
}}
/>
</div>
<details
data-testid="bot-settings-advanced"
className="group mt-5"
onToggle={(event) => {
if (event.currentTarget.open) setAdvancedOpened(true);
}}
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-ui-body text-muted-foreground">
<span className="text-muted-foreground">
<Trans>Advanced</Trans>
</span>
<span aria-hidden="true" className="transition-transform group-open:rotate-90">
›
</span>
</summary>
<ComputerModePicker value={computerMode} onChange={setComputerMode} />
<Suspense fallback={null}>
<ScratchpadSection botId={bot.id} />
{advancedOpened ? (
<KnowledgeSection botId={bot.id} onSkillsChange={onSkillsChange} />
) : null}
</Suspense>
<label htmlFor={`${ids}-model`} className={fieldLabelClass}>
<Trans>Model</Trans>
<NativeSelect
id={`${ids}-model`}
className="mt-2 w-full"
value={modelKey}
onChange={(event) => {
setModelKey(event.target.value);
setThinkingLevel("");
}}
>
<NativeSelectOption value="">
{t`Space default`}
{me?.defaultModel
? ` (${catalogLabel(catalog, me.defaultProvider, me.defaultModel) ?? me.defaultModel})`
: ""}
</NativeSelectOption>
{modelKey && !connectedOptions.some((option) => option.key === modelKey) ? (
<NativeSelectOption value={modelKey}>
{parseModelOptionKey(modelKey)?.modelId ?? modelKey}
</NativeSelectOption>
) : null}
{connectedOptions.map((option) => (
<NativeSelectOption key={option.key} value={option.key}>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
</label>
{thinkingOptions.length ? (
<label htmlFor={`${ids}-thinking`} className={fieldLabelClass}>
<Trans>Thinking</Trans>
<NativeSelect
id={`${ids}-thinking`}
className="mt-2 w-full"
value={thinkingLevel}
onChange={(event) => setThinkingLevel(event.target.value)}
>
<NativeSelectOption value="">
{t`Default (${thinkingLevelLabel(defaultThinkingLevel)})`}
</NativeSelectOption>
{thinkingOptions.map((level) => (
<NativeSelectOption key={level} value={level}>
{thinkingLevelLabel(level)}
</NativeSelectOption>
))}
</NativeSelect>
</label>
) : null}
{memoryProviderConfigured ? (
<div className="mt-4 text-ui-body text-muted-foreground">
<Trans>Memory scope</Trans>
<div className="mt-2 flex gap-2">
{(
[
{ value: null, label: t`Inherit default` },
{ value: "isolated" as const, label: t`Isolated` },
{ value: "shared" as const, label: t`Shared` },
] satisfies Array<{ value: "isolated" | "shared" | null; label: string }>
).map((option) => (
<Toggle
key={option.label}
variant="outline"
size="sm"
pressed={memoryScope === option.value}
onPressedChange={(pressed) => {
if (pressed) setMemoryScope(option.value);
}}
className="flex-1 aria-pressed:border-foreground/40 aria-pressed:text-foreground"
>
{option.label}
</Toggle>
))}
</div>
</div>
) : null}
<label
htmlFor={`${ids}-auto-speak`}
className="mt-5 flex cursor-pointer items-center gap-3 text-ui-body text-foreground/75"
>
<Switch
id={`${ids}-auto-speak`}
checked={autoSpeak}
onCheckedChange={(checked) => setAutoSpeak(checked)}
/>
<Trans>Read replies aloud</Trans>
</label>
{voices.length ? (
<label htmlFor={`${ids}-voice`} className={fieldLabelClass}>
<Trans>Voice</Trans>
<NativeSelect
id={`${ids}-voice`}
className="mt-2 w-full"
value={voiceId}
onChange={(event) => setVoiceId(event.target.value)}
>
<NativeSelectOption value="">{t`Account default`}</NativeSelectOption>
{voices.map((voice) => (
<NativeSelectOption key={voice.id} value={voice.id}>
{voice.label}
</NativeSelectOption>
))}
</NativeSelect>
</label>
) : null}
</details>
{error ? <p className="mt-2 text-ui-body-sm text-destructive">{error}</p> : null}
<div className="mt-5 flex flex-col items-start gap-3">
<Button
disabled={saving}
onClick={() => {
void enqueueSave({
name,
title,
description,
color,
notifyOnFinish,
});
}}
>
<Trans>Save</Trans>
</Button>
<Button variant="ghost" size="sm" className="-ms-2.5" onClick={() => void onExport()}>
<Trans>Export</Trans>
</Button>
<Button
variant="ghost"
size="sm"
className="-ms-2.5 text-destructive hover:text-destructive"
onClick={onClear}
>
<Trans>Clear conversation</Trans>
</Button>
</div>
</div>
);
}
function modelOptionKey(provider: string, modelId: string) {
return `${provider}::${modelId}`;
}
function thinkingLevelLabel(level: ThinkingLevel) {
if (level === "xhigh") return t`Extra high`;
if (level === "low") return t`Low`;
if (level === "medium") return t`Medium`;
if (level === "high") return t`High`;
if (level === "minimal") return t`Minimal`;
if (level === "max") return t`Max`;
return `${level.slice(0, 1).toUpperCase()}${level.slice(1)}`;
}
function parseModelOptionKey(key: string) {
const separator = key.indexOf("::");
if (separator <= 0) return null;
return { provider: key.slice(0, separator), modelId: key.slice(separator + 2) };
}
function catalogLabel(
catalog: ModelCatalogEntry[],
provider: string | null | undefined,
modelId: string,
) {
if (!provider) return undefined;
return catalog.find((entry) => entry.provider === provider && entry.id === modelId)?.label;
}