cynarAI 97787a0272
Production readiness phase 1: journey audit, fixes 1–4, E2E (#175)
* Production readiness phase 1: journeys doc, onboarding persistence, teardown, chat UX

- Add docs/PRODUCTION-READINESS.md with journey matrix and defect backlog
- Persist onboarding draft in session storage across reload
- Localize onboarding discovery main-bot role; German Chief of Staff string
- Destroy team sandboxes and home folders when deleting an account
- Keep composer/transcript visible during queued runs; extend send watchdog
- Add Playwright specs: reload persistence, DE intake, queued progress, console guard

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix CI: sync Lingui catalogs, onboarding name draft, E2E stability

- Localize Chief of Staff in discovery shell; sync all locale PO files
- Propagate Oo name to onboarding draft while typing
- Adjust DE intake, queued-run, and console-error E2E specs

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Format chat-queued-progress E2E spec

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): scope account-delete sandbox teardown to owned spaces

Only destroy team sandboxes and homes for spaces the deleting user
owns with no remaining members or bots. Guard home removal under
dataDir/homes. Add unit and postgres journey coverage.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* chore: apply biome formatting

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-07 11:25:39 +02:00

4280 lines
170 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 { closestCenter, DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { Trans, useLingui } from "@lingui/react/macro";
import type {
Bot,
BotSection,
CardAct,
ComputerMode,
ComputerReleaseReason,
ComputerStatus,
Group,
ImportBotInput,
MainConversationRef,
Me,
Routine,
RunStatus,
Space,
SpaceMemoryConfig,
TaughtSkill,
ThreadMessage,
ThreadSnapshot,
VoiceStatus,
} from "@rakazo/contracts";
import {
ATTACHMENT_MAX_COUNT,
type MessageReaction,
normalizeCreateBotProfile,
} from "@rakazo/contracts";
import {
applyWorkspaceFocus,
attachmentsForThread,
type ComposerMention,
cronFromPreset,
deriveWorkspaceFocus,
inferAttachmentMimeType,
isActive,
isRunTerminalEvent,
latestAnswerableAskMessageId,
liveScreenRunId,
matchVoiceByName,
type PaneState,
parseVoiceChatCommand,
recordScreenPaneChoice,
reorderBotTo,
resolveComposerSendPlan,
runThreadSubscription,
screenPaneMode,
searchHitThreadTarget,
speechFromBlocks,
userVisibleMessages,
type WorkspaceArtifact,
type WorkspaceFocus,
} from "@rakazo/core";
import {
BotAvatar,
Button,
DropdownMenuItem,
GroupAvatar,
type GroupAvatarMember,
InputGroup,
InputGroupAddon,
InputGroupInput,
Popover,
PopoverContent,
PopoverTrigger,
} from "@rakazo/ui-web";
import {
ArrowLeft,
Calendar,
Globe,
PanelRight,
Pencil,
Plus,
Search,
Trash2,
X,
} from "lucide-react";
import {
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ArtifactPane } from "../components/ArtifactPane";
import { InAppBrowser } from "../components/browser/InAppBrowser";
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
import { MainInbox } from "../components/MainInbox";
import { OoGroupStage, OoStage } from "../components/stage/OoStage";
import { useSidebarResize } from "../hooks/useSidebarResize";
import { useWideLayout } from "../hooks/useWideLayout";
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
import { rosterActivitySubtitle } from "../lib/agent-activity";
import { agentPointerFeed } from "../lib/agent-pointer";
import { HEUTE_APP_PATH, isHeuteAppPath } from "../lib/app-routes";
import type { ArtifactTarget } from "../lib/artifact-open";
import { attachmentMaxBytes, uploadAttachment } from "../lib/artifact-upload";
import { authClient } from "../lib/auth";
import { takeInitialBootstrap } from "../lib/bootstrap";
import { requestBrowserNotificationPermission } from "../lib/browser-notifications";
import { loadComputerScreen } from "../lib/computer-screen";
import { scheduleFocusPrompt } from "../lib/focus-prompt";
import { formatBytes } from "../lib/format-bytes";
import { isMultiOoGroupChat } from "../lib/group-chat";
import {
isWorkingRunStatus,
resolveActiveGroupSpeaker,
resolveWorkingGroupBots,
} from "../lib/group-stage";
import { lazyWithRecovery } from "../lib/lazy-recovery";
import { localTimezone } from "../lib/local-timezone";
import { localizedPreview } from "../lib/onboarding-copy";
import { ooSound } from "../lib/oo-sound";
import { ooVocals } from "../lib/oo-vocals";
import { revokePendingAttachmentPreviews } from "../lib/pending-attachments";
import { markAfterPaint, markOnce } from "../lib/performance";
import { isEmptyProjectSpace } from "../lib/project-empty-state";
import { routineSaveName } from "../lib/routine-name";
import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc";
import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage";
import { sharedInflight } from "../lib/shared-inflight";
import {
activeThreadRuns,
applyThreadSendReceipt,
clearActiveThreadRuns,
computerPanelReconnects,
computerTakeoverBlocked,
isComputerStatusEvent,
prependThreadMessagePage,
reconcileRefreshedThread,
threadRunError,
userHoldsComputerControl,
} from "../lib/thread-events";
import { transcriptCanSnapAfterFrame, transcriptIsNearEnd } from "../lib/transcript-scroll";
import { speaker } from "../lib/tts";
import { disableWebPush } from "../lib/web-push";
import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref";
import { ActivityList } from "./ActivityList";
import type { ContextMenuPosition } from "./BotContextMenu";
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
import { HeuteBrief } from "./HeuteBrief";
import { HostComputerPrompt } from "./HostComputerPrompt";
import { ProjectSessionTimeline } from "./ProjectSessionTimeline";
import {
draftFromRoutine,
emptyRoutineDraft,
type RoutineDraftState,
RoutineEditor,
RoutineListHeader,
RoutineListRow,
routineNeedsOneShotArm,
} from "./RoutineEditor";
import type { SettingsSection } from "./SettingsOverlay";
import { SpaceSearchResults } from "./SpaceSearch";
import { ArchivedSection } from "./shell/archived-section";
import type { AutopilotAction } from "./shell/autopilot-line";
import { BotSettings, CreateBotForm } from "./shell/bot-panel";
import { ConversationBotPicker } from "./shell/bot-picker";
import {
collapsedSidebarSectionsStorageKey,
readCollapsedSidebarSections,
} from "./shell/collapsed-sidebar-sections";
import { CommandPalette, isCommandPaletteHotkey } from "./shell/command-palette";
import { Composer } from "./shell/composer";
import { ComputerPanelPreview } from "./shell/computer-panel-preview";
import { embeddableScreenUrl } from "./shell/computer-screen";
import {
ClearConversationDialog,
DeleteBotDialog,
DeleteItemDialog,
NewBotSectionDialog,
NewSpaceDialog,
PickerInfoDialog,
RenameBotSectionDialog,
} from "./shell/dialogs";
import { FullscreenComputerOverlay } from "./shell/fullscreen-computer-overlay";
import { MainConversationRow } from "./shell/main-conversation-row";
import { PointerMenu } from "./shell/pointer-menu";
import { cleanRosterPreview, formatRosterTime } from "./shell/roster-format";
import { rosterRowClass } from "./shell/roster-row-style";
import { ProjectRosterRow, TeamRosterRow } from "./shell/roster-rows";
import type { PendingAttachment } from "./shell/shell-types";
import { FALLBACK_BOT_COLOR } from "./shell/shell-types";
import { SidePanelHeader } from "./shell/side-panel-header";
import { SidebarGroupHeader } from "./shell/sidebar-group-header";
import { SidebarHeader } from "./shell/sidebar-header";
import { SidebarToggleButtons } from "./shell/sidebar-toggle-buttons";
import {
applyThreadEvent,
firstThreadRoute,
newClientNonce,
threadSnapshotSignal,
} from "./shell/thread-state";
import { Transcript } from "./shell/transcript";
import { useAgentSkills } from "./shell/use-agent-skills";
import { useBotsSidebarCollapse } from "./shell/use-bots-sidebar-collapse";
import { useBrowserNotifications } from "./shell/use-browser-notifications";
import { useComputerViewport } from "./shell/use-computer-viewport";
import { useHeuteActionCount } from "./shell/use-heute-action-count";
import { useMentionTargets } from "./shell/use-mention-targets";
import { useMessagingStatus } from "./shell/use-messaging-status";
import { useMobileSidebar } from "./shell/use-mobile-sidebar";
import { useShellSounds } from "./shell/use-shell-sounds";
import { useSidebarModel } from "./shell/use-sidebar-model";
import { useSpaceSearch } from "./shell/use-space-search";
import { UserMenu } from "./shell/user-menu";
const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
import("./BotContextMenu").then((module) => ({ default: module.BotContextMenu })),
);
const MessagingSettingsOverlay = lazyWithRecovery("MessagingSettingsOverlay", () =>
import("./MessagingSettingsOverlay").then((module) => ({
default: module.MessagingSettingsOverlay,
})),
);
const SettingsOverlay = lazyWithRecovery("SettingsOverlay", () =>
import("./SettingsOverlay").then((module) => ({ default: module.SettingsOverlay })),
);
const PeerMessagesOverlay = lazyWithRecovery("PeerMessagesOverlay", () =>
import("./PeerMessagesOverlay").then((module) => ({ default: module.PeerMessagesOverlay })),
);
const PluginsOverlay = lazyWithRecovery("PluginsOverlay", () =>
import("./PluginsOverlay").then((module) => ({ default: module.PluginsOverlay })),
);
const McpServersOverlay = lazyWithRecovery("McpServersOverlay", () =>
import("./McpServersOverlay").then((module) => ({ default: module.McpServersOverlay })),
);
const CallView = lazyWithRecovery("CallView", () =>
import("./CallView").then((module) => ({ default: module.CallView })),
);
type Panel =
| "computer"
| "settings"
| "routine"
| "create"
| "create-group"
| "group-settings"
| "browser"
| "artifact"
| null;
/** Bound Settings leave so a hung voice status refresh cannot block dismissal. */
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
/** The screen lingers after the work so its final state and the bot's reply land first. */
const AUTO_SCREEN_CLOSE_DELAY_MS = 4_000;
/** How long one pointer announcement counts as the bot working on its screen. */
const SCREEN_ACTIVE_MS = 5_000;
function defaultWorkspacePaneWidth(): number {
return Math.round(Math.max(420, Math.min(760, window.innerWidth * 0.42)));
}
function voiceStatusRefreshTimeout(): Promise<never> {
return new Promise((_, reject) => {
AbortSignal.timeout(VOICE_STATUS_REFRESH_TIMEOUT_MS).addEventListener("abort", () => {
reject(new DOMException("Voice status refresh timed out", "TimeoutError"));
});
});
}
export type NavPillar = "heute" | "team" | "projekte";
import { SortableGroup } from "../components/SortableGroup";
export function ShellPage() {
const { t } = useLingui();
const { botId, groupId } = useParams();
const location = useLocation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const onHeuteRoute = isHeuteAppPath(location.pathname);
useEffect(() => {
if (botId === "heute") {
navigate(HEUTE_APP_PATH, { replace: true });
}
}, [botId, navigate]);
const [activeNavPillar, setActiveNavPillar] = useState<NavPillar>(() => {
if (isHeuteAppPath(location.pathname)) return "heute";
if (groupId) return "projekte";
if (botId) return "team";
return "heute";
});
useEffect(() => {
if (isHeuteAppPath(location.pathname)) setActiveNavPillar("heute");
}, [location.pathname]);
useEffect(() => {
if (botId) setActiveNavPillar("team");
}, [botId]);
useEffect(() => {
if (groupId) setActiveNavPillar("projekte");
}, [groupId]);
// Mirrors searchParams for effects that only need to read it once on run,
// not re-run on every unrelated query-param change (e.g. the SSE subscribe
// effect below, which should only restart when the active bot changes).
const searchParamsRef = useRef(searchParams);
searchParamsRef.current = searchParams;
const session = authClient.useSession();
const userId = session.data?.user.id;
const [groups, setGroups] = useState<Group[]>([]);
const [bots, setBots] = useState<Bot[]>([]);
const botsRef = useRef(bots);
botsRef.current = bots;
const botOrderEpochRef = useRef(0);
const pendingBotOrderRef = useRef<string[] | null>(null);
const savingBotOrderRef = useRef(false);
const [botSections, setBotSections] = useState<BotSection[]>([]);
const [spaces, setSpaces] = useState<Space[]>([]);
const [mainConversation, setMainConversation] = useState<MainConversationRef | null>(null);
const [archivedBots, setArchivedBots] = useState<Bot[]>([]);
const [archivedGroups, setArchivedGroups] = useState<Group[]>([]);
const {
width: leftSidebarWidth,
isResizing: isResizingLeft,
startResizing: startResizingLeft,
} = useSidebarResize(316, "left", 240, 450, "rakazo:left-sidebar-width");
const {
width: rightSidebarWidth,
isResizing: isResizingRight,
startResizing: startResizingRight,
} = useSidebarResize(384, "right", 280, 500, "rakazo:side-panel-width");
// The screen and presented files get a wider pane than settings: they are what the user watches.
const {
width: workspacePaneWidth,
isResizing: isResizingWorkspacePane,
startResizing: startResizingWorkspacePane,
} = useSidebarResize(
defaultWorkspacePaneWidth(),
"right",
360,
960,
"rakazo:workspace-pane-width",
);
const [archivedOpen, setArchivedOpen] = useState(false);
const [expandedTeamGroups, setExpandedTeamGroups] = useState<Record<string, boolean>>({});
const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set<string>());
useEffect(() => {
setCollapsedSidebarSections(readCollapsedSidebarSections(userId));
}, [userId]);
const {
botsSidebarCollapsed,
setBotsSidebarCollapsedPref,
onBotsSidebarEdgePointerDown,
onBotsSidebarEdgePointerMove,
onBotsSidebarEdgePointerUp,
onBotsSidebarEdgePointerCancel,
} = useBotsSidebarCollapse(userId);
const [panel, setPanel] = useState<Panel>(null);
/** The pane the conversation's focus opened; cleared as soon as the user changes the pane. */
const [autoPanel, setAutoPanel] = useState<Panel>(null);
const [artifactPane, setArtifactPane] = useState<WorkspaceArtifact | null>(null);
/** Run that last pointed at the bot's screen; the live screen only follows real screen work. */
const [screenRunId, setScreenRunId] = useState<string | null>(null);
const [screenActive, setScreenActive] = useState(false);
const screenActiveTimer = useRef<number | undefined>(undefined);
const [screenHabit, setScreenHabit] = useState(0);
const wideLayout = useWideLayout();
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
const [snapshot, setSnapshot] = useState<ThreadSnapshot | null>(null);
const snapshotRef = useRef<ThreadSnapshot | null>(null);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [replyTarget, setReplyTarget] = useState<ThreadMessage | null>(null);
const [replyQuote, setReplyQuote] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const sendsInFlight = useRef(0);
const [sendError, setSendError] = useState<string | null>(null);
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
// Safety watchdog: clear a stuck send only when no run is active (local models can take longer).
useEffect(() => {
if (!sending) return;
const timer = setTimeout(() => {
const runs = activeThreadRuns(snapshotRef.current);
if (runs.some((run) => isActive(run.status as RunStatus))) return;
sendsInFlight.current = 0;
setSending(false);
}, 120_000);
return () => clearTimeout(timer);
}, [sending]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [peerConversation, setPeerConversation] = useState<{
peerBotId: string;
peerBotName: string;
} | null>(null);
const [routines, setRoutines] = useState<Routine[]>([]);
const [routinesBotId, setRoutinesBotId] = useState<string | null>(null);
const [taughtSkills, setTaughtSkills] = useState<TaughtSkill[]>([]);
const [taughtSkillsBotId, setTaughtSkillsBotId] = useState<string | null>(null);
const [teachBusy, setTeachBusy] = useState(false);
const [computer, setComputer] = useState<ComputerStatus | null>(null);
const computerRef = useRef<ComputerStatus | null>(null);
const threadRefreshEpoch = useRef(0);
const groupRefreshEpoch = useRef(0);
const botsRefreshEpoch = useRef(0);
const botsRefreshApplied = useRef(0);
const archivedBotsRefreshEpoch = useRef(0);
const botsRefreshInFlight = useRef(0);
// A very fast run can finish over SSE while its threads.send response is still
// returning. Do not let that late receipt resurrect terminal work as queued.
const terminalRunReceipts = useRef(new Set<string>());
// Last-known computer/screen per bot, so switching back to an already-seen
// bot paints its computer pane instantly instead of blanking it while the
// thread + screen RPCs round-trip again (see refreshThread / refreshComputerScreen).
const computerCacheRef = useRef(
new Map<string, { computer: ComputerStatus | null; screenUrl: string | null }>(),
);
// Caps computerCacheRef so a long session that opens many distinct bots
// over time doesn't accumulate one entry per bot forever. Re-inserting on
// every update keeps Map iteration order as least-recently-used first, so
// eviction drops the bot that's been out of view longest.
const COMPUTER_CACHE_LIMIT = 20;
function cacheComputerFor(
botId: string,
patch: Partial<{ computer: ComputerStatus | null; screenUrl: string | null }>,
) {
const cache = computerCacheRef.current;
const prev = cache.get(botId) ?? { computer: null, screenUrl: null };
cache.delete(botId);
cache.set(botId, { ...prev, ...patch });
if (cache.size > COMPUTER_CACHE_LIMIT) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
}
function commitSnapshot(next: ThreadSnapshot | null) {
snapshotRef.current = next;
setSnapshot(next);
}
function commitComputer(next: ComputerStatus | null) {
computerRef.current = next;
setComputer(next);
}
function updateSnapshot(update: (prev: ThreadSnapshot | null) => ThreadSnapshot | null) {
commitSnapshot(update(snapshotRef.current));
}
const [pluginsOpen, setPluginsOpen] = useState(false);
const [mcpOpen, setMcpOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>("general");
const [messagingSettingsOpen, setMessagingSettingsOpen] = useState(false);
const [memoryProviderConfig, setMemoryProviderConfig] = useState<
SpaceMemoryConfig | null | undefined
>(undefined);
const memoryProviderConfigRevision = useRef(0);
const [callOpen, setCallOpen] = useState(false);
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus | null>(null);
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
const [dismissedRunErrorIds, setDismissedRunErrorIds] =
useState<ReadonlySet<string>>(readSeenRunErrorIds);
const [menuOpen, setMenuOpen] = useState(false);
const {
mobileSidebarOpen,
setMobileSidebarOpen,
onMobileSidebarTouchStart,
onMobileSidebarTouchEnd,
onMobileSidebarTouchCancel,
} = useMobileSidebar();
const [draggedBotId, setDraggedBotId] = useState<string | null>(null);
const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [conversationPickerOpen, setConversationPickerOpen] = useState(false);
const focusPromptAbortRef = useRef<AbortController | null>(null);
const focusPromptBotIdRef = useRef<string | null>(null);
const [newSpaceOpen, setNewSpaceOpen] = useState(false);
const [newSpaceDialogVariant, setNewSpaceDialogVariant] = useState<"space" | "project">("space");
const openNewSpaceDialog = useCallback((variant: "space" | "project") => {
setNewSpaceDialogVariant(variant);
setNewSpaceOpen(true);
}, []);
const [pickerInfoTopic, setPickerInfoTopic] = useState<"group" | "space" | null>(null);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [activityMode, setActivityMode] = useState(readActivityMode);
const toggleActivityMode = useCallback(() => {
setActivityMode((on) => {
const next = !on;
writeActivityMode(next);
return next;
});
}, []);
const [botMenu, setBotMenu] = useState<{
kind: "bot" | "group";
id: string;
position: ContextMenuPosition;
} | null>(null);
// The context menu anchors to the pointer, so return focus to the row that opened it.
const botMenuAnchor = useRef<HTMLElement | null>(null);
useEffect(() => {
if (botMenu || !botMenuAnchor.current) return;
botMenuAnchor.current.focus();
botMenuAnchor.current = null;
}, [botMenu]);
const [deleteTarget, setDeleteTarget] = useState<Bot | null>(null);
const [deleteGroupTarget, setDeleteGroupTarget] = useState<Group | null>(null);
const [deleteSpaceTarget, setDeleteSpaceTarget] = useState<Space | null>(null);
const [spaceMenu, setSpaceMenu] = useState<{
id: string;
position: ContextMenuPosition;
} | null>(null);
const spaceMenuAnchor = useRef<HTMLElement | null>(null);
useEffect(() => {
if (spaceMenu || !spaceMenuAnchor.current) return;
spaceMenuAnchor.current.focus();
spaceMenuAnchor.current = null;
}, [spaceMenu]);
const closeSpaceMenu = useCallback(() => setSpaceMenu(null), []);
const [clearTarget, setClearTarget] = useState<
{ kind: "bot"; chat: Bot } | { kind: "group"; chat: Group } | null
>(null);
const [newSectionTarget, setNewSectionTarget] = useState<
{ kind: "bot"; chat: Bot } | { kind: "group"; chat: Group } | null
>(null);
const [renameSectionTarget, setRenameSectionTarget] = useState<{
section: BotSection;
spaceId: string;
} | null>(null);
const [sectionMenu, setSectionMenu] = useState<{
section: BotSection;
spaceId: string;
position: ContextMenuPosition;
} | null>(null);
const sectionMenuAnchor = useRef<HTMLElement | null>(null);
useEffect(() => {
if (sectionMenu || !sectionMenuAnchor.current) return;
sectionMenuAnchor.current.focus();
sectionMenuAnchor.current = null;
}, [sectionMenu]);
const closeSectionMenu = useCallback(() => setSectionMenu(null), []);
const [booting, setBooting] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [initialBotsLoaded, setInitialBotsLoaded] = useState(false);
const [bootstrapMe, setBootstrapMe] = useState<Me | null>();
const [routineDraft, setRoutineDraft] = useState<RoutineDraftState>(emptyRoutineDraft());
const [routineWebhookSecret, setRoutineWebhookSecret] = useState<string | null>(null);
const [editingRoutine, setEditingRoutine] = useState<Routine | null>(null);
const [deleteRoutineTarget, setDeleteRoutineTarget] = useState<Routine | null>(null);
const [savingRoutine, setSavingRoutine] = useState(false);
const [runningRoutine, setRunningRoutine] = useState(false);
const [routineError, setRoutineError] = useState<string | null>(null);
const [screenUrl, setScreenUrl] = useState<string | null>(null);
const [computerOpen, setComputerOpen] = useState(false);
const [computerError, setComputerError] = useState<string | null>(null);
// Screen-load failures can sit beside a still-valid embed URL; boot and
// takeover failures must stay visible even when a URL remains.
const [computerErrorFromScreen, setComputerErrorFromScreen] = useState(false);
const computerViewport = useComputerViewport(computerOpen);
const { messagingSurfaceEnabled, messagingProviders } = useMessagingStatus(session);
const [usage, setUsage] = useState<{
inputTokens: number;
outputTokens: number;
runs: number;
} | null>(null);
const routineSavePending = useRef(false);
const webhookSecretProvisionRef = useRef(new Map<string, Promise<string>>());
const ensureWebhookSecret = (botId: string) =>
sharedInflight(webhookSecretProvisionRef.current, botId, async () => {
const result = await rpc.bots.rotateWebhookSecret({ botId });
setRoutineWebhookSecret(result.secret);
setBots((current) =>
current.map((bot) => (bot.id === botId ? { ...bot, webhookConfigured: true } : bot)),
);
return result.secret;
});
const routineSaveRequest = useRef(0);
const routineRunPending = useRef(false);
const bootstrappedThread = useRef<ThreadSnapshot | null>(null);
const expandedHistoryThread = useRef<string | null>(null);
const historyEpoch = useRef(0);
const jumpGeneration = useRef(0);
const initiallyScrolledThread = useRef<string | null>(null);
const messageScroll = useRef<HTMLDivElement>(null);
const pinnedAroundRef = useRef<{
botId?: string;
groupId?: string;
messageId: string;
threadId: string;
messages: ThreadMessage[];
olderCursor: number | null;
} | null>(null);
const manuallyUnread = useRef(new Set<string>());
const readVisibleGroups = useRef(new Set<string>());
const computerVisible = useRef(false);
computerVisible.current = panel === "computer" || computerOpen;
const autoSpoken = useRef<string | null>(null);
const autoSpokenBotId = useRef<string | null>(null);
const inGroup = Boolean(groupId);
const projectSessionKey = searchParams.get("s");
const voiceEntry = searchParams.get("voice") === "1";
const currentSpaceId = selectedSpaceId() ?? bootstrapMe?.spaceId;
const homeSpaceId = spaces.find((space) => space.isDefault)?.id ?? bootstrapMe?.spaceId;
const emptyProjectSpace = useMemo(
() => isEmptyProjectSpace(currentSpaceId, spaces, groups, homeSpaceId),
[currentSpaceId, groups, homeSpaceId, spaces],
);
const active = inGroup
? undefined
: (bots.find((b) => b.id === botId) ?? (emptyProjectSpace && !botId ? undefined : bots[0]));
const activeGroup = groups.find((group) => group.id === groupId);
const activeProjectSection = activeGroup?.sectionId
? botSections.find((section) => section.id === activeGroup.sectionId)
: undefined;
const showProjectTimeline =
inGroup && activeNavPillar !== "heute" && !projectSessionKey && Boolean(activeGroup?.sectionId);
const activePendingAttachments = useMemo(
() => attachmentsForThread(pendingAttachments, inGroup ? groupId : active?.id),
[active?.id, groupId, inGroup, pendingAttachments],
);
const activeRoutines = !inGroup && routinesBotId === active?.id ? routines : [];
const activeTaughtSkills = taughtSkillsBotId === active?.id ? taughtSkills : [];
const recordingSkill = activeTaughtSkills.find((skill) => skill.status === "recording") ?? null;
const routeBotId = useRef<string | undefined>(botId);
routeBotId.current = botId;
const routePathname = useRef(location.pathname);
routePathname.current = location.pathname;
const routeOnHeute = useRef(onHeuteRoute);
routeOnHeute.current = onHeuteRoute;
const holdingHeuteRoute = () => routeOnHeute.current || isHeuteAppPath(routePathname.current);
const routeGroupId = useRef<string | undefined>(groupId);
routeGroupId.current = groupId;
const activeBotId = useRef<string | undefined>(inGroup ? undefined : active?.id);
activeBotId.current = inGroup ? undefined : active?.id;
const activeGroupId = useRef<string | undefined>(groupId);
activeGroupId.current = groupId;
const screenRequest = useRef(0);
const contextBot =
botMenu?.kind === "bot" ? bots.find((bot) => bot.id === botMenu.id) : undefined;
const contextGroup =
botMenu?.kind === "group" ? groups.find((group) => group.id === botMenu.id) : undefined;
const contextChat = contextBot ?? contextGroup;
const closeBotMenu = useCallback(() => setBotMenu(null), []);
const updateBotUnread = useCallback((id: string, unread: boolean) => {
setBots((current) => {
const bot = current.find((candidate) => candidate.id === id);
if (!bot || bot.unread === unread) return current;
return current.map((candidate) =>
candidate.id === id ? { ...candidate, unread } : candidate,
);
});
}, []);
const markBotRead = useCallback(
async (id: string) => {
await rpc.threads.markRead({ botId: id });
manuallyUnread.current.delete(id);
updateBotUnread(id, false);
},
[updateBotUnread],
);
const markBotUnread = useCallback(
async (id: string) => {
manuallyUnread.current.add(id);
try {
await rpc.threads.markUnread({ botId: id });
} catch (err) {
manuallyUnread.current.delete(id);
throw err;
}
updateBotUnread(id, true);
},
[updateBotUnread],
);
// A bot the user marked unread by hand stays unread until they open it again,
// otherwise the auto-read below would undo the action on the next window focus.
const markBotReadIfVisible = useCallback(
(id: string) => {
if (manuallyUnread.current.has(id)) return;
if (document.visibilityState === "visible" && document.hasFocus()) {
void markBotRead(id).catch(() => undefined);
}
},
[markBotRead],
);
const { flushPendingBrowserNotifications, notifyBrowserForEvent } =
useBrowserNotifications(botsRef);
const refreshBots = useCallback(
async (includeArchived = false, replaceBotOrder = false) => {
markOnce("rk:renderer:bots-request-start");
const request = ++botsRefreshEpoch.current;
const botOrderEpoch = botOrderEpochRef.current;
const preserveBotOrder = savingBotOrderRef.current || pendingBotOrderRef.current !== null;
const archivedRequest = includeArchived ? ++archivedBotsRefreshEpoch.current : null;
botsRefreshInFlight.current += 1;
try {
const [navigation, archived, archivedGroupList] = await Promise.all([
rpc.spaces.list(),
includeArchived ? rpc.bots.listArchived() : Promise.resolve(null),
includeArchived ? rpc.groups.listArchived() : Promise.resolve(null),
]);
const { bots: list, botSections: sections, groups: groupList } = navigation.current;
markOnce("rk:renderer:bots-response");
const botsFresh = request === botsRefreshEpoch.current;
const archivedFresh =
archivedRequest != null && archivedRequest === archivedBotsRefreshEpoch.current;
// A newer non-archived refresh can win the bots epoch while an older
// includeArchived request still owns archivedBotsRefreshEpoch — apply
// whichever slices are still current.
if (!botsFresh && !archivedFresh) return;
if (archivedFresh && archived) setArchivedBots(archived);
if (archivedFresh && archivedGroupList) setArchivedGroups(archivedGroupList);
if (!botsFresh) return;
if (
botOrderEpoch === botOrderEpochRef.current &&
(replaceBotOrder || (!preserveBotOrder && !savingBotOrderRef.current))
) {
setBots(list);
}
setBotSections(sections);
setGroups(groupList);
setSpaces(navigation.spaces);
setMainConversation(navigation.main);
setInitialBotsLoaded(true);
botsRefreshApplied.current = request;
if (
includeArchived &&
list.length === 0 &&
archived?.length === 0 &&
groupList.length === 0 &&
archivedGroupList?.length === 0 &&
!navigation.spaces.some((space) => space.hasContent)
) {
// Only the very first bot everywhere needs onboarding. An empty
// current space with content elsewhere stays in the app so the
// space can be switched to or deleted instead of trapping the user.
navigate("/onboarding", { replace: true });
return;
}
const currentGroupId = routeGroupId.current;
const currentBotId = routeBotId.current;
const refreshHomeSpaceId =
navigation.spaces.find((space) => space.isDefault)?.id ?? selectedSpaceId() ?? undefined;
const refreshSpaceId = selectedSpaceId() ?? refreshHomeSpaceId;
const emptyProject = isEmptyProjectSpace(
refreshSpaceId,
navigation.spaces,
groupList,
refreshHomeSpaceId,
);
if (emptyProject) {
if (currentGroupId || (currentBotId && !list.some((bot) => bot.id === currentBotId))) {
navigate("/app", { replace: true });
}
return;
}
const inProject = Boolean(refreshSpaceId && refreshSpaceId !== refreshHomeSpaceId);
if (currentGroupId) {
if (!groupList.some((group) => group.id === currentGroupId)) {
navigate(firstThreadRoute(list, groupList, inProject), { replace: true });
}
return;
}
if (!currentBotId || !list.some((bot) => bot.id === currentBotId)) {
if (holdingHeuteRoute()) return;
navigate(firstThreadRoute(list, groupList, inProject), { replace: true });
}
} finally {
botsRefreshInFlight.current -= 1;
}
},
[navigate],
);
function snapTranscriptToEndAfterFrame() {
const queuedElement = messageScroll.current;
if (!queuedElement) return;
const queuedScrollTop = queuedElement.scrollTop;
window.requestAnimationFrame(() => {
const element = messageScroll.current;
if (transcriptCanSnapAfterFrame(element, queuedElement, queuedScrollTop)) {
queuedElement.scrollTop = queuedElement.scrollHeight;
}
});
}
async function refreshGroupThread(id: string, signal?: AbortSignal) {
const scrollElement = messageScroll.current;
const stickToEnd = !scrollElement || transcriptIsNearEnd(scrollElement);
markOnce("rk:renderer:thread-request-start");
const request = ++groupRefreshEpoch.current;
const snap = await rpc.threads.get({ groupId: id }, signal ? { signal } : undefined);
markOnce("rk:renderer:thread-response");
if (activeGroupId.current !== id || request !== groupRefreshEpoch.current) return snap;
const reconciled = reconcileRefreshedThread(
snapshotRef.current,
snap,
computerRef.current,
expandedHistoryThread.current === snap.threadId,
);
commitSnapshot(reconciled.snapshot);
commitComputer(null);
setRoutines([]);
setRoutinesBotId(null);
// Keep the search-jump viewport; expandedHistoryThread merge still accepts live messages.
if (
stickToEnd &&
(!scrollElement || transcriptIsNearEnd(scrollElement)) &&
expandedHistoryThread.current !== snap.threadId
) {
snapTranscriptToEndAfterFrame();
}
return snap;
}
async function refreshThread(id: string, signal?: AbortSignal) {
const scrollElement = messageScroll.current;
const stickToEnd = !scrollElement || transcriptIsNearEnd(scrollElement);
markOnce("rk:renderer:thread-request-start");
const epoch = historyEpoch.current;
const request = ++threadRefreshEpoch.current;
// Apply threads.get as soon as it returns so stop/takeover status is not held behind
// routines/skills/screen fetches (progress can advance the cursor meanwhile).
const snap = await rpc.threads.get({ botId: id }, signal ? { signal } : undefined);
markOnce("rk:renderer:thread-response");
if (
activeBotId.current !== id ||
epoch !== historyEpoch.current ||
request !== threadRefreshEpoch.current
) {
return snap;
}
const reconciled = reconcileRefreshedThread(
snapshotRef.current,
snap,
computerRef.current,
expandedHistoryThread.current === snap.threadId,
);
commitSnapshot(reconciled.snapshot);
commitComputer(reconciled.computer);
cacheComputerFor(id, { computer: reconciled.computer });
if (
stickToEnd &&
(!scrollElement || transcriptIsNearEnd(scrollElement)) &&
expandedHistoryThread.current !== snap.threadId
) {
snapTranscriptToEndAfterFrame();
}
void Promise.all([
rpc.routines.list({ botId: id }).catch(() => null),
rpc.skills.list({ botId: id }).catch(() => null),
refreshComputerScreen(id).catch(() => null),
]).then(([routines, skills]) => {
if (
activeBotId.current !== id ||
epoch !== historyEpoch.current ||
request !== threadRefreshEpoch.current
) {
return;
}
if (routines) {
setRoutines(routines);
setRoutinesBotId(id);
}
if (skills) {
setTaughtSkills(skills);
setTaughtSkillsBotId(id);
}
});
return snap;
}
async function refreshComputerScreen(id: string) {
if (!computerVisible.current) return null;
const request = ++screenRequest.current;
return loadComputerScreen({
load: () => rpc.computer.screenUrl({ botId: id }),
isCurrent: () =>
request === screenRequest.current && activeBotId.current === id && computerVisible.current,
commit: (screen) => {
setScreenUrl(screen.url);
setComputerError(screen.error);
setComputerErrorFromScreen(Boolean(screen.error));
cacheComputerFor(id, { screenUrl: screen.url });
},
fallbackError: t`Could not connect to the computer screen`,
});
}
async function loadOlderMessages() {
const targetBotId = inGroup ? undefined : active?.id;
const targetGroupId = inGroup ? groupId : undefined;
const snapshotMatchesTarget = targetGroupId
? snapshot?.groupId === targetGroupId
: snapshot?.botId === targetBotId;
if (
(!targetBotId && !targetGroupId) ||
!snapshotMatchesTarget ||
snapshot?.olderCursor == null ||
loadingOlder
)
return;
pinnedAroundRef.current = null;
const scrollElement = messageScroll.current;
const previousHeight = scrollElement?.scrollHeight ?? 0;
const epoch = historyEpoch.current;
const before = snapshot.olderCursor;
setLoadingOlder(true);
try {
const page = await rpc.threads.messages({
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
before,
});
if (
epoch !== historyEpoch.current ||
activeBotId.current !== targetBotId ||
activeGroupId.current !== targetGroupId
)
return;
expandedHistoryThread.current = page.threadId;
updateSnapshot((prev) => prependThreadMessagePage(prev, page));
window.requestAnimationFrame(() => {
const element = messageScroll.current;
if (element) element.scrollTop += element.scrollHeight - previousHeight;
});
} finally {
setLoadingOlder(false);
}
}
useEffect(() => {
let cancelled = false;
const providerConfigRevision = memoryProviderConfigRevision.current;
void rpc.memory
.providerConfig()
.then((providerConfig) => {
if (!cancelled && memoryProviderConfigRevision.current === providerConfigRevision) {
setMemoryProviderConfig(providerConfig);
}
})
.catch(() => {
if (!cancelled && memoryProviderConfigRevision.current === providerConfigRevision) {
setMemoryProviderConfig(null);
}
});
const appliedAtStart = botsRefreshApplied.current;
void takeInitialBootstrap(botId)
.then((bootstrap) => {
if (cancelled) return;
const groupList = bootstrap.groups;
setBootstrapMe(bootstrap.me);
if (!selectedSpaceId()) {
selectSpace(bootstrap.me.spaceId);
}
// Skip list/route writes only if a later refreshBots() successfully
// committed (failed refreshes bump epoch but not botsRefreshApplied).
const applyBotLists = appliedAtStart === botsRefreshApplied.current;
if (applyBotLists) {
setBots(bootstrap.bots);
setBotSections(bootstrap.botSections);
setArchivedBots(bootstrap.archivedBots);
setArchivedGroups(bootstrap.archivedGroups);
setGroups(groupList);
setSpaces(bootstrap.spaces);
setMainConversation(bootstrap.main);
setInitialBotsLoaded(true);
}
if (!groupId && bootstrap.thread) {
bootstrappedThread.current = bootstrap.thread;
commitSnapshot(bootstrap.thread);
commitComputer(bootstrap.thread.computer ?? null);
setRoutines(bootstrap.routines);
setRoutinesBotId(bootstrap.thread.botId ?? null);
markOnce("rk:renderer:bots-response");
markOnce("rk:renderer:thread-response");
}
// A focus refresh can land first and skip the list writes; a fresh account still
// belongs in onboarding, so this check never depends on applyBotLists.
if (
bootstrap.bots.length === 0 &&
bootstrap.archivedBots.length === 0 &&
groupList.length === 0 &&
bootstrap.archivedGroups.length === 0 &&
!bootstrap.spaces.some((space) => space.hasContent)
) {
navigate("/onboarding", { replace: true });
return;
}
if (!applyBotLists) return;
const bootstrapHomeSpaceId =
bootstrap.spaces.find((space) => space.isDefault)?.id ?? bootstrap.me.spaceId;
const bootstrapSpaceId = selectedSpaceId() ?? bootstrap.me.spaceId;
const emptyProject = isEmptyProjectSpace(
bootstrapSpaceId,
bootstrap.spaces,
groupList,
bootstrapHomeSpaceId,
);
if (emptyProject) {
if (groupId || (botId && !bootstrap.bots.some((bot) => bot.id === botId))) {
navigate("/app", { replace: true });
}
return;
}
const inProject = bootstrapSpaceId !== bootstrapHomeSpaceId;
if (groupId) {
if (!groupList.some((group) => group.id === groupId)) {
navigate(firstThreadRoute(bootstrap.bots, groupList, inProject), { replace: true });
}
return;
}
if (inProject && !botId) {
if (holdingHeuteRoute()) return;
navigate(firstThreadRoute(bootstrap.bots, groupList, true), { replace: true });
return;
}
if (holdingHeuteRoute()) return;
const selectedBotId = bootstrap.thread?.botId ?? bootstrap.bots[0]?.id;
if (selectedBotId && selectedBotId !== botId) {
navigate(`/app/${selectedBotId}`, { replace: true });
}
})
.catch(() => {
if (cancelled) return;
setBootstrapMe(null);
void refreshBots(true);
});
let refreshTimer: number | undefined;
const refreshVisibleBots = () => {
if (document.visibilityState !== "visible") return;
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(() => void refreshBots().catch(() => undefined), 50);
};
window.addEventListener("focus", refreshVisibleBots);
document.addEventListener("visibilitychange", refreshVisibleBots);
// Poll skips while a refresh is in flight; focus/visibility and event-driven
// callers still bump botsRefreshEpoch so only the latest response applies.
const poll = window.setInterval(() => {
if (botsRefreshInFlight.current > 0) return;
refreshVisibleBots();
}, 3_000);
return () => {
cancelled = true;
window.clearTimeout(refreshTimer);
window.clearInterval(poll);
window.removeEventListener("focus", refreshVisibleBots);
document.removeEventListener("visibilitychange", refreshVisibleBots);
};
}, []);
const { agentSkills, setAgentSkills, refreshAgentSkills } = useAgentSkills();
useEffect(() => {
void rpc.voice
.status()
.then(setVoiceStatus)
.catch(() => undefined);
const unsubSpeech = speaker.subscribe((state) => {
setSpeakingMessageId(state.status === "idle" ? null : (state.messageId ?? null));
});
return () => {
unsubSpeech();
};
}, []);
useEffect(() => {
if (!active || !snapshot || snapshot.botId !== active.id) return;
const lastBot = [...snapshot.messages].reverse().find((message) => message.role === "bot");
if (autoSpokenBotId.current !== active.id) {
autoSpokenBotId.current = active.id;
autoSpoken.current = lastBot?.id ?? null;
return;
}
if (callOpen || !active.autoSpeak) {
autoSpoken.current = lastBot?.id ?? null;
return;
}
if (snapshot.run && ["running", "queued", "leased"].includes(snapshot.run.status)) return;
if (!lastBot || lastBot.id === autoSpoken.current) return;
const text = speechFromBlocks(lastBot.blocks);
if (!text) return;
autoSpoken.current = lastBot.id;
void speaker.speak(text, { botId: active.id, messageId: lastBot.id });
}, [
snapshot?.messages,
snapshot?.run?.status,
snapshot?.botId,
active?.autoSpeak,
active?.id,
callOpen,
]);
useEffect(() => {
if (!active) return;
// Opening a bot clears the manual unread flag so it can auto-read again.
manuallyUnread.current.delete(active.id);
const markVisibleBotRead = () => {
markBotReadIfVisible(active.id);
};
markVisibleBotRead();
window.addEventListener("focus", markVisibleBotRead);
document.addEventListener("visibilitychange", markVisibleBotRead);
return () => {
window.removeEventListener("focus", markVisibleBotRead);
document.removeEventListener("visibilitychange", markVisibleBotRead);
};
}, [active?.id, markBotReadIfVisible]);
useEffect(() => {
if (!active) return;
const pendingJump = searchParamsRef.current.get("m");
if (!pendingJump) {
pinnedAroundRef.current = null;
}
screenRequest.current += 1;
setComputerError(null);
setComputerErrorFromScreen(false);
const cached = computerCacheRef.current.get(active.id);
if (cached) {
// Paint the last-known computer instantly; refreshThread/refreshComputerScreen
// below still run and reconcile with fresh data in the background.
setScreenUrl(cached.screenUrl);
commitComputer(cached.computer);
} else {
setScreenUrl(null);
}
expandedHistoryThread.current = null;
historyEpoch.current += 1;
const abort = new AbortController();
void runThreadSubscription({
signal: abort.signal,
loadInitial: async () => {
const primed = bootstrappedThread.current;
bootstrappedThread.current = null;
// Pending search jumps load the around-page separately; avoid replacing it with latest.
return primed?.botId === active.id
? primed
: pendingJump
? rpc.threads.get({ botId: active.id }, { signal: threadSnapshotSignal(abort.signal) })
: refreshThread(active.id, threadSnapshotSignal(abort.signal));
},
loadHead: () =>
rpc.threads.head({ botId: active.id }, { signal: threadSnapshotSignal(abort.signal) }),
refresh: () => refreshThread(active.id, threadSnapshotSignal(abort.signal)),
currentSnapshot: () => snapshotRef.current,
subscribe: (cursor) =>
rpc.threads.subscribe({ botId: active.id, cursor }, { signal: abort.signal }),
beforeEvent: (event) => {
if (isRunTerminalEvent(event) && event.runId) {
terminalRunReceipts.current.add(event.runId);
if (terminalRunReceipts.current.size > 100) {
const oldest = terminalRunReceipts.current.values().next().value;
if (oldest !== undefined) terminalRunReceipts.current.delete(oldest);
}
}
},
applyEvent: (event) =>
applyThreadEvent(event, commitSnapshot, commitComputer, snapshotRef, computerRef),
onEvent: (event, initial) => {
const currentBot = botsRef.current.find((bot) => bot.id === active.id);
notifyBrowserForEvent(
event,
initial.threadId,
initial.cursor,
true,
currentBot?.name ?? active.name,
currentBot?.notifyOnFinish ?? false,
false,
);
if (event.type === "thread.cleared") {
expandedHistoryThread.current = null;
pinnedAroundRef.current = null;
historyEpoch.current += 1;
}
if (event.type === "bot.archived") {
void refreshBots(true).catch(() => undefined);
} else if (
event.type === "bot.spawned" ||
event.type === "bot.updated" ||
event.type === "bot.deleted" ||
event.type === "run.started" ||
isRunTerminalEvent(event) ||
event.type === "thread.cleared"
) {
void refreshBots().catch(() => undefined);
}
if (event.type === "group.created") {
void refreshBots().catch(() => undefined);
}
if (event.type === "thread.message.created") {
const blocks = (event.payload.blocks as Array<{ kind?: string }>) ?? [];
if (
blocks.some((block) => block.kind === "child_bot" || block.kind === "project_created")
) {
void refreshBots().catch(() => undefined);
}
if (event.payload.role === "bot") markBotReadIfVisible(active.id);
}
if (event.type === "run.waiting_input" || event.type === "computer.takeover.requested") {
ooSound.play("need-you");
ooVocals.reactForEvent("need-you");
}
if (
isRunTerminalEvent(event) ||
event.type === "run.waiting_input" ||
event.type === "skill.teaching.stopped"
) {
// waiting_input: reconcile ask cards if a stale post-send refresh raced SSE.
void refreshThread(active.id).catch(() => undefined);
} else if (isComputerStatusEvent(event)) {
void refreshComputerScreen(active.id).catch(() => undefined);
} else if (event.type === "thread.computer") {
agentPointerFeed.publish(event.botId, event.payload, event.createdAt);
const screenRun = liveScreenRunId(event);
if (screenRun) {
setScreenRunId(screenRun);
setScreenActive(true);
window.clearTimeout(screenActiveTimer.current);
screenActiveTimer.current = window.setTimeout(
() => setScreenActive(false),
SCREEN_ACTIVE_MS,
);
}
}
},
});
return () => {
abort.abort();
};
}, [active?.id, markBotReadIfVisible, notifyBrowserForEvent]);
useEffect(() => {
if (!groupId || !activeGroup) return;
manuallyUnread.current.delete(activeGroup.id);
readVisibleGroups.current.delete(groupId);
const markVisibleGroupRead = () => {
if (
document.visibilityState !== "visible" ||
!document.hasFocus() ||
readVisibleGroups.current.has(groupId)
)
return;
readVisibleGroups.current.add(groupId);
void rpc.threads
.markRead({ groupId })
.then(() => {
setGroups((current) => {
const group = current.find((candidate) => candidate.id === groupId);
if (!group?.unread) return current;
return current.map((candidate) =>
candidate.id === groupId ? { ...candidate, unread: false } : candidate,
);
});
})
.catch(() => {
readVisibleGroups.current.delete(groupId);
});
};
markVisibleGroupRead();
window.addEventListener("focus", markVisibleGroupRead);
document.addEventListener("visibilitychange", markVisibleGroupRead);
const pendingJump = searchParamsRef.current.get("m");
if (!pendingJump) {
pinnedAroundRef.current = null;
expandedHistoryThread.current = null;
}
historyEpoch.current += 1;
const abort = new AbortController();
void runThreadSubscription({
signal: abort.signal,
loadInitial: () =>
pendingJump
? rpc.threads.get({ groupId }, { signal: threadSnapshotSignal(abort.signal) })
: refreshGroupThread(groupId, threadSnapshotSignal(abort.signal)),
loadHead: () => rpc.threads.head({ groupId }, { signal: threadSnapshotSignal(abort.signal) }),
refresh: () => refreshGroupThread(groupId, threadSnapshotSignal(abort.signal)),
currentSnapshot: () => snapshotRef.current,
subscribe: (cursor) => rpc.threads.subscribe({ groupId, cursor }, { signal: abort.signal }),
applyEvent: (event) =>
applyThreadEvent(event, commitSnapshot, commitComputer, snapshotRef, computerRef),
onEvent: (event, initial) => {
const eventBot = botsRef.current.find((bot) => bot.id === event.botId);
notifyBrowserForEvent(
event,
initial.threadId,
initial.cursor,
true,
eventBot?.name ?? activeGroup.name,
true,
true,
);
if (event.type === "thread.message.created" && event.payload.role === "bot") {
readVisibleGroups.current.delete(groupId);
markVisibleGroupRead();
}
if (event.type === "group.created") {
void refreshBots().catch(() => undefined);
}
if (event.type === "group.updated") {
void refreshGroupThread(groupId).catch(() => undefined);
void refreshBots().catch(() => undefined);
}
if (event.type === "run.started" || isRunTerminalEvent(event)) {
void refreshBots().catch(() => undefined);
}
if (event.type === "thread.message.created") {
const blocks = (event.payload.blocks as Array<{ kind?: string }>) ?? [];
if (blocks.some((block) => block.kind === "project_created")) {
void refreshBots().catch(() => undefined);
}
}
if (event.type === "run.waiting_input" || event.type === "computer.takeover.requested") {
ooSound.play("need-you");
ooVocals.reactForEvent("need-you");
}
if (isRunTerminalEvent(event) || event.type === "run.waiting_input") {
// waiting_input: reconcile ask cards if a stale post-send refresh raced SSE.
void refreshGroupThread(groupId).catch(() => undefined);
}
},
});
return () => {
window.removeEventListener("focus", markVisibleGroupRead);
document.removeEventListener("visibilitychange", markVisibleGroupRead);
abort.abort();
};
}, [activeGroup?.id, groupId, notifyBrowserForEvent]);
const { query, setQuery, searchHits, searchLoading, showSpaceSearch, jumpToSearchHit } =
useSpaceSearch(navigate);
const {
homeSpaceBots,
conversationSidebarGroups,
mainConversationEntry,
projectSidebarGroups,
sensors,
handleProjectDragEnd,
} = useSidebarModel({
query,
spaces,
bots,
groups,
botSections,
bootstrapMe,
homeSpaceId,
mainConversation,
});
const openSpaceChat = useCallback(
(spaceId: string, path: string) => {
setMobileSidebarOpen(false);
const previousSpaceId = selectedSpaceId();
// Persist the active space (including primary) so voice/RPC headers match the chat.
const selectionStored = selectSpace(spaceId);
if (!selectionStored) return;
const previousEffective = previousSpaceId ?? bootstrapMe?.spaceId;
const boundaryChanged = previousEffective !== spaceId;
// Soft-navigate within the same space; reload only when the auth boundary changes
// so bootstrapped bots/groups match the request header.
if (boundaryChanged) {
window.location.assign(path);
return;
}
navigate(path);
},
[bootstrapMe?.spaceId, navigate],
);
// A project is a conversation with its own main agent, created when first opened.
const startProject = useCallback(
async (spaceId: string) => {
setActiveNavPillar("projekte");
const main = await rpc.spaces.ensureMain({ spaceId });
if (spaceId === (selectedSpaceId() ?? bootstrapMe?.spaceId)) await refreshBots();
openSpaceChat(spaceId, `/app/g/${main.groupId}`);
},
[bootstrapMe?.spaceId, openSpaceChat, refreshBots],
);
const openMainConversation = useCallback(() => {
if (!mainConversation) return;
setActiveNavPillar("team");
openSpaceChat(mainConversation.spaceId, `/app/g/${mainConversation.groupId}`);
}, [mainConversation, openSpaceChat]);
const openConversationBot = useCallback(
(botId: string) => {
setConversationPickerOpen(false);
setMobileSidebarOpen(false);
if (botId === mainConversation?.botId) {
openMainConversation();
return;
}
setActiveNavPillar("team");
const bot =
homeSpaceBots.find((item) => item.id === botId) ?? bots.find((item) => item.id === botId);
if (bot) {
openSpaceChat(bot.spaceId, `/app/${botId}`);
return;
}
navigate(`/app/${botId}`);
},
[bots, homeSpaceBots, mainConversation?.botId, navigate, openMainConversation, openSpaceChat],
);
const flushBotOrder = useCallback(async () => {
if (savingBotOrderRef.current) return;
savingBotOrderRef.current = true;
try {
while (pendingBotOrderRef.current) {
const botIds = pendingBotOrderRef.current;
pendingBotOrderRef.current = null;
try {
await rpc.bots.reorder({ botIds });
} catch {
// Keep a newer order queued during this failed save; only roll back
// when nothing else is pending.
if (pendingBotOrderRef.current === null) {
await refreshBots(false, true).catch(() => undefined);
}
}
}
} finally {
savingBotOrderRef.current = false;
// A reorder may have arrived while saving=true and returned early.
if (pendingBotOrderRef.current) {
void flushBotOrder();
}
}
}, [refreshBots]);
const reorderRosterBot = useCallback(
(sourceId: string, targetId: string, groupBotIds: string[]) => {
if (!groupBotIds.includes(sourceId) || !groupBotIds.includes(targetId)) return;
const current = botsRef.current;
const reordered = reorderBotTo(current, sourceId, targetId);
if (reordered === current) return;
const next = [...reordered];
botOrderEpochRef.current += 1;
botsRef.current = next;
setBots(next);
pendingBotOrderRef.current = next.map((bot) => bot.id);
void flushBotOrder();
},
[flushBotOrder],
);
const toggleSidebarSection = useCallback(
(key: string) => {
setCollapsedSidebarSections((previous) => {
const next = new Set(previous);
if (next.has(key)) next.delete(key);
else next.add(key);
const storageKey = collapsedSidebarSectionsStorageKey(userId);
if (storageKey) {
try {
window.localStorage.setItem(storageKey, JSON.stringify([...next]));
} catch {
// Keep the UI usable when storage is unavailable.
}
}
return next;
});
},
[userId],
);
async function jumpToMessage(target: { botId?: string; groupId?: string; messageId: string }) {
const threadTarget = searchHitThreadTarget(target);
const epoch = historyEpoch.current;
jumpGeneration.current += 1;
const jumpId = jumpGeneration.current;
const [snap, page] = await Promise.all([
rpc.threads.get(threadTarget),
rpc.threads.messages({ ...threadTarget, around: { messageId: target.messageId } }),
]);
// The epoch check drops a jump that raced a conversation clear (or a bot switch): applying
// the fetched page would pin deleted messages that every later refresh keeps restoring.
// jumpId drops an older jump that finished after a newer click.
if (epoch !== historyEpoch.current || jumpId !== jumpGeneration.current) return;
if (target.groupId && activeGroupId.current !== target.groupId) return;
if (target.botId && activeBotId.current !== target.botId) return;
const targetInPage = userVisibleMessages(page.messages, { includePeerReceipts: true }).some(
(message) => message.id === target.messageId,
);
expandedHistoryThread.current = targetInPage ? page.threadId : null;
pinnedAroundRef.current = targetInPage
? {
...threadTarget,
messageId: target.messageId,
threadId: page.threadId,
messages: page.messages,
olderCursor: page.olderCursor,
}
: null;
if (targetInPage) initiallyScrolledThread.current = page.threadId;
commitSnapshot({
...snap,
messages: targetInPage ? page.messages : snap.messages,
olderCursor: targetInPage ? page.olderCursor : snap.olderCursor,
});
if (threadTarget.botId) {
commitComputer(snap.computer ?? null);
// Don't block parent-scroll on routines metadata; a list failure must not abort the jump.
void rpc.routines
.list({ botId: threadTarget.botId })
.then((routines) => {
if (epoch !== historyEpoch.current || jumpId !== jumpGeneration.current) return;
if (activeBotId.current !== threadTarget.botId) return;
setRoutines(routines);
setRoutinesBotId(threadTarget.botId);
})
.catch(() => undefined);
} else {
commitComputer(null);
setRoutines([]);
setRoutinesBotId(null);
}
window.requestAnimationFrame(() => {
if (epoch !== historyEpoch.current || jumpId !== jumpGeneration.current) return;
if (!targetInPage) {
const element = messageScroll.current;
if (element) {
element.scrollTop = element.scrollHeight;
initiallyScrolledThread.current = page.threadId;
}
return;
}
document
.querySelector(`[data-message-id="${target.messageId}"]`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
}
useEffect(() => {
const messageId = searchParams.get("m");
const routineId = searchParams.get("routine");
if (inGroup && groupId && messageId) {
void jumpToMessage({ groupId, messageId }).finally(() => {
// Keep expandedHistoryThread; only strip the jump URL so refresh does not remount.
const next = new URLSearchParams(searchParams);
next.delete("m");
setSearchParams(next, { replace: true });
});
return;
}
if (!active) return;
if (routineId && routinesBotId === active.id) {
const openRoutine = (routine: Routine | undefined) => {
if (routine) {
setRoutineDraft(draftFromRoutine(routine));
setRoutineWebhookSecret(null);
setEditingRoutine(routine);
setPanel("routine");
} else {
setPanel("computer");
}
};
const routine = routines.find((item) => item.id === routineId);
if (routine) {
openRoutine(routine);
} else {
// The bot may have set it up after the list was loaded.
const botId = active.id;
void rpc.routines
.list({ botId })
.then((fresh) => {
if (activeBotId.current !== botId) return;
setRoutines(fresh);
openRoutine(fresh.find((item) => item.id === routineId));
})
.catch(() => setPanel("computer"));
}
const next = new URLSearchParams(searchParams);
next.delete("routine");
setSearchParams(next, { replace: true });
}
if (messageId) {
void jumpToMessage({ botId: active.id, messageId }).finally(() => {
const next = new URLSearchParams(searchParams);
next.delete("m");
setSearchParams(next, { replace: true });
});
}
}, [active?.id, groupId, inGroup, routines, routinesBotId, searchParams, setSearchParams]);
useEffect(() => {
if (!inGroup || !groupId || !projectSessionKey || projectSessionKey === "live") return;
void jumpToMessage({ groupId, messageId: projectSessionKey });
}, [groupId, inGroup, projectSessionKey]);
useEffect(() => {
if (!botId && !groupId && emptyProjectSpace && initialBotsLoaded) {
setActiveNavPillar("projekte");
}
}, [botId, emptyProjectSpace, groupId, initialBotsLoaded]);
const activeSnapshot = inGroup
? snapshot?.groupId === groupId
? snapshot
: null
: snapshot?.botId === active?.id
? snapshot
: null;
const activeGroupMemberCount =
activeGroup?.members?.length ?? activeSnapshot?.members?.length ?? 0;
const multiOoInGroup = inGroup && isMultiOoGroupChat(activeGroupMemberCount);
const activeReplyTarget =
replyTarget && activeSnapshot?.messages.some((message) => message.id === replyTarget.id)
? replyTarget
: null;
const activeReplyQuote = activeReplyTarget ? replyQuote : null;
const clearReply = useCallback(() => {
setReplyTarget(null);
setReplyQuote(null);
}, []);
const currentRuns = useMemo(() => activeThreadRuns(activeSnapshot), [activeSnapshot]);
const answerableAskMessageId = latestAnswerableAskMessageId(activeSnapshot);
const workingRuns = useMemo(
() => currentRuns.filter((run) => isWorkingRunStatus(run.status)),
[currentRuns],
);
const transcriptRunning = workingRuns.length > 0;
const composerRunning = sending || currentRuns.some((run) => isActive(run.status as RunStatus));
const runError = threadRunError(activeSnapshot, dismissedRunErrorIds);
const displayedRunError = !sendError ? runError : null;
const displayedRunErrorId = displayedRunError ? (activeSnapshot?.run?.id ?? null) : null;
const handleRunErrorPresented = useCallback((runId: string) => {
rememberSeenRunErrorId(runId);
}, []);
const transcriptMessages = useMemo(
() => userVisibleMessages(activeSnapshot?.messages ?? [], { includePeerReceipts: true }),
[activeSnapshot?.messages],
);
const transcriptArtifactTarget = useMemo<ArtifactTarget>(
() => (inGroup ? { groupId: groupId ?? "" } : { botId: active?.id ?? "" }),
[active?.id, groupId, inGroup],
);
const workspaceFocus = useMemo(
() =>
deriveWorkspaceFocus(
activeSnapshot ? { ...activeSnapshot, computer: inGroup ? null : computer } : null,
{ screenRunId, screenActive, screenMode: screenPaneMode(screenHabit) },
),
[activeSnapshot, computer, inGroup, screenActive, screenHabit, screenRunId],
);
const workspaceFocusRef = useRef<{ threadId: string; focus: WorkspaceFocus } | null>(null);
const autoPanelRef = useRef(autoPanel);
autoPanelRef.current = autoPanel;
const autoCloseTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(autoCloseTimer.current), []);
// The pane follows the bot's work on focus edges only, so it never jumps while nothing changed.
useEffect(() => {
const threadId = activeSnapshot?.threadId;
const sameThread = Boolean(threadId) && workspaceFocusRef.current?.threadId === threadId;
// New work or another conversation cancels a pending close; an unrelated edge does not.
if (!sameThread || workspaceFocus.pane !== null) window.clearTimeout(autoCloseTimer.current);
if (!threadId) {
workspaceFocusRef.current = null;
return;
}
const previous = sameThread ? (workspaceFocusRef.current?.focus ?? null) : null;
workspaceFocusRef.current = { threadId, focus: workspaceFocus };
// Narrow windows keep the conversation full width; a pane would cover it.
if (!wideLayout) return;
const paneState: PaneState = {
open:
panel === "computer"
? "screen"
: panel === "artifact"
? "artifact"
: panel
? "other"
: null,
auto: panel !== null && panel === autoPanel,
};
const next = applyWorkspaceFocus(previous, workspaceFocus, paneState);
if (!next || next.open === "other") return;
if (next.open === null) {
const closing = autoPanelRef.current;
autoCloseTimer.current = window.setTimeout(() => {
if (closing === null || autoPanelRef.current !== closing) return;
setPanel(null);
setAutoPanel(null);
}, AUTO_SCREEN_CLOSE_DELAY_MS);
return;
}
if (next.open === "artifact") setArtifactPane(workspaceFocus.artifact);
const nextPanel: Panel = next.open === "screen" ? "computer" : next.open;
setPanel(nextPanel);
setAutoPanel(next.auto ? nextPanel : null);
}, [workspaceFocus.key, activeSnapshot?.threadId, wideLayout]);
const learnScreenPane = useCallback(
(choice: "opened" | "dismissed") => {
const botId = activeBotId.current;
if (!botId) return;
const next = recordScreenPaneChoice(screenHabit, choice);
setScreenHabit(next);
writeScreenPaneHabit(userId, botId, next);
},
[screenHabit, userId],
);
useEffect(() => {
if (autoPanel === null || panel === autoPanel) return;
// Closing the live screen while the bot still works on it teaches this bot's default.
const focus = workspaceFocusRef.current?.focus;
if (autoPanel === "computer" && panel === null && focus?.pane === "screen" && !focus.takeover) {
learnScreenPane("dismissed");
}
setAutoPanel(null);
}, [panel]);
const openArtifactBeside = useCallback((artifact: WorkspaceArtifact) => {
setArtifactPane(artifact);
setPanel("artifact");
}, []);
const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members;
// Bots of every space, so a group message from a bot that is not a member still has a speaker.
const knownBots = useMemo(() => {
const byId = new Map<string, { name: string; color: string; status?: string }>();
for (const space of spaces) for (const bot of space.bots) byId.set(bot.id, bot);
for (const bot of bots) byId.set(bot.id, bot);
return byId;
}, [bots, spaces]);
const resolveTranscriptBot = useCallback(
(botId: string) => {
const bot = bots.find((candidate) => candidate.id === botId);
if (bot) return bot;
return transcriptMembers?.find((member) => member.botId === botId);
},
[bots, transcriptMembers],
);
const resolveSpeakerBot = useCallback(
(botId: string) => resolveTranscriptBot(botId) ?? knownBots.get(botId),
[knownBots, resolveTranscriptBot],
);
const enrichStageMember = useCallback(
(botId: string) => {
const bot = resolveTranscriptBot(botId);
if (!bot || !("title" in bot)) return undefined;
return {
title: bot.title,
description: bot.description,
};
},
[resolveTranscriptBot],
);
const workingBots: GroupAvatarMember[] = useMemo(() => {
if (!inGroup) {
return workingRuns.map((run) => {
const bot = resolveTranscriptBot(run.botId);
return {
botId: run.botId,
color: bot?.color ?? FALLBACK_BOT_COLOR,
name: bot?.name,
status: run.status,
};
});
}
return resolveWorkingGroupBots(activeSnapshot, enrichStageMember).map((member) => ({
botId: member.botId,
color: member.color,
name: member.name,
status: member.status,
}));
}, [activeSnapshot, enrichStageMember, inGroup, resolveTranscriptBot, workingRuns]);
const encounterMode = Boolean((active || activeGroup) && !showProjectTimeline && !callOpen);
const voiceEncounterActive = encounterMode || callOpen || voiceEntry;
const voiceCallBot = useMemo(() => {
if (active) return active;
if (!inGroup || !activeSnapshot) return undefined;
const speaker = resolveActiveGroupSpeaker(
activeSnapshot,
resolveWorkingGroupBots(activeSnapshot, enrichStageMember),
enrichStageMember,
);
if (!speaker) return undefined;
const bot = resolveTranscriptBot(speaker.botId);
if (!bot) return undefined;
const title = "title" in bot ? bot.title : (speaker.title ?? "");
const description = "description" in bot ? bot.description : (speaker.description ?? "");
return {
id: speaker.botId,
name: speaker.name,
title,
description,
color: speaker.color,
status: speaker.status ?? ("status" in bot ? bot.status : undefined),
};
}, [active, activeSnapshot, enrichStageMember, inGroup, resolveTranscriptBot]);
const voiceSetupPrompted = useRef(false);
useEffect(() => {
if (!voiceEntry || showProjectTimeline || callOpen) return;
if (!voiceCallBot) return;
if (voiceStatus?.ready) {
voiceSetupPrompted.current = false;
setCallOpen(true);
return;
}
if (voiceSetupPrompted.current) return;
voiceSetupPrompted.current = true;
openSettings("voice");
}, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]);
const heuteActionCount = useHeuteActionCount(groups, botsRef);
useShellSounds({
bots,
transcriptRunning,
callOpen,
displayedRunErrorId,
activeSnapshot,
inGroup,
});
const resolveTranscriptMemberName = useCallback(
(botId: string | undefined) =>
memberName(transcriptMembers, botId) ??
(inGroup && botId ? knownBots.get(botId)?.name : undefined),
[inGroup, knownBots, transcriptMembers],
);
const replyTargetName = activeReplyTarget
? activeReplyTarget.role === "user"
? t`You`
: (resolveTranscriptMemberName(activeReplyTarget.botId) ?? active?.name ?? t`Bot`)
: undefined;
const composerMentionTargets = useMentionTargets({
bots,
groups,
groupId,
inGroup,
activeGroup,
activeSnapshot,
initialBotsLoaded,
});
const shellReady =
initialBotsLoaded &&
(inGroup
? Boolean(activeGroup && activeSnapshot)
: bots.length === 0 || Boolean(active && activeSnapshot));
const refreshThreadRef = useRef(refreshThread);
refreshThreadRef.current = refreshThread;
const refreshGroupThreadRef = useRef(refreshGroupThread);
refreshGroupThreadRef.current = refreshGroupThread;
const loadOlderMessagesRef = useRef(loadOlderMessages);
loadOlderMessagesRef.current = loadOlderMessages;
const jumpToMessageRef = useRef(jumpToMessage);
jumpToMessageRef.current = jumpToMessage;
useLayoutEffect(() => {
if (initialBotsLoaded) {
markOnce("rk:renderer:bots-committed");
markAfterPaint("rk:renderer:bots-painted");
}
if (active && snapshot?.botId === active.id) {
markOnce("rk:renderer:thread-committed");
markAfterPaint("rk:renderer:thread-painted");
}
if (shellReady) {
markOnce("rk:renderer:shell-ready");
markAfterPaint("rk:renderer:shell-painted");
}
}, [active, initialBotsLoaded, shellReady, snapshot?.botId]);
useLayoutEffect(() => {
const pin = pinnedAroundRef.current;
if (inGroup) {
if (!groupId || !snapshot || snapshot.groupId !== groupId) return;
if (initiallyScrolledThread.current === snapshot.threadId) return;
if (expandedHistoryThread.current === snapshot.threadId) return;
if (pin?.groupId === groupId) return;
} else {
if (!active || !snapshot || snapshot.botId !== active.id) return;
if (initiallyScrolledThread.current === snapshot.threadId) return;
if (expandedHistoryThread.current === snapshot.threadId) return;
if (pin?.botId === active.id) return;
}
const element = messageScroll.current;
if (!element) return;
element.scrollTop = element.scrollHeight;
initiallyScrolledThread.current = snapshot.threadId;
}, [active, groupId, inGroup, snapshot?.botId, snapshot?.groupId, snapshot?.threadId]);
const openBot = useCallback((id: string) => navigate(`/app/${id}`), [navigate]);
const openGroup = useCallback(
(id: string) => {
setActiveNavPillar("projekte");
navigate(`/app/g/${id}`);
},
[navigate],
);
const openProjectSession = useCallback(
(sourceMessageId: string) => {
const next = new URLSearchParams(searchParams);
next.set("s", sourceMessageId);
setSearchParams(next, { replace: true });
},
[searchParams, setSearchParams],
);
const openProjectChat = useCallback(() => {
const next = new URLSearchParams(searchParams);
next.set("s", "live");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const openProjectGroupChat = useCallback(
(targetGroupId: string) => {
if (targetGroupId === groupId) {
openProjectChat();
return;
}
navigate(`/app/g/${targetGroupId}?s=live`);
},
[groupId, navigate, openProjectChat],
);
const clearVoiceEntryParam = useCallback(() => {
if (searchParams.get("voice") !== "1") return;
const next = new URLSearchParams(searchParams);
next.delete("voice");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const openProjectVoiceFirst = useCallback(
(targetGroupId?: string) => {
const id = targetGroupId ?? groupId;
if (!id) return;
if (id === groupId) {
const next = new URLSearchParams(searchParams);
next.set("s", "live");
next.set("voice", "1");
setSearchParams(next, { replace: true });
return;
}
navigate(`/app/g/${id}?s=live&voice=1`);
},
[groupId, navigate, searchParams, setSearchParams],
);
const startVoiceEncounter = useCallback(() => {
if (voiceStatus?.ready) {
setCallOpen(true);
return;
}
openSettings("voice");
}, [voiceStatus?.ready]);
const closeProjectSession = useCallback(() => {
const next = new URLSearchParams(searchParams);
next.delete("s");
next.delete("voice");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const closeVoiceStage = useCallback(() => {
setCallOpen(false);
clearVoiceEntryParam();
}, [clearVoiceEntryParam]);
const loadOlder = useCallback(() => loadOlderMessagesRef.current(), []);
const jumpToReplyMessage = useCallback((messageId: string) => {
const existing = document.querySelector(
`[data-message-id="${messageId.replace(/[^\w-]/g, (c) => `\\${c}`)}"]`,
);
if (existing) {
// Cancel any in-flight around-fetch so it cannot overwrite this scroll.
jumpGeneration.current += 1;
existing.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
const groupId = activeGroupId.current;
if (groupId) {
void jumpToMessageRef.current({ groupId, messageId });
return;
}
const botId = activeBotId.current;
if (botId) void jumpToMessageRef.current({ botId, messageId });
}, []);
const answerMessage = useCallback(async (message: ThreadMessage, text: string) => {
const botId = activeBotId.current;
const groupId = activeGroupId.current;
if (!botId && !groupId) return;
await rpc.threads.answer({
...(groupId ? { groupId } : { botId: botId! }),
runId: message.runId ?? "",
messageId: message.id,
answer: text,
});
if (groupId && activeGroupId.current === groupId) {
await refreshGroupThreadRef.current(groupId);
} else if (botId && activeBotId.current === botId) {
await refreshThreadRef.current(botId);
}
}, []);
const actOnCard = useCallback(async (message: ThreadMessage, cardId: string, act: CardAct) => {
const botId = activeBotId.current;
const groupId = activeGroupId.current;
if (!botId && !groupId) return;
await rpc.threads.act({
...(groupId ? { groupId } : { botId: botId! }),
messageId: message.id,
cardId,
act,
clientNonce: newClientNonce(),
});
if (groupId && activeGroupId.current === groupId) {
await refreshGroupThreadRef.current(groupId);
} else if (botId && activeBotId.current === botId) {
await refreshThreadRef.current(botId);
}
}, []);
const actOnAutopilot = useCallback(async (action: AutopilotAction) => {
const botId = activeBotId.current;
const groupId = activeGroupId.current;
if (!botId && !groupId) return;
const target = groupId ? { groupId } : { botId: botId! };
if (action === "stop") await rpc.autopilot.stop(target);
else await rpc.autopilot.resume(target);
if (groupId && activeGroupId.current === groupId) {
await refreshGroupThreadRef.current(groupId);
} else if (botId && activeBotId.current === botId) {
await refreshThreadRef.current(botId);
}
}, []);
const startAutopilot = useCallback(async (goal: string, until?: string) => {
const botId = activeBotId.current;
const groupId = activeGroupId.current;
if (!botId && !groupId) return;
const target = groupId ? { groupId } : { botId: botId! };
await rpc.autopilot.start({ ...target, goal, ...(until ? { until } : {}) });
if (groupId && activeGroupId.current === groupId) {
await refreshGroupThreadRef.current(groupId);
} else if (botId && activeBotId.current === botId) {
await refreshThreadRef.current(botId);
}
}, []);
const reactToMessage = useCallback(
async (message: ThreadMessage, reaction: MessageReaction) => {
const botId = activeBotId.current;
const groupId = activeGroupId.current;
if (!botId && !groupId) return;
try {
await rpc.threads.react({
...(groupId ? { groupId } : { botId: botId! }),
messageId: message.id,
reaction,
clientNonce: newClientNonce(),
});
} catch (error) {
const stillHere = groupId
? activeGroupId.current === groupId
: activeBotId.current === botId;
if (!stillHere) return;
setSendError(error instanceof Error ? error.message : t`Could not update reaction`);
}
},
[t],
);
const onAttachmentPick = useCallback(
async (files: FileList | null) => {
const threadKey = activeGroupId.current ?? activeBotId.current;
if (!threadKey || !files?.length) return;
const existing = attachmentsForThread(pendingAttachments, threadKey);
const next: PendingAttachment[] = [];
const skipped: string[] = [];
for (const file of Array.from(files)) {
if (existing.length + next.length >= ATTACHMENT_MAX_COUNT) {
skipped.push(t`${file.name} (max ${ATTACHMENT_MAX_COUNT} attachments)`);
continue;
}
const mimeType = inferAttachmentMimeType(file.name, file.type);
if (!mimeType) {
skipped.push(file.name);
continue;
}
const maxBytes = attachmentMaxBytes(mimeType);
if (file.size > maxBytes) {
skipped.push(t`${file.name} (over ${formatBytes(maxBytes)})`);
continue;
}
next.push({
id: `${file.name}-${file.size}-${file.lastModified}-${next.length}`,
threadKey,
file,
previewUrl: mimeType.startsWith("image/") ? URL.createObjectURL(file) : undefined,
});
}
if (next.length) setPendingAttachments((current) => [...current, ...next]);
setAttachmentNotice(skipped.length ? t`Skipped ${skipped.join(", ")}` : null);
if (fileInputRef.current) fileInputRef.current.value = "";
},
[pendingAttachments, t],
);
const removeAttachment = useCallback((attachment: PendingAttachment) => {
revokePendingAttachmentPreviews([attachment]);
setPendingAttachments((current) => current.filter((item) => item.id !== attachment.id));
}, []);
const sendMessage = useCallback(
async (text: string, mentions: ComposerMention[] = []) => {
const initialBotTarget = activeBotId.current;
const initialGroupTarget = activeGroupId.current;
if (!initialBotTarget && !initialGroupTarget) return;
const originThreadKey = initialGroupTarget ?? initialBotTarget;
const attachments = attachmentsForThread(pendingAttachments, originThreadKey);
const plan = resolveComposerSendPlan({
text,
mentions,
hasAttachments: attachments.length > 0,
});
if (plan.isNoOp) return;
const reroutedToGroup = Boolean(
plan.rerouteGroupId && plan.rerouteGroupId !== initialGroupTarget,
);
const groupTarget = plan.rerouteGroupId ?? initialGroupTarget;
const botTarget = reroutedToGroup ? undefined : initialBotTarget;
if (
plan.shouldSend &&
(groupTarget || botsRef.current.find((bot) => bot.id === botTarget)?.notifyOnFinish)
) {
const permissionRequest = requestBrowserNotificationPermission();
if (permissionRequest) void permissionRequest.then(flushPendingBrowserNotifications);
}
const trimmed = plan.trimmed;
if (botTarget && !groupTarget && plan.shouldSend && attachments.length === 0) {
const voiceCmd = parseVoiceChatCommand(trimmed);
if (voiceCmd) {
setSendError(null);
setAttachmentNotice(null);
try {
const localVoiceToken = "local-kokoro";
if (voiceCmd.kind === "speak_off") {
await rpc.bots.update({ botId: botTarget, autoSpeak: false });
await refreshBots();
setAttachmentNotice(t`Speaking is off.`);
return;
}
if (voiceCmd.kind === "speak_on") {
await rpc.voice
.connect({ provider: "kokoro", apiKey: localVoiceToken })
.catch(() => undefined);
await rpc.bots.update({ botId: botTarget, autoSpeak: true });
await refreshBots();
void rpc.voice
.status()
.then(setVoiceStatus)
.catch(() => undefined);
setAttachmentNotice(t`Speaking is on.`);
return;
}
const voices = await rpc.voice.voices({});
const match = matchVoiceByName(voices, voiceCmd.voiceName);
if (!match) {
setSendError(t`Voice "${voiceCmd.voiceName}" not found.`);
return;
}
await rpc.voice
.connect({ provider: "kokoro", apiKey: localVoiceToken })
.catch(() => undefined);
await rpc.voice.setVoice({ voiceId: match.id, provider: "kokoro" });
await rpc.bots.update({ botId: botTarget, voiceId: match.id, autoSpeak: true });
await refreshBots();
void rpc.voice
.status()
.then(setVoiceStatus)
.catch(() => undefined);
setAttachmentNotice(t`Voice set to ${match.label}.`);
return;
} catch (error) {
setSendError(error instanceof Error ? error.message : t`Could not update voice`);
return;
}
}
}
sendsInFlight.current += 1;
setSending(true);
setSendError(null);
const dropDelayedSetup = () => {
// Only after successful engagement so a failed upload/send keeps the setup card.
if (initialBotTarget && focusPromptBotIdRef.current === initialBotTarget) {
cancelFocusPrompt();
}
};
try {
if (plan.shouldRunRoutines) {
const sendNonce = newClientNonce();
await Promise.all(
plan.routineIds.map((routineId) =>
rpc.routines.testRun({
routineId,
clientNonce: `routine-mention:${sendNonce}:${routineId}`,
}),
),
);
}
if (!plan.shouldSend) {
dropDelayedSetup();
clearReply();
revokePendingAttachmentPreviews(attachments);
setPendingAttachments((current) =>
current.filter((attachment) => attachment.threadKey !== originThreadKey),
);
setAttachmentNotice(null);
if (reroutedToGroup && groupTarget) {
navigate(`/app/g/${groupTarget}`);
return;
}
if (groupTarget && activeGroupId.current === groupTarget) {
await refreshGroupThreadRef.current(groupTarget);
} else if (botTarget && activeBotId.current === botTarget) {
await refreshThreadRef.current(botTarget);
}
return;
}
const artifactIds: string[] = [];
for (const pending of attachments) {
const mimeType = inferAttachmentMimeType(pending.file.name, pending.file.type);
if (!mimeType) {
throw new Error(t`Unsupported file type: ${pending.file.name}`);
}
const artifact = await uploadAttachment(
groupTarget ? { groupId: groupTarget } : { botId: botTarget! },
pending.file,
mimeType,
);
artifactIds.push(artifact.id);
}
const clientNonce = newClientNonce();
if (groupTarget) {
await rpc.threads.send({
groupId: groupTarget,
clientNonce,
text: trimmed || undefined,
mentions: plan.mentionPayload.length ? plan.mentionPayload : undefined,
artifactIds: artifactIds.length ? artifactIds : undefined,
replyToMessageId: reroutedToGroup ? undefined : activeReplyTarget?.id,
replyQuote: reroutedToGroup ? undefined : (activeReplyQuote ?? undefined),
});
} else if (botTarget) {
const sent = await rpc.threads.send({
botId: botTarget,
clientNonce,
text: trimmed || undefined,
mentions: plan.mentionPayload.length ? plan.mentionPayload : undefined,
artifactIds: artifactIds.length ? artifactIds : undefined,
replyToMessageId: activeReplyTarget?.id,
replyQuote: activeReplyQuote ?? undefined,
});
if (activeBotId.current === botTarget) {
updateSnapshot((current) =>
applyThreadSendReceipt(
current,
{
botId: botTarget,
runId: sent.runId,
taskId: sent.taskId,
},
terminalRunReceipts.current,
),
);
}
}
ooSound.play("message-sent");
dropDelayedSetup();
clearReply();
revokePendingAttachmentPreviews(attachments);
setPendingAttachments((current) =>
current.filter((attachment) => attachment.threadKey !== originThreadKey),
);
// Refresh sidebar status even when a bot→group reroute navigates away below.
void refreshBots().catch(() => undefined);
if (reroutedToGroup && groupTarget) {
navigate(`/app/g/${groupTarget}`);
return;
}
if (groupTarget && activeGroupId.current === groupTarget) setAttachmentNotice(null);
if (botTarget && activeBotId.current === botTarget) setAttachmentNotice(null);
if (groupTarget) await refreshGroupThreadRef.current(groupTarget);
else if (botTarget) await refreshThreadRef.current(botTarget);
} catch (error) {
if (reroutedToGroup && groupTarget) {
setSendError(error instanceof Error ? error.message : t`Failed to send message`);
} else if (groupTarget && activeGroupId.current === groupTarget) {
setSendError(error instanceof Error ? error.message : t`Failed to send message`);
} else if (botTarget && activeBotId.current === botTarget) {
setSendError(error instanceof Error ? error.message : t`Failed to send message`);
}
} finally {
sendsInFlight.current = Math.max(0, sendsInFlight.current - 1);
if (sendsInFlight.current === 0) setSending(false);
}
},
[
activeReplyTarget?.id,
activeReplyQuote,
clearReply,
flushPendingBrowserNotifications,
navigate,
pendingAttachments,
refreshBots,
t,
],
);
const followUpMessage = useCallback(async (text: string) => {
const groupTarget = activeGroupId.current;
if (groupTarget) {
await rpc.threads.followUp({ groupId: groupTarget, text });
await refreshGroupThreadRef.current(groupTarget);
return;
}
const id = activeBotId.current;
if (!id) return;
await rpc.threads.followUp({ botId: id, text });
await refreshThreadRef.current(id);
}, []);
const stopRun = useCallback(async () => {
if (sending) return;
setSending(true);
try {
const botTarget = activeBotId.current;
const groupTarget = activeGroupId.current;
if (groupTarget) {
setSendError(null);
try {
await rpc.threads.stop({ groupId: groupTarget });
} catch (error) {
if (activeGroupId.current === groupTarget) {
setSendError(error instanceof Error ? error.message : t`Failed to stop`);
}
return;
}
// Stop has no terminal event; clear run UI before refresh races with in-flight gets.
if (activeGroupId.current === groupTarget) {
updateSnapshot((prev) =>
prev && prev.groupId === groupTarget ? clearActiveThreadRuns(prev) : prev,
);
}
await refreshGroupThreadRef.current(groupTarget).catch(() => undefined);
return;
}
if (!botTarget) return;
setSendError(null);
try {
await rpc.threads.stop({ botId: botTarget });
} catch (error) {
if (activeBotId.current === botTarget) {
setSendError(error instanceof Error ? error.message : t`Failed to stop`);
}
return;
}
// Stop does not emit a terminal thread event. Clear local run/busy immediately so a
// superseded in-flight refresh (older cursor) cannot leave Stop enabled / Take control
// blocked while the API is already idle.
if (activeBotId.current === botTarget) {
updateSnapshot((prev) =>
!prev || (prev.botId !== botTarget && prev.botId) ? prev : clearActiveThreadRuns(prev),
);
const currentComputer = computerRef.current;
if (currentComputer?.busyBotName) {
commitComputer({ ...currentComputer, busyBotName: null });
}
}
await refreshThreadRef.current(botTarget).catch(() => undefined);
} finally {
setSending(false);
}
}, [sending, t]);
const stopTeaching = useCallback(async () => {
const id = activeBotId.current;
if (!id || teachBusy) return;
const recording = taughtSkills.find(
(skill) => skill.status === "recording" && taughtSkillsBotId === id,
);
if (!recording) return;
setTeachBusy(true);
try {
await rpc.skills.stop({ skillId: recording.id });
await refreshThreadRef.current(id);
setComputerOpen(false);
} finally {
setTeachBusy(false);
}
}, [teachBusy, taughtSkills, taughtSkillsBotId]);
// Transcript and MessageView are memoized; these must stay referentially stable or every
// Shell state change re-renders the whole transcript.
const refreshActiveThread = useCallback(async () => {
const groupId = activeGroupId.current;
if (groupId) {
await refreshGroupThreadRef.current(groupId);
return;
}
const id = activeBotId.current;
if (!id) return;
await refreshThreadRef.current(id);
}, []);
// Teach chrome needs skills applied before this resolves — refreshThread only
// kicks skills.list off in the background, so Stop teaching would never mount
// if that background call failed or lagged behind local recovery.
const refreshActiveTeaching = useCallback(async () => {
const id = activeBotId.current;
if (!id) return;
await refreshThreadRef.current(id);
const skills = await rpc.skills.list({ botId: id });
if (activeBotId.current !== id) return;
setTaughtSkills(skills);
setTaughtSkillsBotId(id);
}, []);
const addSkillRoutine = useCallback((name: string, prompt: string) => {
setRoutineDraft({ ...emptyRoutineDraft(), name, prompt });
setRoutineWebhookSecret(null);
setEditingRoutine(null);
setPanel("routine");
}, []);
const speakingMessageIdRef = useRef(speakingMessageId);
speakingMessageIdRef.current = speakingMessageId;
const speakMessage = useCallback((message: ThreadMessage) => {
if (speakingMessageIdRef.current === message.id) {
speaker.stop();
return;
}
const text = speechFromBlocks(message.blocks);
const id = message.botId ?? activeBotId.current;
if (text && id) void speaker.speak(text, { botId: id, messageId: message.id });
}, []);
async function createGroup(input: { name: string; botIds: string[] }) {
const group = await rpc.groups.create(input);
setGroups((current) =>
current.some((item) => item.id === group.id) ? current : [group, ...current],
);
navigate(`/app/g/${group.id}`);
setPanel(null);
await refreshBots().catch(() => undefined);
}
function cancelFocusPrompt() {
focusPromptAbortRef.current?.abort();
focusPromptAbortRef.current = null;
focusPromptBotIdRef.current = null;
}
function openSettings(section: SettingsSection = "general") {
setSettingsSection(section);
setSettingsOpen(true);
}
async function importBot(manifest: unknown) {
const bot = await rpc.bots.import(manifest as ImportBotInput);
setBots((current) =>
current.some((item) => item.id === bot.id) ? current : [bot, ...current],
);
navigate(`/app/${bot.id}`);
setPanel(null);
await refreshBots().catch(() => undefined);
}
async function createBot(input: {
name: string;
title: string;
description: string;
computerMode: ComputerMode;
}) {
const isFirstBot = botsRef.current.length === 0;
const bot = await rpc.bots.create({
...normalizeCreateBotProfile(input),
notifyOnFinish: true,
computerMode: input.computerMode,
});
setBots((current) =>
current.some((item) => item.id === bot.id) ? current : [bot, ...current],
);
navigate(`/app/${bot.id}`);
setPanel(null);
// Register cancellation before awaiting start so leaving the bot during
// startup cannot miss the abort and still schedule a late focus card.
cancelFocusPrompt();
const controller = new AbortController();
focusPromptAbortRef.current = controller;
focusPromptBotIdRef.current = bot.id;
const started = await rpc.onboarding
.start({ botId: bot.id })
.then(() => true)
.catch(() => false);
if (!started || controller.signal.aborted || focusPromptBotIdRef.current !== bot.id) {
if (focusPromptAbortRef.current === controller) {
focusPromptAbortRef.current = null;
focusPromptBotIdRef.current = null;
}
await refreshBots().catch(() => undefined);
return;
}
void scheduleFocusPrompt({
immediate: isFirstBot,
signal: controller.signal,
prompt: async () => {
if (focusPromptBotIdRef.current !== bot.id || activeBotId.current !== bot.id) return;
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
},
}).finally(() => {
if (focusPromptAbortRef.current === controller) {
focusPromptAbortRef.current = null;
focusPromptBotIdRef.current = null;
}
});
await refreshBots().catch(() => undefined);
}
async function bootComputer({
takeControl,
overlay,
force = false,
}: {
takeControl: boolean;
overlay: boolean;
force?: boolean;
}) {
if (!active) return;
const needsBoot = force || computer?.state !== "running" || !screenUrl;
if (overlay && needsBoot) setBooting(true);
setComputerError(null);
setComputerErrorFromScreen(false);
try {
if (needsBoot) await rpc.computer.boot({ botId: active.id });
if (takeControl) await rpc.computer.takeover({ botId: active.id });
await refreshThread(active.id);
} catch (error) {
setComputerError(error instanceof Error ? error.message : t`Could not take control`);
setComputerErrorFromScreen(false);
throw error;
} finally {
setBooting(false);
}
}
useEffect(() => {
if (panel !== "computer" || !active) return;
const botId = active.id;
let cancelled = false;
void (async () => {
// Refresh from the server first. A stale SSE "booting" snapshot used to
// skip this effect, so an RPC takeover never showed "You have control".
const snap = await refreshThread(botId).catch(() => null);
if (cancelled || activeBotId.current !== botId) return;
const state = snap?.computer?.state;
const screen = state === "running" ? await refreshComputerScreen(botId) : null;
if (cancelled || activeBotId.current !== botId) return;
if (!computerPanelReconnects(state, screen)) return;
await bootComputer({ takeControl: false, overlay: false, force: true }).catch(
() => undefined,
);
})();
return () => {
cancelled = true;
};
}, [panel, active?.id]);
useEffect(() => {
setComputerOpen(false);
setComputerError(null);
setComputerErrorFromScreen(false);
setScreenRunId(null);
setScreenActive(false);
window.clearTimeout(screenActiveTimer.current);
}, [active?.id]);
useEffect(() => {
setScreenHabit(readScreenPaneHabit(userId, active?.id));
}, [active?.id, userId]);
// A presented file belongs to its conversation, and a new conversation starts without auto panes.
useEffect(() => {
setPanel((current) => (current === "artifact" ? null : current));
setArtifactPane(null);
setAutoPanel(null);
}, [active?.id, groupId]);
useEffect(() => {
if (focusPromptBotIdRef.current && focusPromptBotIdRef.current !== active?.id) {
cancelFocusPrompt();
}
}, [active?.id]);
useEffect(() => () => cancelFocusPrompt(), []);
useEffect(() => {
if (!computer?.busyBotName) {
setComputerError(null);
setComputerErrorFromScreen(false);
}
}, [computer?.busyBotName]);
useEffect(() => {
if (panel !== "routine") {
routineSaveRequest.current += 1;
setRoutineError(null);
}
}, [panel]);
// The routine panel copies a routine's data into local draft state at click time
// rather than deriving it from `active`, so it goes stale across a bot switch —
// without this, Save on bot B could silently update bot A's routine.
useEffect(() => {
setEditingRoutine(null);
setDeleteRoutineTarget(null);
setPanel((current) => (current === "routine" ? null : current));
}, [active?.id]);
useEffect(() => {
const threadKey = inGroup ? groupId : active?.id;
setPendingAttachments((current) => {
const stale = current.filter((attachment) => attachment.threadKey !== threadKey);
revokePendingAttachmentPreviews(stale);
return attachmentsForThread(current, threadKey);
});
clearReply();
setAttachmentNotice(null);
setSendError(null);
}, [active?.id, clearReply, groupId, inGroup]);
useEffect(() => {
if (!computerOpen) return;
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") setComputerOpen(false);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [computerOpen]);
useEffect(() => {
function onKey(event: KeyboardEvent) {
if (!isCommandPaletteHotkey(event)) return;
event.preventDefault();
setCommandPaletteOpen((open) => !open);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
useEffect(() => {
if ((panel !== "computer" && !computerOpen) || !active || computer?.state !== "running") return;
const ping = () => void rpc.computer.heartbeat({ botId: active.id }).catch(() => undefined);
ping();
const timer = window.setInterval(ping, 60_000);
return () => window.clearInterval(timer);
}, [panel, computerOpen, active?.id, computer?.state]);
async function openComputer() {
if (!active) return;
const needsTakeover = !userHoldsComputerControl(computer, active.id);
const blocked = computerTakeoverBlocked(computer, snapshot?.run?.status);
try {
await bootComputer({
takeControl: needsTakeover && !blocked,
overlay: (needsTakeover && !blocked) || computer?.state !== "running",
force: computer?.state !== "running",
});
setComputerOpen(true);
} catch {
// computerError already set in bootComputer
}
}
const releaseComputer = useCallback(
async (reason?: ComputerReleaseReason) => {
const botId = activeBotId.current;
if (!botId) return;
try {
await rpc.computer.release({ botId, reason });
if (activeBotId.current !== botId) return;
setComputerOpen(false);
await refreshThreadRef.current(botId).catch(() => undefined);
} catch {
if (activeBotId.current !== botId) return;
setComputerError(t`Could not continue`);
setComputerErrorFromScreen(false);
}
},
[t],
);
function dismissComposerError() {
// The strip shows one message at a time, so only dismiss the run failure when it is the
// one on screen; otherwise a live run would be silenced before it has even failed.
const failedRunId = displayedRunErrorId;
setSendError(null);
if (failedRunId) {
rememberSeenRunErrorId(failedRunId);
setDismissedRunErrorIds((current) => new Set(current).add(failedRunId));
}
}
const embeddedScreenUrl = embeddableScreenUrl(screenUrl);
// Only what the work opened gets the wide pane; settings, routines and a hand-opened screen keep
// one width so switching between them never jumps.
const workspacePaneOpen = panel === "artifact" || (panel === "computer" && autoPanel === panel);
const hasControl = userHoldsComputerControl(computer, active?.id);
const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl);
const computerScreenError =
computerError && !hideScreenLoadError ? (
<div role="alert" className="flex flex-col items-center gap-3 px-6 text-center text-sm">
<p className="text-destructive">{computerError}</p>
<Button
variant="outline"
size="sm"
onClick={() => active && void refreshComputerScreen(active.id)}
>
<Trans>Retry screen</Trans>
</Button>
</div>
) : null;
const userName = session.data?.user.name ?? t`You`;
const initials = userName
.split(" ")
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
const shell = (
<div
data-testid="shell-root"
data-ready={shellReady}
className="relative flex h-full min-w-0 overflow-hidden bg-background text-foreground/90 p-0 md:p-3 gap-0 md:gap-3"
onTouchStartCapture={onMobileSidebarTouchStart}
onTouchEndCapture={onMobileSidebarTouchEnd}
onTouchCancelCapture={onMobileSidebarTouchCancel}
>
<ComputerUpdateProgress
onCompleted={() => {
if (active) void refreshThread(active.id);
}}
/>
{bootstrapMe !== undefined ? (
<HostComputerPrompt initialMe={bootstrapMe ?? undefined} />
) : null}
{mobileSidebarOpen ? (
<button
type="button"
aria-label={t`Close navigation`}
onClick={() => setMobileSidebarOpen(false)}
className="absolute inset-y-0 end-0 start-[min(calc(100%-48px),316px)] z-30 bg-overlay md:hidden"
/>
) : null}
{!mobileSidebarOpen ? (
<div
data-testid="mobile-sidebar-swipe-edge"
aria-hidden="true"
className="absolute bottom-20 start-0 top-16 z-20 w-8 touch-none md:hidden"
/>
) : null}
<aside
data-testid="bots-sidebar"
data-collapsed={botsSidebarCollapsed ? "true" : "false"}
inert={botsSidebarCollapsed && !mobileSidebarOpen ? true : undefined}
style={{
width: mobileSidebarOpen ? undefined : botsSidebarCollapsed ? 0 : leftSidebarWidth,
maxWidth: mobileSidebarOpen ? 316 : undefined,
}}
className={`absolute inset-y-0 start-0 z-40 flex w-[calc(100%-48px)] shrink-0 flex-col md:rounded-2xl md:border md:border-sidebar-border md:shadow-2xl bg-sidebar md:static md:z-auto md:translate-x-0 ${
mobileSidebarOpen ? "translate-x-0" : "-translate-x-full rtl:translate-x-full"
} ${
botsSidebarCollapsed
? "md:w-0 md:max-w-0 md:overflow-hidden md:border-e-0 md:opacity-0 md:pointer-events-none"
: ""
} ${!isResizingLeft ? "transition-[transform,width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`}
>
<div
onPointerDown={startResizingLeft}
className="absolute -right-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
/>
<SidebarHeader
activityMode={activityMode}
onToggleActivityMode={toggleActivityMode}
createMenuOpen={createMenuOpen}
onCreateMenuOpenChange={setCreateMenuOpen}
onCloseMobileSidebar={() => setMobileSidebarOpen(false)}
bots={bots}
onCreateBot={() => setPanel("create")}
onOpenBot={(id) => navigate(`/app/${id}`)}
onCreateGroup={() => setPanel("create-group")}
onCreateSpace={() => openNewSpaceDialog("space")}
onShowGroupInfo={() => setPickerInfoTopic("group")}
onShowSpaceInfo={() => setPickerInfoTopic("space")}
/>
<InputGroup
data-testid="sidebar-search"
className="mx-3 lg:mx-5 2xl:mx-7 mb-3 w-auto rounded-xl bg-card dark:bg-input border border-border text-muted-foreground focus-within:border-ring"
>
<InputGroupAddon>
<Search strokeWidth={1.8} aria-hidden="true" className="size-icon-md" />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t`Search`}
autoComplete="off"
name="sidebar-search"
/>
</InputGroup>
<div className="rk-scroll flex flex-1 flex-col gap-0.5 overflow-y-auto px-3 lg:px-5 2xl:px-7 pb-2.5">
{showSpaceSearch ? (
<SpaceSearchResults
hits={searchHits}
loading={searchLoading}
onSelect={(hit) => void jumpToSearchHit(hit)}
/>
) : (
<>
{activityMode ? (
<ActivityList
onOpenRun={(run) => {
setMobileSidebarOpen(false);
if (run.groupId) navigate(`/app/g/${run.groupId}`);
else navigate(`/app/${run.botId}`);
}}
/>
) : null}
<div className="mb-3">
<button
type="button"
aria-label={
heuteActionCount > 0 ? t`Today, ${heuteActionCount} need you` : t`Today`
}
onClick={() => {
ooSound.play("interact");
setActiveNavPillar("heute");
setMobileSidebarOpen(false);
navigate(HEUTE_APP_PATH);
}}
className={rosterRowClass(activeNavPillar === "heute")}
>
<div className="relative grid size-control-lg shrink-0 place-items-center rounded-full bg-muted text-muted-foreground">
<Calendar strokeWidth={2.2} className="size-icon-md" />
{heuteActionCount > 0 ? (
<span
aria-hidden="true"
className="absolute -right-0.5 -top-0.5 grid min-w-[18px] place-items-center rounded-full bg-warning px-1 text-ui-label font-bold leading-none text-background"
>
{heuteActionCount > 9 ? "9+" : heuteActionCount}
</span>
) : null}
</div>
<div className="font-semibold text-foreground/90 tracking-tight text-ui-body">
<Trans>Today</Trans>
</div>
</button>
</div>
<div className="sidebar-unterhaltungen">
<div className="flex items-center justify-between pt-4 pb-2 px-2.5">
<div className="rk-shell-section-label">{t`Talk to your Oos`}</div>
<div className="flex items-center">
<Popover open={conversationPickerOpen} onOpenChange={setConversationPickerOpen}>
<PopoverTrigger
type="button"
aria-label={t`New conversation`}
data-testid="conversation-picker-trigger"
className="rk-touch-target rk-sidebar-muted hover:text-foreground/90 transition-colors"
>
<Plus strokeWidth={2.5} className="size-icon-md" />
</PopoverTrigger>
{conversationPickerOpen ? (
<PopoverContent
align="end"
className="w-auto gap-0 overflow-hidden p-0 data-closed:animate-none"
>
<ConversationBotPicker
bots={bots}
onCreateBot={() => {
setConversationPickerOpen(false);
setPanel("create");
}}
onOpenBot={openConversationBot}
/>
</PopoverContent>
) : null}
</Popover>
</div>
</div>
{mainConversationEntry ? (
<div data-sidebar-group="main">
<MainConversationRow
name={mainConversationEntry.group.name}
botId={mainConversationEntry.bot.id}
color={mainConversationEntry.bot.color}
status={mainConversationEntry.bot.status}
unread={mainConversationEntry.group.unread}
time={formatRosterTime(mainConversationEntry.group.updatedAt)}
subtitle={rosterActivitySubtitle(
mainConversationEntry.bot.status,
cleanRosterPreview(localizedPreview(mainConversationEntry.group)),
)}
active={inGroup && groupId === mainConversationEntry.groupId}
onOpen={openMainConversation}
/>
</div>
) : null}
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
<div className="p-4 text-center text-ui-label rk-sidebar-muted">
<p>{t`No conversations yet.`}</p>
<Button
variant="outline"
size="sm"
className="mt-2 text-ui-label"
onClick={() => setConversationPickerOpen(true)}
>
<Plus className="mr-1 size-icon-sm" />
{t`New conversation`}
</Button>
</div>
) : null}
{conversationSidebarGroups.map((group) => {
const collapsed = Boolean(group.title) && collapsedSidebarSections.has(group.key);
const groupBotIds = group.bots.flatMap((item) =>
item.kind === "bot" ? [item.chat.id] : [],
);
return (
<div key={group.key} data-sidebar-group={group.key}>
{group.title ? (
<SidebarGroupHeader
variant="conversation"
group={group}
collapsed={collapsed}
activeSpaceId={bootstrapMe?.spaceId}
botSections={botSections}
spaces={spaces}
onOpenEmptySpace={(spaceId) => openSpaceChat(spaceId, "/onboarding")}
onToggle={toggleSidebarSection}
sectionMenuAnchor={sectionMenuAnchor}
onSectionMenu={setSectionMenu}
spaceMenuAnchor={spaceMenuAnchor}
onSpaceMenu={setSpaceMenu}
/>
) : null}
{!collapsed &&
group.bots
.slice(0, expandedTeamGroups[group.key] ? undefined : 4)
.map((item) => (
<TeamRosterRow
key={`${item.kind}:${item.chat.id}`}
item={item}
groupBotIds={groupBotIds}
draggedBotId={draggedBotId}
onDraggedBotIdChange={setDraggedBotId}
onReorder={reorderRosterBot}
selected={!inGroup && active?.id === item.chat.id}
onOpen={() => {
setActiveNavPillar("team");
openSpaceChat(item.chat.spaceId, `/app/${item.chat.id}`);
}}
activeSpaceId={bootstrapMe?.spaceId}
menuAnchor={botMenuAnchor}
onMenu={setBotMenu}
/>
))}
{!collapsed && group.bots.length > 4 && (
<button
type="button"
onClick={() =>
setExpandedTeamGroups((prev) => ({
...prev,
[group.key]: !prev[group.key],
}))
}
className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium rk-sidebar-muted hover:bg-sidebar-accent hover:text-foreground transition-colors"
>
{expandedTeamGroups[group.key]
? t`Show less`
: t`Show all ${group.bots.length}`}
</button>
)}
</div>
);
})}
<div className="sidebar-projekte">
{/* PROJEKTE SECTION */}
<div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-sidebar-border px-2.5">
<div>
<div className="rk-shell-section-label">{t`Projects`}</div>
<p className="mt-0.5 px-0.5 text-ui-caption rk-sidebar-muted">
{t`Threads by topic · folders`}
</p>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
aria-label={t`Create a project`}
data-testid="create-project-trigger"
onClick={() => openNewSpaceDialog("project")}
className="rk-touch-target rk-sidebar-muted hover:text-foreground/90 transition-colors"
>
<Plus strokeWidth={2.5} className="size-icon-md" />
</button>
</div>
</div>
{projectSidebarGroups.length === 0 ? (
<div className="p-4 text-center text-ui-label rk-sidebar-muted">
<p>{t`No projects yet.`}</p>
<Button
variant="outline"
size="sm"
className="mt-2 text-ui-label"
onClick={() => openNewSpaceDialog("project")}
>
<Plus className="mr-1 size-icon-sm" />
{t`Create a project`}
</Button>
</div>
) : null}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleProjectDragEnd}
>
<SortableContext
items={projectSidebarGroups.filter((g) => g.title).map((g) => g.spaceId)}
strategy={verticalListSortingStrategy}
>
{projectSidebarGroups.map((group) => {
const collapsed =
Boolean(group.title) && collapsedSidebarSections.has(group.key);
return (
<SortableGroup key={`proj-${group.key}`} group={group}>
<div data-sidebar-group={group.key}>
{group.title ? (
<SidebarGroupHeader
variant="project"
group={group}
collapsed={collapsed}
activeSpaceId={bootstrapMe?.spaceId}
botSections={botSections}
spaces={spaces}
onOpenEmptySpace={(spaceId) => void startProject(spaceId)}
onToggle={toggleSidebarSection}
sectionMenuAnchor={sectionMenuAnchor}
onSectionMenu={setSectionMenu}
spaceMenuAnchor={spaceMenuAnchor}
onSpaceMenu={setSpaceMenu}
/>
) : null}
{!collapsed && group.startSpaceId ? (
<div className="px-3 pb-2">
<Button
variant="outline"
size="sm"
className="w-full text-ui-label"
data-testid={`project-start-${group.spaceId}`}
onClick={() => void startProject(group.spaceId)}
>
<Plus className="mr-1 size-icon-sm" />
{t`Start`}
</Button>
</div>
) : null}
{!collapsed &&
group.bots.map((item) => (
<ProjectRosterRow
key={`${item.kind}:${item.chat.id}`}
item={item}
selected={
item.kind === "bot"
? !inGroup && active?.id === item.chat.id
: inGroup && activeGroup?.id === item.chat.id
}
onOpen={() => {
setActiveNavPillar("projekte");
openSpaceChat(
item.chat.spaceId,
item.kind === "bot"
? `/app/${item.chat.id}`
: `/app/g/${item.chat.id}`,
);
}}
activeSpaceId={bootstrapMe?.spaceId}
menuAnchor={botMenuAnchor}
onMenu={setBotMenu}
/>
))}
</div>
</SortableGroup>
);
})}
</SortableContext>
</DndContext>
</div>
</div>
</>
)}
{archivedBots.length + archivedGroups.length > 0 && !showSpaceSearch ? (
<ArchivedSection
bots={archivedBots}
groups={archivedGroups}
open={archivedOpen}
onToggle={() => setArchivedOpen((open) => !open)}
onRestoreBot={(bot) =>
void rpc.bots.restore({ botId: bot.id }).then(() => refreshBots(true))
}
onRestoreGroup={(group) =>
void rpc.groups.restore({ groupId: group.id }).then(() => refreshBots(true))
}
onDeleteBot={setDeleteTarget}
onDeleteGroup={setDeleteGroupTarget}
/>
) : null}
</div>
<UserMenu
open={menuOpen}
onOpenChange={setMenuOpen}
initials={initials}
userName={userName}
onOpenSettings={() => openSettings("general")}
onOpenUsage={() => {
void rpc.usage
.summary()
.then(setUsage)
.catch(() => undefined);
openSettings("usage");
}}
onLogOut={() =>
// This browser stops receiving the account's pushes once it signs out.
void disableWebPush()
.catch(() => undefined)
.then(() => authClient.signOut())
.then(() => {
clearSpaceSelection();
navigate("/");
})
}
/>
</aside>
<button
type="button"
data-testid="bots-sidebar-edge"
aria-label={botsSidebarCollapsed ? t`Show bots` : t`Hide bots`}
aria-pressed={!botsSidebarCollapsed}
className={`absolute inset-y-0 z-50 hidden w-2 cursor-ew-resize touch-none border-0 bg-transparent p-0 md:block ${
botsSidebarCollapsed ? "start-0" : "start-[308px]"
}`}
onPointerDown={onBotsSidebarEdgePointerDown}
onPointerMove={onBotsSidebarEdgePointerMove}
onPointerUp={onBotsSidebarEdgePointerUp}
onPointerCancel={onBotsSidebarEdgePointerCancel}
/>
<main
aria-hidden={mobileSidebarOpen || undefined}
inert={mobileSidebarOpen}
className="flex min-w-0 flex-1 flex-col bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]"
>
{activeNavPillar === "heute" ? (
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="app-drag flex items-center border-b border-sidebar-border px-3 py-[17px] md:px-[22px] lg:px-8 2xl:px-12">
<div className="flex min-w-0 items-center gap-2">
<SidebarToggleButtons
collapsed={botsSidebarCollapsed}
onCollapsedChange={setBotsSidebarCollapsedPref}
onOpenNavigation={() => setMobileSidebarOpen(true)}
/>
</div>
</div>
<HeuteBrief
bots={bots}
groups={groups}
onOpenBot={(botId, spaceId) => {
setActiveNavPillar("team");
openSpaceChat(spaceId, `/app/${botId}`);
}}
onOpenGroup={(groupId, spaceId) => {
setActiveNavPillar("projekte");
openSpaceChat(spaceId, `/app/g/${groupId}`);
}}
onOpenRun={(run) => {
if (run.groupId) {
setActiveNavPillar("projekte");
navigate(`/app/g/${run.groupId}`);
} else {
setActiveNavPillar("team");
navigate(`/app/${run.botId}`);
}
}}
onStartChat={() => {
const first = bots[0];
if (first) {
setActiveNavPillar("team");
openSpaceChat(first.spaceId, `/app/${first.id}`);
return;
}
setCreateMenuOpen(true);
}}
/>
</div>
) : (
<>
<div className="app-drag flex items-center justify-between border-b border-sidebar-border px-3.5 py-2 md:px-6 md:py-3 lg:px-8 lg:py-4 2xl:px-12 2xl:py-6 bg-background/80 backdrop-blur-md">
<div className="flex min-w-0 items-center gap-2.5">
<SidebarToggleButtons
collapsed={botsSidebarCollapsed}
onCollapsedChange={setBotsSidebarCollapsedPref}
onOpenNavigation={() => setMobileSidebarOpen(true)}
/>
{inGroup && projectSessionKey ? (
<button
type="button"
aria-label={t`Back to timeline`}
onClick={closeProjectSession}
className="app-no-drag grid size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent"
>
<ArrowLeft strokeWidth={1.8} aria-hidden="true" className="size-icon-lg" />
</button>
) : null}
<button
type="button"
data-testid="bot-settings-trigger"
onClick={() => setPanel(inGroup ? "group-settings" : "settings")}
className="app-no-drag flex min-w-0 items-center gap-3"
>
{!inGroup && active ? (
<BotAvatar
color={active.color}
identity={active.id}
size={36}
status={active.status}
className="shrink-0"
/>
) : inGroup ? (
<GroupAvatar
members={activeGroup?.members ?? activeSnapshot?.members ?? []}
size={36}
className="shrink-0"
/>
) : null}
<div className="min-w-0 text-start flex flex-col justify-center">
<span
className="block truncate text-ui-body font-semibold text-foreground tracking-tight leading-snug"
dir="auto"
>
{inGroup
? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`)
: (active?.name ?? t`Choose a conversation`)}
</span>
{!inGroup && active && (composerRunning || active.status !== "idle") ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{composerRunning || active.status === "running" ? t`Busy` : active.status}
</span>
) : multiOoInGroup && projectSessionKey ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`Group conversation`}
</span>
) : inGroup &&
activeGroup?.kind === "main" &&
activeGroupMemberCount <= 1 &&
mainConversationEntry ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`1:1 with ${mainConversationEntry.bot.name}`}
</span>
) : inGroup && activeGroup?.kind !== "main" ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`Project · sessions & folders`}
</span>
) : null}
</div>
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
title={t`Browser`}
aria-label={t`Browser`}
onClick={() => setPanel(panel === "browser" ? null : "browser")}
data-active={panel === "browser" ? "" : undefined}
className="app-no-drag rk-touch-target grid size-control-sm min-h-control-sm min-w-control-sm place-items-center rounded-[9px] hover:bg-accent data-active:bg-accent"
>
<Globe strokeWidth={1.7} className="text-foreground/75 size-icon-lg" />
</button>
{!inGroup && active ? (
<button
type="button"
title={t`Agent computer`}
aria-label={t`Agent computer`}
onClick={() => {
const next = panel === "computer" ? null : "computer";
if (next === "computer" && transcriptRunning) learnScreenPane("opened");
setPanel(next);
if (next === "computer" && active) {
// Refresh run/computer so Take control isn't stuck on a stale busyBotName.
void refreshThread(active.id).catch(() => undefined);
}
}}
data-active={panel === "computer" ? "" : undefined}
className="app-no-drag rk-touch-target grid size-control-sm min-h-control-sm min-w-control-sm place-items-center rounded-[9px] hover:bg-accent data-active:bg-accent"
>
<PanelRight strokeWidth={1.7} className="text-foreground/75 size-icon-lg" />
</button>
) : null}
</div>
</div>
{!active && !activeGroup && emptyProjectSpace && initialBotsLoaded ? (
<div className="grid flex-1 place-items-center">
<Button
data-testid="project-start-main"
onClick={() => currentSpaceId && void startProject(currentSpaceId)}
>
<Plus aria-hidden="true" className="size-icon-md" />
{t`Start`}
</Button>
</div>
) : !active && !activeGroup && initialBotsLoaded ? (
<div className="grid flex-1 place-items-center">
<Button onClick={() => setShowCreateBotModal(true)}>
<Plus aria-hidden="true" className="size-icon-md" />
<Trans>Create new Bot</Trans>
</Button>
</div>
) : showProjectTimeline && bots.length > 0 ? (
<ProjectSessionTimeline
sectionId={activeGroup!.sectionId!}
sectionName={activeProjectSection?.name ?? activeGroup?.name ?? t`Project`}
groupId={groupId!}
groupName={activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`}
onOpenSession={openProjectSession}
onStartSession={openProjectChat}
onOpenGroupChat={openProjectGroupChat}
onStartVoiceFirst={(goalGroupId) => openProjectVoiceFirst(goalGroupId)}
/>
) : showProjectTimeline && bots.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12">
<p className="text-ui-body text-muted-foreground">
<Trans>Create a bot first to start a project.</Trans>
</p>
<Button onClick={() => setShowCreateBotModal(true)}>
<Plus aria-hidden="true" className="size-icon-md" />
<Trans>Create new Bot</Trans>
</Button>
</div>
) : (
<>
{!callOpen && active && !inGroup ? (
<OoStage
botId={active.id}
botName={active.name}
botTitle={active.title}
botDescription={active.description}
botColor={active.color}
botStatus={active.status}
snapshot={activeSnapshot}
running={transcriptRunning}
hasMessages={transcriptMessages.length > 0}
/>
) : null}
{!callOpen && inGroup ? (
<OoGroupStage
groupName={activeGroup?.name ?? activeSnapshot?.groupName}
snapshot={activeSnapshot}
enrichMember={enrichStageMember}
/>
) : null}
<Transcript
key={activeSnapshot?.threadId}
encounterMode={encounterMode}
voiceEncounterActive={voiceEncounterActive}
scrollRef={messageScroll}
artifactTarget={transcriptArtifactTarget}
onOpenArtifact={wideLayout ? openArtifactBeside : undefined}
messages={transcriptMessages}
olderCursor={activeSnapshot?.olderCursor ?? null}
loadingOlder={loadingOlder}
answerableAskMessageId={answerableAskMessageId}
running={transcriptRunning}
workingBots={workingBots}
onLoadOlder={loadOlder}
onOpenBot={openBot}
onOpenGroup={openGroup}
onAnswer={answerMessage}
onCardAct={actOnCard}
onAutopilot={actOnAutopilot}
onReply={(message) => {
setReplyTarget(message);
setReplyQuote(null);
}}
onQuote={(message, quote) => {
setReplyTarget(message);
setReplyQuote(quote);
}}
onReact={reactToMessage}
onJumpToMessage={jumpToReplyMessage}
onOpenPeerMessages={(peer) => {
setPeerConversation(peer);
}}
memberName={resolveTranscriptMemberName}
peerBot={resolveSpeakerBot}
onRefresh={refreshActiveThread}
onBotChanged={refreshBots}
onAddRoutine={addSkillRoutine}
voiceReady={Boolean(voiceStatus?.ready)}
speakingMessageId={speakingMessageId}
onSpeak={speakMessage}
/>
</>
)}
{activeGroup?.kind === "main" && !showProjectTimeline ? (
<MainInbox
bots={bots}
onOpenBot={openBot}
onOpenGroup={openGroup}
onOpenInSpace={openSpaceChat}
/>
) : null}
{recordingSkill ? (
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">
<Trans>Teaching in progress. Stop teaching before sending a new message.</Trans>
</div>
) : null}
{(active || activeGroup) && !showProjectTimeline ? (
<Composer
key={inGroup ? `group:${groupId}` : `bot:${active?.id}`}
running={composerRunning}
disabled={Boolean(recordingSkill)}
pendingAttachments={activePendingAttachments}
attachmentNotice={attachmentNotice}
sendError={sendError}
runError={displayedRunError}
runErrorId={displayedRunErrorId}
onRunErrorPresented={handleRunErrorPresented}
onDismissError={dismissComposerError}
sending={sending}
fileInputRef={fileInputRef}
onAttachmentPick={onAttachmentPick}
onRemoveAttachment={removeAttachment}
onSend={sendMessage}
onStop={stopRun}
onVoice={
active || inGroup
? () => {
startVoiceEncounter();
}
: undefined
}
voiceReady={Boolean(voiceStatus?.ready)}
voiceColor={voiceCallBot?.color}
replyTarget={activeReplyTarget}
replyQuote={activeReplyQuote}
replyTargetName={replyTargetName}
onClearReply={clearReply}
mentionTargets={composerMentionTargets}
agentSkills={agentSkills}
onSlashOpen={refreshAgentSkills}
onConnectModel={bootstrapMe?.needsModel ? () => openSettings("models") : undefined}
onAutopilot={startAutopilot}
onSlashAction={(action) => {
if (action === "chat-settings") {
setPanel(inGroup ? "group-settings" : "settings");
return;
}
if (action === "settings-general") {
openSettings("general");
return;
}
if (action === "settings-usage") {
void rpc.usage
.summary()
.then(setUsage)
.catch(() => undefined);
openSettings("usage");
}
}}
/>
) : null}
</>
)}
</main>
<aside
data-testid="side-panel"
data-panel={panel ?? "closed"}
data-auto={panel !== null && panel === autoPanel ? "" : undefined}
style={{
width:
panel &&
(active ||
activeGroup ||
panel === "create" ||
panel === "create-group" ||
panel === "browser")
? workspacePaneOpen
? workspacePaneWidth
: rightSidebarWidth
: 0,
}}
className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl md:relative ${
panel &&
(
active ||
activeGroup ||
panel === "create" ||
panel === "create-group" ||
panel === "browser"
)
? "max-w-[384px] md:max-w-none opacity-100"
: "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0"
} ${!(workspacePaneOpen ? isResizingWorkspacePane : isResizingRight) ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none" : ""}`}
>
<div
onPointerDown={workspacePaneOpen ? startResizingWorkspacePane : startResizingRight}
className="absolute -left-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
/>
{panel === "artifact" ? (
artifactPane ? (
<ArtifactPane
key={artifactPane.artifactId}
artifact={artifactPane}
onClose={() => setPanel(null)}
/>
) : null
) : panel === "browser" ? (
<InAppBrowser onClose={() => setPanel(null)} />
) : panel && (active || activeGroup || panel === "create" || panel === "create-group") ? (
<div className="rk-scroll h-full w-full overflow-y-auto px-5 lg:px-7 2xl:px-9 py-[17px]">
{panel !== "routine" &&
panel !== "create" &&
panel !== "create-group" &&
panel !== "group-settings" ? (
<SidePanelHeader
panel={panel}
active={active}
computer={computer}
computerOpen={computerOpen}
booting={booting}
refreshThread={refreshThread}
onPanelChange={setPanel}
/>
) : null}
{panel === "computer" && active ? (
<div>
<ComputerPanelPreview
active={active}
computer={computer}
computerOpen={computerOpen}
booting={booting}
embeddedScreenUrl={embeddedScreenUrl}
computerScreenError={computerScreenError}
sandboxProvider={bootstrapMe?.sandboxProvider}
onOpen={() => void openComputer()}
/>
<RoutineListHeader
onCreate={() => {
setRoutineDraft(emptyRoutineDraft());
setRoutineWebhookSecret(null);
setEditingRoutine(null);
setRoutineError(null);
setPanel("routine");
}}
/>
{activeRoutines.map((routine) => {
const routineRunning =
snapshot?.run?.routineId === routine.id && isActive(snapshot.run.status);
return (
<RoutineListRow
key={routine.id}
routine={routine}
running={routineRunning}
onOpen={() => {
setRoutineDraft(draftFromRoutine(routine));
setRoutineWebhookSecret(null);
setEditingRoutine(routine);
setRoutineError(null);
setPanel("routine");
}}
onStop={() => void stopRun()}
/>
);
})}
</div>
) : null}
{panel === "create-group" ? (
<CreateGroupForm
bots={emptyProjectSpace ? (homeSpaceBots as Bot[]) : bots}
onCancel={() => setPanel(null)}
onCreate={(input) => createGroup(input)}
/>
) : null}
{panel === "group-settings" && activeGroup ? (
<GroupSettings
key={activeGroup.id}
group={activeGroup}
bots={bots}
onSave={async (input) => {
const updated = await rpc.groups.update({ groupId: activeGroup.id, ...input });
setGroups((current) =>
current.map((group) => (group.id === updated.id ? updated : group)),
);
setPanel(null);
await Promise.all([refreshBots(), refreshGroupThread(activeGroup.id)]).catch(
() => undefined,
);
}}
onRemove={async () => {
await rpc.groups.remove({ groupId: activeGroup.id });
const remainingGroups = groups.filter((group) => group.id !== activeGroup.id);
setGroups(remainingGroups);
setPanel(null);
navigate(
firstThreadRoute(bots, remainingGroups, currentSpaceId !== homeSpaceId),
{ replace: true },
);
await refreshBots().catch(() => undefined);
}}
/>
) : null}
{panel === "create" ? (
<CreateBotForm
onCancel={() => setPanel(null)}
onCreate={(input) => createBot(input)}
onImport={(manifest) => importBot(manifest)}
/>
) : null}
{panel === "settings" && active ? (
<BotSettings
key={active.id}
bot={active}
memoryProviderConfigured={memoryProviderConfig != null}
onSkillsChange={setAgentSkills}
onSave={async ({ computerMode, ...patch }) => {
if (computerMode !== active.computerMode) {
await rpc.bots.setComputer({
botId: active.id,
mode: computerMode,
});
}
await rpc.bots.update({ botId: active.id, ...patch });
await refreshBots();
}}
onExport={async () => {
const manifest = await rpc.export.bot({ botId: active.id });
const blob = new Blob([JSON.stringify(manifest, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${active.name.toLowerCase().replace(/\s+/g, "-")}-export.json`;
a.click();
URL.revokeObjectURL(url);
}}
onClear={() => setClearTarget({ kind: "bot", chat: active })}
/>
) : null}
{panel === "routine" && active ? (
<RoutineEditor
draft={routineDraft}
onChange={setRoutineDraft}
editing={editingRoutine}
timezone={editingRoutine?.timezone ?? localTimezone()}
webhook={{
path:
typeof window !== "undefined"
? `${window.location.origin}/api/v1/bots/${active.id}/webhook`
: `/api/v1/bots/${active.id}/webhook`,
secret: routineWebhookSecret,
configured: active.webhookConfigured || Boolean(routineWebhookSecret),
}}
githubPath={
typeof window !== "undefined"
? `${window.location.origin}/api/v1/bots/${active.id}/github`
: `/api/v1/bots/${active.id}/github`
}
messageProviders={messagingProviders}
saving={savingRoutine}
running={runningRoutine}
error={routineError}
onBack={() => setPanel("computer")}
onClose={() => setPanel(null)}
onEnsureWebhook={async () => {
await ensureWebhookSecret(active.id);
}}
onSave={async () => {
if (routineSavePending.current) return;
const targetBotId = active.id;
const targetRoutine = editingRoutine;
if (targetRoutine && targetRoutine.botId !== targetBotId) return;
if (
!routineDraft.schedules.length &&
!routineDraft.webhookEnabled &&
!routineDraft.githubEnabled &&
!routineDraft.messageProvider
) {
setRoutineError(t`Add a schedule, webhook, GitHub, or message trigger`);
return;
}
const saveRequest = ++routineSaveRequest.current;
routineSavePending.current = true;
setSavingRoutine(true);
setRoutineError(null);
try {
if (
(routineDraft.webhookEnabled || routineDraft.githubEnabled) &&
!active.webhookConfigured &&
!routineWebhookSecret
) {
await ensureWebhookSecret(targetBotId);
}
const crons = routineDraft.schedules.map(cronFromPreset);
let saved: Routine;
if (targetRoutine) {
const armOneShot = routineNeedsOneShotArm(targetRoutine, crons);
let runAt: string | undefined;
if (armOneShot) {
if (!routineDraft.runAtLocal) {
setRoutineError(t`Add a run time for this one-shot.`);
return;
}
const parsed = new Date(routineDraft.runAtLocal);
if (!Number.isFinite(parsed.getTime()) || parsed.getTime() <= Date.now()) {
setRoutineError(t`Run time must be in the future.`);
return;
}
runAt = parsed.toISOString();
}
saved = await rpc.routines.update({
routineId: targetRoutine.id,
name: routineSaveName(targetRoutine, routineDraft.name) || t`Routine`,
prompt: routineDraft.prompt || t`Check in.`,
crons,
active: armOneShot ? true : routineDraft.active,
webhookEnabled: routineDraft.webhookEnabled,
githubEnabled: routineDraft.githubEnabled,
messageProvider: routineDraft.messageProvider,
...(runAt ? { runAt } : {}),
});
} else {
saved = await rpc.routines.create({
botId: targetBotId,
name: routineDraft.name || t`Routine`,
prompt: routineDraft.prompt || t`Check in.`,
crons,
timezone: localTimezone(),
active: routineDraft.active,
notify: true,
webhookEnabled: routineDraft.webhookEnabled,
githubEnabled: routineDraft.githubEnabled,
messageProvider: routineDraft.messageProvider,
});
}
if (
routineSaveRequest.current === saveRequest &&
activeBotId.current === targetBotId
) {
setEditingRoutine(saved);
setRoutineDraft(draftFromRoutine(saved));
}
} catch (error) {
if (
routineSaveRequest.current !== saveRequest ||
activeBotId.current !== targetBotId
) {
return;
}
setRoutineError(
error instanceof Error ? error.message : t`Could not save routine`,
);
return;
} finally {
routineSavePending.current = false;
setSavingRoutine(false);
}
if (
routineSaveRequest.current !== saveRequest ||
activeBotId.current !== targetBotId
) {
return;
}
await refreshThread(targetBotId).catch(() => undefined);
}}
onTestRun={async () => {
if (routineRunPending.current) return;
const targetBotId = active.id;
const targetRoutine = editingRoutine;
if (!targetRoutine) return;
routineRunPending.current = true;
setRunningRoutine(true);
setRoutineError(null);
try {
await rpc.routines.testRun({ routineId: targetRoutine.id });
await refreshThread(targetBotId);
} catch (error) {
if (activeBotId.current === targetBotId) {
setRoutineError(
error instanceof Error ? error.message : t`Could not run routine`,
);
}
} finally {
routineRunPending.current = false;
setRunningRoutine(false);
}
}}
onDelete={() => {
if (editingRoutine) {
setDeleteRoutineTarget(editingRoutine);
return;
}
setPanel("computer");
}}
/>
) : null}
</div>
) : null}
</aside>
{/* Centered Create Bot Modal */}
{showCreateBotModal &&
createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<button
type="button"
className="absolute inset-0 bg-overlay"
aria-label={t`Close`}
onClick={() => setShowCreateBotModal(false)}
/>
<div className="relative z-10 max-h-[90vh] w-[480px] overflow-y-auto rounded-2xl border border-border bg-popover p-6 text-popover-foreground shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
<Trans>Create new Bot</Trans>
</h2>
<button
type="button"
onClick={() => setShowCreateBotModal(false)}
className="text-muted-foreground hover:text-foreground"
aria-label={t`Close`}
>
<X strokeWidth={1.8} className="size-icon-md" />
</button>
</div>
<CreateBotForm
onCancel={() => setShowCreateBotModal(false)}
onCreate={async (input) => {
await createBot(input);
}}
onImport={async (manifest) => {
await importBot(manifest);
setShowCreateBotModal(false);
}}
/>
</div>
</div>,
document.body,
)}
<Suspense fallback={null}>
{contextChat && botMenu ? (
<BotContextMenu
bot={contextChat}
position={botMenu.position}
onClose={closeBotMenu}
sections={botSections}
onTogglePinned={() => {
setBotMenu(null);
const request = contextBot
? rpc.bots.update({ botId: contextBot.id, pinned: !contextBot.pinned })
: rpc.groups.update({
groupId: contextGroup!.id,
pinned: !contextGroup!.pinned,
});
void request.then(() => refreshBots());
}}
onToggleUnread={() => {
const unread = !contextChat.unread;
setBotMenu(null);
if (contextBot) {
const request = unread ? markBotUnread(contextBot.id) : markBotRead(contextBot.id);
void request.catch(() => undefined);
} else {
const request = unread
? rpc.threads.markUnread({ groupId: contextGroup!.id })
: rpc.threads.markRead({ groupId: contextGroup!.id });
void request
.then(() =>
setGroups((current) =>
current.map((group) =>
group.id === contextGroup!.id ? { ...group, unread } : group,
),
),
)
.catch(() => undefined);
}
}}
onMoveToSection={(sectionId) => {
setBotMenu(null);
if (sectionId === contextChat.sectionId) return;
const request = contextBot
? rpc.bots.update({ botId: contextBot.id, sectionId })
: rpc.groups.update({ groupId: contextGroup!.id, sectionId });
void request.then(() => refreshBots());
}}
onCreateSection={() => {
setNewSectionTarget(
contextBot
? { kind: "bot", chat: contextBot }
: { kind: "group", chat: contextGroup! },
);
setBotMenu(null);
}}
onRenameSection={(sectionId) => {
const section = botSections.find((item) => item.id === sectionId);
const spaceId = bootstrapMe?.spaceId;
if (section && spaceId) setRenameSectionTarget({ section, spaceId });
setBotMenu(null);
}}
onEdit={() => {
navigate(contextBot ? `/app/${contextBot.id}` : `/app/g/${contextGroup!.id}`);
setPanel(contextBot ? "settings" : "group-settings");
setBotMenu(null);
}}
onDuplicate={() => {
setBotMenu(null);
const request = contextBot
? rpc.bots.duplicate({ botId: contextBot.id })
: rpc.groups.duplicate({ groupId: contextGroup!.id });
void request.then(async (chat) => {
await refreshBots();
navigate(contextBot ? `/app/${chat.id}` : `/app/g/${chat.id}`);
});
}}
onClear={() => {
setClearTarget(
contextBot
? { kind: "bot", chat: contextBot }
: { kind: "group", chat: contextGroup! },
);
setBotMenu(null);
}}
onArchive={() => {
setBotMenu(null);
const request = contextBot
? rpc.bots.archive({ botId: contextBot.id })
: rpc.groups.archive({ groupId: contextGroup!.id });
void request.then(() => refreshBots(true));
}}
onDelete={() => {
if (contextBot) setDeleteTarget(contextBot);
else setDeleteGroupTarget(contextGroup!);
setBotMenu(null);
}}
/>
) : null}
{spaceMenu ? (
<PointerMenu
position={spaceMenu.position}
label={t`Actions for space`}
onClose={closeSpaceMenu}
>
<DropdownMenuItem
variant="destructive"
onClick={() => {
const target = spaces.find((space) => space.id === spaceMenu.id);
if (target) setDeleteSpaceTarget(target);
setSpaceMenu(null);
}}
>
<Trash2 />
{t`Delete space`}
</DropdownMenuItem>
</PointerMenu>
) : null}
{deleteTarget ? (
<DeleteBotDialog
bot={deleteTarget}
onCancel={() => setDeleteTarget(null)}
onConfirm={async (deleteMemories) => {
await rpc.bots.remove({ botId: deleteTarget.id, deleteMemories });
setDeleteTarget(null);
setPanel(null);
await refreshBots(true);
}}
/>
) : null}
{deleteGroupTarget ? (
<DeleteItemDialog
item={deleteGroupTarget}
noun="group"
onCancel={() => setDeleteGroupTarget(null)}
onConfirm={async () => {
await rpc.groups.remove({ groupId: deleteGroupTarget.id });
setDeleteGroupTarget(null);
setPanel(null);
await refreshBots(true);
}}
/>
) : null}
{deleteSpaceTarget ? (
<DeleteItemDialog
item={deleteSpaceTarget}
noun="space"
description={
<Trans>Only empty spaces can be deleted. Delete its bots and groups first.</Trans>
}
onCancel={() => setDeleteSpaceTarget(null)}
onConfirm={async () => {
const targetId = deleteSpaceTarget.id;
const result = await rpc.spaces.remove({ spaceId: targetId });
setDeleteSpaceTarget(null);
setPanel(null);
const effectiveSpaceId = selectedSpaceId() ?? bootstrapMe?.spaceId;
if (effectiveSpaceId === targetId) {
// The auth boundary changed, so reload like a space switch.
if (selectSpace(result.activeSpaceId)) {
window.location.assign("/app");
return;
}
}
await refreshBots(true);
}}
/>
) : null}
{newSectionTarget ? (
<NewBotSectionDialog
bot={newSectionTarget.chat}
onCancel={() => setNewSectionTarget(null)}
onConfirm={async (name) => {
await rpc.botSections.create(
newSectionTarget.kind === "bot"
? { botId: newSectionTarget.chat.id, name }
: { groupId: newSectionTarget.chat.id, name },
);
setNewSectionTarget(null);
await refreshBots();
}}
/>
) : null}
{renameSectionTarget ? (
<RenameBotSectionDialog
section={renameSectionTarget.section}
onCancel={() => setRenameSectionTarget(null)}
onConfirm={async (name) => {
await rpc.botSections.update(
{
sectionId: renameSectionTarget.section.id,
name,
},
{ context: { spaceId: renameSectionTarget.spaceId } },
);
setRenameSectionTarget(null);
await refreshBots();
}}
/>
) : null}
{sectionMenu ? (
<PointerMenu
position={sectionMenu.position}
label={t`Actions for ${sectionMenu.section.name}`}
onClose={closeSectionMenu}
>
<DropdownMenuItem
onClick={() => {
setRenameSectionTarget({
section: sectionMenu.section,
spaceId: sectionMenu.spaceId,
});
setSectionMenu(null);
}}
>
<Pencil />
{t`Rename section`}
</DropdownMenuItem>
</PointerMenu>
) : null}
<CommandPalette
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
bots={bots}
onSelectBot={(id) => {
setMobileSidebarOpen(false);
navigate(`/app/${id}`);
}}
/>
{newSpaceOpen ? (
<NewSpaceDialog
variant={newSpaceDialogVariant}
canLinkFolder={bootstrapMe?.isDeploymentOwner === true}
onCancel={() => setNewSpaceOpen(false)}
onConfirm={async ({ name, workspacePath }) => {
const space = await rpc.spaces.create({
name,
...(workspacePath ? { workspacePath } : {}),
});
setNewSpaceOpen(false);
if (!selectSpace(space.id)) {
await refreshBots();
return;
}
window.location.assign("/app");
}}
/>
) : null}
{pickerInfoTopic ? (
<PickerInfoDialog topic={pickerInfoTopic} onClose={() => setPickerInfoTopic(null)} />
) : null}
{clearTarget ? (
<ClearConversationDialog
bot={clearTarget.chat}
onCancel={() => setClearTarget(null)}
onConfirm={async () => {
await rpc.threads.clear(
clearTarget.kind === "bot"
? { botId: clearTarget.chat.id }
: { groupId: clearTarget.chat.id },
);
if (
(clearTarget.kind === "bot" && active?.id === clearTarget.chat.id) ||
(clearTarget.kind === "group" && activeGroup?.id === clearTarget.chat.id)
) {
expandedHistoryThread.current = null;
pinnedAroundRef.current = null;
historyEpoch.current += 1;
updateSnapshot((current) =>
current ? { ...current, messages: [], olderCursor: null, run: null } : current,
);
}
setClearTarget(null);
await refreshBots();
}}
/>
) : null}
{deleteRoutineTarget ? (
<DeleteItemDialog
item={deleteRoutineTarget}
noun="routine"
onCancel={() => setDeleteRoutineTarget(null)}
onConfirm={async () => {
const target = deleteRoutineTarget;
await rpc.routines.remove({ routineId: target.id });
setDeleteRoutineTarget(null);
setEditingRoutine((current) => (current?.id === target.id ? null : current));
if (activeBotId.current !== target.botId) return;
await refreshThread(target.botId);
if (activeBotId.current === target.botId) setPanel("computer");
}}
/>
) : null}
{pluginsOpen ? (
<PluginsOverlay
activeBotId={activeBotId.current}
onClose={() => setPluginsOpen(false)}
onOpenMcp={() => {
setPluginsOpen(false);
setMcpOpen(true);
}}
/>
) : null}
{mcpOpen ? <McpServersOverlay onClose={() => setMcpOpen(false)} /> : null}
{messagingSettingsOpen ? (
<MessagingSettingsOverlay onClose={() => setMessagingSettingsOpen(false)} />
) : null}
</Suspense>
<Suspense fallback={null}>
{settingsOpen ? (
<SettingsOverlay
name={userName}
email={session.data?.user.email}
usage={usage}
initialSection={settingsSection}
isDeploymentOwner={bootstrapMe?.isDeploymentOwner === true}
modelsManaged={bootstrapMe?.modelsManaged === true}
sandboxProvider={bootstrapMe?.sandboxProvider}
messagingEnabled={messagingSurfaceEnabled}
onOpenMessaging={() => {
setSettingsOpen(false);
setMessagingSettingsOpen(true);
}}
memoryConfig={memoryProviderConfig}
onMemoryConfigChange={(config) => {
memoryProviderConfigRevision.current += 1;
setMemoryProviderConfig(config);
}}
onVoiceStatusMaybeChanged={async () => {
try {
setVoiceStatus(
await Promise.race([rpc.voice.status(), voiceStatusRefreshTimeout()]),
);
} catch {
// Prefer reopening Voice settings over CallView with stale readiness.
setVoiceStatus(null);
}
}}
activeBotId={activeBotId.current}
onOpenMcp={() => {
setSettingsOpen(false);
setMcpOpen(true);
}}
onClose={() => {
setSettingsOpen(false);
setSettingsSection("general");
// A model connected in settings unblocks the composer.
if (bootstrapMe?.needsModel) {
void rpc
.me()
.then(setBootstrapMe)
.catch(() => undefined);
}
}}
/>
) : null}
{peerConversation && active ? (
<PeerMessagesOverlay
botId={active.id}
botName={active.name}
botColor={active.color}
peerBotId={peerConversation.peerBotId}
peerBotName={peerConversation.peerBotName}
peerBotColor={
resolveTranscriptBot(peerConversation.peerBotId)?.color ?? FALLBACK_BOT_COLOR
}
onClose={() => setPeerConversation(null)}
/>
) : null}
{callOpen && voiceCallBot ? (
<CallView
botId={voiceCallBot.id}
botName={voiceCallBot.name}
botTitle={voiceCallBot.title}
botDescription={voiceCallBot.description}
botColor={voiceCallBot.color}
botStatus={voiceCallBot.status}
transcribe={Boolean(voiceStatus?.transcribe)}
snapshot={activeSnapshot}
onSend={sendMessage}
onFollowUp={followUpMessage}
onAnswer={answerMessage}
onClose={closeVoiceStage}
/>
) : null}
</Suspense>
{booting ? (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-[22px] bg-background/95">
<div className="text-[19px] font-medium text-foreground">
<Trans>Booting up {active?.name}’s computer</Trans>
</div>
<div className="h-[5px] w-[min(420px,70%)] overflow-hidden rounded-full bg-accent">
<div className="h-full w-2/3 rounded-full bg-primary" />
</div>
</div>
) : computerOpen && active ? (
<FullscreenComputerOverlay
active={active}
computer={computer}
computerViewport={computerViewport}
embeddedScreenUrl={embeddedScreenUrl}
computerScreenError={computerScreenError}
hasControl={hasControl}
recordingSkill={recordingSkill}
teachBusy={teachBusy}
composerRunning={composerRunning}
sending={sending}
sendError={sendError}
onStopRun={stopRun}
onStopTeaching={stopTeaching}
onRelease={releaseComputer}
onRefreshTeaching={refreshActiveTeaching}
refreshThread={refreshThread}
onClose={() => setComputerOpen(false)}
/>
) : null}
</div>
);
return shell;
}