import { closestCenter, DndContext, DragOverlay, MouseSensor, TouchSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS as DndCSS } from "@dnd-kit/utilities"; import { i18n } from "@lingui/core"; import { t } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import { ChatMarkdown } from "@rakazo/chat-ui/web"; import type { AgentSkillCatalogEntry, Bot, BotSection, ComputerMode, ComputerReleaseReason, ComputerStatus, Connection, ConnectionCatalogItem, Group, MainConversationRef, Me, ProductEvent, Routine, SearchHit, Space, SpaceMemoryConfig, TaughtSkill, ThreadMessage, ThreadSnapshot, VoiceStatus, } from "@rakazo/contracts"; import { ATTACHMENT_ALLOWED_MIME_TYPES, ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_COUNT, canReactToThreadMessage, MESSAGE_REACTIONS, type MessageReaction, normalizeCreateBotProfile, } from "@rakazo/contracts"; import { attachmentsForThread, buildComposerMentionOptions, type ComposerMention, clampMentionHighlightIndex, cronFromPreset, groupBotsForSidebar, inferAttachmentMimeType, isActive, isPeerReceiptBlocks, isRunTerminalEvent, isToolActivityBlock, latestAnswerableAskMessageId, matchVoiceByName, mentionChipKey, parseVoiceChatCommand, projectMessageReactions, reorderBotTo, resolveComposerSendPlan, resolveMentionPickerKey, runThreadSubscription, SLASH_ACTIONS, type SlashActionId, searchHitThreadTarget, serializeComposerPrompt, speechFromBlocks, truncateSlashDescription, userVisibleMessages, } from "@rakazo/core"; import { AvatarStyleProvider, BotAvatar, Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, EvooMascot, GroupAvatar, type GroupAvatarMember, InputGroup, InputGroupAddon, InputGroupInput, Popover, PopoverContent, PopoverTrigger, resolvePersonaColorDef, Wordmark, } from "@rakazo/ui-web"; import { ArrowDown, ArrowLeft, ArrowUp, Bell, Box, Calendar, ChevronDown, Clock, Copy, Folder, Gauge, Globe, GripVertical, LayoutGrid, Lock, LogOut, Maximize2, Menu, Mic, MoreHorizontal, PanelLeftClose, PanelLeftOpen, PanelRight, Paperclip, Pencil, Plus, Puzzle, RefreshCw, Reply, Search, Settings, Smile, Sparkles, Square, TextQuote, Trash2, Volume2, X, } from "lucide-react"; import { type ClipboardEvent, type DragEvent, type MutableRefObject, memo, type RefObject, Suspense, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArtifactFileCard } from "../components/ArtifactFileCard"; import { AskCard } from "../components/AskCard"; import { ActiveBotGlyph, CollaborationMarker } from "../components/ai/CollaborationMarker"; import { DelegatedTaskCard, SentMessageCard } from "../components/ai/DelegatedTaskCard"; import { ProjectCreatedCard } from "../components/ai/ProjectCreatedCard"; import { InAppBrowser } from "../components/browser/InAppBrowser"; import { CloudAgentCard } from "../components/CloudAgentCard"; import { ComputerMaintenanceActions } from "../components/ComputerMaintenanceActions"; import { ComputersUnavailableHint, computersAreUnavailable, } from "../components/ComputersUnavailableHint"; import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress"; import { AgentCursor } from "../components/computer/AgentCursor"; import { DataTrustPanel } from "../components/DataTrustPanel"; import { MainInbox } from "../components/MainInbox"; import { MessageHoverMetadata } from "../components/MessageHoverMetadata"; import { OoGroupStage, OoStage } from "../components/stage/OoStage"; import { TradingModeIndicator } from "../components/TradingModeIndicator"; import { SkillDraftCard } from "../components/teach/SkillDraftCard"; import { TeachCaptureOverlay } from "../components/teach/TeachCaptureOverlay"; import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOverlay"; import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome"; import { VoiceAuroraRim } from "../components/voice/VoiceAurora"; import { useSidebarResize } from "../hooks/useSidebarResize"; import { readActivityMode, writeActivityMode } from "../lib/activity-mode"; import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity"; import { agentPointerFeed } from "../lib/agent-pointer"; import type { ArtifactTarget } from "../lib/artifact-open"; import { authClient } from "../lib/auth"; import { takeInitialBootstrap } from "../lib/bootstrap"; import { BOTS_SIDEBAR_EDGE_DRAG_PX, readBotsSidebarCollapsed, writeBotsSidebarCollapsed, } from "../lib/bots-sidebar-pref"; import { deliverBrowserNotification as deliverNativeBrowserNotification, requestBrowserNotificationPermission, shouldNotifyBrowser, } from "../lib/browser-notifications"; import { loadComputerScreen } from "../lib/computer-screen"; import { desktopBridge } from "../lib/desktop"; import { scheduleFocusPrompt } from "../lib/focus-prompt"; import { isWorkingRunStatus, resolveActiveGroupSpeaker, resolveWorkingGroupBots, } from "../lib/group-stage"; import { buildHeuteBrief } from "../lib/heute-brief"; import { lazyWithRecovery } from "../lib/lazy-recovery"; import { localTimezone } from "../lib/local-timezone"; import { copyableMessageText } from "../lib/message-text"; import { messageProviderLabel } from "../lib/messaging"; import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire"; import { ooSound } from "../lib/oo-sound"; import { ooVocals } from "../lib/oo-vocals"; import { ooVoiceMark } from "../lib/oo-voice-mark"; import { isFileDrag, isFilePaste, revokePendingAttachmentPreviews, } from "../lib/pending-attachments"; import { markAfterPaint, markOnce } from "../lib/performance"; import { isEmptyProjectSpace } from "../lib/project-empty-state"; import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order"; import { quoteDraftForSelection } from "../lib/quote-selection"; 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, computerPanelAutoBoot, computerPanelAutoUsesBoot, computerPanelNeedsMaintenance, computerTakeoverBlocked, isComputerStatusEvent, isThreadSnapshotEvent, prependThreadMessagePage, reconcileRefreshedThread, reduceComputerStatus, reduceThreadSnapshot, threadRunError, userHoldsComputerControl, } from "../lib/thread-events"; import { useTradingBotStatus } from "../lib/trading-bot-status"; import { transcriptCanSnapAfterFrame, transcriptIsNearEnd, transcriptMovedDown, } from "../lib/transcript-scroll"; import { speaker } from "../lib/tts"; 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 { BotSettings, CreateBotForm } from "./shell/bot-panel"; import { BotCreatePicker, ConversationBotPicker } from "./shell/bot-picker"; import { CommandPalette, isCommandPaletteHotkey } from "./shell/command-palette"; import { ClearConversationDialog, DeleteBotDialog, DeleteItemDialog, NewBotSectionDialog, NewSpaceDialog, PickerInfoDialog, RenameBotSectionDialog, } from "./shell/dialogs"; import { MainConversationRow } from "./shell/main-conversation-row"; import { AppConnectCard, ArtifactImage, ChartBlockView, ChoiceCard, McpApprovalCard, } from "./shell/message-cards"; import { rosterNameClass, rosterPreviewClass, rosterRowClass, rosterTimeClass, rosterUnreadDotClass, } from "./shell/roster-row-style"; import { WindowChrome } from "./WindowChrome"; 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" | null; type PendingAttachment = { id: string; threadKey: string; file: File; previewUrl?: string; }; type PendingBrowserNotification = { event: Pick; botId: string; botName: string; groupNotification: boolean; }; const ATTACHMENT_ACCEPT = ATTACHMENT_ALLOWED_MIME_TYPES.join(","); /** Identity colour for bots the roster no longer knows about. */ const FALLBACK_BOT_COLOR = "#85858A"; const THREAD_SNAPSHOT_TIMEOUT_MS = 2_000; /** Bound Settings leave so a hung voice status refresh cannot block dismissal. */ const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000; const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32; const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56; function threadSnapshotSignal(parent: AbortSignal): AbortSignal { return AbortSignal.any([parent, AbortSignal.timeout(THREAD_SNAPSHOT_TIMEOUT_MS)]); } function voiceStatusRefreshTimeout(): Promise { return new Promise((_, reject) => { AbortSignal.timeout(VOICE_STATUS_REFRESH_TIMEOUT_MS).addEventListener("abort", () => { reject(new DOMException("Voice status refresh timed out", "TimeoutError")); }); }); } function collapsedSidebarSectionsStorageKey(userId: string | null | undefined): string | null { if (!userId) return null; return `rakazo:collapsed-sidebar-sections:${userId}`; } function readCollapsedSidebarSections(userId: string | null | undefined): Set { const storageKey = collapsedSidebarSectionsStorageKey(userId); if (!storageKey) return new Set(); try { const value = window.localStorage.getItem(storageKey); const keys: unknown = value ? JSON.parse(value) : []; return new Set( Array.isArray(keys) ? keys.filter((key): key is string => typeof key === "string") : [], ); } catch { return new Set(); } } export type NavPillar = "heute" | "team" | "projekte"; function stripSpaceTitlePrefix(title: string | null, spaceName: string): string | null { if (!title) return null; const prefix = `${spaceName} · `; if (title.startsWith(prefix)) return title.slice(prefix.length); if (title === spaceName) return null; return title; } import { ProjectDragGrip, SortableGroup } from "../components/SortableGroup"; export function ShellPage() { const { t } = useLingui(); const { botId, groupId } = useParams(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const [activeNavPillar, setActiveNavPillar] = useState(() => { if (groupId) return "projekte"; if (botId) return "team"; return "heute"; }); const tradingStatus = useTradingBotStatus(); 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([]); const [bots, setBots] = useState([]); const botsRef = useRef(bots); botsRef.current = bots; const botOrderEpochRef = useRef(0); const pendingBotOrderRef = useRef(null); const savingBotOrderRef = useRef(false); const [botSections, setBotSections] = useState([]); const [spaces, setSpaces] = useState([]); const [mainConversation, setMainConversation] = useState(null); const [archivedBots, setArchivedBots] = useState([]); const [archivedGroups, setArchivedGroups] = useState([]); const { width: leftSidebarWidth, isResizing: isResizingLeft, startResizing: startResizingLeft, } = useSidebarResize(316, "left", 240, 450); const { width: rightSidebarWidth, isResizing: isResizingRight, startResizing: startResizingRight, } = useSidebarResize(384, "right", 280, 500); const [heuteActionCount, setHeuteActionCount] = useState(0); const [archivedOpen, setArchivedOpen] = useState(false); const [expandedTeamGroups, setExpandedTeamGroups] = useState>({}); const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set()); useEffect(() => { setCollapsedSidebarSections(readCollapsedSidebarSections(userId)); }, [userId]); useEffect(() => { setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId)); }, [userId]); const [panel, setPanel] = useState(null); const [showCreateBotModal, setShowCreateBotModal] = useState(false); // Persisted project (space) sort order const [projectOrder, setProjectOrder] = useState(() => readProjectSidebarOrder()); const handleReorderProjects = useCallback((orderedIds: string[]) => { setProjectOrder(orderedIds); writeProjectSidebarOrder(orderedIds); }, []); const [draggedProjectId, setDraggedProjectId] = useState(null); const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), useSensor(TouchSensor, { activationConstraint: { distance: 8 } }), ); const [query, setQuery] = useState(""); const [searchHits, setSearchHits] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [snapshot, setSnapshot] = useState(null); const snapshotRef = useRef(null); const [pendingAttachments, setPendingAttachments] = useState([]); const [replyTarget, setReplyTarget] = useState(null); const [replyQuote, setReplyQuote] = useState(null); const [sending, setSending] = useState(false); const sendsInFlight = useRef(0); const [sendError, setSendError] = useState(null); const [attachmentNotice, setAttachmentNotice] = useState(null); // Safety watchdog: ensure sending state is never stuck indefinitely if an RPC hangs or errors out useEffect(() => { if (!sending) return; const timer = setTimeout(() => { sendsInFlight.current = 0; setSending(false); }, 10_000); return () => clearTimeout(timer); }, [sending]); const fileInputRef = useRef(null); const [peerConversation, setPeerConversation] = useState<{ peerBotId: string; peerBotName: string; } | null>(null); const [routines, setRoutines] = useState([]); const [routinesBotId, setRoutinesBotId] = useState(null); const [taughtSkills, setTaughtSkills] = useState([]); const [taughtSkillsBotId, setTaughtSkillsBotId] = useState(null); const [agentSkills, setAgentSkills] = useState([]); const [mentionRoutines, setMentionRoutines] = useState>([]); const [mentionConnectors, setMentionConnectors] = useState< Array<{ id: string; name: string; authStatus: "connected" | "needs_auth"; connectionId?: string; }> >([]); const [teachBusy, setTeachBusy] = useState(false); const [computer, setComputer] = useState(null); const computerRef = useRef(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()); // 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(), ); // 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("general"); const [messagingSettingsOpen, setMessagingSettingsOpen] = useState(false); const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false); const [messagingProviders, setMessagingProviders] = useState([]); const [memoryProviderConfig, setMemoryProviderConfig] = useState< SpaceMemoryConfig | null | undefined >(undefined); const memoryProviderConfigRevision = useRef(0); const [callOpen, setCallOpen] = useState(false); const [voiceStatus, setVoiceStatus] = useState(null); const [speakingMessageId, setSpeakingMessageId] = useState(null); const [dismissedRunErrorIds, setDismissedRunErrorIds] = useState>(readSeenRunErrorIds); const [menuOpen, setMenuOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const mobileSidebarSwipeRef = useRef<{ startX: number; startY: number } | null>(null); const [draggedBotId, setDraggedBotId] = useState(null); const [createMenuOpen, setCreateMenuOpen] = useState(false); const [conversationPickerOpen, setConversationPickerOpen] = useState(false); const [botsSidebarCollapsed, setBotsSidebarCollapsed] = useState(false); const focusPromptAbortRef = useRef(null); const focusPromptBotIdRef = useRef(null); const botsSidebarEdgeDragRef = useRef<{ startX: number; mode: "expand" | "collapse" } | 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); useEffect(() => { const desktop = window.matchMedia("(min-width: 768px)"); function closeMobileSidebar() { if (desktop.matches) setMobileSidebarOpen(false); } closeMobileSidebar(); desktop.addEventListener("change", closeMobileSidebar); return () => desktop.removeEventListener("change", closeMobileSidebar); }, []); 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(null); useEffect(() => { if (botMenu || !botMenuAnchor.current) return; botMenuAnchor.current.focus(); botMenuAnchor.current = null; }, [botMenu]); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteGroupTarget, setDeleteGroupTarget] = useState(null); const [deleteSpaceTarget, setDeleteSpaceTarget] = useState(null); const [spaceMenu, setSpaceMenu] = useState<{ id: string; position: ContextMenuPosition; } | null>(null); const spaceMenuAnchor = useRef(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(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(); const [routineDraft, setRoutineDraft] = useState(emptyRoutineDraft()); const [routineWebhookSecret, setRoutineWebhookSecret] = useState(null); const [editingRoutine, setEditingRoutine] = useState(null); const [deleteRoutineTarget, setDeleteRoutineTarget] = useState(null); const [savingRoutine, setSavingRoutine] = useState(false); const [runningRoutine, setRunningRoutine] = useState(false); const [routineError, setRoutineError] = useState(null); const [screenUrl, setScreenUrl] = useState(null); const [computerOpen, setComputerOpen] = useState(false); const [computerViewport, setComputerViewport] = useState<{ height: number; offsetTop: number; } | null>(null); const [computerError, setComputerError] = useState(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); useEffect(() => { if (!computerOpen) { setComputerViewport(null); return; } const viewport = window.visualViewport; if (!viewport) return; const sync = () => { const height = Math.round(viewport.height); const offsetTop = Math.round(viewport.offsetTop); // Desktop scrollbars and sub-pixel viewport events must not rewrite the // overlay height: that feedback loop moves the maintenance menu forever. const keyboardInset = Math.round(window.innerHeight) - height - offsetTop; if (keyboardInset < 80) { setComputerViewport((current) => (current === null ? current : null)); return; } setComputerViewport((current) => { if (current && current.height === height && current.offsetTop === offsetTop) return current; return { height, offsetTop }; }); }; sync(); viewport.addEventListener("resize", sync); viewport.addEventListener("scroll", sync); return () => { viewport.removeEventListener("resize", sync); viewport.removeEventListener("scroll", sync); }; }, [computerOpen]); useEffect(() => { if (!session.data?.user) return; let cancelled = false; void rpc.messaging .status() .then((status) => { if (!cancelled) { setMessagingSurfaceEnabled(status.enabled); setMessagingProviders(status.providers); } }) .catch(() => undefined); return () => { cancelled = true; }; }, [session.data?.user]); const [usage, setUsage] = useState<{ inputTokens: number; outputTokens: number; runs: number; } | null>(null); const autoBooted = useRef(null); const routineSavePending = useRef(false); const webhookSecretProvisionRef = useRef(new Map>()); 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(null); const expandedHistoryThread = useRef(null); const historyEpoch = useRef(0); const jumpGeneration = useRef(0); const initiallyScrolledThread = useRef(null); const messageScroll = useRef(null); const pinnedAroundRef = useRef<{ botId?: string; groupId?: string; messageId: string; threadId: string; messages: ThreadMessage[]; olderCursor: number | null; } | null>(null); const manuallyUnread = useRef(new Set()); const readVisibleGroups = useRef(new Set()); const notifiedBrowserEvents = useRef(new Set()); const pendingBrowserNotifications = useRef(new Map()); const computerVisible = useRef(false); computerVisible.current = panel === "computer" || computerOpen; const autoSpoken = useRef(null); const autoSpokenBotId = useRef(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(botId); routeBotId.current = botId; const routeGroupId = useRef(groupId); routeGroupId.current = groupId; const activeBotId = useRef(inGroup ? undefined : active?.id); activeBotId.current = inGroup ? undefined : active?.id; const activeGroupId = useRef(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 deliverBrowserNotification = useCallback((pending: PendingBrowserNotification): boolean => { const currentBot = botsRef.current.find((bot) => bot.id === pending.botId); if (!currentBot || typeof Notification === "undefined") return true; const result = deliverNativeBrowserNotification( pending.event, currentBot.name || pending.botName, { enabled: pending.groupNotification || currentBot.notifyOnFinish, pageVisible: document.visibilityState === "visible", windowFocused: document.hasFocus(), permission: Notification.permission, notifiedEventIds: notifiedBrowserEvents.current, show: (title, body, tag) => new Notification(title, { body, tag }), }, ); return result !== "pending"; }, []); const flushPendingBrowserNotifications = useCallback(() => { for (const [threadId, pending] of pendingBrowserNotifications.current) { if (deliverBrowserNotification(pending)) { pendingBrowserNotifications.current.delete(threadId); } } }, [deliverBrowserNotification]); const notifyBrowserForEvent = useCallback( ( event: Pick, subscribedThreadId: string | undefined, initialCursor: number, streamReady: boolean, botName: string, enabled: boolean, groupNotification: boolean, ) => { const botId = event.botId; if (typeof botId !== "string") return; const eligible = shouldNotifyBrowser(event, { subscribedThreadId: subscribedThreadId ?? "", initialCursor, streamReady, pageVisible: document.visibilityState === "visible", windowFocused: document.hasFocus(), permission: "granted", notifiedEventIds: notifiedBrowserEvents.current, }); if (!eligible || !enabled) return; const pending = { event, botId, botName, groupNotification, } satisfies PendingBrowserNotification; if (typeof Notification === "undefined" || Notification.permission === "denied") return; if (Notification.permission === "default") { pendingBrowserNotifications.current.set(event.threadId, pending); return; } if (deliverBrowserNotification(pending)) { pendingBrowserNotifications.current.delete(event.threadId); } else { pendingBrowserNotifications.current.set(event.threadId, pending); } }, [deliverBrowserNotification], ); 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; } if (currentGroupId) { if (!groupList.some((group) => group.id === currentGroupId)) { navigate(firstThreadRoute(list, groupList), { replace: true }); } return; } if (!currentBotId || !list.some((bot) => bot.id === currentBotId)) { navigate(firstThreadRoute(list, groupList), { 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"); } if (!applyBotLists) return; 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; } 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; } if (groupId) { if (!groupList.some((group) => group.id === groupId)) { navigate(firstThreadRoute(bootstrap.bots, groupList), { replace: true }); } 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); }; }, []); useEffect(() => { let cancelled = false; void rpc.agentSkills .list() .then((skills) => { if (!cancelled) setAgentSkills(skills); }) .catch(() => { if (!cancelled) setAgentSkills([]); }); return () => { cancelled = true; }; }, []); const refreshAgentSkills = useCallback(() => { void rpc.agentSkills .list() .then(setAgentSkills) .catch(() => undefined); }, []); 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.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); } }, }); 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 sidebarGroups = useMemo(() => { const needle = query.toLowerCase(); const sidebarSpaces = spaces.length > 0 ? spaces.map((space) => space.id === bootstrapMe?.spaceId ? { ...space, bots, groups, botSections } : space, ) : bootstrapMe ? [ { id: bootstrapMe.spaceId, name: "Personal", isDefault: true, hasContent: true, canDelete: false, bots, groups, botSections, }, ] : []; const showSpaceNames = sidebarSpaces.length > 1; return sidebarSpaces.flatMap((space) => { const visibleBots = space.bots.filter((bot) => `${bot.name} ${bot.title ?? ""} ${bot.preview ?? ""}`.toLowerCase().includes(needle), ); const visibleGroups = space.groups.filter((group) => `${group.name} ${group.preview}`.toLowerCase().includes(needle), ); const sections = groupBotsForSidebar( [ ...visibleBots.map((chat) => ({ kind: "bot" as const, chat })), ...visibleGroups.map((chat) => ({ kind: "group" as const, chat })), ].map((item) => ({ ...item, pinned: item.chat.pinned, sectionId: item.chat.sectionId })), space.botSections, ).map((group, index) => ({ ...group, sectionId: group.key.startsWith("section:") ? group.key.slice("section:".length) : null, key: showSpaceNames ? `space:${space.id}:${group.key}` : group.key, title: showSpaceNames ? group.title ? `${space.name} · ${group.title}` : space.name : group.title, showLock: showSpaceNames, emptySpaceId: undefined as string | undefined, spaceId: space.id, spaceName: space.name, canDeleteSpace: index === 0 && space.canDelete === true, })); if (sections.length > 0) return sections; // Keep empty spaces selectable; chat clicks are the only switch control. if (!showSpaceNames) return []; if (needle && (space.bots.length > 0 || space.groups.length > 0)) return []; return [ { key: `space:${space.id}:empty`, title: space.name, bots: [], sectionId: null, showLock: true, emptySpaceId: space.id, spaceId: space.id, spaceName: space.name, canDeleteSpace: space.canDelete === true, }, ]; }); }, [bootstrapMe, botSections, bots, groups, spaces, query]); const homeSpaceBots = useMemo(() => { if (!homeSpaceId) return bots; const home = spaces.find((space) => space.id === homeSpaceId); return home?.bots ?? bots; }, [bots, homeSpaceId, spaces]); const conversationSidebarGroups = useMemo(() => { if (!homeSpaceId) return []; return sidebarGroups .filter((group) => group.spaceId === homeSpaceId) .map((group) => ({ ...group, title: stripSpaceTitlePrefix(group.title, group.spaceName), showLock: false, bots: group.bots .filter((item) => item.kind === "bot") .filter((item) => item.chat.kind !== "main"), })) .filter((group) => group.bots.length > 0); }, [homeSpaceId, sidebarGroups]); const mainConversationEntry = useMemo(() => { if (!mainConversation) return null; const inCurrentSpace = mainConversation.spaceId === bootstrapMe?.spaceId; const space = spaces.find((item) => item.id === mainConversation.spaceId); const bot = (inCurrentSpace ? bots : space?.bots)?.find( (item) => item.id === mainConversation.botId, ); const group = (inCurrentSpace ? groups : space?.groups)?.find( (item) => item.id === mainConversation.groupId, ); if (!bot || !group) return null; return { ...mainConversation, bot, group }; }, [bootstrapMe?.spaceId, bots, groups, mainConversation, spaces]); const projectSidebarGroups = useMemo(() => { if (!homeSpaceId) return []; const entries: typeof sidebarGroups = []; const seenProjectSpaceIds = new Set(); const seenProjectGroupChatIds = new Set(); const dedupeSidebarBots = (bots: (typeof sidebarGroups)[number]["bots"]) => { const seen = new Set(); return bots.filter((item) => { const key = `${item.kind}:${item.chat.id}`; if (seen.has(key)) return false; seen.add(key); return true; }); }; for (const space of spaces) { if (space.id === homeSpaceId || seenProjectSpaceIds.has(space.id)) continue; seenProjectSpaceIds.add(space.id); const groupsInSpace = sidebarGroups.flatMap((group) => group.spaceId === space.id ? group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main") : [], ); const projectBots = dedupeSidebarBots(groupsInSpace); entries.push({ key: projectBots.length > 0 ? `project-space:${space.id}` : `project-space:${space.id}:empty`, title: space.name, bots: projectBots, sectionId: null, showLock: true, emptySpaceId: projectBots.length > 0 ? undefined : space.id, spaceId: space.id, spaceName: space.name, canDeleteSpace: space.canDelete === true, }); } for (const group of sidebarGroups) { if (group.spaceId !== homeSpaceId) continue; const groupChats = dedupeSidebarBots( group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main"), ).filter((item) => { if (seenProjectGroupChatIds.has(item.chat.id)) return false; seenProjectGroupChatIds.add(item.chat.id); return true; }); if (groupChats.length === 0) continue; entries.push({ ...group, title: stripSpaceTitlePrefix(group.title, group.spaceName), showLock: false, emptySpaceId: undefined, bots: groupChats, }); } // Apply persisted drag-to-reorder order if present if (projectOrder.length > 0) { const orderMap = new Map(entries.map((e, i) => [e.spaceId, i])); entries.sort((a, b) => { const aInOrder = orderMap.has(a.spaceId); const bInOrder = orderMap.has(b.spaceId); if (aInOrder && bInOrder) return (orderMap.get(a.spaceId) ?? 0) - (orderMap.get(b.spaceId) ?? 0); if (aInOrder) return -1; if (bInOrder) return 1; return 0; }); } return entries; }, [homeSpaceId, sidebarGroups, spaces, projectOrder]); // DnD handlers const handleProjectDragEnd = useCallback( (event: { active: { id: string | number }; over: { id: string | number } | null }) => { if (!event.over) return; const activeId = String(event.active.id); const overId = String(event.over.id); if (activeId === overId) return; const sorted: any[] = [...projectSidebarGroups]; const activeIdx = sorted.findIndex((g) => g.spaceId === activeId); const overIdx = sorted.findIndex((g) => g.spaceId === overId); if (activeIdx === -1 || overIdx === -1) return; const [moved] = sorted.splice(activeIdx, 1); sorted.splice(overIdx, 0, moved); const ids = sorted.map((g: any) => g.spaceId) as string[]; handleReorderProjects(ids); }, [projectSidebarGroups, handleReorderProjects], ); const handleProjectDragStart = useCallback((event: { active: { id: string | number } }) => { setDraggedProjectId(String(event.active.id)); }, []); 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], ); const openProjectStart = useCallback( (spaceId: string, flow: "create" | "group") => { setActiveNavPillar("projekte"); openSpaceChat(spaceId, `/app?project=start&flow=${flow}`); }, [openSpaceChat], ); 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], ); const spaceQuery = query.trim(); const showSpaceSearch = spaceQuery.length > 0; useEffect(() => { if (!showSpaceSearch) { setSearchHits([]); setSearchLoading(false); return; } const abort = new AbortController(); const timer = window.setTimeout(() => { setSearchLoading(true); void rpc.search .query({ q: spaceQuery }) .then((result) => { if (!abort.signal.aborted) setSearchHits(result.hits); }) .catch(() => { if (!abort.signal.aborted) setSearchHits([]); }) .finally(() => { if (!abort.signal.aborted) setSearchLoading(false); }); }, 200); return () => { abort.abort(); window.clearTimeout(timer); }; }, [showSpaceSearch, Calendar, Settings, spaceQuery]); async function jumpToSearchHit(hit: SearchHit) { setQuery(""); setSearchHits([]); const params = new URLSearchParams(); if (hit.messageId) params.set("m", hit.messageId); if (hit.routineId) params.set("routine", hit.routineId); navigate({ pathname: hit.groupId ? `/app/g/${hit.groupId}` : `/app/${hit.botId}`, search: params.toString() ? `?${params.toString()}` : undefined, }); } 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 routine = routines.find((item) => item.id === routineId); if (routine) { setRoutineDraft(draftFromRoutine(routine)); setRoutineWebhookSecret(null); setEditingRoutine(routine); setPanel("routine"); } else { 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]); useEffect(() => { if (searchParams.get("project") !== "start" || !emptyProjectSpace || !initialBotsLoaded) { return; } setActiveNavPillar("projekte"); const flow = searchParams.get("flow"); setPanel( flow === "create" ? "create" : flow === "group" && homeSpaceBots.length > 0 ? "create-group" : flow === "group" ? "create" : bots.length === 0 ? "create" : "create-group", ); const next = new URLSearchParams(searchParams); next.delete("project"); next.delete("flow"); setSearchParams(next, { replace: true }); }, [ bots.length, emptyProjectSpace, homeSpaceBots.length, initialBotsLoaded, searchParams, setSearchParams, ]); const activeSnapshot = inGroup ? snapshot?.groupId === groupId ? snapshot : null : snapshot?.botId === active?.id ? snapshot : null; 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 = currentRuns.some((run) => isActive(run.status)); 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( () => (inGroup ? { groupId: groupId ?? "" } : { botId: active?.id ?? "" }), [active?.id, groupId, inGroup], ); 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(); 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 prevTranscriptRunning = useRef(false); const prevRunErrorId = useRef(null); const seenHandoffKeys = useRef(new Set()); const appSoundStarted = useRef(false); const prevBotStatuses = useRef(new Map()); useEffect(() => wireOoAudioVoiceDucking(), []); useEffect(() => { if (appSoundStarted.current) return; appSoundStarted.current = true; ooSound.play("app-start"); window.setTimeout(() => ooVoiceMark.maybePlayColdOpen(), 280); }, []); useEffect(() => { let cancelled = false; let timer: number | undefined; const tick = async () => { try { const [active, recent] = await Promise.all([ rpc.runs.list({ filter: "active" }), rpc.runs.list({ filter: "recent" }), ]); if (cancelled) return; const brief = buildHeuteBrief({ bots: botsRef.current, groups, activeRuns: active.runs, recentRuns: recent.runs, }); setHeuteActionCount(brief.actionCount); } catch { if (cancelled) return; } finally { if (!cancelled) { timer = window.setTimeout(() => void tick(), 15_000); } } }; void tick(); return () => { cancelled = true; if (timer !== undefined) window.clearTimeout(timer); }; }, [groups]); useEffect(() => { for (const bot of bots) { const prev = prevBotStatuses.current.get(bot.id); if ( prev !== bot.status && (bot.status === "waiting_input" || bot.status === "waiting_takeover") ) { ooSound.play("need-you"); ooVocals.reactForEvent("need-you"); } prevBotStatuses.current.set(bot.id, bot.status); } }, [bots]); useEffect(() => { if (prevTranscriptRunning.current && !transcriptRunning && !callOpen) { ooSound.play("success-resolve"); ooVocals.reactForEvent("success"); } prevTranscriptRunning.current = transcriptRunning; }, [transcriptRunning, callOpen]); useEffect(() => { const runId = displayedRunErrorId; if (runId && runId !== prevRunErrorId.current) { ooSound.play("attention"); } prevRunErrorId.current = runId; }, [displayedRunErrorId]); const seenGroupSparkKeys = useRef(new Set()); useEffect(() => { for (const message of activeSnapshot?.messages ?? []) { for (const block of message.blocks) { if (block.kind === "handoff") { const key = `${message.id}:handoff:${block.toBotId}`; if (seenHandoffKeys.current.has(key)) continue; seenHandoffKeys.current.add(key); if (seenHandoffKeys.current.size > 80) { const oldest = seenHandoffKeys.current.values().next().value; if (oldest !== undefined) seenHandoffKeys.current.delete(oldest); } ooSound.play("handoff"); continue; } if (inGroup && block.kind === "bot_message_received") { const key = `${message.id}:peer:${block.fromBotId}`; if (seenGroupSparkKeys.current.has(key)) continue; seenGroupSparkKeys.current.add(key); if (seenGroupSparkKeys.current.size > 80) { const oldest = seenGroupSparkKeys.current.values().next().value; if (oldest !== undefined) seenGroupSparkKeys.current.delete(oldest); } ooSound.play("group-spark"); } } } }, [activeSnapshot?.messages, 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 = useMemo(() => { const mentionBots = new Map( bots.map((bot) => [bot.id, { id: bot.id, name: bot.name, color: bot.color }]), ); // Group members stay mentionable when the sidebar bot list is still catching up // after creating the group and remounting the shell. if (inGroup) { for (const member of [...(activeGroup?.members ?? []), ...(activeSnapshot?.members ?? [])]) { if (!mentionBots.has(member.botId)) { mentionBots.set(member.botId, { id: member.botId, name: member.name, color: member.color, }); } } } return buildComposerMentionOptions({ query: "", includeEveryone: inGroup, currentGroupId: groupId, bots: [...mentionBots.values()], groups: groups.map((group) => ({ id: group.id, name: group.name })), routines: mentionRoutines.map((routine) => ({ id: routine.id, name: routine.name, crons: routine.crons, botId: routine.botId, botName: routine.botName, })), connectors: mentionConnectors, }); }, [ activeGroup?.members, activeSnapshot?.members, bots, groupId, groups, inGroup, mentionConnectors, mentionRoutines, ]); 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; const mentionBotsKey = useMemo( () => bots.map((bot) => `${bot.id}:${bot.name}`).join(","), [bots], ); const botsForMentionsRef = useRef(bots); botsForMentionsRef.current = bots; useEffect(() => { const bots = botsForMentionsRef.current; if (!initialBotsLoaded || bots.length === 0) { setMentionRoutines([]); setMentionConnectors([]); return; } let cancelled = false; const botNameById = new Map(bots.map((bot) => [bot.id, bot.name])); void Promise.all( bots.map((bot) => rpc.routines .list({ botId: bot.id }) .then((rows) => rows.map((routine) => ({ ...routine, botName: botNameById.get(bot.id) ?? bot.name, })), ) .catch(() => [] as Array), ), ).then((lists) => { if (!cancelled) setMentionRoutines(lists.flat()); }); void Promise.all([ rpc.connections.list().catch(() => [] as Connection[]), rpc.connections.catalog({}).catch(() => [] as ConnectionCatalogItem[]), ]).then(([connections, catalog]) => { if (cancelled) return; const connected = connections.filter((row) => row.status === "connected"); const options: Array<{ id: string; name: string; authStatus: "connected" | "needs_auth"; connectionId?: string; }> = connected.map((row) => ({ id: row.id, name: row.displayName, authStatus: "connected" as const, connectionId: row.id, })); for (const item of catalog) { if (item.connected || item.noAuth) continue; if ( connected.some( (row) => row.provider.toLowerCase() === item.slug.toLowerCase() || row.displayName.toLowerCase() === item.name.toLowerCase(), ) ) { continue; } options.push({ id: `catalog:${item.connectorId}:${item.slug}`, name: item.name, authStatus: "needs_auth", }); } setMentionConnectors(options); }); return () => { cancelled = true; }; }, [initialBotsLoaded, mentionBotsKey]); 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 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; } if (file.size > ATTACHMENT_MAX_BYTES) { skipped.push(t`${file.name} (over 10 MiB)`); continue; } const mimeType = inferAttachmentMimeType(file.name, file.type); if (!mimeType) { skipped.push(file.name); 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 contentBase64 = await readFileAsBase64(pending.file); const artifact = await rpc.artifacts.create( groupTarget ? { groupId: groupTarget, name: pending.file.name, mimeType, contentBase64 } : { botId: botTarget!, name: pending.file.name, mimeType, contentBase64 }, ); 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 setBotsSidebarCollapsedPref(collapsed: boolean) { setBotsSidebarCollapsed(collapsed); writeBotsSidebarCollapsed(userId, collapsed); } function openSettings(section: SettingsSection = "general") { setSettingsSection(section); setSettingsOpen(true); } 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") { autoBooted.current = null; return; } if (!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; const action = computerPanelAutoBoot(state, screen); if (action === "wait") { if (state === "running") autoBooted.current = botId; return; } if (action === "boot" && autoBooted.current === botId) return; autoBooted.current = botId; if (!computerPanelAutoUsesBoot(action)) return; await bootComputer({ takeControl: false, overlay: action === "boot", force: true, }).catch(() => undefined); })(); return () => { cancelled = true; }; }, [panel, active?.id]); useEffect(() => { setComputerOpen(false); setComputerError(null); setComputerErrorFromScreen(false); }, [active?.id]); 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); const hasControl = userHoldsComputerControl(computer, active?.id); const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl); const computerScreenError = computerError && !hideScreenLoadError ? (

