evOo-local/apps/web/src/pages/shell/bot-panel.tsx
cynarAI dc1d28c670
fix(web): make the shell follow the light theme (#107)
The composer, the shell frame and sidebar, the right side panel and the
create-bot modal were hard-coded dark while the text on them follows the
theme, so in the light theme the typed draft, the sidebar rows and the
side panel forms rendered dark on dark.

- Composer: the island uses the card and foreground tokens; attach, stop
  and chips use border and foreground tokens, and send is the default
  primary button. The focus and shadow utilities on the bar are removed:
  .apple-island always overrode them, so they never rendered.
- Shell frame and page background use the background token. The frame's
  radial gradient never rendered under Tailwind v4 and is dropped.
- Sidebar: the panel uses the sidebar tokens on desktop too. The main
  conversation, roster, project and Heute rows share one token-based
  row style; muted text measures 4.4:1 on the light sidebar, so row
  times use a dimmed foreground instead.
- Side panel and create-bot modal use background, popover and overlay
  tokens. Settings helper texts and inactive tabs use the full muted
  color, which stays above 4.5:1 in light.
- Stage vignette, stage pill, ensemble and hero orb, and the transcript
  paths behind the call overlay use tokens.
- E2E: chat-contrast.spec.ts gains a shell test that measures the
  composer draft, sidebar rows, the new bot form and the bot settings
  form in both themes and captures screenshots.


Claude-Session: https://claude.ai/code/session_01SSVQGR4WU92LaddTP19GcQ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 15:27:51 +02:00

721 lines
24 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,
ModelTier,
ModelTierMapping,
ThinkingLevel,
VoiceInfo,
} from "@rakazo/contracts";
import {
BOT_DESCRIPTION_MAX_LENGTH,
BOT_NAME_MAX_LENGTH,
BOT_TITLE_MAX_LENGTH,
MODEL_TIERS,
ModelTierSchema,
normalizeBotTags,
} from "@rakazo/contracts";
import {
Button,
Input,
NativeSelect,
NativeSelectOptGroup,
NativeSelectOption,
Switch,
Textarea,
Toggle,
} from "@rakazo/ui-web";
import { X } from "lucide-react";
import { Suspense, useEffect, useId, useRef, useState } from "react";
import { connectedModelOptions } from "../../lib/connected-models";
import { lazyWithRecovery } from "../../lib/lazy-recovery";
import { modelTierLabel } from "../../lib/model-tier-label";
import { rpc } from "../../lib/rpc";
import { AvatarStudioPopover } from "./avatar-studio-popover";
import { BotCapabilitiesFields } from "./bot-capabilities";
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;
tags?: string[];
restrictBuiltinTools?: boolean;
allowedBuiltinTools?: string[];
modelTier?: ModelTier | 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.modelTier
? tierOptionKey(bot.modelTier)
: bot.modelProvider && bot.modelId
? modelOptionKey(bot.modelProvider, bot.modelId)
: "",
);
const [thinkingLevel, setThinkingLevel] = useState(bot.thinkingLevel ?? "");
const [tags, setTags] = useState(bot.tags.join(", "));
const [restrictBuiltinTools, setRestrictBuiltinTools] = useState(bot.restrictBuiltinTools);
const [allowedBuiltinTools, setAllowedBuiltinTools] = useState(bot.allowedBuiltinTools);
const [toolNames, setToolNames] = useState<string[] | null>(null);
const [credentials, setCredentials] = useState<ModelCredential[]>([]);
const [catalog, setCatalog] = useState<ModelCatalogEntry[]>([]);
const [tiers, setTiers] = useState<ModelTierMapping[]>([]);
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 rpc.bots
.builtinTools({ botId: bot.id })
.then(setToolNames)
.catch(() => undefined);
void Promise.all([
rpc.models.credentials(),
rpc.models.list(),
rpc.me(),
rpc.models.tiers.list().catch(() => []),
])
.then(([nextCredentials, nextCatalog, nextMe, nextTiers]) => {
setCredentials(nextCredentials);
setCatalog(nextCatalog);
setMe(nextMe);
setTiers(nextTiers);
// Only mark ready on success — a failed catalog load must not clear
// an existing thinkingLevel override on save.
setModelMetaReady(true);
})
.catch(() => undefined);
}, []);
const connectedOptions = connectedModelOptions(credentials, catalog).map((option) => ({
...option,
key: modelOptionKey(option.provider, option.modelId),
}));
const selectedTier = parseTierOptionKey(modelKey);
const selectedTierModel = tiers.find((entry) => entry.tier === selectedTier);
// A mapped tier runs its model; an unmapped one falls back to the Space default.
const effectiveProvider = selectedTierModel
? selectedTierModel.provider
: modelKey && !selectedTier
? parseModelOptionKey(modelKey)?.provider
: (me?.defaultProvider ?? null);
const effectiveModelId = selectedTierModel
? selectedTierModel.modelId
: modelKey && !selectedTier
? parseModelOptionKey(modelKey)?.modelId
: (me?.defaultModel ?? null);
const effectiveEntry =
effectiveProvider && effectiveModelId
? catalog.find(
(entry) => entry.provider === effectiveProvider && entry.id === effectiveModelId,
)
: undefined;
// One connection serves every model of its tiers with the same reasoning settings.
const effectiveCredential = selectedTierModel
? credentials.find((entry) => entry.id === selectedTierModel.credentialId)
: credentials.find(
(entry) => entry.provider === effectiveProvider && entry.modelId === effectiveModelId,
);
const tierOptions = MODEL_TIERS.flatMap((tier) => {
const mapped = tiers.find((entry) => entry.tier === tier);
// Tiers appear once mapped; an unmapped current choice stays visible.
if (!mapped && tier !== selectedTier) return [];
const model = mapped
? (catalogLabel(catalog, mapped.provider, mapped.modelId) ?? mapped.modelId)
: null;
return [
{
key: tierOptionKey(tier),
label: model ? `${modelTierLabel(tier)} (${model})` : modelTierLabel(tier),
},
];
});
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 && !selectedTier ? 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;
const nextTags = normalizeBotTags(tags.split(","));
if (nextName) setName(nextName);
setTitle(nextTitle);
setDescription(nextDescription);
setTags(nextTags.join(", "));
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,
tags: nextTags,
restrictBuiltinTools,
// Names a newer release no longer ships would make the whole save fail validation.
allowedBuiltinTools: toolNames
? allowedBuiltinTools.filter((tool) => toolNames.includes(tool))
: allowedBuiltinTools,
modelTier: selectedTier,
...(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={fieldLabelClass}>
<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">
<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>
{tierOptions.length ? (
<NativeSelectOptGroup label={t`Tiers`}>
{tierOptions.map((option) => (
<NativeSelectOption key={option.key} value={option.key}>
{option.label}
</NativeSelectOption>
))}
</NativeSelectOptGroup>
) : null}
{modelKey &&
!selectedTier &&
!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}
<BotCapabilitiesFields
tags={tags}
onTagsChange={setTags}
restrictBuiltinTools={restrictBuiltinTools}
onRestrictBuiltinToolsChange={setRestrictBuiltinTools}
allowedBuiltinTools={allowedBuiltinTools}
onAllowedBuiltinToolsChange={setAllowedBuiltinTools}
toolNames={toolNames ?? []}
/>
</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}`;
}
// Model keys always contain "::", so a tier key can never collide with one.
function tierOptionKey(tier: ModelTier) {
return `tier:${tier}`;
}
function parseTierOptionKey(key: string): ModelTier | null {
if (!key.startsWith("tier:")) return null;
const tier = ModelTierSchema.safeParse(key.slice("tier:".length));
return tier.success ? tier.data : null;
}
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;
}