{computerError}

) : null; const userName = session.data?.user.name ?? t`You`; const initials = userName .split(" ") .map((p) => p[0]) .join("") .slice(0, 2) .toUpperCase(); const shell = (
{ if ( mobileSidebarOpen || event.touches.length !== 1 || window.matchMedia("(min-width: 768px)").matches ) { mobileSidebarSwipeRef.current = null; return; } const touch = event.touches[0]; if (!touch) return; const rtl = document.documentElement.getAttribute("dir") === "rtl"; const startsAtEdge = rtl ? touch.clientX >= window.innerWidth - MOBILE_SIDEBAR_SWIPE_EDGE_PX : touch.clientX <= MOBILE_SIDEBAR_SWIPE_EDGE_PX; mobileSidebarSwipeRef.current = startsAtEdge ? { startX: touch.clientX, startY: touch.clientY } : null; }} onTouchEndCapture={(event) => { const swipe = mobileSidebarSwipeRef.current; mobileSidebarSwipeRef.current = null; const touch = event.changedTouches[0]; if ( !swipe || !touch || mobileSidebarOpen || window.matchMedia("(min-width: 768px)").matches ) { return; } const rtl = document.documentElement.getAttribute("dir") === "rtl"; const horizontal = rtl ? swipe.startX - touch.clientX : touch.clientX - swipe.startX; const vertical = Math.abs(touch.clientY - swipe.startY); if (horizontal >= MOBILE_SIDEBAR_SWIPE_DISTANCE_PX && horizontal > vertical * 1.25) { setMobileSidebarOpen(true); } }} onTouchCancelCapture={() => { mobileSidebarSwipeRef.current = null; }} > { if (active) void refreshThread(active.id); }} /> {bootstrapMe !== undefined ? ( ) : null} {mobileSidebarOpen ? ( + {/* Unmount with the state change so the panel it opens never coexists with the menu. */} {createMenuOpen ? ( { setCreateMenuOpen(false); setMobileSidebarOpen(false); setPanel("create"); }} onOpenBot={(id) => { setCreateMenuOpen(false); setMobileSidebarOpen(false); navigate(`/app/${id}`); }} onCreateGroup={() => { setCreateMenuOpen(false); setMobileSidebarOpen(false); setPanel("create-group"); }} onCreateSpace={() => { setCreateMenuOpen(false); setMobileSidebarOpen(false); openNewSpaceDialog("space"); }} onShowGroupInfo={() => { setCreateMenuOpen(false); setMobileSidebarOpen(false); setPickerInfoTopic("group"); }} onShowSpaceInfo={() => { setCreateMenuOpen(false); setMobileSidebarOpen(false); setPickerInfoTopic("space"); }} /> ) : null}
setQuery(e.target.value)} placeholder={t`Search`} autoComplete="off" name="sidebar-search" />
{showSpaceSearch ? ( void jumpToSearchHit(hit)} /> ) : ( <> {activityMode ? ( { setMobileSidebarOpen(false); if (run.groupId) navigate(`/app/g/${run.groupId}`); else navigate(`/app/${run.botId}`); }} /> ) : null}
{t`Sprich mit deinen Oo's`}
{conversationPickerOpen ? ( { setConversationPickerOpen(false); setPanel("create"); }} onOpenBot={openConversationBot} /> ) : null}
{mainConversationEntry ? (
) : null} {conversationSidebarGroups.length === 0 && !mainConversationEntry ? (

{t`Noch keine Unterhaltung.`}

) : 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 (
{group.title ? (
{group.canDeleteSpace ? ( ) : null}
) : null} {!collapsed && group.bots .slice(0, expandedTeamGroups[group.key] ? undefined : 4) .map((item) => ( ))} {!collapsed && group.bots.length > 4 && ( )}
); })}
{/* PROJEKTE SECTION */}
{t`Projekte`}

{t`Threads by topic · folders`}

{projectSidebarGroups.length === 0 ? (

{t`No projects yet.`}

) : null} setDraggedProjectId(null)} > g.title).map((g) => g.spaceId)} strategy={verticalListSortingStrategy} > {projectSidebarGroups.map((group) => { const collapsed = Boolean(group.title) && collapsedSidebarSections.has(group.key); return (
{group.title ? (
{/* Drag grip — positioned to the left of the title text */} {group.canDeleteSpace ? ( ) : null}
) : null} {!collapsed && group.emptySpaceId ? (
) : null} {!collapsed && group.bots.map((item) => ( ))}
); })}
)} {archivedBots.length + archivedGroups.length > 0 && !showSpaceSearch ? (
{archivedOpen ? ( <> {archivedBots.map((bot) => (
{bot.name}
))} {archivedGroups.map((group) => (
{group.name}
))} ) : null}
) : null}
{initials} {userName} {menuOpen ? ( ) : null} { 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); }} /> ) : ( <>
{/* Collapsed bots sidebar: this header is the leading edge for window chrome. */} {botsSidebarCollapsed && desktopBridge() ? : null} {inGroup && projectSessionKey ? ( ) : null}
{!inGroup && active ? ( ) : null}
{!active && !activeGroup && emptyProjectSpace && initialBotsLoaded ? (
) : !active && !activeGroup && initialBotsLoaded ? (
) : showProjectTimeline && bots.length > 0 ? ( openProjectVoiceFirst(goalGroupId)} /> ) : showProjectTimeline && bots.length === 0 ? (

Erstelle zuerst einen Bot, um Projekte starten zu können.

) : ( <> {!callOpen && active && !inGroup ? ( 0} voiceReady={Boolean(voiceStatus?.ready)} onStartVoice={startVoiceEncounter} /> ) : null} {!callOpen && inGroup ? ( ) : null} { void sendMessage(prompt); }} scrollRef={messageScroll} artifactTarget={transcriptArtifactTarget} messages={transcriptMessages} olderCursor={activeSnapshot?.olderCursor ?? null} loadingOlder={loadingOlder} answerableAskMessageId={answerableAskMessageId} running={transcriptRunning} workingBots={workingBots} onLoadOlder={loadOlder} onOpenBot={openBot} onOpenGroup={openGroup} onAnswer={answerMessage} 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 ? ( ) : null} {recordingSkill ? (
Teaching in progress. Stop teaching before sending a new message.
) : null} {(active || activeGroup) && !showProjectTimeline ? ( { startVoiceEncounter(); } : undefined } voiceReady={Boolean(voiceStatus?.ready)} voiceColor={voiceCallBot?.color} replyTarget={activeReplyTarget} replyQuote={activeReplyQuote} replyTargetName={replyTargetName} onClearReply={clearReply} mentionTargets={composerMentionTargets} agentSkills={agentSkills} onSlashOpen={refreshAgentSkills} 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} )}