cynarAI 49431def83
feat: show the bot's live cursor and make the in-app browser work (#113)
Bots now announce every click and typing step before it runs, and the
web app draws a cursor in the bot's colour that glides to that point
over the live computer screen, ripples on clicks and shows typing.

- contracts: ComputerPointerPayload on the existing thread.computer event
- executor: computer_act and browser_act announce pointer steps
  (best effort); the page-browser helper reports element screen boxes,
  which are stripped before results reach the model
- web: agentPointerFeed + AgentCursor over the side-panel preview and
  the full-window screen, mapped through the noVNC letterbox
- in-app browser: native WebContentsView on desktop (own session, http(s)
  only, clamped bounds, back/forward, reload, DevTools, load errors) and
  a sandboxed iframe on the web; replaces the dead panel, the fake
  50%/40% cursors and the unimplemented devServer bridge


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 19:08:48 +02:00

7900 lines
310 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import {
closestCenter,
DndContext,
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<ProductEvent, "id" | "type" | "threadId" | "botId" | "payload">;
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<never> {
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<string> {
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<NavPillar>(() => {
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<Group[]>([]);
const [bots, setBots] = useState<Bot[]>([]);
const botsRef = useRef(bots);
botsRef.current = bots;
const botOrderEpochRef = useRef(0);
const pendingBotOrderRef = useRef<string[] | null>(null);
const savingBotOrderRef = useRef(false);
const [botSections, setBotSections] = useState<BotSection[]>([]);
const [spaces, setSpaces] = useState<Space[]>([]);
const [mainConversation, setMainConversation] = useState<MainConversationRef | null>(null);
const [archivedBots, setArchivedBots] = useState<Bot[]>([]);
const [archivedGroups, setArchivedGroups] = useState<Group[]>([]);
const {
width: leftSidebarWidth,
isResizing: isResizingLeft,
startResizing: startResizingLeft,
} = useSidebarResize(316, "left", 240, 450);
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<Record<string, boolean>>({});
const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set<string>());
useEffect(() => {
setCollapsedSidebarSections(readCollapsedSidebarSections(userId));
}, [userId]);
useEffect(() => {
setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId));
}, [userId]);
const [panel, setPanel] = useState<Panel>(null);
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
// Persisted project (space) sort order
const [projectOrder, setProjectOrder] = useState<string[]>(() => readProjectSidebarOrder());
const handleReorderProjects = useCallback((orderedIds: string[]) => {
setProjectOrder(orderedIds);
writeProjectSidebarOrder(orderedIds);
}, []);
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { distance: 8 } }),
);
const [query, setQuery] = useState("");
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [snapshot, setSnapshot] = useState<ThreadSnapshot | null>(null);
const snapshotRef = useRef<ThreadSnapshot | null>(null);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [replyTarget, setReplyTarget] = useState<ThreadMessage | null>(null);
const [replyQuote, setReplyQuote] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const sendsInFlight = useRef(0);
const [sendError, setSendError] = useState<string | null>(null);
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
// Safety watchdog: 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<HTMLInputElement>(null);
const [peerConversation, setPeerConversation] = useState<{
peerBotId: string;
peerBotName: string;
} | null>(null);
const [routines, setRoutines] = useState<Routine[]>([]);
const [routinesBotId, setRoutinesBotId] = useState<string | null>(null);
const [taughtSkills, setTaughtSkills] = useState<TaughtSkill[]>([]);
const [taughtSkillsBotId, setTaughtSkillsBotId] = useState<string | null>(null);
const [agentSkills, setAgentSkills] = useState<AgentSkillCatalogEntry[]>([]);
const [mentionRoutines, setMentionRoutines] = useState<Array<Routine & { botName?: string }>>([]);
const [mentionConnectors, setMentionConnectors] = useState<
Array<{
id: string;
name: string;
authStatus: "connected" | "needs_auth";
connectionId?: string;
}>
>([]);
const [teachBusy, setTeachBusy] = useState(false);
const [computer, setComputer] = useState<ComputerStatus | null>(null);
const computerRef = useRef<ComputerStatus | null>(null);
const threadRefreshEpoch = useRef(0);
const groupRefreshEpoch = useRef(0);
const botsRefreshEpoch = useRef(0);
const botsRefreshApplied = useRef(0);
const archivedBotsRefreshEpoch = useRef(0);
const botsRefreshInFlight = useRef(0);
// A very fast run can finish over SSE while its threads.send response is still
// returning. Do not let that late receipt resurrect terminal work as queued.
const terminalRunReceipts = useRef(new Set<string>());
// Last-known computer/screen per bot, so switching back to an already-seen
// bot paints its computer pane instantly instead of blanking it while the
// thread + screen RPCs round-trip again (see refreshThread / refreshComputerScreen).
const computerCacheRef = useRef(
new Map<string, { computer: ComputerStatus | null; screenUrl: string | null }>(),
);
// Caps computerCacheRef so a long session that opens many distinct bots
// over time doesn't accumulate one entry per bot forever. Re-inserting on
// every update keeps Map iteration order as least-recently-used first, so
// eviction drops the bot that's been out of view longest.
const COMPUTER_CACHE_LIMIT = 20;
function cacheComputerFor(
botId: string,
patch: Partial<{ computer: ComputerStatus | null; screenUrl: string | null }>,
) {
const cache = computerCacheRef.current;
const prev = cache.get(botId) ?? { computer: null, screenUrl: null };
cache.delete(botId);
cache.set(botId, { ...prev, ...patch });
if (cache.size > COMPUTER_CACHE_LIMIT) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
}
function commitSnapshot(next: ThreadSnapshot | null) {
snapshotRef.current = next;
setSnapshot(next);
}
function commitComputer(next: ComputerStatus | null) {
computerRef.current = next;
setComputer(next);
}
function updateSnapshot(update: (prev: ThreadSnapshot | null) => ThreadSnapshot | null) {
commitSnapshot(update(snapshotRef.current));
}
const [pluginsOpen, setPluginsOpen] = useState(false);
const [mcpOpen, setMcpOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>("general");
const [messagingSettingsOpen, setMessagingSettingsOpen] = useState(false);
const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false);
const [messagingProviders, setMessagingProviders] = useState<string[]>([]);
const [memoryProviderConfig, setMemoryProviderConfig] = useState<
SpaceMemoryConfig | null | undefined
>(undefined);
const memoryProviderConfigRevision = useRef(0);
const [callOpen, setCallOpen] = useState(false);
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus | null>(null);
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
const [dismissedRunErrorIds, setDismissedRunErrorIds] =
useState<ReadonlySet<string>>(readSeenRunErrorIds);
const [menuOpen, setMenuOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const mobileSidebarSwipeRef = useRef<{ startX: number; startY: number } | null>(null);
const [draggedBotId, setDraggedBotId] = useState<string | null>(null);
const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [conversationPickerOpen, setConversationPickerOpen] = useState(false);
const [botsSidebarCollapsed, setBotsSidebarCollapsed] = useState(false);
const focusPromptAbortRef = useRef<AbortController | null>(null);
const focusPromptBotIdRef = useRef<string | null>(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<HTMLElement | null>(null);
useEffect(() => {
if (botMenu || !botMenuAnchor.current) return;
botMenuAnchor.current.focus();
botMenuAnchor.current = null;
}, [botMenu]);
const [deleteTarget, setDeleteTarget] = useState<Bot | null>(null);
const [deleteGroupTarget, setDeleteGroupTarget] = useState<Group | null>(null);
const [deleteSpaceTarget, setDeleteSpaceTarget] = useState<Space | null>(null);
const [spaceMenu, setSpaceMenu] = useState<{
id: string;
position: ContextMenuPosition;
} | null>(null);
const spaceMenuAnchor = useRef<HTMLElement | null>(null);
useEffect(() => {
if (spaceMenu || !spaceMenuAnchor.current) return;
spaceMenuAnchor.current.focus();
spaceMenuAnchor.current = null;
}, [spaceMenu]);
const closeSpaceMenu = useCallback(() => setSpaceMenu(null), []);
const [clearTarget, setClearTarget] = useState<
{ kind: "bot"; chat: Bot } | { kind: "group"; chat: Group } | null
>(null);
const [newSectionTarget, setNewSectionTarget] = useState<
{ kind: "bot"; chat: Bot } | { kind: "group"; chat: Group } | null
>(null);
const [renameSectionTarget, setRenameSectionTarget] = useState<{
section: BotSection;
spaceId: string;
} | null>(null);
const [sectionMenu, setSectionMenu] = useState<{
section: BotSection;
spaceId: string;
position: ContextMenuPosition;
} | null>(null);
const sectionMenuAnchor = useRef<HTMLElement | null>(null);
useEffect(() => {
if (sectionMenu || !sectionMenuAnchor.current) return;
sectionMenuAnchor.current.focus();
sectionMenuAnchor.current = null;
}, [sectionMenu]);
const closeSectionMenu = useCallback(() => setSectionMenu(null), []);
const [booting, setBooting] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [initialBotsLoaded, setInitialBotsLoaded] = useState(false);
const [bootstrapMe, setBootstrapMe] = useState<Me | null>();
const [routineDraft, setRoutineDraft] = useState<RoutineDraftState>(emptyRoutineDraft());
const [routineWebhookSecret, setRoutineWebhookSecret] = useState<string | null>(null);
const [editingRoutine, setEditingRoutine] = useState<Routine | null>(null);
const [deleteRoutineTarget, setDeleteRoutineTarget] = useState<Routine | null>(null);
const [savingRoutine, setSavingRoutine] = useState(false);
const [runningRoutine, setRunningRoutine] = useState(false);
const [routineError, setRoutineError] = useState<string | null>(null);
const [screenUrl, setScreenUrl] = useState<string | null>(null);
const [computerOpen, setComputerOpen] = useState(false);
const [computerViewport, setComputerViewport] = useState<{
height: number;
offsetTop: number;
} | null>(null);
const [computerError, setComputerError] = useState<string | null>(null);
// Screen-load failures can sit beside a still-valid embed URL; boot and
// takeover failures must stay visible even when a URL remains.
const [computerErrorFromScreen, setComputerErrorFromScreen] = useState(false);
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<string | null>(null);
const routineSavePending = useRef(false);
const webhookSecretProvisionRef = useRef(new Map<string, Promise<string>>());
const ensureWebhookSecret = (botId: string) =>
sharedInflight(webhookSecretProvisionRef.current, botId, async () => {
const result = await rpc.bots.rotateWebhookSecret({ botId });
setRoutineWebhookSecret(result.secret);
setBots((current) =>
current.map((bot) => (bot.id === botId ? { ...bot, webhookConfigured: true } : bot)),
);
return result.secret;
});
const routineSaveRequest = useRef(0);
const routineRunPending = useRef(false);
const bootstrappedThread = useRef<ThreadSnapshot | null>(null);
const expandedHistoryThread = useRef<string | null>(null);
const historyEpoch = useRef(0);
const jumpGeneration = useRef(0);
const initiallyScrolledThread = useRef<string | null>(null);
const messageScroll = useRef<HTMLDivElement>(null);
const pinnedAroundRef = useRef<{
botId?: string;
groupId?: string;
messageId: string;
threadId: string;
messages: ThreadMessage[];
olderCursor: number | null;
} | null>(null);
const manuallyUnread = useRef(new Set<string>());
const readVisibleGroups = useRef(new Set<string>());
const notifiedBrowserEvents = useRef(new Set<string>());
const pendingBrowserNotifications = useRef(new Map<string, PendingBrowserNotification>());
const computerVisible = useRef(false);
computerVisible.current = panel === "computer" || computerOpen;
const autoSpoken = useRef<string | null>(null);
const autoSpokenBotId = useRef<string | null>(null);
const inGroup = Boolean(groupId);
const projectSessionKey = searchParams.get("s");
const voiceEntry = searchParams.get("voice") === "1";
const currentSpaceId = selectedSpaceId() ?? bootstrapMe?.spaceId;
const homeSpaceId = spaces.find((space) => space.isDefault)?.id ?? bootstrapMe?.spaceId;
const emptyProjectSpace = useMemo(
() => isEmptyProjectSpace(currentSpaceId, spaces, groups, homeSpaceId),
[currentSpaceId, groups, homeSpaceId, spaces],
);
const active = inGroup
? undefined
: (bots.find((b) => b.id === botId) ?? (emptyProjectSpace && !botId ? undefined : bots[0]));
const activeGroup = groups.find((group) => group.id === groupId);
const activeProjectSection = activeGroup?.sectionId
? botSections.find((section) => section.id === activeGroup.sectionId)
: undefined;
const showProjectTimeline =
inGroup && activeNavPillar !== "heute" && !projectSessionKey && Boolean(activeGroup?.sectionId);
const activePendingAttachments = useMemo(
() => attachmentsForThread(pendingAttachments, inGroup ? groupId : active?.id),
[active?.id, groupId, inGroup, pendingAttachments],
);
const activeRoutines = !inGroup && routinesBotId === active?.id ? routines : [];
const activeTaughtSkills = taughtSkillsBotId === active?.id ? taughtSkills : [];
const recordingSkill = activeTaughtSkills.find((skill) => skill.status === "recording") ?? null;
const routeBotId = useRef<string | undefined>(botId);
routeBotId.current = botId;
const routeGroupId = useRef<string | undefined>(groupId);
routeGroupId.current = groupId;
const activeBotId = useRef<string | undefined>(inGroup ? undefined : active?.id);
activeBotId.current = inGroup ? undefined : active?.id;
const activeGroupId = useRef<string | undefined>(groupId);
activeGroupId.current = groupId;
const screenRequest = useRef(0);
const contextBot =
botMenu?.kind === "bot" ? bots.find((bot) => bot.id === botMenu.id) : undefined;
const contextGroup =
botMenu?.kind === "group" ? groups.find((group) => group.id === botMenu.id) : undefined;
const contextChat = contextBot ?? contextGroup;
const closeBotMenu = useCallback(() => setBotMenu(null), []);
const updateBotUnread = useCallback((id: string, unread: boolean) => {
setBots((current) => {
const bot = current.find((candidate) => candidate.id === id);
if (!bot || bot.unread === unread) return current;
return current.map((candidate) =>
candidate.id === id ? { ...candidate, unread } : candidate,
);
});
}, []);
const markBotRead = useCallback(
async (id: string) => {
await rpc.threads.markRead({ botId: id });
manuallyUnread.current.delete(id);
updateBotUnread(id, false);
},
[updateBotUnread],
);
const markBotUnread = useCallback(
async (id: string) => {
manuallyUnread.current.add(id);
try {
await rpc.threads.markUnread({ botId: id });
} catch (err) {
manuallyUnread.current.delete(id);
throw err;
}
updateBotUnread(id, true);
},
[updateBotUnread],
);
// A bot the user marked unread by hand stays unread until they open it again,
// otherwise the auto-read below would undo the action on the next window focus.
const markBotReadIfVisible = useCallback(
(id: string) => {
if (manuallyUnread.current.has(id)) return;
if (document.visibilityState === "visible" && document.hasFocus()) {
void markBotRead(id).catch(() => undefined);
}
},
[markBotRead],
);
const 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<ProductEvent, "id" | "type" | "threadId" | "seq" | "botId" | "payload">,
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<string>();
const seenProjectGroupChatIds = new Set<string>();
const dedupeSidebarBots = (bots: (typeof sidebarGroups)[number]["bots"]) => {
const seen = new Set<string>();
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<ArtifactTarget>(
() => (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<string, { name: string; color: string; status?: string }>();
for (const space of spaces) for (const bot of space.bots) byId.set(bot.id, bot);
for (const bot of bots) byId.set(bot.id, bot);
return byId;
}, [bots, spaces]);
const resolveTranscriptBot = useCallback(
(botId: string) => {
const bot = bots.find((candidate) => candidate.id === botId);
if (bot) return bot;
return transcriptMembers?.find((member) => member.botId === botId);
},
[bots, transcriptMembers],
);
const resolveSpeakerBot = useCallback(
(botId: string) => resolveTranscriptBot(botId) ?? knownBots.get(botId),
[knownBots, resolveTranscriptBot],
);
const enrichStageMember = useCallback(
(botId: string) => {
const bot = resolveTranscriptBot(botId);
if (!bot || !("title" in bot)) return undefined;
return {
title: bot.title,
description: bot.description,
};
},
[resolveTranscriptBot],
);
const workingBots: GroupAvatarMember[] = useMemo(() => {
if (!inGroup) {
return workingRuns.map((run) => {
const bot = resolveTranscriptBot(run.botId);
return {
botId: run.botId,
color: bot?.color ?? FALLBACK_BOT_COLOR,
name: bot?.name,
status: run.status,
};
});
}
return resolveWorkingGroupBots(activeSnapshot, enrichStageMember).map((member) => ({
botId: member.botId,
color: member.color,
name: member.name,
status: member.status,
}));
}, [activeSnapshot, enrichStageMember, inGroup, resolveTranscriptBot, workingRuns]);
const encounterMode = Boolean((active || activeGroup) && !showProjectTimeline && !callOpen);
const voiceEncounterActive = encounterMode || callOpen || voiceEntry;
const voiceCallBot = useMemo(() => {
if (active) return active;
if (!inGroup || !activeSnapshot) return undefined;
const speaker = resolveActiveGroupSpeaker(
activeSnapshot,
resolveWorkingGroupBots(activeSnapshot, enrichStageMember),
enrichStageMember,
);
if (!speaker) return undefined;
const bot = resolveTranscriptBot(speaker.botId);
if (!bot) return undefined;
const title = "title" in bot ? bot.title : (speaker.title ?? "");
const description = "description" in bot ? bot.description : (speaker.description ?? "");
return {
id: speaker.botId,
name: speaker.name,
title,
description,
color: speaker.color,
status: speaker.status ?? ("status" in bot ? bot.status : undefined),
};
}, [active, activeSnapshot, enrichStageMember, inGroup, resolveTranscriptBot]);
const voiceSetupPrompted = useRef(false);
useEffect(() => {
if (!voiceEntry || showProjectTimeline || callOpen) return;
if (!voiceCallBot) return;
if (voiceStatus?.ready) {
voiceSetupPrompted.current = false;
setCallOpen(true);
return;
}
if (voiceSetupPrompted.current) return;
voiceSetupPrompted.current = true;
openSettings("voice");
}, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]);
const prevTranscriptRunning = useRef(false);
const prevRunErrorId = useRef<string | null>(null);
const seenHandoffKeys = useRef(new Set<string>());
const appSoundStarted = useRef(false);
const prevBotStatuses = useRef(new Map<string, string>());
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<string>());
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<Routine & { botName?: string }>),
),
).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 ? (
<div role="alert" className="flex flex-col items-center gap-3 px-6 text-center text-sm">
<p className="text-destructive">{computerError}</p>
<Button
variant="outline"
size="sm"
onClick={() => active && void refreshComputerScreen(active.id)}
>
<Trans>Retry screen</Trans>
</Button>
</div>
) : null;
const userName = session.data?.user.name ?? t`You`;
const initials = userName
.split(" ")
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
const shell = (
<div
data-testid="shell-root"
data-ready={shellReady}
className="relative flex h-full min-w-0 overflow-hidden bg-background text-foreground/90 p-0 md:p-3 gap-0 md:gap-3"
onTouchStartCapture={(event) => {
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;
}}
>
<ComputerUpdateProgress
onCompleted={() => {
if (active) void refreshThread(active.id);
}}
/>
{bootstrapMe !== undefined ? (
<HostComputerPrompt initialMe={bootstrapMe ?? undefined} />
) : null}
{mobileSidebarOpen ? (
<button
type="button"
aria-label={t`Close navigation`}
onClick={() => setMobileSidebarOpen(false)}
className="absolute inset-y-0 end-0 start-[min(calc(100%-48px),316px)] z-30 bg-overlay md:hidden"
/>
) : null}
{!mobileSidebarOpen ? (
<div
data-testid="mobile-sidebar-swipe-edge"
aria-hidden="true"
className="absolute bottom-20 start-0 top-16 z-20 w-8 touch-none md:hidden"
/>
) : null}
<aside
data-testid="bots-sidebar"
data-collapsed={botsSidebarCollapsed ? "true" : "false"}
inert={botsSidebarCollapsed && !mobileSidebarOpen ? true : undefined}
style={{
width: mobileSidebarOpen ? undefined : botsSidebarCollapsed ? 0 : leftSidebarWidth,
maxWidth: mobileSidebarOpen ? 316 : undefined,
}}
className={`absolute inset-y-0 start-0 z-40 flex w-[calc(100%-48px)] shrink-0 flex-col md:rounded-2xl md:border md:border-sidebar-border md:shadow-2xl bg-sidebar md:static md:z-auto md:translate-x-0 ${
mobileSidebarOpen ? "translate-x-0" : "-translate-x-full rtl:translate-x-full"
} ${
botsSidebarCollapsed
? "md:w-0 md:max-w-0 md:overflow-hidden md:border-e-0 md:opacity-0 md:pointer-events-none"
: ""
} ${!isResizingLeft ? "transition-[transform,width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`}
>
<div
onPointerDown={startResizingLeft}
className="absolute -right-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
/>
<div className="app-drag flex items-center justify-between px-4 lg:px-6 2xl:px-8 pb-3 pt-4">
<WindowChrome />
<div className="flex-1 px-1">
<Wordmark />
</div>
<div className="relative flex items-center gap-2.5">
<button
type="button"
aria-label={t`Activity`}
aria-pressed={activityMode}
title={t`Activity`}
data-activity-mode={activityMode ? "on" : "off"}
onClick={toggleActivityMode}
className={`app-no-drag flex size-control-sm items-center justify-center rounded-full ${
activityMode
? "bg-primary text-primary-foreground"
: "rk-sidebar-muted hover:text-foreground/75"
}`}
>
<Bell
strokeWidth={1.8}
fill={activityMode ? "currentColor" : "none"}
aria-hidden="true"
className="size-icon-md"
/>
</button>
<Popover open={createMenuOpen} onOpenChange={setCreateMenuOpen}>
<PopoverTrigger
className="app-no-drag text-[21px] rk-sidebar-muted hover:text-foreground/75"
title={t`Create`}
data-testid="create-menu-trigger"
>
+
</PopoverTrigger>
{/* Unmount with the state change so the panel it opens never coexists with the menu. */}
{createMenuOpen ? (
<PopoverContent
align="end"
className="app-no-drag w-auto gap-0 overflow-hidden p-0 data-closed:animate-none"
>
<BotCreatePicker
bots={bots}
onCreateBot={() => {
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");
}}
/>
</PopoverContent>
) : null}
</Popover>
</div>
</div>
<InputGroup
data-testid="sidebar-search"
className="mx-3 lg:mx-5 2xl:mx-7 mb-3 w-auto rounded-xl bg-card dark:bg-input border border-border text-muted-foreground focus-within:border-ring"
>
<InputGroupAddon>
<Search strokeWidth={1.8} aria-hidden="true" className="size-icon-md" />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t`Search`}
autoComplete="off"
name="sidebar-search"
/>
</InputGroup>
<div className="rk-scroll flex flex-1 flex-col gap-0.5 overflow-y-auto px-3 lg:px-5 2xl:px-7 pb-2.5">
{showSpaceSearch ? (
<SpaceSearchResults
hits={searchHits}
loading={searchLoading}
onSelect={(hit) => void jumpToSearchHit(hit)}
/>
) : (
<>
{activityMode ? (
<ActivityList
onOpenRun={(run) => {
setMobileSidebarOpen(false);
if (run.groupId) navigate(`/app/g/${run.groupId}`);
else navigate(`/app/${run.botId}`);
}}
/>
) : null}
<div className="mb-3">
<button
type="button"
aria-label={
heuteActionCount > 0 ? t`Heute, ${heuteActionCount} need you` : t`Heute`
}
onClick={() => {
ooSound.play("interact");
setActiveNavPillar("heute");
setMobileSidebarOpen(false);
}}
className={rosterRowClass(activeNavPillar === "heute")}
>
<div className="relative grid size-control-lg shrink-0 place-items-center rounded-full bg-muted text-muted-foreground">
<Calendar strokeWidth={2.2} className="size-icon-md" />
{heuteActionCount > 0 ? (
<span
aria-hidden="true"
className="absolute -right-0.5 -top-0.5 grid min-w-[18px] place-items-center rounded-full bg-warning px-1 text-ui-label font-bold leading-none text-background"
>
{heuteActionCount > 9 ? "9+" : heuteActionCount}
</span>
) : null}
</div>
<div className="font-semibold text-foreground/90 tracking-tight text-ui-body">
Heute
</div>
</button>
</div>
<div className="sidebar-unterhaltungen">
<div className="flex items-center justify-between pt-4 pb-2 px-2.5">
<div className="rk-shell-section-label">{t`Sprich mit deinen Oo's`}</div>
<div className="flex items-center">
<Popover open={conversationPickerOpen} onOpenChange={setConversationPickerOpen}>
<PopoverTrigger
type="button"
aria-label={t`Neue Unterhaltung`}
data-testid="conversation-picker-trigger"
className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
>
<Plus strokeWidth={2.5} className="size-icon-md" />
</PopoverTrigger>
{conversationPickerOpen ? (
<PopoverContent
align="end"
className="w-auto gap-0 overflow-hidden p-0 data-closed:animate-none"
>
<ConversationBotPicker
bots={bots}
onCreateBot={() => {
setConversationPickerOpen(false);
setPanel("create");
}}
onOpenBot={openConversationBot}
/>
</PopoverContent>
) : null}
</Popover>
</div>
</div>
{mainConversationEntry ? (
<div data-sidebar-group="main">
<MainConversationRow
name={mainConversationEntry.group.name}
botId={mainConversationEntry.bot.id}
color={mainConversationEntry.bot.color}
status={mainConversationEntry.bot.status}
unread={mainConversationEntry.group.unread}
time={formatRosterTime(mainConversationEntry.group.updatedAt)}
subtitle={rosterActivitySubtitle(
mainConversationEntry.bot.status,
cleanRosterPreview(mainConversationEntry.group.preview),
)}
active={inGroup && groupId === mainConversationEntry.groupId}
onOpen={openMainConversation}
/>
</div>
) : null}
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
<div className="p-4 text-center text-ui-label rk-sidebar-muted">
<p>{t`Noch keine Unterhaltung.`}</p>
<Button
variant="outline"
size="sm"
className="mt-2 text-ui-label"
onClick={() => setConversationPickerOpen(true)}
>
<Plus className="mr-1 size-icon-sm" />
{t`Neue Unterhaltung`}
</Button>
</div>
) : null}
{conversationSidebarGroups.map((group) => {
const collapsed = Boolean(group.title) && collapsedSidebarSections.has(group.key);
const groupBotIds = group.bots.flatMap((item) =>
item.kind === "bot" ? [item.chat.id] : [],
);
return (
<div key={group.key} data-sidebar-group={group.key}>
{group.title ? (
<div className="flex items-center pt-3 pb-0.5">
<button
type="button"
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => {
if (group.emptySpaceId) {
openSpaceChat(group.emptySpaceId, "/onboarding");
return;
}
toggleSidebarSection(group.key);
}}
onContextMenu={
group.sectionId
? (event) => {
event.preventDefault();
// Prefer section rename over delete-space when both apply;
// the dedicated space-actions button still opens the space menu.
const sections =
group.spaceId === bootstrapMe?.spaceId
? botSections
: (spaces.find((space) => space.id === group.spaceId)
?.botSections ?? []);
const section = sections.find(
(item) => item.id === group.sectionId,
);
if (!section) return;
sectionMenuAnchor.current = event.currentTarget;
setSectionMenu({
section,
spaceId: group.spaceId,
position: { x: event.clientX, y: event.clientY },
});
}
: group.canDeleteSpace
? (event) => {
event.preventDefault();
spaceMenuAnchor.current = event.currentTarget;
setSpaceMenu({
id: group.spaceId,
position: { x: event.clientX, y: event.clientY },
});
}
: undefined
}
aria-expanded={group.emptySpaceId ? undefined : !collapsed}
aria-label={
group.emptySpaceId
? t`Open ${group.title}`
: collapsed
? t`Expand ${group.title}`
: t`Collapse ${group.title}`
}
>
<span className="flex min-w-0 items-center gap-1.5 truncate">
<Folder strokeWidth={2} aria-hidden="true" className="size-icon-sm" />
<span className="truncate">{group.title}</span>
</span>
{group.emptySpaceId ? null : (
<ChevronDown
strokeWidth={1.8}
className={`size-icon-md ${collapsed ? "-rotate-90 transition-transform" : "transition-transform"}`}
aria-hidden="true"
/>
)}
</button>
{group.canDeleteSpace ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Actions for ${group.spaceName}`}
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
spaceMenuAnchor.current = event.currentTarget;
setSpaceMenu({
id: group.spaceId,
position: { x: rect.left, y: rect.bottom },
});
}}
>
<MoreHorizontal aria-hidden="true" className="size-icon-md" />
</Button>
) : null}
</div>
) : null}
{!collapsed &&
group.bots
.slice(0, expandedTeamGroups[group.key] ? undefined : 4)
.map((item) => (
<button
key={`${item.kind}:${item.chat.id}`}
type="button"
draggable={item.kind === "bot"}
data-roster-bot-id={item.kind === "bot" ? item.chat.id : undefined}
aria-keyshortcuts={
item.kind === "bot" ? "Alt+ArrowUp Alt+ArrowDown" : undefined
}
onDragStart={(event) => {
if (item.kind !== "bot") return;
setDraggedBotId(item.chat.id);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", item.chat.id);
}}
onDragOver={(event) => {
if (
item.kind === "bot" &&
draggedBotId &&
groupBotIds.includes(draggedBotId)
) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}
}}
onDrop={(event) => {
if (item.kind !== "bot" || !draggedBotId) return;
event.preventDefault();
reorderRosterBot(draggedBotId, item.chat.id, groupBotIds);
setDraggedBotId(null);
}}
onDragEnd={() => setDraggedBotId(null)}
onKeyDown={(event) => {
if (
item.kind !== "bot" ||
!event.altKey ||
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
)
return;
const index = groupBotIds.indexOf(item.chat.id);
const target =
groupBotIds[index + (event.key === "ArrowUp" ? -1 : 1)];
if (!target) return;
event.preventDefault();
reorderRosterBot(item.chat.id, target, groupBotIds);
}}
aria-label={
item.kind === "bot"
? t`${item.chat.name}, 1:1 conversation`
: t`${item.chat.name}, group conversation`
}
onClick={() => {
setActiveNavPillar("team");
openSpaceChat(item.chat.spaceId, `/app/${item.chat.id}`);
}}
onContextMenu={(event) => {
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
event.preventDefault();
botMenuAnchor.current = event.currentTarget;
setBotMenu({
kind: item.kind,
id: item.chat.id,
position: { x: event.clientX, y: event.clientY },
});
}}
className={`${rosterRowClass(
!inGroup && active?.id === item.chat.id,
)} cursor-grab active:cursor-grabbing`}
style={{
opacity: draggedBotId === item.chat.id ? 0.55 : 1,
}}
>
<div className="relative shrink-0">
<BotAvatar
color={item.chat.color}
identity={item.chat.id}
size={40}
status={item.chat.status}
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-sidebar ${
rosterStatusDot(item.chat.status) === "idle"
? "bg-success"
: "bg-warning animate-pulse"
}`}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1.5">
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<span
dir="auto"
data-roster-bot-name=""
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${rosterNameClass(
item.chat.unread,
)}`}
>
{item.chat.name}
</span>
{needsYouChipLabel(item.chat.status) ? (
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-foreground dark:text-warning">
{needsYouChipLabel(item.chat.status)}
</span>
) : item.chat.title ? (
<span className="min-w-0 truncate rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label font-medium text-muted-foreground">
{item.chat.title}
</span>
) : null}
{item.chat.unread ? (
<span className="sr-only">
<Trans> (unread)</Trans>
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span className={rosterTimeClass}>
{formatRosterTime(item.chat.updatedAt)}
</span>
{item.chat.unread ? (
<span aria-hidden="true" className={rosterUnreadDotClass} />
) : null}
</div>
</div>
<div
dir="auto"
className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(
item.chat.unread,
)}`}
>
{rosterActivitySubtitle(
item.chat.status,
cleanRosterPreview(item.chat.preview),
)}
</div>
</div>
</button>
))}
{!collapsed && group.bots.length > 4 && (
<button
type="button"
onClick={() =>
setExpandedTeamGroups((prev) => ({
...prev,
[group.key]: !prev[group.key],
}))
}
className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium rk-sidebar-muted hover:bg-sidebar-accent hover:text-foreground transition-colors"
>
{expandedTeamGroups[group.key]
? "Weniger anzeigen"
: `Alle ${group.bots.length} anzeigen...`}
</button>
)}
</div>
);
})}
<div className="sidebar-projekte">
{/* PROJEKTE SECTION */}
<div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-sidebar-border px-2.5">
<div>
<div className="rk-shell-section-label">{t`Projekte`}</div>
<p className="mt-0.5 px-0.5 text-ui-caption rk-sidebar-muted">
{t`Threads by topic · folders`}
</p>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
aria-label={t`Create a project`}
data-testid="create-project-trigger"
onClick={() => openNewSpaceDialog("project")}
className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
>
<Plus strokeWidth={2.5} className="size-icon-md" />
</button>
</div>
</div>
{projectSidebarGroups.length === 0 ? (
<div className="p-4 text-center text-ui-label rk-sidebar-muted">
<p>{t`No projects yet.`}</p>
<Button
variant="outline"
size="sm"
className="mt-2 text-ui-label"
onClick={() => openNewSpaceDialog("project")}
>
<Plus className="mr-1 size-icon-sm" />
{t`Create a project`}
</Button>
</div>
) : null}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleProjectDragEnd}
onDragStart={handleProjectDragStart}
onDragCancel={() => setDraggedProjectId(null)}
>
<SortableContext
items={projectSidebarGroups.filter((g) => g.title).map((g) => g.spaceId)}
strategy={verticalListSortingStrategy}
>
{projectSidebarGroups.map((group) => {
const collapsed =
Boolean(group.title) && collapsedSidebarSections.has(group.key);
return (
<SortableGroup key={`proj-${group.key}`} group={group}>
<div data-sidebar-group={group.key}>
{group.title ? (
<div className="flex items-center pt-3 pb-0.5 relative">
{/* Drag grip — positioned to the left of the title text */}
<ProjectDragGrip className="absolute left-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded flex items-center justify-center text-muted-foreground/30 hover:text-muted-foreground/70 active:text-muted-foreground transition-colors cursor-grab active:cursor-grabbing">
<GripVertical className="size-icon-sm" strokeWidth={2} />
</ProjectDragGrip>
<button
type="button"
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => {
if (group.emptySpaceId) {
openSpaceChat(group.emptySpaceId, "/app");
return;
}
toggleSidebarSection(group.key);
}}
onContextMenu={
group.sectionId
? (event) => {
event.preventDefault();
const sections =
group.spaceId === bootstrapMe?.spaceId
? botSections
: (spaces.find(
(space) => space.id === group.spaceId,
)?.botSections ?? []);
const section = sections.find(
(item) => item.id === group.sectionId,
);
if (!section) return;
sectionMenuAnchor.current = event.currentTarget;
setSectionMenu({
section,
spaceId: group.spaceId,
position: { x: event.clientX, y: event.clientY },
});
}
: group.canDeleteSpace
? (event) => {
event.preventDefault();
spaceMenuAnchor.current = event.currentTarget;
setSpaceMenu({
id: group.spaceId,
position: { x: event.clientX, y: event.clientY },
});
}
: undefined
}
aria-expanded={group.emptySpaceId ? undefined : !collapsed}
aria-label={
group.emptySpaceId
? t`Select ${group.title}`
: collapsed
? t`Expand ${group.title}`
: t`Collapse ${group.title}`
}
>
<span className="flex min-w-0 items-center gap-1.5 truncate">
{group.showLock ? (
<Lock
strokeWidth={2}
aria-hidden="true"
className="size-icon-sm"
/>
) : null}
<span className="truncate">{group.title}</span>
</span>
{group.emptySpaceId ? null : (
<ChevronDown
strokeWidth={1.8}
className={`size-icon-md ${collapsed ? "-rotate-90 transition-transform" : "transition-transform"}`}
aria-hidden="true"
/>
)}
</button>
{group.canDeleteSpace ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Actions for ${group.spaceName}`}
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
spaceMenuAnchor.current = event.currentTarget;
setSpaceMenu({
id: group.spaceId,
position: { x: rect.left, y: rect.bottom },
});
}}
>
<MoreHorizontal aria-hidden="true" className="size-icon-md" />
</Button>
) : null}
</div>
) : null}
{!collapsed && group.emptySpaceId ? (
<div className="px-3 pb-2">
<Button
variant="outline"
size="sm"
className="w-full text-ui-label"
data-testid={`project-start-${group.spaceId}`}
onClick={() => openProjectStart(group.spaceId, "group")}
>
<Plus className="mr-1 size-icon-sm" />
{t`Starten`}
</Button>
</div>
) : null}
{!collapsed &&
group.bots.map((item) => (
<button
key={`${item.kind}:${item.chat.id}`}
type="button"
aria-label={
item.kind === "bot"
? t`${item.chat.name}, 1:1 conversation`
: t`${item.chat.name}, group conversation`
}
onClick={() => {
setActiveNavPillar("projekte");
openSpaceChat(
item.chat.spaceId,
item.kind === "bot"
? `/app/${item.chat.id}`
: `/app/g/${item.chat.id}`,
);
}}
onContextMenu={(event) => {
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
event.preventDefault();
botMenuAnchor.current = event.currentTarget;
setBotMenu({
kind: item.kind,
id: item.chat.id,
position: { x: event.clientX, y: event.clientY },
});
}}
className={rosterRowClass(
item.kind === "bot"
? !inGroup && active?.id === item.chat.id
: inGroup && activeGroup?.id === item.chat.id,
)}
>
{item.kind === "bot" ? (
<BotAvatar
color={item.chat.color}
identity={item.chat.id}
size={40}
status={item.chat.status}
/>
) : (
<GroupAvatar members={(item.chat as any).members} size={40} />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1.5">
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<span
dir="auto"
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${rosterNameClass(
item.chat.unread,
)}`}
>
{item.chat.name}
</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span className={rosterTimeClass}>
{formatRosterTime(item.chat.updatedAt)}
</span>
{item.chat.unread ? (
<span
aria-hidden="true"
className={rosterUnreadDotClass}
/>
) : null}
</div>
</div>
<div
dir="auto"
className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(
item.chat.unread,
)}`}
>
{cleanRosterPreview(item.chat.preview)}
</div>
</div>
</button>
))}
</div>
</SortableGroup>
);
})}
</SortableContext>
</DndContext>
</div>
</div>
</>
)}
{archivedBots.length + archivedGroups.length > 0 && !showSpaceSearch ? (
<div className="mt-2 border-t border-border pt-2">
<button
type="button"
aria-expanded={archivedOpen}
onClick={() => setArchivedOpen((open) => !open)}
className="flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-ui-body rk-sidebar-muted hover:bg-sidebar-accent"
>
<span>
<Trans>Archived</Trans>
</span>
<span>{archivedBots.length + archivedGroups.length}</span>
</button>
{archivedOpen ? (
<>
{archivedBots.map((bot) => (
<div key={bot.id} className="flex items-center gap-2 rounded-lg px-2.5 py-2">
<BotAvatar
color={bot.color}
identity={bot.id}
size={28}
status={bot.status}
/>
<span
className="min-w-0 flex-1 truncate text-ui-body text-foreground/75"
dir="auto"
>
{bot.name}
</span>
<Button
variant="ghost"
size="xs"
onClick={() =>
void rpc.bots.restore({ botId: bot.id }).then(() => refreshBots(true))
}
>
<Trans>Restore</Trans>
</Button>
<Button
variant="ghost"
size="xs"
className="text-destructive hover:text-destructive"
aria-label={t`Delete ${bot.name}`}
onClick={() => setDeleteTarget(bot)}
>
<Trans>Delete</Trans>
</Button>
</div>
))}
{archivedGroups.map((group) => (
<div key={group.id} className="flex items-center gap-2 rounded-lg px-2.5 py-2">
<GroupAvatar members={group.members} size={28} />
<span
className="min-w-0 flex-1 truncate text-ui-body text-foreground/75"
dir="auto"
>
{group.name}
</span>
<Button
variant="ghost"
size="xs"
onClick={() =>
void rpc.groups
.restore({ groupId: group.id })
.then(() => refreshBots(true))
}
>
<Trans>Restore</Trans>
</Button>
<Button
variant="ghost"
size="xs"
className="text-destructive hover:text-destructive"
aria-label={t`Delete ${group.name}`}
onClick={() => setDeleteGroupTarget(group)}
>
<Trans>Delete</Trans>
</Button>
</div>
))}
</>
) : null}
</div>
) : null}
</div>
<TradingModeIndicator snapshot={tradingStatus} className="mx-3 mb-1 lg:mx-5 2xl:mx-7" />
<DataTrustPanel />
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
<PopoverTrigger
data-testid="user-menu-trigger"
className="flex items-center gap-[11px] px-[18px] py-3.5"
>
<span className="grid size-control-lg place-items-center rounded-full bg-accent text-ui-body-sm text-foreground/75">
{initials}
</span>
<span className="text-[14.5px] text-foreground/90">{userName}</span>
</PopoverTrigger>
{menuOpen ? (
<PopoverContent
side="top"
align="start"
className="w-[calc(316px-1.5rem)] max-w-[calc(100vw-3rem)] gap-0 p-1 data-closed:animate-none"
>
<Button
variant="ghost"
className="w-full justify-start font-normal"
aria-label={t`Settings`}
onClick={() => {
setMenuOpen(false);
openSettings("general");
}}
>
<Settings className="text-muted-foreground" strokeWidth={1.75} />
<Trans>Settings</Trans>
</Button>
<Button
variant="ghost"
className="w-full justify-start font-normal"
aria-label={t`Usage`}
onClick={() => {
setMenuOpen(false);
void rpc.usage
.summary()
.then(setUsage)
.catch(() => undefined);
openSettings("usage");
}}
>
<Gauge className="text-muted-foreground" strokeWidth={1.75} />
<Trans>Usage</Trans>
</Button>
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() =>
void authClient.signOut().then(() => {
clearSpaceSelection();
navigate("/");
})
}
>
<LogOut className="text-muted-foreground" strokeWidth={1.75} />
<Trans>Log out</Trans>
</Button>
</PopoverContent>
) : null}
</Popover>
</aside>
<button
type="button"
data-testid="bots-sidebar-edge"
aria-label={botsSidebarCollapsed ? t`Show bots` : t`Hide bots`}
aria-pressed={!botsSidebarCollapsed}
className={`absolute inset-y-0 z-50 hidden w-2 cursor-ew-resize touch-none border-0 bg-transparent p-0 md:block ${
botsSidebarCollapsed ? "start-0" : "start-[308px]"
}`}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
botsSidebarEdgeDragRef.current = {
startX: event.clientX,
mode: botsSidebarCollapsed ? "expand" : "collapse",
};
}}
onPointerMove={(event) => {
const drag = botsSidebarEdgeDragRef.current;
if (!drag) return;
const rtl =
typeof document !== "undefined" &&
document.documentElement.getAttribute("dir") === "rtl";
const delta = rtl ? drag.startX - event.clientX : event.clientX - drag.startX;
if (drag.mode === "expand" && delta >= BOTS_SIDEBAR_EDGE_DRAG_PX) {
botsSidebarEdgeDragRef.current = null;
setBotsSidebarCollapsedPref(false);
} else if (drag.mode === "collapse" && delta <= -BOTS_SIDEBAR_EDGE_DRAG_PX) {
botsSidebarEdgeDragRef.current = null;
setBotsSidebarCollapsedPref(true);
}
}}
onPointerUp={(event) => {
const drag = botsSidebarEdgeDragRef.current;
botsSidebarEdgeDragRef.current = null;
if (!drag) return;
if (Math.abs(event.clientX - drag.startX) < BOTS_SIDEBAR_EDGE_DRAG_PX) {
setBotsSidebarCollapsedPref(!botsSidebarCollapsed);
}
}}
onPointerCancel={() => {
botsSidebarEdgeDragRef.current = null;
}}
/>
<main
aria-hidden={mobileSidebarOpen || undefined}
inert={mobileSidebarOpen}
className="flex min-w-0 flex-1 flex-col bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]"
>
{activeNavPillar === "heute" ? (
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="app-drag flex items-center border-b border-sidebar-border px-3 py-[17px] md:px-[22px] lg:px-8 2xl:px-12">
<div className="flex min-w-0 items-center gap-2">
{botsSidebarCollapsed && desktopBridge() ? <WindowChrome /> : null}
<button
type="button"
aria-label={t`Open navigation`}
onClick={() => setMobileSidebarOpen(true)}
className="app-no-drag grid size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent md:hidden"
>
<Menu strokeWidth={1.7} className="size-icon-lg" />
</button>
<button
type="button"
data-testid="toggle-bots-sidebar"
aria-label={botsSidebarCollapsed ? t`Show bots` : t`Minimize bots`}
title={botsSidebarCollapsed ? t`Show bots` : t`Minimize bots`}
onClick={() => setBotsSidebarCollapsedPref(!botsSidebarCollapsed)}
className="app-no-drag hidden size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent md:grid"
>
{botsSidebarCollapsed ? (
<PanelLeftOpen strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
) : (
<PanelLeftClose strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
)}
</button>
</div>
</div>
<HeuteBrief
bots={bots}
groups={groups}
onOpenBot={(botId, spaceId) => {
setActiveNavPillar("team");
openSpaceChat(spaceId, `/app/${botId}`);
}}
onOpenGroup={(groupId, spaceId) => {
setActiveNavPillar("projekte");
openSpaceChat(spaceId, `/app/g/${groupId}`);
}}
onOpenRun={(run) => {
if (run.groupId) {
setActiveNavPillar("projekte");
navigate(`/app/g/${run.groupId}`);
} else {
setActiveNavPillar("team");
navigate(`/app/${run.botId}`);
}
}}
onStartChat={() => {
const first = bots[0];
if (first) {
setActiveNavPillar("team");
openSpaceChat(first.spaceId, `/app/${first.id}`);
return;
}
setCreateMenuOpen(true);
}}
/>
</div>
) : (
<>
<div className="app-drag flex items-center justify-between border-b border-sidebar-border px-3.5 py-2 md:px-6 md:py-3 lg:px-8 lg:py-4 2xl:px-12 2xl:py-6 bg-background/80 backdrop-blur-md">
<div className="flex min-w-0 items-center gap-2.5">
{/* Collapsed bots sidebar: this header is the leading edge for window chrome. */}
{botsSidebarCollapsed && desktopBridge() ? <WindowChrome /> : null}
<button
type="button"
aria-label={t`Open navigation`}
onClick={() => setMobileSidebarOpen(true)}
className="app-no-drag grid size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent md:hidden"
>
<Menu strokeWidth={1.7} className="size-icon-lg" />
</button>
<button
type="button"
data-testid="toggle-bots-sidebar"
aria-label={botsSidebarCollapsed ? t`Show bots` : t`Minimize bots`}
title={botsSidebarCollapsed ? t`Show bots` : t`Minimize bots`}
onClick={() => setBotsSidebarCollapsedPref(!botsSidebarCollapsed)}
className="app-no-drag hidden size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent md:grid"
>
{botsSidebarCollapsed ? (
<PanelLeftOpen strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
) : (
<PanelLeftClose strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
)}
</button>
{inGroup && projectSessionKey ? (
<button
type="button"
aria-label={t`Back to timeline`}
onClick={closeProjectSession}
className="app-no-drag grid size-control-lg shrink-0 place-items-center rounded-lg text-foreground/75 hover:bg-accent"
>
<ArrowLeft strokeWidth={1.8} aria-hidden="true" className="size-icon-lg" />
</button>
) : null}
<button
type="button"
data-testid="bot-settings-trigger"
onClick={() => setPanel(inGroup ? "group-settings" : "settings")}
className="app-no-drag flex min-w-0 items-center gap-3"
>
{!inGroup && active ? (
<BotAvatar
color={active.color}
identity={active.id}
size={36}
status={active.status}
className="shrink-0"
/>
) : inGroup ? (
<GroupAvatar
members={activeGroup?.members ?? activeSnapshot?.members ?? []}
size={36}
className="shrink-0"
/>
) : null}
<div className="min-w-0 text-start flex flex-col justify-center">
<span
className="block truncate text-ui-body font-semibold text-foreground tracking-tight leading-snug"
dir="auto"
>
{inGroup
? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`)
: (active?.name ?? t`Unterhaltung wählen`)}
</span>
{!inGroup && active && (composerRunning || active.status !== "idle") ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{composerRunning || active.status === "running"
? t`Beschäftigt`
: active.status}
</span>
) : inGroup && projectSessionKey ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`Gruppenunterhaltung`}
</span>
) : inGroup && activeGroup?.kind !== "main" ? (
<span className="block truncate text-ui-body-sm font-medium text-muted-foreground/80 leading-snug">
{t`Projekt · Sessions & Ordner`}
</span>
) : null}
</div>
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
title={t`Browser`}
aria-label={t`Browser`}
onClick={() => setPanel(panel === "browser" ? null : "browser")}
data-active={panel === "browser" ? "" : undefined}
className="app-no-drag grid h-[30px] w-[34px] place-items-center rounded-[9px] hover:bg-accent data-active:bg-accent"
>
<Globe strokeWidth={1.7} className="text-foreground/75 size-icon-lg" />
</button>
{!inGroup && active ? (
<button
type="button"
title={t`Agent computer`}
onClick={() => {
const next = panel === "computer" ? null : "computer";
setPanel(next);
if (next === "computer" && active) {
// Refresh run/computer so Take control isn't stuck on a stale busyBotName.
void refreshThread(active.id).catch(() => undefined);
}
}}
data-active={panel === "computer" ? "" : undefined}
className="app-no-drag grid h-[30px] w-[34px] place-items-center rounded-[9px] hover:bg-accent data-active:bg-accent"
>
<PanelRight strokeWidth={1.7} className="text-foreground/75 size-icon-lg" />
</button>
) : null}
</div>
</div>
{!active && !activeGroup && emptyProjectSpace && initialBotsLoaded ? (
<div className="grid flex-1 place-items-center">
<Button
data-testid="project-start-main"
onClick={() => currentSpaceId && openProjectStart(currentSpaceId, "create")}
>
<Plus aria-hidden="true" className="size-icon-md" />
{t`Starten`}
</Button>
</div>
) : !active && !activeGroup && initialBotsLoaded ? (
<div className="grid flex-1 place-items-center">
<Button onClick={() => setShowCreateBotModal(true)}>
<Plus aria-hidden="true" className="size-icon-md" />
<Trans>Create new Bot</Trans>
</Button>
</div>
) : showProjectTimeline && bots.length > 0 ? (
<ProjectSessionTimeline
sectionId={activeGroup!.sectionId!}
sectionName={activeProjectSection?.name ?? activeGroup?.name ?? t`Projekt`}
groupId={groupId!}
groupName={activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`}
onOpenSession={openProjectSession}
onStartSession={openProjectChat}
onOpenGroupChat={openProjectGroupChat}
onStartVoiceFirst={(goalGroupId) => openProjectVoiceFirst(goalGroupId)}
/>
) : showProjectTimeline && bots.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12">
<p className="text-ui-body text-muted-foreground">
<Trans>Erstelle zuerst einen Bot, um Projekte starten zu können.</Trans>
</p>
<Button onClick={() => setShowCreateBotModal(true)}>
<Plus aria-hidden="true" className="size-icon-md" />
<Trans>Create new Bot</Trans>
</Button>
</div>
) : (
<>
{!callOpen && active && !inGroup ? (
<OoStage
botId={active.id}
botName={active.name}
botTitle={active.title}
botDescription={active.description}
botColor={active.color}
botStatus={active.status}
snapshot={activeSnapshot}
running={transcriptRunning}
hasMessages={transcriptMessages.length > 0}
voiceReady={Boolean(voiceStatus?.ready)}
onStartVoice={startVoiceEncounter}
/>
) : null}
{!callOpen && inGroup ? (
<OoGroupStage
groupName={activeGroup?.name ?? activeSnapshot?.groupName}
snapshot={activeSnapshot}
enrichMember={enrichStageMember}
voiceReady={Boolean(voiceStatus?.ready)}
onStartVoice={startVoiceEncounter}
/>
) : null}
<Transcript
key={activeSnapshot?.threadId}
encounterMode={encounterMode}
voiceEncounterActive={voiceEncounterActive}
activeName={
inGroup ? (activeGroup?.name ?? activeSnapshot?.groupName) : active?.name
}
onPromptSelect={(prompt) => {
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 ? (
<MainInbox bots={bots} onOpenBot={openBot} onOpenGroup={openGroup} />
) : null}
{recordingSkill ? (
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">
<Trans>Teaching in progress. Stop teaching before sending a new message.</Trans>
</div>
) : null}
{(active || activeGroup) && !showProjectTimeline ? (
<Composer
key={inGroup ? `group:${groupId}` : `bot:${active?.id}`}
activeName={
inGroup ? (activeGroup?.name ?? activeSnapshot?.groupName) : active?.name
}
running={composerRunning}
disabled={Boolean(recordingSkill)}
pendingAttachments={activePendingAttachments}
attachmentNotice={attachmentNotice}
sendError={sendError}
runError={displayedRunError}
runErrorId={displayedRunErrorId}
onRunErrorPresented={handleRunErrorPresented}
onDismissError={dismissComposerError}
sending={sending}
fileInputRef={fileInputRef}
onAttachmentPick={onAttachmentPick}
onRemoveAttachment={removeAttachment}
onSend={sendMessage}
onStop={stopRun}
onVoice={
active || inGroup
? () => {
startVoiceEncounter();
}
: undefined
}
voiceReady={Boolean(voiceStatus?.ready)}
voiceColor={voiceCallBot?.color}
replyTarget={activeReplyTarget}
replyQuote={activeReplyQuote}
replyTargetName={replyTargetName}
onClearReply={clearReply}
mentionTargets={composerMentionTargets}
agentSkills={agentSkills}
onSlashOpen={refreshAgentSkills}
onSlashAction={(action) => {
if (action === "chat-settings") {
setPanel(inGroup ? "group-settings" : "settings");
return;
}
if (action === "settings-general") {
openSettings("general");
return;
}
if (action === "settings-usage") {
void rpc.usage
.summary()
.then(setUsage)
.catch(() => undefined);
openSettings("usage");
}
}}
/>
) : null}
</>
)}
</main>
<aside
data-testid="side-panel"
data-panel={panel ?? "closed"}
style={{
width:
panel &&
(active ||
activeGroup ||
panel === "create" ||
panel === "create-group" ||
panel === "browser")
? rightSidebarWidth
: 0,
}}
className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl md:relative ${
panel &&
(
active ||
activeGroup ||
panel === "create" ||
panel === "create-group" ||
panel === "browser"
)
? "max-w-[384px] md:max-w-none opacity-100"
: "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0"
} ${!isResizingRight ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`}
>
<div
onPointerDown={startResizingRight}
className="absolute -left-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
/>
{panel === "browser" ? (
<InAppBrowser onClose={() => setPanel(null)} />
) : panel && (active || activeGroup || panel === "create" || panel === "create-group") ? (
<div className="rk-scroll h-full w-full overflow-y-auto px-5 lg:px-7 2xl:px-9 py-[17px]">
{panel !== "routine" &&
panel !== "create" &&
panel !== "create-group" &&
panel !== "group-settings" ? (
<div className="mb-4 flex items-center justify-between">
<span className="text-ui-body text-muted-foreground">
{panel === "settings" ? (
<Trans>Settings</Trans>
) : active ? (
(computer?.state ?? active.status)
) : (
<Trans>Group</Trans>
)}
</span>
<div className="flex gap-1">
{active &&
panel === "computer" &&
!computerOpen &&
computerPanelNeedsMaintenance(computer?.state, booting) ? (
<ComputerMaintenanceActions
botId={active.id}
computer={computer}
onChanged={async () => {
await refreshThread(active.id);
}}
/>
) : null}
{active ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={panel === "settings" ? t`Show computer` : t`Show settings`}
onClick={() => setPanel(panel === "settings" ? "computer" : "settings")}
className={panel === "settings" ? "text-foreground" : "text-muted-foreground"}
>
<Settings strokeWidth={1.7} className="size-icon-md" />
</Button>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Close panel`}
onClick={() => setPanel(null)}
>
<X strokeWidth={1.8} className="size-icon-md" />
</Button>
</div>
</div>
) : null}
{panel === "computer" && active ? (
<div>
<div
data-testid="computer-preview"
className="group relative aspect-[16/10] overflow-hidden rounded-[14px] bg-background"
>
{computerOpen ? (
<div className="grid h-full place-items-center text-sm text-muted-foreground/80">
<Trans>Open in full window</Trans>
</div>
) : computer?.kind === "desktop" ? (
<DesktopKindEmptyState className="grid h-full place-items-center px-6 text-center text-sm text-muted-foreground/80" />
) : computer?.state === "running" && embeddedScreenUrl && !computerScreenError ? (
<>
<iframe
title={t`Bot screen preview`}
src={embeddedScreenUrl}
sandbox={screenIframeSandbox(embeddedScreenUrl)}
className="h-full w-full border-0 bg-black"
allow="clipboard-read; clipboard-write"
style={{ pointerEvents: "none" }}
/>
<AgentCursor botId={active.id} color={active.color} name={active.name} />
</>
) : (
<div className="grid h-full place-items-center px-6 text-center text-sm text-muted-foreground/80">
{computerScreenError ??
(computersAreUnavailable(bootstrapMe?.sandboxProvider) ? (
<ComputersUnavailableHint />
) : (
computerPlaceholder(
computer?.state,
booting,
computerLabel(computer?.mode, active.name),
)
))}
</div>
)}
{!computerScreenError ? (
<button
type="button"
data-testid="computer-preview-open"
className="absolute inset-0 flex cursor-pointer items-center justify-center bg-overlay/40 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label={t`Open`}
onClick={() => void openComputer()}
>
<span className="inline-flex items-center gap-2 rounded-full bg-overlay px-3.5 py-2 text-ui-body text-foreground shadow-md">
<Maximize2 strokeWidth={1.9} aria-hidden className="size-icon-md" />
<Trans>Open</Trans>
</span>
</button>
) : null}
</div>
<p className="mt-2 truncate text-ui-body text-muted-foreground" dir="auto">
{t`${active.name}'s screen`}
</p>
<RoutineListHeader
onCreate={() => {
setRoutineDraft(emptyRoutineDraft());
setRoutineWebhookSecret(null);
setEditingRoutine(null);
setRoutineError(null);
setPanel("routine");
}}
/>
{activeRoutines.map((routine) => {
const routineRunning =
snapshot?.run?.routineId === routine.id && isActive(snapshot.run.status);
return (
<RoutineListRow
key={routine.id}
routine={routine}
running={routineRunning}
onOpen={() => {
setRoutineDraft(draftFromRoutine(routine));
setRoutineWebhookSecret(null);
setEditingRoutine(routine);
setRoutineError(null);
setPanel("routine");
}}
onStop={() => void stopRun()}
/>
);
})}
</div>
) : null}
{panel === "create-group" ? (
<CreateGroupForm
bots={emptyProjectSpace ? (homeSpaceBots as Bot[]) : bots}
onCancel={() => setPanel(null)}
onCreate={(input) => createGroup(input)}
/>
) : null}
{panel === "group-settings" && activeGroup ? (
<GroupSettings
key={activeGroup.id}
group={activeGroup}
bots={bots}
onSave={async (input) => {
const updated = await rpc.groups.update({ groupId: activeGroup.id, ...input });
setGroups((current) =>
current.map((group) => (group.id === updated.id ? updated : group)),
);
setPanel(null);
await Promise.all([refreshBots(), refreshGroupThread(activeGroup.id)]).catch(
() => undefined,
);
}}
onRemove={async () => {
await rpc.groups.remove({ groupId: activeGroup.id });
const remainingGroups = groups.filter((group) => group.id !== activeGroup.id);
setGroups(remainingGroups);
setPanel(null);
navigate(firstThreadRoute(bots, remainingGroups), { replace: true });
await refreshBots().catch(() => undefined);
}}
/>
) : null}
{panel === "create" ? (
<CreateBotForm
onCancel={() => setPanel(null)}
onCreate={(input) => createBot(input)}
/>
) : null}
{/* Centered Create Bot Modal */}
{showCreateBotModal &&
createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<button
type="button"
className="absolute inset-0 bg-overlay"
aria-label={t`Close`}
onClick={() => setShowCreateBotModal(false)}
/>
<div className="relative z-10 max-h-[90vh] w-[480px] overflow-y-auto rounded-2xl border border-border bg-popover p-6 text-popover-foreground shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
<Trans>Create new Bot</Trans>
</h2>
<button
type="button"
onClick={() => setShowCreateBotModal(false)}
className="text-muted-foreground hover:text-foreground"
aria-label={t`Close`}
>
<X strokeWidth={1.8} className="size-icon-md" />
</button>
</div>
<CreateBotForm
onCancel={() => setShowCreateBotModal(false)}
onCreate={async (input) => {
await createBot(input);
}}
/>
</div>
</div>,
document.body,
)}
{panel === "settings" && active ? (
<BotSettings
key={active.id}
bot={active}
memoryProviderConfigured={memoryProviderConfig != null}
onSkillsChange={setAgentSkills}
onSave={async ({ computerMode, ...patch }) => {
if (computerMode !== active.computerMode) {
await rpc.bots.setComputer({
botId: active.id,
mode: computerMode,
});
}
await rpc.bots.update({ botId: active.id, ...patch });
await refreshBots();
}}
onExport={async () => {
const manifest = await rpc.export.bot({ botId: active.id });
const blob = new Blob([JSON.stringify(manifest, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${active.name.toLowerCase().replace(/\s+/g, "-")}-export.json`;
a.click();
URL.revokeObjectURL(url);
}}
onClear={() => setClearTarget({ kind: "bot", chat: active })}
/>
) : null}
{panel === "routine" && active ? (
<RoutineEditor
draft={routineDraft}
onChange={setRoutineDraft}
editing={editingRoutine}
timezone={editingRoutine?.timezone ?? localTimezone()}
webhook={{
path:
typeof window !== "undefined"
? `${window.location.origin}/api/v1/bots/${active.id}/webhook`
: `/api/v1/bots/${active.id}/webhook`,
secret: routineWebhookSecret,
configured: active.webhookConfigured || Boolean(routineWebhookSecret),
}}
githubPath={
typeof window !== "undefined"
? `${window.location.origin}/api/v1/bots/${active.id}/github`
: `/api/v1/bots/${active.id}/github`
}
messageProviders={messagingProviders}
saving={savingRoutine}
running={runningRoutine}
error={routineError}
onBack={() => setPanel("computer")}
onClose={() => setPanel(null)}
onEnsureWebhook={async () => {
await ensureWebhookSecret(active.id);
}}
onSave={async () => {
if (routineSavePending.current) return;
const targetBotId = active.id;
const targetRoutine = editingRoutine;
if (targetRoutine && targetRoutine.botId !== targetBotId) return;
if (
!routineDraft.schedules.length &&
!routineDraft.webhookEnabled &&
!routineDraft.githubEnabled &&
!routineDraft.messageProvider
) {
setRoutineError(t`Add a schedule, webhook, GitHub, or message trigger`);
return;
}
const saveRequest = ++routineSaveRequest.current;
routineSavePending.current = true;
setSavingRoutine(true);
setRoutineError(null);
try {
if (
(routineDraft.webhookEnabled || routineDraft.githubEnabled) &&
!active.webhookConfigured &&
!routineWebhookSecret
) {
await ensureWebhookSecret(targetBotId);
}
const crons = routineDraft.schedules.map(cronFromPreset);
let saved: Routine;
if (targetRoutine) {
const armOneShot = routineNeedsOneShotArm(targetRoutine, crons);
let runAt: string | undefined;
if (armOneShot) {
if (!routineDraft.runAtLocal) {
setRoutineError(t`Add a run time for this one-shot.`);
return;
}
const parsed = new Date(routineDraft.runAtLocal);
if (!Number.isFinite(parsed.getTime()) || parsed.getTime() <= Date.now()) {
setRoutineError(t`Run time must be in the future.`);
return;
}
runAt = parsed.toISOString();
}
saved = await rpc.routines.update({
routineId: targetRoutine.id,
name: routineDraft.name || t`Routine`,
prompt: routineDraft.prompt || t`Check in.`,
crons,
active: armOneShot ? true : routineDraft.active,
webhookEnabled: routineDraft.webhookEnabled,
githubEnabled: routineDraft.githubEnabled,
messageProvider: routineDraft.messageProvider,
...(runAt ? { runAt } : {}),
});
} else {
saved = await rpc.routines.create({
botId: targetBotId,
name: routineDraft.name || t`Routine`,
prompt: routineDraft.prompt || t`Check in.`,
crons,
timezone: localTimezone(),
active: routineDraft.active,
notify: true,
webhookEnabled: routineDraft.webhookEnabled,
githubEnabled: routineDraft.githubEnabled,
messageProvider: routineDraft.messageProvider,
});
}
if (
routineSaveRequest.current === saveRequest &&
activeBotId.current === targetBotId
) {
setEditingRoutine(saved);
setRoutineDraft(draftFromRoutine(saved));
}
} catch (error) {
if (
routineSaveRequest.current !== saveRequest ||
activeBotId.current !== targetBotId
) {
return;
}
setRoutineError(
error instanceof Error ? error.message : t`Could not save routine`,
);
return;
} finally {
routineSavePending.current = false;
setSavingRoutine(false);
}
if (
routineSaveRequest.current !== saveRequest ||
activeBotId.current !== targetBotId
) {
return;
}
await refreshThread(targetBotId).catch(() => undefined);
}}
onTestRun={async () => {
if (routineRunPending.current) return;
const targetBotId = active.id;
const targetRoutine = editingRoutine;
if (!targetRoutine) return;
routineRunPending.current = true;
setRunningRoutine(true);
setRoutineError(null);
try {
await rpc.routines.testRun({ routineId: targetRoutine.id });
await refreshThread(targetBotId);
} catch (error) {
if (activeBotId.current === targetBotId) {
setRoutineError(
error instanceof Error ? error.message : t`Could not run routine`,
);
}
} finally {
routineRunPending.current = false;
setRunningRoutine(false);
}
}}
onDelete={() => {
if (editingRoutine) {
setDeleteRoutineTarget(editingRoutine);
return;
}
setPanel("computer");
}}
/>
) : null}
</div>
) : null}
</aside>
<Suspense fallback={null}>
{contextChat && botMenu ? (
<BotContextMenu
bot={contextChat}
position={botMenu.position}
onClose={closeBotMenu}
sections={botSections}
onTogglePinned={() => {
setBotMenu(null);
const request = contextBot
? rpc.bots.update({ botId: contextBot.id, pinned: !contextBot.pinned })
: rpc.groups.update({
groupId: contextGroup!.id,
pinned: !contextGroup!.pinned,
});
void request.then(() => refreshBots());
}}
onToggleUnread={() => {
const unread = !contextChat.unread;
setBotMenu(null);
if (contextBot) {
const request = unread ? markBotUnread(contextBot.id) : markBotRead(contextBot.id);
void request.catch(() => undefined);
} else {
const request = unread
? rpc.threads.markUnread({ groupId: contextGroup!.id })
: rpc.threads.markRead({ groupId: contextGroup!.id });
void request
.then(() =>
setGroups((current) =>
current.map((group) =>
group.id === contextGroup!.id ? { ...group, unread } : group,
),
),
)
.catch(() => undefined);
}
}}
onMoveToSection={(sectionId) => {
setBotMenu(null);
if (sectionId === contextChat.sectionId) return;
const request = contextBot
? rpc.bots.update({ botId: contextBot.id, sectionId })
: rpc.groups.update({ groupId: contextGroup!.id, sectionId });
void request.then(() => refreshBots());
}}
onCreateSection={() => {
setNewSectionTarget(
contextBot
? { kind: "bot", chat: contextBot }
: { kind: "group", chat: contextGroup! },
);
setBotMenu(null);
}}
onRenameSection={(sectionId) => {
const section = botSections.find((item) => item.id === sectionId);
const spaceId = bootstrapMe?.spaceId;
if (section && spaceId) setRenameSectionTarget({ section, spaceId });
setBotMenu(null);
}}
onEdit={() => {
navigate(contextBot ? `/app/${contextBot.id}` : `/app/g/${contextGroup!.id}`);
setPanel(contextBot ? "settings" : "group-settings");
setBotMenu(null);
}}
onDuplicate={() => {
setBotMenu(null);
const request = contextBot
? rpc.bots.duplicate({ botId: contextBot.id })
: rpc.groups.duplicate({ groupId: contextGroup!.id });
void request.then(async (chat) => {
await refreshBots();
navigate(contextBot ? `/app/${chat.id}` : `/app/g/${chat.id}`);
});
}}
onClear={() => {
setClearTarget(
contextBot
? { kind: "bot", chat: contextBot }
: { kind: "group", chat: contextGroup! },
);
setBotMenu(null);
}}
onArchive={() => {
setBotMenu(null);
const request = contextBot
? rpc.bots.archive({ botId: contextBot.id })
: rpc.groups.archive({ groupId: contextGroup!.id });
void request.then(() => refreshBots(true));
}}
onDelete={() => {
if (contextBot) setDeleteTarget(contextBot);
else setDeleteGroupTarget(contextGroup!);
setBotMenu(null);
}}
/>
) : null}
{spaceMenu ? (
<DropdownMenu
open
onOpenChange={(open) => {
if (!open) closeSpaceMenu();
}}
>
{/* Invisible anchor at the pointer position, mirroring the bot menu. */}
<DropdownMenuTrigger
render={
<button
type="button"
tabIndex={-1}
aria-hidden
className="fixed size-0 p-0 opacity-0"
style={{ left: spaceMenu.position.x, top: spaceMenu.position.y }}
/>
}
/>
<DropdownMenuContent
aria-label={t`Actions for space`}
align="start"
sideOffset={0}
className="w-[220px]"
>
<DropdownMenuItem
variant="destructive"
onClick={() => {
const target = spaces.find((space) => space.id === spaceMenu.id);
if (target) setDeleteSpaceTarget(target);
setSpaceMenu(null);
}}
>
<Trash2 />
{t`Delete space`}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{deleteTarget ? (
<DeleteBotDialog
bot={deleteTarget}
onCancel={() => setDeleteTarget(null)}
onConfirm={async (deleteMemories) => {
await rpc.bots.remove({ botId: deleteTarget.id, deleteMemories });
setDeleteTarget(null);
setPanel(null);
await refreshBots(true);
}}
/>
) : null}
{deleteGroupTarget ? (
<DeleteItemDialog
item={deleteGroupTarget}
noun="group"
onCancel={() => setDeleteGroupTarget(null)}
onConfirm={async () => {
await rpc.groups.remove({ groupId: deleteGroupTarget.id });
setDeleteGroupTarget(null);
setPanel(null);
await refreshBots(true);
}}
/>
) : null}
{deleteSpaceTarget ? (
<DeleteItemDialog
item={deleteSpaceTarget}
noun="space"
description={
<Trans>Only empty spaces can be deleted. Delete its bots and groups first.</Trans>
}
onCancel={() => setDeleteSpaceTarget(null)}
onConfirm={async () => {
const targetId = deleteSpaceTarget.id;
const result = await rpc.spaces.remove({ spaceId: targetId });
setDeleteSpaceTarget(null);
setPanel(null);
const effectiveSpaceId = selectedSpaceId() ?? bootstrapMe?.spaceId;
if (effectiveSpaceId === targetId) {
// The auth boundary changed, so reload like a space switch.
if (selectSpace(result.activeSpaceId)) {
window.location.assign("/app");
return;
}
}
await refreshBots(true);
}}
/>
) : null}
{newSectionTarget ? (
<NewBotSectionDialog
bot={newSectionTarget.chat}
onCancel={() => setNewSectionTarget(null)}
onConfirm={async (name) => {
await rpc.botSections.create(
newSectionTarget.kind === "bot"
? { botId: newSectionTarget.chat.id, name }
: { groupId: newSectionTarget.chat.id, name },
);
setNewSectionTarget(null);
await refreshBots();
}}
/>
) : null}
{renameSectionTarget ? (
<RenameBotSectionDialog
section={renameSectionTarget.section}
onCancel={() => setRenameSectionTarget(null)}
onConfirm={async (name) => {
await rpc.botSections.update(
{
sectionId: renameSectionTarget.section.id,
name,
},
{ context: { spaceId: renameSectionTarget.spaceId } },
);
setRenameSectionTarget(null);
await refreshBots();
}}
/>
) : null}
{sectionMenu ? (
<DropdownMenu
open
onOpenChange={(open) => {
if (!open) closeSectionMenu();
}}
>
<DropdownMenuTrigger
render={
<button
type="button"
tabIndex={-1}
aria-hidden
className="fixed size-0 p-0 opacity-0"
style={{ left: sectionMenu.position.x, top: sectionMenu.position.y }}
/>
}
/>
<DropdownMenuContent
aria-label={t`Actions for ${sectionMenu.section.name}`}
align="start"
sideOffset={0}
className="w-[220px]"
>
<DropdownMenuItem
onClick={() => {
setRenameSectionTarget({
section: sectionMenu.section,
spaceId: sectionMenu.spaceId,
});
setSectionMenu(null);
}}
>
<Pencil />
{t`Rename section`}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<CommandPalette
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
bots={bots}
onSelectBot={(id) => {
setMobileSidebarOpen(false);
navigate(`/app/${id}`);
}}
/>
{newSpaceOpen ? (
<NewSpaceDialog
variant={newSpaceDialogVariant}
canLinkFolder={bootstrapMe?.isDeploymentOwner === true}
onCancel={() => setNewSpaceOpen(false)}
onConfirm={async ({ name, workspacePath }) => {
const space = await rpc.spaces.create({
name,
...(workspacePath ? { workspacePath } : {}),
});
setNewSpaceOpen(false);
if (!selectSpace(space.id)) {
await refreshBots();
return;
}
window.location.assign("/app");
}}
/>
) : null}
{pickerInfoTopic ? (
<PickerInfoDialog topic={pickerInfoTopic} onClose={() => setPickerInfoTopic(null)} />
) : null}
{clearTarget ? (
<ClearConversationDialog
bot={clearTarget.chat}
onCancel={() => setClearTarget(null)}
onConfirm={async () => {
await rpc.threads.clear(
clearTarget.kind === "bot"
? { botId: clearTarget.chat.id }
: { groupId: clearTarget.chat.id },
);
if (
(clearTarget.kind === "bot" && active?.id === clearTarget.chat.id) ||
(clearTarget.kind === "group" && activeGroup?.id === clearTarget.chat.id)
) {
expandedHistoryThread.current = null;
pinnedAroundRef.current = null;
historyEpoch.current += 1;
updateSnapshot((current) =>
current ? { ...current, messages: [], olderCursor: null, run: null } : current,
);
}
setClearTarget(null);
await refreshBots();
}}
/>
) : null}
{deleteRoutineTarget ? (
<DeleteItemDialog
item={deleteRoutineTarget}
noun="routine"
onCancel={() => setDeleteRoutineTarget(null)}
onConfirm={async () => {
const target = deleteRoutineTarget;
await rpc.routines.remove({ routineId: target.id });
setDeleteRoutineTarget(null);
setEditingRoutine((current) => (current?.id === target.id ? null : current));
if (activeBotId.current !== target.botId) return;
await refreshThread(target.botId);
if (activeBotId.current === target.botId) setPanel("computer");
}}
/>
) : null}
{pluginsOpen ? (
<PluginsOverlay
activeBotId={activeBotId.current}
onClose={() => setPluginsOpen(false)}
onOpenMcp={() => {
setPluginsOpen(false);
setMcpOpen(true);
}}
/>
) : null}
{mcpOpen ? <McpServersOverlay onClose={() => setMcpOpen(false)} /> : null}
{messagingSettingsOpen ? (
<MessagingSettingsOverlay onClose={() => setMessagingSettingsOpen(false)} />
) : null}
</Suspense>
<Suspense fallback={null}>
{settingsOpen ? (
<SettingsOverlay
name={userName}
email={session.data?.user.email}
usage={usage}
initialSection={settingsSection}
avatarStyle={bootstrapMe?.avatarStyle ?? "robot"}
isDeploymentOwner={bootstrapMe?.isDeploymentOwner === true}
sandboxProvider={bootstrapMe?.sandboxProvider}
messagingEnabled={messagingSurfaceEnabled}
onOpenMessaging={() => {
setSettingsOpen(false);
setMessagingSettingsOpen(true);
}}
tradingStatus={tradingStatus}
onAvatarStyleChange={async (avatarStyle) => {
const nextMe = await rpc.preferences.update({ avatarStyle });
setBootstrapMe(nextMe);
}}
memoryConfig={memoryProviderConfig}
onMemoryConfigChange={(config) => {
memoryProviderConfigRevision.current += 1;
setMemoryProviderConfig(config);
}}
onVoiceStatusMaybeChanged={async () => {
try {
setVoiceStatus(
await Promise.race([rpc.voice.status(), voiceStatusRefreshTimeout()]),
);
} catch {
// Prefer reopening Voice settings over CallView with stale readiness.
setVoiceStatus(null);
}
}}
activeBotId={activeBotId.current}
onOpenMcp={() => {
setSettingsOpen(false);
setMcpOpen(true);
}}
onClose={() => {
setSettingsOpen(false);
setSettingsSection("general");
}}
/>
) : null}
{peerConversation && active ? (
<PeerMessagesOverlay
botId={active.id}
botName={active.name}
botColor={active.color}
peerBotId={peerConversation.peerBotId}
peerBotName={peerConversation.peerBotName}
peerBotColor={
resolveTranscriptBot(peerConversation.peerBotId)?.color ?? FALLBACK_BOT_COLOR
}
onClose={() => setPeerConversation(null)}
/>
) : null}
{callOpen && voiceCallBot ? (
<CallView
botId={voiceCallBot.id}
botName={voiceCallBot.name}
botTitle={voiceCallBot.title}
botDescription={voiceCallBot.description}
botColor={voiceCallBot.color}
botStatus={voiceCallBot.status}
transcribe={Boolean(voiceStatus?.transcribe)}
snapshot={activeSnapshot}
onSend={sendMessage}
onFollowUp={followUpMessage}
onAnswer={answerMessage}
onClose={closeVoiceStage}
/>
) : null}
</Suspense>
{booting ? (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-[22px] bg-background/95">
<div className="text-[19px] font-medium text-foreground">
<Trans>Booting up {active?.name}’s computer</Trans>
</div>
<div className="h-[5px] w-[min(420px,70%)] overflow-hidden rounded-full bg-accent">
<div className="h-full w-2/3 rounded-full bg-primary" />
</div>
</div>
) : computerOpen && active ? (
<div className="fixed inset-0 z-30 bg-background">
<div
data-testid="computer-viewport"
className="fixed inset-x-0 top-0 flex flex-col bg-background"
style={{
height: computerViewport ? `${computerViewport.height}px` : "100dvh",
top: computerViewport ? `${computerViewport.offsetTop}px` : undefined,
}}
>
<div
data-testid="computer-chrome"
className="flex items-center justify-between gap-4 border-b border-sidebar-border px-[18px] py-3.5"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<BotAvatar
color={active.color}
identity={active.id}
size={28}
status={active.status}
/>
{recordingSkill ? (
<TeachRecordingChrome
recording={recordingSkill}
busy={teachBusy}
onStop={stopTeaching}
variant="overlay"
/>
) : (
<span className="truncate text-ui-body font-medium text-foreground" dir="auto">
{computerLabel(computer?.mode, active.name)}
</span>
)}
{!recordingSkill && hasControl ? (
computer?.takeoverRequested ? (
<span className="rounded-full bg-warning/15 px-[11px] py-1 text-ui-body-sm text-warning">
<Trans>Needs you</Trans>
</span>
) : (
<span className="rounded-full bg-success/15 px-[11px] py-1 text-ui-body-sm text-success">
<Trans>You have control</Trans>
</span>
)
) : null}
</div>
<div className="flex items-center gap-3">
{composerRunning ? (
<Button
type="button"
variant="outline"
size="sm"
aria-label={t`Stop`}
data-testid="computer-overlay-stop"
onClick={() => void stopRun()}
disabled={sending}
>
<Trans>Stop</Trans>
</Button>
) : null}
{recordingSkill ? (
<TeachStopButton busy={teachBusy} onStop={stopTeaching} />
) : hasControl ? (
<ComputerReleaseActions
takeoverRequested={Boolean(computer?.takeoverRequested)}
onRelease={releaseComputer}
/>
) : null}
{active && !recordingSkill ? (
<TeachComputerOverlayControl
key={active.id}
botId={active.id}
computer={computer}
busy={teachBusy}
onRefresh={refreshActiveTeaching}
/>
) : null}
{active && !recordingSkill ? (
<ComputerMaintenanceActions
botId={active.id}
computer={computer}
onChanged={async () => {
await refreshThread(active.id);
}}
/>
) : null}
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label={t`Close computer`}
onClick={() => setComputerOpen(false)}
>
<X strokeWidth={1.8} className="size-icon-md" />
</Button>
</div>
</div>
{sendError ? (
<div
role="alert"
className="border-b border-destructive/40 bg-destructive/10 px-[18px] py-2 text-ui-body-sm text-destructive"
>
{sendError}
</div>
) : null}
<div className="relative min-h-0 flex-1 bg-background">
{computer?.kind === "desktop" ? (
<DesktopKindEmptyState className="grid h-full place-items-center px-8 text-center text-sm text-muted-foreground/80" />
) : computer?.state === "running" && embeddedScreenUrl && !computerScreenError ? (
<>
<iframe
title={t`Bot screen`}
src={embeddedScreenUrl}
sandbox={screenIframeSandbox(embeddedScreenUrl)}
className="h-full w-full border-0 bg-black"
allow="clipboard-read; clipboard-write; fullscreen"
style={{
pointerEvents: recordingSkill || !hasControl ? "none" : "auto",
}}
/>
{active && !hasControl ? (
<AgentCursor botId={active.id} color={active.color} name={active.name} />
) : null}
{active ? (
<TeachCaptureOverlay
botId={active.id}
skill={recordingSkill}
enabled={Boolean(recordingSkill)}
screenWidth={computer?.screenWidth}
screenHeight={computer?.screenHeight}
/>
) : null}
</>
) : (
<div className="grid h-full place-items-center text-sm text-muted-foreground/80">
{computerScreenError ??
(computer?.state === "suspended"
? t`Computer is asleep`
: computerLabel(computer?.mode, active.name))}
</div>
)}
</div>
</div>
</div>
) : null}
</div>
);
return <AvatarStyleProvider value="organic">{shell}</AvatarStyleProvider>;
}
const Transcript = memo(function Transcript({
scrollRef,
artifactTarget,
messages,
olderCursor,
loadingOlder,
answerableAskMessageId,
running,
workingBots,
onLoadOlder,
onOpenBot,
onOpenGroup,
onAnswer,
onReply,
onQuote,
onReact,
onJumpToMessage,
onOpenPeerMessages,
memberName,
peerBot,
onRefresh,
onBotChanged,
onAddRoutine,
voiceReady,
speakingMessageId,
onSpeak,
activeName,
onPromptSelect,
encounterMode = false,
voiceEncounterActive = false,
}: {
scrollRef: RefObject<HTMLDivElement | null>;
artifactTarget: ArtifactTarget;
messages: ThreadMessage[];
olderCursor: number | null;
loadingOlder: boolean;
answerableAskMessageId: string | null;
running: boolean;
workingBots: GroupAvatarMember[];
onLoadOlder: () => void | Promise<void>;
onOpenBot: (botId: string) => void;
onOpenGroup: (groupId: string) => void;
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
onReply: (message: ThreadMessage) => void;
onQuote: (message: ThreadMessage, quote: string) => void;
onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>;
onJumpToMessage: (messageId: string) => void;
onOpenPeerMessages: (peer: { peerBotId: string; peerBotName: string }) => void;
memberName?: (botId: string | undefined) => string | undefined;
peerBot: (botId: string) => { color: string; status?: string } | undefined;
onRefresh: () => Promise<void>;
onBotChanged: () => Promise<void>;
onAddRoutine: (name: string, prompt: string) => void;
voiceReady: boolean;
speakingMessageId: string | null;
onSpeak: (message: ThreadMessage) => void;
activeName?: string;
onPromptSelect?: (prompt: string) => void;
encounterMode?: boolean;
voiceEncounterActive?: boolean;
}) {
const { t } = useLingui();
const [atEnd, setAtEnd] = useState(true);
const following = useRef(true);
const autoScrolling = useRef(false);
const lastScrollTop = useRef<number | null>(null);
const autoScrollTimer = useRef<number | undefined>(undefined);
const jumpButtonRef = useRef<HTMLButtonElement>(null);
const messageById = useMemo(
() => new Map(messages.map((message) => [message.id, message])),
[messages],
);
const reactionView = useMemo(() => projectMessageReactions(messages), [messages]);
const transcriptSubordinate = encounterMode && voiceEncounterActive;
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
const workingLabel =
workingBotName != null && workingBotName !== ""
? t`${workingBotName} is working`
: t`Bots are working`;
const [quoteDraft, setQuoteDraft] = useState<{
message: ThreadMessage;
text: string;
range: Range;
} | null>(null);
const selectingWithMouse = useRef(false);
const evaluateSelection = useCallback(() => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
setQuoteDraft(null);
return;
}
const range = selection.getRangeAt(0);
const rowOf = (node: Node) =>
(node instanceof Element ? node : node.parentElement)?.closest<HTMLElement>(
"[data-message-id]",
) ?? null;
const draft = quoteDraftForSelection(
{
startRow: rowOf(range.startContainer),
endRow: rowOf(range.endContainer),
text: selection.toString(),
},
messageById,
);
setQuoteDraft(draft ? { ...draft, range } : null);
}, [messageById]);
// Keyboard and assistive-tech selections never reach a mouseup, so the pill
// lifecycle listens on selectionchange; the mouse flag keeps it hidden while
// a drag is still in flight.
useEffect(() => {
const onMouseDown = (event: MouseEvent) => {
selectingWithMouse.current = true;
if ((event.target as Element | null)?.closest?.("[data-quote-selection]")) return;
setQuoteDraft(null);
};
const onMouseUp = () => {
selectingWithMouse.current = false;
evaluateSelection();
};
const onSelectionChange = () => {
if (!selectingWithMouse.current) evaluateSelection();
};
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setQuoteDraft(null);
};
document.addEventListener("mousedown", onMouseDown, true);
document.addEventListener("mouseup", onMouseUp, true);
document.addEventListener("selectionchange", onSelectionChange);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("mouseup", onMouseUp, true);
document.removeEventListener("selectionchange", onSelectionChange);
document.removeEventListener("keydown", onKey);
};
}, [evaluateSelection]);
const snapToEnd = useCallback(() => {
const element = scrollRef.current;
if (!element) return;
following.current = true;
autoScrolling.current = false;
setAtEnd(true);
element.scrollTo({ top: element.scrollHeight, behavior: "auto" });
}, [scrollRef]);
const jumpToLatest = useCallback(() => {
const element = scrollRef.current;
if (!element) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
following.current = true;
autoScrolling.current = !reducedMotion;
setAtEnd(true);
element.scrollTo({
top: element.scrollHeight,
behavior: reducedMotion ? "auto" : "smooth",
});
window.clearTimeout(autoScrollTimer.current);
// Fallback only: onScroll clears autoScrolling once near-end is reached.
autoScrollTimer.current = window.setTimeout(
() => {
autoScrolling.current = false;
},
reducedMotion ? 0 : 2_000,
);
}, [scrollRef]);
useLayoutEffect(() => {
if (following.current) snapToEnd();
}, [messages, running, snapToEnd]);
useLayoutEffect(() => {
const button = jumpButtonRef.current;
if (atEnd && button && document.activeElement === button) {
button.blur();
}
}, [atEnd]);
const loadOlder = useCallback(() => {
const wasFollowing = following.current;
// Prepend must not race the messages-driven snap-to-end follow path.
following.current = false;
autoScrolling.current = false;
const pending = onLoadOlder();
if (!pending) return;
return Promise.resolve(pending).catch((error) => {
const element = scrollRef.current;
if (wasFollowing && element && transcriptIsNearEnd(element)) {
following.current = true;
setAtEnd(true);
}
throw error;
});
}, [onLoadOlder, scrollRef]);
useEffect(
() => () => {
window.clearTimeout(autoScrollTimer.current);
},
[],
);
return (
// Clip the scroller's padding when the column leaves no room, e.g. beside the main inbox.
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div
ref={scrollRef}
data-testid="transcript"
onPointerDown={(event) => {
lastScrollTop.current = event.currentTarget.scrollTop;
autoScrolling.current = false;
following.current = false;
}}
onTouchStart={(event) => {
lastScrollTop.current = event.currentTarget.scrollTop;
autoScrolling.current = false;
following.current = false;
}}
onWheel={(event) => {
if (event.deltaY < 0) {
lastScrollTop.current = event.currentTarget.scrollTop;
autoScrolling.current = false;
following.current = false;
}
}}
onScroll={(event) => {
const scrolledDown = transcriptMovedDown(
lastScrollTop.current,
event.currentTarget.scrollTop,
);
lastScrollTop.current = event.currentTarget.scrollTop;
const nearEnd = transcriptIsNearEnd(event.currentTarget);
setAtEnd(nearEnd);
if (nearEnd) {
if (scrolledDown) following.current = true;
if (autoScrolling.current) {
autoScrolling.current = false;
window.clearTimeout(autoScrollTimer.current);
}
} else if (!autoScrolling.current) {
following.current = false;
}
}}
className={`rk-scroll flex min-h-0 flex-1 flex-col overflow-y-auto transition-opacity duration-300 ${
encounterMode ? "gap-2 px-3 py-3 md:px-6 md:py-4" : "gap-3.5 px-3.5 py-4 md:px-8 md:py-6"
} ${transcriptSubordinate ? "opacity-75" : ""}`}
>
{encounterMode ? (
<p className="mx-auto w-full max-w-2xl px-1 text-ui-label font-medium uppercase tracking-wider text-muted-foreground/80">
{voiceEncounterActive ? (
<Trans>Transkript — sekundär zur Stimme</Trans>
) : (
<Trans>Conversation record</Trans>
)}
</p>
) : null}
{olderCursor != null ? (
<button
type="button"
disabled={loadingOlder}
onClick={() => void loadOlder()}
className="self-center rounded-lg px-3 py-1.5 text-ui-body-sm text-muted-foreground hover:bg-muted hover:text-foreground/75 disabled:opacity-50"
>
{loadingOlder ? t`Loading…` : t`Load earlier messages`}
</button>
) : null}
<div
className={`mx-auto flex w-full flex-1 flex-col pb-6 ${
encounterMode ? "max-w-2xl gap-3" : "max-w-4xl gap-6 pb-8"
}`}
>
{reactionView.visibleMessages.length === 0 && encounterMode ? (
<div className="flex flex-col gap-2 py-2">
<p className="text-ui-body-sm text-muted-foreground">
{activeName
? t`Start the conversation — ${activeName} is on stage above.`
: t`Start the conversation.`}
</p>
<div className="flex flex-wrap gap-2">
{[t`Portfolio-Check`, t`Alpha-Signale`, t`Architektur-Audit`].map((label) => (
<button
key={label}
type="button"
onClick={() => onPromptSelect?.(label)}
className="rounded-full border border-border bg-muted/40 px-3 py-1 text-ui-body-sm text-foreground/85 hover:bg-accent"
>
{label}
</button>
))}
</div>
</div>
) : null}
{reactionView.visibleMessages.length === 0 && !encounterMode ? (
<div className="flex flex-1 flex-col items-center justify-center py-16 text-center select-none animate-in fade-in duration-500">
<div className="relative mb-5 flex items-center justify-center p-3">
<EvooMascot size={76} pose="greeting" interactive trackPointer="hover" />
</div>
<h2 className="text-3xl font-bold tracking-tight text-foreground mb-2">
{t`Guten Tag.`} evOo {t`ist bereit.`}
</h2>
<p className="max-w-md text-ui-body leading-relaxed text-foreground/85 mb-8 font-normal">
{activeName
? t`Frag ${activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews.`
: t`Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt.`}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5 w-full max-w-2xl px-4">
<button
type="button"
onClick={() => {
onPromptSelect?.(t`⚡ Führe einen autonomen Portfolio- und Risiko-Check durch`);
}}
className="apple-glass-card flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] cursor-pointer"
>
<span className="text-xl mb-2">⚡</span>
<span className="text-[14.5px] font-semibold text-foreground">
{t`Portfolio-Check`}
</span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
{t`Echtzeit-Risiko & Drawdown-Analyse`}
</span>
</button>
<button
type="button"
onClick={() => {
onPromptSelect?.(t`📈 Scanne den Markt nach aktuellen Alpha-Signalen`);
}}
className="apple-glass-card flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] cursor-pointer"
>
<span className="text-xl mb-2">📈</span>
<span className="text-[14.5px] font-semibold text-foreground">
{t`Alpha-Signale`}
</span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
{t`Volatilität & Orderbuch-Liquidität`}
</span>
</button>
<button
type="button"
onClick={() => {
onPromptSelect?.(t`💎 Führe ein Steve Jobs UX & Architektur-Audit durch`);
}}
className="apple-glass-card flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] cursor-pointer"
>
<span className="text-xl mb-2">💎</span>
<span className="text-[14.5px] font-semibold text-foreground">
{t`Steve Jobs Audit`}
</span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
{t`Schlichtheit, Eleganz & Detailtiefe`}
</span>
</button>
</div>
</div>
) : null}
{reactionView.visibleMessages.map((message) => {
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
const peerReceipt = isPeerReceiptBlocks(message.blocks);
const messageReactions = reactionView.reactions.get(message.id);
return (
<div
key={message.id}
data-message-id={message.id}
// Only persisted messages can be reply targets; synthetic rows
// (progress:, subagent:) carry a `prefix:` id.
data-quotable={message.id.includes(":") ? undefined : ""}
className={peerReceipt ? "relative py-0.5" : "group/message relative hover:z-20"}
>
{!peerReceipt && !message.id.startsWith("progress:") ? (
<time
dateTime={message.createdAt}
data-testid="message-hover-time"
className={cn(
"pointer-events-none absolute top-1 z-10 text-ui-label tabular-nums text-muted-foreground opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 group-has-[[aria-expanded=true]]/message:opacity-100",
message.role === "user" ? "start-0" : "end-0",
)}
>
{new Date(message.createdAt).toLocaleTimeString(i18n.locale || "en", {
hour: "numeric",
minute: "2-digit",
})}
</time>
) : null}
<div
className={
peerReceipt
? undefined
: `relative flex ${message.role === "user" ? "justify-end" : "justify-start"}`
}
>
<div
data-testid={peerReceipt ? undefined : "message-bubble-frame"}
className={
peerReceipt
? undefined
: `relative min-w-0 ${
message.role === "user"
? encounterMode
? "w-fit max-w-[92%] md:max-w-[min(78%,calc(100%_-_3rem))]"
: "w-fit max-w-[88%] md:max-w-[min(72%,calc(100%_-_5rem))]"
: "w-full max-w-full"
}`
}
>
{peerReceipt ? null : (
<MessageHoverActions
message={message}
side={message.role === "user" ? "start" : "end"}
onReply={onReply}
onReact={onReact}
/>
)}
<MessageView
artifactTarget={artifactTarget}
message={message}
canAnswer={message.id === answerableAskMessageId}
onOpenBot={onOpenBot}
onOpenGroup={onOpenGroup}
onOpenPeerMessages={onOpenPeerMessages}
onAnswer={onAnswer}
encounterMode={encounterMode}
speakerName={
peerReceipt
? undefined
: message.role === "bot"
? memberName?.(message.botId)
: undefined
}
memberName={memberName}
peerBot={peerBot}
replyPreview={
message.replyToMessageId
? messageById.get(message.replyToMessageId)
: undefined
}
replyToMessageId={message.replyToMessageId}
onJumpToMessage={onJumpToMessage}
onRefresh={onRefresh}
onBotChanged={onBotChanged}
onAddRoutine={onAddRoutine}
voiceReady={voiceReady}
speaking={speakingMessageId === message.id}
onSpeak={() => onSpeak(message)}
/>
</div>
</div>
{!peerReceipt && messageReactions ? (
<div
data-testid="message-reactions"
className={cn(
"mt-1 flex flex-wrap gap-1",
message.role === "user" && "justify-end",
)}
>
{[...messageReactions].map(([emoji, count]) => (
<span
key={emoji}
className="rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label"
>
{emoji}
{count > 1 ? ` ${count}` : ""}
</span>
))}
</div>
) : null}
</div>
);
})}
{running &&
!encounterMode &&
!messages.some(
(message) =>
message.id.startsWith("progress:") &&
message.blocks.some(
(block) =>
block.kind === "progress" && !isToolActivityBlock(block) && Boolean(block.text),
),
) ? (
<ActiveBotGlyph bots={workingBots} label={workingLabel} />
) : null}
</div>
</div>
{quoteDraft ? (
<QuoteSelectionButton
range={quoteDraft.range}
onQuote={() => {
onQuote(quoteDraft.message, quoteDraft.text);
window.getSelection()?.removeAllRanges();
setQuoteDraft(null);
}}
/>
) : null}
<button
ref={jumpButtonRef}
type="button"
aria-label={t`Jump to latest`}
aria-hidden={atEnd}
tabIndex={atEnd ? -1 : 0}
onClick={jumpToLatest}
className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${
atEnd ? "pointer-events-none translate-y-2 opacity-0" : "translate-y-0 opacity-100"
}`}
>
<ArrowDown size={17} strokeWidth={1.8} />
</button>
</div>
);
});
/**
* Floating Quote action anchored to the selection's bounding rect. Measures
* itself after mount so it can flip below the selection when there is no room
* above and stay clamped inside the viewport; re-anchors on scroll/resize.
*/
const QuoteSelectionButton = memo(function QuoteSelectionButton({
range,
onQuote,
}: {
range: Range;
onQuote: () => void;
}) {
const { t } = useLingui();
const buttonRef = useRef<HTMLButtonElement>(null);
const [placement, setPlacement] = useState<{
top: number;
left: number;
above: boolean;
} | null>(null);
useLayoutEffect(() => {
const update = () => {
if (range.collapsed || !document.contains(range.commonAncestorContainer)) {
setPlacement(null);
return;
}
const rect = range.getBoundingClientRect();
const width = buttonRef.current?.offsetWidth ?? 0;
const height = buttonRef.current?.offsetHeight ?? 0;
const above = rect.top >= height + 8;
setPlacement({
top: above ? rect.top - 8 : rect.bottom + 8,
left: Math.min(
Math.max(rect.left + rect.width / 2, width / 2 + 8),
window.innerWidth - width / 2 - 8,
),
above,
});
};
update();
window.addEventListener("resize", update);
// Scroll doesn't bubble — listen on the capture phase to catch any scroller.
window.addEventListener("scroll", update, { capture: true, passive: true });
return () => {
window.removeEventListener("resize", update);
window.removeEventListener("scroll", update, true);
};
}, [range]);
return createPortal(
<button
ref={buttonRef}
type="button"
data-quote-selection
data-testid="quote-selection"
onMouseDown={(event) => {
// Keep the highlight alive until the click commits the quote.
event.preventDefault();
event.stopPropagation();
}}
onClick={onQuote}
style={placement ? { top: placement.top, left: placement.left } : { visibility: "hidden" }}
className={cn(
"fixed z-50 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-background px-3 py-1.5 text-ui-body-sm font-medium text-foreground shadow-md hover:bg-muted",
placement?.above === false ? "translate-y-0" : "-translate-y-full",
)}
>
<TextQuote strokeWidth={2} className="size-icon-sm" />
{t`Quote`}
</button>,
document.body,
);
});
const Composer = memo(function Composer({
activeName,
running,
disabled,
pendingAttachments,
attachmentNotice,
sendError,
runError,
runErrorId,
onRunErrorPresented,
onDismissError,
sending,
fileInputRef,
onAttachmentPick,
onRemoveAttachment,
onSend,
onStop,
onVoice,
voiceReady = false,
voiceColor,
replyTarget,
replyQuote,
replyTargetName,
onClearReply,
mentionTargets,
agentSkills,
onSlashOpen,
onSlashAction,
}: {
activeName?: string;
running: boolean;
disabled?: boolean;
pendingAttachments: PendingAttachment[];
attachmentNotice: string | null;
sendError: string | null;
runError: string | null;
runErrorId: string | null;
onRunErrorPresented: (runId: string) => void;
onDismissError: () => void;
sending: boolean;
fileInputRef: RefObject<HTMLInputElement | null>;
onAttachmentPick: (files: FileList | null) => void | Promise<void>;
onRemoveAttachment: (attachment: PendingAttachment) => void;
onSend: (text: string, mentions?: ComposerMention[]) => Promise<void>;
onStop: () => Promise<void>;
onVoice?: () => void;
voiceReady?: boolean;
voiceColor?: string;
replyTarget?: ThreadMessage | null;
replyQuote?: string | null;
replyTargetName?: string;
onClearReply?: () => void;
mentionTargets?: ComposerMention[];
agentSkills?: AgentSkillCatalogEntry[];
onSlashOpen?: () => void;
onSlashAction?: (action: SlashActionId) => void;
}) {
const { t } = useLingui();
const [draft, setDraft] = useState("");
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
const [slashQuery, setSlashQuery] = useState<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const runErrorRef = useRef<HTMLDivElement>(null);
const submitFingerprint = useRef<string | null>(null);
useEffect(() => {
if (!sendError && !runError) return;
const timer = setTimeout(() => {
onDismissError();
}, 8000);
return () => clearTimeout(timer);
}, [sendError, runError, onDismissError]);
const presentedRunErrorIdRef = useRef<string | null>(null);
const mentionListboxId = useId();
const dragDepth = useRef(0);
const [draggingFiles, setDraggingFiles] = useState(false);
const canSend =
draft.trim().length > 0 ||
selectedSkill !== null ||
selectedMentions.length > 0 ||
pendingAttachments.length > 0;
useEffect(() => {
if (!runError || !runErrorId) return;
const currentRunErrorId = runErrorId;
let frame = 0;
function recordIfPresented() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const element = runErrorRef.current;
if (!element || document.visibilityState !== "visible") return;
const rect = element.getBoundingClientRect();
const topElement = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
if (topElement && (topElement === element || element.contains(topElement))) {
if (presentedRunErrorIdRef.current === currentRunErrorId) return;
presentedRunErrorIdRef.current = currentRunErrorId;
onRunErrorPresented(currentRunErrorId);
}
});
}
recordIfPresented();
const observer = new MutationObserver(recordIfPresented);
observer.observe(document.body, { childList: true, subtree: true });
document.addEventListener("transitionend", recordIfPresented);
document.addEventListener("visibilitychange", recordIfPresented);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
document.removeEventListener("transitionend", recordIfPresented);
document.removeEventListener("visibilitychange", recordIfPresented);
};
}, [onRunErrorPresented, runError, runErrorId]);
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
function syncHeight() {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
}
syncHeight();
let lastWidth = el.getBoundingClientRect().width;
const observer = new ResizeObserver(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const width = textarea.getBoundingClientRect().width;
if (width === lastWidth) return;
lastWidth = width;
syncHeight();
});
observer.observe(el);
return () => observer.disconnect();
}, [draft]);
function updateDraft(value: string) {
submitFingerprint.current = null;
setDraft(value);
const mentionMatch = /(?:^|\s)@([\w-]*)$/.exec(value);
setMentionQuery(mentionMatch ? (mentionMatch[1] ?? "") : null);
// `/` only at the start of the draft so forced skills expand (`Use skill:` / `/Name` prefix).
const slashMatch = selectedSkill === null ? /^\/([^\n]*)$/.exec(value) : null;
const nextSlash = slashMatch ? (slashMatch[1] ?? "") : null;
if (nextSlash !== null && slashQuery === null) onSlashOpen?.();
setSlashQuery(nextSlash);
}
function focusComposer() {
textareaRef.current?.focus();
}
function insertMention(mention: ComposerMention) {
setDraft((current) => current.replace(/@([\w-]*)$/, ""));
setMentionQuery(null);
setMentionHighlightIndex(0);
setSelectedMentions((current) =>
current.some((selected) => mentionChipKey(selected) === mentionChipKey(mention))
? current
: [...current, mention],
);
focusComposer();
}
function insertSkill(skill: AgentSkillCatalogEntry) {
setSelectedSkill(skill);
setDraft("");
setSlashQuery(null);
}
function runSlashAction(action: SlashActionId) {
setDraft("");
setSlashQuery(null);
onSlashAction?.(action);
}
function removeLastChip() {
if (selectedMentions.length > 0) {
setSelectedMentions((current) => current.slice(0, -1));
return;
}
if (selectedSkill) setSelectedSkill(null);
}
const mentionOptions = useMemo(() => {
if (mentionQuery === null || !mentionTargets?.length) return [];
const query = mentionQuery.trim().toLowerCase();
return mentionTargets
.filter((target) => !query || target.name.toLowerCase().startsWith(query))
.slice(0, 10);
}, [mentionQuery, mentionTargets]);
useEffect(() => {
setMentionHighlightIndex(0);
}, [mentionQuery]);
const activeMentionIndex = clampMentionHighlightIndex(
mentionHighlightIndex,
mentionOptions.length,
);
const mentionPickerOpen = mentionOptions.length > 0;
const activeMentionOptionId = mentionPickerOpen
? `${mentionListboxId}-option-${activeMentionIndex}`
: undefined;
const slashSkillOptions = useMemo(() => {
if (slashQuery === null) return [];
const query = slashQuery.trim().toLowerCase();
const skills = agentSkills ?? [];
return skills
.filter((skill) => {
if (!query) return true;
return (
skill.name.toLowerCase().includes(query) ||
skill.description.toLowerCase().includes(query)
);
})
.slice(0, 8);
}, [agentSkills, slashQuery]);
const slashActionOptions = useMemo(() => {
if (slashQuery === null) return [];
const query = slashQuery.trim().toLowerCase();
return SLASH_ACTIONS.filter((action) => !query || action.label.toLowerCase().includes(query));
}, [slashQuery]);
const showSlashPicker =
slashQuery !== null &&
mentionQuery === null &&
(slashSkillOptions.length > 0 || slashActionOptions.length > 0);
function send() {
if (!canSend || disabled) return;
const text = serializeComposerPrompt(draft, selectedSkill, selectedMentions);
const fingerprint = [text, ...selectedMentions.map((mention) => mentionChipKey(mention))].join(
"\n",
);
if (submitFingerprint.current === fingerprint) return;
submitFingerprint.current = fingerprint;
setDraft("");
setMentionQuery(null);
setMentionHighlightIndex(0);
setSlashQuery(null);
setSelectedSkill(null);
const mentions = selectedMentions;
setSelectedMentions([]);
void onSend(text, mentions);
}
function handleDragEnter(event: DragEvent<HTMLFieldSetElement>) {
const dataTransfer = event.dataTransfer;
if (!isFileDrag(dataTransfer)) return;
event.preventDefault();
if (disabled) {
dragDepth.current = 0;
setDraggingFiles(false);
return;
}
dragDepth.current += 1;
setDraggingFiles(true);
}
function handleDragOver(event: DragEvent<HTMLFieldSetElement>) {
const dataTransfer = event.dataTransfer;
if (!isFileDrag(dataTransfer)) return;
event.preventDefault();
dataTransfer.dropEffect = disabled ? "none" : "copy";
if (disabled) {
dragDepth.current = 0;
setDraggingFiles(false);
return;
}
setDraggingFiles(true);
}
function handleDragLeave(event: DragEvent<HTMLFieldSetElement>) {
if (!isFileDrag(event.dataTransfer)) return;
if (disabled) {
dragDepth.current = 0;
setDraggingFiles(false);
return;
}
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDraggingFiles(false);
}
function handleDrop(event: DragEvent<HTMLFieldSetElement>) {
const dataTransfer = event.dataTransfer;
if (!isFileDrag(dataTransfer)) return;
event.preventDefault();
dragDepth.current = 0;
setDraggingFiles(false);
if (!disabled) void onAttachmentPick(dataTransfer.files);
}
function handlePaste(event: ClipboardEvent<HTMLTextAreaElement>) {
const clipboardData = event.clipboardData;
// Only intercept real FileList pastes; leave text-only / empty-files native.
if (disabled || !clipboardData || !isFilePaste(clipboardData)) return;
event.preventDefault();
void onAttachmentPick(clipboardData.files);
const text = clipboardData.getData("text/plain");
if (!text) return;
const textarea = event.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
updateDraft(`${draft.slice(0, start)}${text}${draft.slice(end)}`);
const caret = start + text.length;
window.requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(caret, caret);
});
}
const showComposerPlaceholder =
draft.length === 0 && selectedSkill === null && selectedMentions.length === 0;
const replyName = replyTarget ? (replyTargetName ?? previewMessageText(replyTarget)) : "";
return (
<fieldset
aria-label={t`Message composer`}
data-dragging={draggingFiles ? "files" : undefined}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative z-30 m-0 min-w-0 border-0 px-3 pb-4 pt-3 md:px-6 md:pb-6 ${
draggingFiles ? "rounded-[14px] ring-2 ring-inset ring-ring" : ""
}`}
>
<div className="mx-auto w-full max-w-4xl">
{/* Quick Action Prompt Chips */}
{showComposerPlaceholder ? (
<div className="mb-3 flex items-center gap-2 overflow-x-auto no-scrollbar py-1">
<span className="flex items-center gap-1.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground shrink-0 px-2.5 py-1 rounded-full bg-muted/40 border border-border me-1 shadow-xs">
<Sparkles className="text-amber-400 size-icon-sm" />
Quick
</span>
{quickPromptsForBot(activeName).map((promptText) => (
<button
key={promptText}
type="button"
onClick={() => {
updateDraft(promptText);
focusComposer();
}}
className="shrink-0 rounded-full border border-border bg-muted/40 px-3.5 py-1.5 text-ui-body-sm font-medium text-foreground/85 hover:bg-accent hover:text-foreground transition-all shadow-sm active:scale-95 cursor-pointer"
>
{promptText}
</button>
))}
</div>
) : null}
{sendError || runError ? (
<div
ref={runErrorRef}
role="alert"
data-testid="composer-error"
className="mb-3 flex items-center gap-2 rounded-[14px] border border-destructive/40 bg-destructive/10 px-4 py-2 text-ui-body-sm text-destructive"
>
<span className="min-w-0 flex-1">{sendError ?? runError}</span>
<button
type="button"
aria-label={t`Dismiss error`}
data-testid="composer-error-dismiss"
onClick={() => {
onDismissError();
window.requestAnimationFrame(() => textareaRef.current?.focus());
}}
className="shrink-0 text-destructive hover:text-foreground"
>
<X strokeWidth={2} className="size-icon-sm" />
</button>
</div>
) : null}
{replyTarget ? (
<div
data-testid="reply-chip"
className="mb-2 flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1.5 text-ui-body-sm text-foreground/75"
>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{replyQuote
? t`Replying to ${replyName}: “${replyQuote}”`
: t`Replying to ${replyName}`}
</span>
<button
type="button"
aria-label={t`Cancel reply`}
onClick={onClearReply}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X strokeWidth={2} className="size-icon-sm" />
</button>
</div>
) : null}
{attachmentNotice ? (
<div className="mb-3 rounded-[14px] border border-warning/40 bg-warning/10 px-4 py-2 text-ui-body-sm text-warning">
{attachmentNotice}
</div>
) : null}
{pendingAttachments.length ? (
<div className="mb-3 flex flex-wrap gap-2">
{pendingAttachments.map((attachment) => (
<div
key={attachment.id}
className="flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1.5 text-ui-body-sm text-foreground/75"
>
{attachment.previewUrl ? (
<img
src={attachment.previewUrl}
alt={attachment.file.name}
className="size-control-md rounded object-cover"
/>
) : (
<Paperclip strokeWidth={1.8} className="size-icon-md" />
)}
<span className="max-w-[180px] truncate" dir="auto">
{attachment.file.name}
</span>
<button
type="button"
aria-label={t`Remove ${attachment.file.name}`}
onClick={() => onRemoveAttachment(attachment)}
className="text-muted-foreground hover:text-foreground"
>
<X strokeWidth={2} className="size-icon-sm" />
</button>
</div>
))}
</div>
) : null}
{mentionPickerOpen ? (
<div
id={mentionListboxId}
role="listbox"
aria-label={t`Mentions`}
data-testid="mention-picker"
className="mb-2 max-h-64 overflow-y-auto rounded-[14px] border border-border bg-muted"
>
{mentionOptions.map((mention, index) => {
const optionId = `${mentionListboxId}-option-${index}`;
const highlighted = index === activeMentionIndex;
return (
<button
id={optionId}
key={mentionChipKey(mention)}
type="button"
role="option"
aria-selected={highlighted}
aria-label={`@${mention.name}`}
data-mention-name={mention.name}
onMouseDown={(event) => event.preventDefault()}
onClick={() => insertMention(mention)}
onMouseEnter={() => setMentionHighlightIndex(index)}
className={`flex w-full items-start gap-3 px-4 py-2.5 text-start hover:bg-accent ${
highlighted ? "bg-accent" : ""
}`}
>
<span aria-hidden="true">
<MentionOptionIcon mention={mention} />
</span>
<span className="min-w-0">
<span dir="auto" className="block text-ui-body text-foreground">
@{mention.name}
</span>
{mention.subtitle ? (
<span
dir="auto"
aria-hidden="true"
className="block truncate text-ui-body-sm text-muted-foreground"
>
{mention.subtitle}
</span>
) : null}
</span>
</button>
);
})}
</div>
) : null}
{showSlashPicker ? (
<div
data-testid="slash-picker"
className="mb-2 overflow-hidden rounded-[14px] border border-border bg-muted"
>
{slashSkillOptions.map((skill) => (
<button
key={skill.id}
type="button"
aria-label={t`Skill ${skill.name}`}
onClick={() => insertSkill(skill)}
className="flex w-full items-start gap-3 px-4 py-2.5 text-start hover:bg-accent"
>
<Box
strokeWidth={1.7}
className="mt-0.5 shrink-0 text-muted-foreground size-icon-md"
/>
<span className="min-w-0">
<span dir="auto" className="block text-ui-body text-foreground">
{skill.name}
</span>
<span dir="auto" className="block truncate text-ui-body-sm text-muted-foreground">
{truncateSlashDescription(skill.description)}
</span>
</span>
</button>
))}
{slashActionOptions.map((action) => {
const label = slashActionLabel(action.id);
return (
<button
key={action.id}
type="button"
aria-label={label}
onClick={() => runSlashAction(action.id)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-start hover:bg-accent"
>
<Settings
strokeWidth={1.7}
className="shrink-0 text-muted-foreground size-icon-md"
/>
<span className="text-ui-body text-foreground">{label}</span>
</button>
);
})}
</div>
) : null}
<div
data-testid="composer-bar"
className="apple-island relative flex items-center gap-3.5 rounded-[30px] py-3 pe-3.5 ps-4 transition-all duration-200"
>
<input
ref={fileInputRef}
type="file"
multiple
accept={ATTACHMENT_ACCEPT}
className="hidden"
onChange={(event) => void onAttachmentPick(event.target.files)}
/>
<Button
variant="ghost"
size="icon"
aria-label={t`Attach file`}
disabled={disabled}
onClick={() => fileInputRef.current?.click()}
className="tactile-press size-10 shrink-0 rounded-full border border-border bg-foreground/5 text-foreground/80 transition-all hover:bg-foreground/10 hover:text-foreground flex items-center justify-center cursor-pointer"
>
<Plus strokeWidth={2.2} className="size-icon-lg" />
</Button>
<div className="flex min-w-0 flex-1 flex-wrap items-end gap-1.5">
{selectedSkill ? (
<span
data-testid="skill-chip"
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-border bg-foreground/[0.08] px-3 py-1 text-ui-body font-medium text-foreground backdrop-blur-md"
>
<Box strokeWidth={1.8} className="shrink-0 text-muted-foreground size-icon-md" />
<span dir="auto" className="truncate">
{selectedSkill.name}
</span>
<button
type="button"
aria-label={t`Remove skill ${selectedSkill.name}`}
onClick={() => setSelectedSkill(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X strokeWidth={2} className="size-icon-sm" />
</button>
</span>
) : null}
{selectedMentions.map((mention) => (
<span
key={mentionChipKey(mention)}
data-testid="mention-chip"
data-mention-kind={mention.kind}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-border bg-foreground/[0.08] px-3 py-1 text-ui-body font-medium text-foreground backdrop-blur-md"
>
<MentionChipIcon mention={mention} />
<span dir="auto" className="truncate">
{mention.name}
</span>
<button
type="button"
aria-label={t`Remove mention ${mention.name}`}
onClick={() =>
setSelectedMentions((current) =>
current.filter(
(selected) => mentionChipKey(selected) !== mentionChipKey(mention),
),
)
}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X strokeWidth={2} className="size-icon-sm" />
</button>
</span>
))}
<textarea
ref={textareaRef}
value={draft}
onChange={(event) => updateDraft(event.target.value)}
onPaste={handlePaste}
onKeyDown={(event) => {
if (
event.key === "Backspace" &&
draft.length === 0 &&
(selectedSkill !== null || selectedMentions.length > 0)
) {
event.preventDefault();
removeLastChip();
return;
}
const action = resolveMentionPickerKey({
key: event.key,
shiftKey: event.shiftKey,
isComposing: event.nativeEvent.isComposing || event.keyCode === 229,
optionCount: mentionOptions.length,
highlightedIndex: activeMentionIndex,
});
if (action.type === "complete") {
const mention = mentionOptions[action.index];
if (!mention) return;
event.preventDefault();
insertMention(mention);
return;
}
if (action.type === "move") {
event.preventDefault();
setMentionHighlightIndex(action.index);
return;
}
if (action.type === "dismiss") {
event.preventDefault();
setMentionQuery(null);
setMentionHighlightIndex(0);
return;
}
if (action.type === "send") {
event.preventDefault();
send();
}
}}
disabled={disabled}
placeholder={showComposerPlaceholder ? t`Message…` : undefined}
aria-label={t`Message`}
role="combobox"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={mentionPickerOpen ? "true" : "false"}
aria-controls={mentionPickerOpen ? mentionListboxId : undefined}
aria-activedescendant={activeMentionOptionId}
name="chat-message"
autoComplete="off"
dir="auto"
rows={1}
className="max-h-36 min-h-[28px] min-w-[8rem] flex-1 resize-none overflow-y-auto bg-transparent py-1 text-[16.5px] leading-[1.55] text-foreground outline-none placeholder:text-muted-foreground/75 disabled:opacity-40 font-normal"
/>
</div>
{onVoice ? (
<Button
variant="outline"
aria-label={t`Voice`}
title={voiceReady ? t`Start voice conversation` : t`Set up voice`}
disabled={disabled}
onClick={onVoice}
data-testid="composer-voice-primary"
className={`tactile-press h-control-lg shrink-0 gap-2 rounded-full px-4 sm:px-5 flex items-center justify-center cursor-pointer transition-all ${
voiceReady
? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10"
: "border-border bg-foreground/5 text-foreground/80 hover:bg-foreground/10"
}`}
>
{voiceReady ? (
<VoiceAuroraRim botColor={voiceColor} className="inset-x-[14%] h-0.5 blur-[3px]" />
) : null}
<Mic strokeWidth={1.9} className="size-icon-lg shrink-0" />
<span className="hidden text-ui-body font-semibold sm:inline">
<Trans>Voice</Trans>
</span>
</Button>
) : null}
{running ? (
<div className="flex items-center gap-2 shrink-0">
<Button
size="icon"
aria-label={t`Send`}
disabled={!canSend || disabled}
onClick={send}
className="tactile-press size-10 rounded-full transition-transform active:scale-90 flex items-center justify-center cursor-pointer"
>
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
</Button>
<Button
variant="outline"
size="icon"
aria-label={t`Stop`}
disabled={sending}
onClick={() => void onStop()}
className="tactile-press size-10 rounded-full border border-border bg-foreground/[0.08] text-foreground/90 shadow-sm transition-colors hover:bg-foreground/15 hover:text-foreground flex items-center justify-center cursor-pointer"
>
<Square strokeWidth={0} fill="currentColor" className="size-icon-sm" />
</Button>
</div>
) : (
<Button
size="icon"
aria-label={t`Send`}
disabled={!canSend || disabled}
onClick={send}
className="tactile-press size-10 shrink-0 rounded-full transition-all active:scale-90 disabled:bg-foreground/[0.08] disabled:text-foreground/20 flex items-center justify-center cursor-pointer"
>
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
</Button>
)}
</div>
</div>
</fieldset>
);
});
function slashActionLabel(id: SlashActionId) {
switch (id) {
case "chat-settings":
return t`Chat Settings`;
case "settings-general":
return t`Settings: General`;
case "settings-usage":
return t`Settings: Usage`;
}
}
function MentionOptionIcon({ mention }: { mention: ComposerMention }) {
if (mention.kind === "routine") {
return (
<Clock strokeWidth={1.7} className="mt-0.5 shrink-0 text-muted-foreground size-icon-md" />
);
}
if (mention.kind === "connector") {
return (
<Puzzle strokeWidth={1.7} className="mt-0.5 shrink-0 text-muted-foreground size-icon-md" />
);
}
if (mention.kind === "group") {
return (
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-accent text-ui-caption text-foreground/75">
G
</span>
);
}
if (mention.kind === "everyone") {
return (
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-accent text-ui-caption text-foreground/75">
@
</span>
);
}
return (
<BotAvatar
color={mention.color ?? FALLBACK_BOT_COLOR}
identity={mention.id}
className="size-icon-md"
/>
);
}
function MentionChipIcon({ mention }: { mention: ComposerMention }) {
if (mention.kind === "routine") {
return <Clock strokeWidth={1.7} className="shrink-0 text-muted-foreground/70 size-icon-sm" />;
}
if (mention.kind === "connector") {
return <Puzzle strokeWidth={1.7} className="shrink-0 text-muted-foreground/70 size-icon-sm" />;
}
if (mention.kind === "group" || mention.kind === "everyone") {
return (
<span className="grid h-4 w-4 shrink-0 place-items-center rounded-full bg-accent text-ui-caption text-foreground/75">
{mention.kind === "group" ? "G" : "@"}
</span>
);
}
return (
<BotAvatar
color={mention.color ?? FALLBACK_BOT_COLOR}
identity={mention.id}
className="size-icon-md"
/>
);
}
function previewMessageText(message: ThreadMessage): string {
const text = message.blocks
.map((block) => (block.kind === "text" || block.kind === "channel_message" ? block.text : ""))
.filter(Boolean)
.join(" ")
.trim();
if (text) return text;
if (message.blocks.some((block) => block.kind === "image" || block.kind === "file")) {
return t`Attachment`;
}
return t`Message`;
}
function formatRosterTime(isoDate?: string | null): string {
if (!isoDate) return "";
try {
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "";
const locale = i18n.locale || "en";
const now = new Date();
const isToday =
d.getDate() === now.getDate() &&
d.getMonth() === now.getMonth() &&
d.getFullYear() === now.getFullYear();
if (isToday) {
return d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
const isYesterday =
d.getDate() === yesterday.getDate() &&
d.getMonth() === yesterday.getMonth() &&
d.getFullYear() === yesterday.getFullYear();
if (isYesterday) return t`Yesterday`;
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 7) {
return d.toLocaleDateString(locale, { weekday: "short" });
}
return d.toLocaleDateString(locale, { month: "short", day: "numeric" });
} catch {
return "";
}
}
function cleanRosterPreview(raw?: string | null): string {
if (!raw) return "";
let text = raw;
// Strip markdown headings
text = text.replace(/^#{1,6}\s+/gm, "");
// Strip routing tokens [to ...] and system protocols [RECOVERY PROTOCOL], [PO ...]
text = text.replace(/\[(?:to|RECOVERY PROTOCOL|PO)[^\]]*\]/gi, "");
// Strip bold/italic/code markers
text = text.replace(/[*_`#]/g, "");
// Strip escape backslashes
text = text.replace(/\\/g, "");
// Collapse whitespace
text = text.replace(/\s+/g, " ").trim();
return text;
}
function quickPromptsForBot(botName?: string): string[] {
const lower = (botName ?? "").toLowerCase();
if (lower.includes("chief")) {
return [
"⚡ Portfolio Health Check",
"📊 Risk & Drawdown Exposure",
"🔄 Team-Status synchronisieren",
"🎯 Tagesziel prüfen",
];
}
if (lower.includes("steve")) {
return [
"✨ UX & Design Audit",
"🚀 Product Architecture",
"💎 Schlichtheit & Eleganz",
"🔍 Konkurrenzanalyse",
];
}
if (lower.includes("apex") || lower.includes("quant") || lower.includes("trad")) {
return [
"📈 Alpha-Signale scannen",
"📉 Volatilitäts-Stresstest",
"💰 Live vs. Paper PnL",
"⚡ Orderbuch Liquidität",
];
}
if (
lower.includes("dev") ||
lower.includes("sec") ||
lower.includes("infra") ||
lower.includes("code")
) {
return [
"🛡️ Security & Vault Check",
"💻 Latenz- & Cluster-Status",
"🔍 Code Review & Refactor",
"🚨 Health & Error Logs",
];
}
return ["💡 Prioritäten für heute?", "⚡ Schneller Statusbericht", "📊 Performance-Metriken"];
}
function MessageHoverActions({
message,
side,
onReply,
onReact,
}: {
message: ThreadMessage;
side: "start" | "end";
onReply: (message: ThreadMessage) => void;
onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>;
}) {
const { t } = useLingui();
const [moreOpen, setMoreOpen] = useState(false);
const [reactionsOpen, setReactionsOpen] = useState(false);
// Streaming progress bubbles keep hover free for selection / stop clicks.
if (message.id.startsWith("progress:")) return null;
function copyMessage() {
const text = copyableMessageText(message);
if (!text || !navigator.clipboard) return;
void navigator.clipboard.writeText(text).catch(() => undefined);
}
const iconButtonClass =
"grid size-control-sm place-items-center text-muted-foreground transition-colors hover:text-foreground";
return (
<MessageHoverMetadata pinned={moreOpen || reactionsOpen} side={side}>
<div data-testid="message-hover-actions" className="flex items-center gap-0.5">
{canReactToThreadMessage(message) ? (
<Popover open={reactionsOpen} onOpenChange={setReactionsOpen}>
<PopoverTrigger
aria-label={t`React`}
className={cn(
iconButtonClass,
"h-11 w-11 [@media(hover:hover)_and_(pointer:fine)]:h-7 [@media(hover:hover)_and_(pointer:fine)]:w-7",
)}
>
<Smile strokeWidth={1.7} className="size-icon-md" />
</PopoverTrigger>
<PopoverContent
align={side === "end" ? "start" : "end"}
className="w-auto flex-row gap-0 rounded-2xl p-1.5"
aria-label={t`Reactions`}
>
{MESSAGE_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={emoji}
className="grid h-11 w-11 place-items-center rounded-xl text-2xl hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => {
setReactionsOpen(false);
void onReact(message, emoji);
}}
>
{emoji}
</button>
))}
</PopoverContent>
</Popover>
) : null}
<button
type="button"
aria-label={t`Reply`}
onClick={() => onReply(message)}
className={`${iconButtonClass} hidden [@media(hover:hover)_and_(pointer:fine)]:grid`}
>
<Reply strokeWidth={1.7} className="size-icon-md" />
</button>
<DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
<DropdownMenuTrigger
aria-label={t`More`}
className={cn(
iconButtonClass,
"h-11 w-11 [@media(hover:hover)_and_(pointer:fine)]:h-7 [@media(hover:hover)_and_(pointer:fine)]:w-7",
)}
>
<MoreHorizontal strokeWidth={1.7} className="size-icon-md" />
</DropdownMenuTrigger>
<DropdownMenuContent align={side === "end" ? "start" : "end"}>
<DropdownMenuItem
className="[@media(hover:hover)_and_(pointer:fine)]:hidden"
onClick={() => onReply(message)}
>
<Reply className="size-icon-md" />
<Trans>Reply</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyMessage}>
<Copy strokeWidth={1.7} className="size-icon-md" />
<Trans>Copy</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</MessageHoverMetadata>
);
}
function firstThreadRoute(
bots: readonly Pick<Bot, "id">[],
groups: readonly Pick<Group, "id">[],
): string {
if (bots[0]) return `/app/${bots[0].id}`;
if (groups[0]) return `/app/g/${groups[0].id}`;
return "/app";
}
function applyThreadEvent(
event: ProductEvent,
commitSnapshot: (next: ThreadSnapshot | null) => void,
commitComputer: (next: ComputerStatus | null) => void,
snapshotRef: MutableRefObject<ThreadSnapshot | null>,
computerRef: MutableRefObject<ComputerStatus | null>,
) {
if (isThreadSnapshotEvent(event)) {
const next = reduceThreadSnapshot(snapshotRef.current, event);
commitSnapshot(next);
}
if (isComputerStatusEvent(event)) {
const next = reduceComputerStatus(computerRef.current, event);
commitComputer(next);
}
}
function ComputerReleaseActions({
takeoverRequested,
onRelease,
}: {
takeoverRequested: boolean;
onRelease: (reason?: ComputerReleaseReason) => Promise<void>;
}) {
if (!takeoverRequested) {
return (
<Button type="button" variant="outline" size="sm" onClick={() => void onRelease()}>
<Trans>Release</Trans>
</Button>
);
}
return (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => void onRelease("skipped")}>
<Trans>Skip</Trans>
</Button>
<Button type="button" size="sm" onClick={() => void onRelease("done")}>
<Trans>I’m done</Trans>
</Button>
</div>
);
}
const MessageView = memo(function MessageView({
artifactTarget,
canAnswer,
message,
onAnswer,
onOpenBot,
onOpenGroup,
onOpenPeerMessages,
speakerName,
memberName,
peerBot,
replyPreview,
replyToMessageId,
onJumpToMessage,
onRefresh,
onBotChanged,
onAddRoutine,
voiceReady,
speaking,
onSpeak,
encounterMode = false,
}: {
artifactTarget: ArtifactTarget;
canAnswer: boolean;
message: ThreadMessage;
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
onOpenBot: (botId: string) => void;
onOpenGroup: (groupId: string) => void;
onOpenPeerMessages: (peer: { peerBotId: string; peerBotName: string }) => void;
speakerName?: string;
memberName?: (botId: string | undefined) => string | undefined;
peerBot: (botId: string) => { color: string; status?: string } | undefined;
replyPreview?: ThreadMessage;
replyToMessageId?: string;
onJumpToMessage?: (messageId: string) => void;
onRefresh: () => Promise<void>;
onBotChanged: () => Promise<void>;
onAddRoutine: (name: string, prompt: string) => void;
voiceReady: boolean;
speaking: boolean;
onSpeak: () => void;
encounterMode?: boolean;
}) {
const { t } = useLingui();
const isNarration =
message.role === "bot" &&
message.blocks.length > 0 &&
message.blocks.every(
(block) => block.kind === "text" || block.kind === "progress" || block.kind === "steps",
);
const isLive = message.id.startsWith("progress:");
const visibleNarrationBlocks = message.blocks.filter((block) => !isToolActivityBlock(block));
const parentJumpId = replyPreview?.id ?? replyToMessageId;
const speakerBot = message.botId ? peerBot?.(message.botId) : undefined;
const speakerColorDef = useMemo(
() => resolvePersonaColorDef(message.botId ?? "bot", speakerBot?.color),
[message.botId, speakerBot?.color],
);
const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null);
const messageContext = (
<>
{effectiveSpeakerName && message.role === "bot" && !encounterMode ? (
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="relative flex items-center justify-center size-7 rounded-xl bg-foreground/[0.08] border border-border shadow-xs">
<EvooMascot size={22} interactive={false} pose={isLive ? "working" : "idle"} />
</div>
<div className="flex items-center gap-2">
<span
className="text-[14.5px] font-semibold text-foreground tracking-tight"
dir="auto"
>
{effectiveSpeakerName}
</span>
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label font-medium text-muted-foreground">
Oo
</span>
</div>
</div>
<span className="text-ui-label font-mono text-muted-foreground tabular-nums">
{new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
) : effectiveSpeakerName && message.role === "bot" && encounterMode ? (
<div className="mb-1 flex items-center gap-2 text-ui-label font-medium uppercase tracking-wide text-muted-foreground">
<span dir="auto">{effectiveSpeakerName}</span>
<span aria-hidden>·</span>
<time dateTime={message.createdAt} className="font-mono tabular-nums normal-case">
{new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</time>
</div>
) : speakerName ? (
<div
className="mb-1.5 flex items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
dir="auto"
style={{ color: speakerColorDef.light }}
>
<BotAvatar
color={speakerBot?.color ?? FALLBACK_BOT_COLOR}
identity={message.botId}
size={22}
/>
{speakerName}
</div>
) : null}
{parentJumpId ? (
<button
type="button"
data-testid="reply-parent-preview"
aria-label={t`Jump to replied message`}
onClick={() => onJumpToMessage?.(parentJumpId)}
className="mb-2 block max-w-[74%] truncate rounded-[14px] border border-border bg-background px-3 py-2 text-start text-ui-body-sm text-muted-foreground hover:border-border hover:text-foreground/75"
dir="auto"
>
{message.replyQuote
? `“${message.replyQuote}”`
: replyPreview
? previewMessageText(replyPreview)
: t`Earlier message`}
</button>
) : null}
</>
);
if (isNarration) {
if (visibleNarrationBlocks.length === 0) return null;
return (
<>
{messageContext}
<div className="flex w-fit max-w-full justify-start">
<div
data-testid="message-bot-bubble"
className={
encounterMode
? "max-w-full space-y-2 border-s-2 border-emerald-500/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-5 py-3.5 text-ui-body leading-[1.62] text-foreground/95"
}
dir="auto"
>
{visibleNarrationBlocks.map((block, i) => {
if (block.kind === "text" || block.kind === "progress") {
return (
<div key={i}>
<ChatMarkdown streaming={block.kind === "progress"}>{block.text}</ChatMarkdown>
</div>
);
}
return null;
})}
{!isLive && voiceReady && message.blocks.some((block) => block.kind === "text") ? (
<button
type="button"
aria-label={speaking ? t`Stop speaking` : t`Speak this reply`}
onClick={onSpeak}
className="mt-1 text-ui-body-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{speaking ? <Trans>Stop</Trans> : <Trans>Speak</Trans>}
</button>
) : null}
</div>
</div>
</>
);
}
return (
<>
{messageContext}
{message.blocks.map((block, i) => {
if (isToolActivityBlock(block)) return null;
if (block.kind === "handoff") {
const from = memberName?.(block.fromBotId) ?? t`bot`;
const to = memberName?.(block.toBotId) ?? t`bot`;
return (
<div
key={i}
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
>
<span>
↪ {to} ← {from}
</span>
<span>{block.text}</span>
</div>
);
}
if (block.kind === "bot_message_sent") {
const peer = block.toBotName;
const peerBotId = block.toBotId;
return (
<SentMessageCard
key={i}
botId={peerBotId}
botName={peer}
color={peerBot(peerBotId)?.color ?? FALLBACK_BOT_COLOR}
text={block.text}
onClick={() => onOpenPeerMessages({ peerBotId, peerBotName: peer })}
/>
);
}
if (block.kind === "task") {
return (
<DelegatedTaskCard
key={i}
botId={block.assigneeBotId}
botName={block.assigneeBotName}
color={peerBot(block.assigneeBotId)?.color ?? FALLBACK_BOT_COLOR}
title={block.title}
text={block.text}
status={block.status}
/>
);
}
if (block.kind === "bot_message_received") {
const peer = block.fromBotName;
const peerBotId = block.fromBotId;
const label = t`Message from ${peer}`;
return (
<CollaborationMarker
key={i}
ariaLabel={label}
color={peerBot(peerBotId)?.color ?? FALLBACK_BOT_COLOR}
identity={peerBotId}
label={label}
onClick={() => onOpenPeerMessages({ peerBotId, peerBotName: peer })}
/>
);
}
if (block.kind === "channel_message") {
return (
<div
key={i}
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
>
<span>
{messageProviderLabel(block.provider, block.transport)} · {block.fromLabel}:{" "}
{block.text}
</span>
</div>
);
}
if (block.kind === "meta") {
return (
<div
key={i}
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
>
<span className="text-warning">◷</span>
<span>{block.text}</span>
</div>
);
}
if (block.kind === "progress") {
return (
<div key={i} className="flex w-fit max-w-full justify-start">
<div
data-testid="message-bot-bubble"
className="chat-bubble-bot max-w-full rounded-[24px] px-5 py-3.5 text-ui-body leading-[1.62] text-foreground/95"
dir="auto"
>
<ChatMarkdown streaming>{block.text}</ChatMarkdown>
</div>
</div>
);
}
if (block.kind === "subagent") {
const running = block.status === "running";
const failed = block.status === "failed";
return (
<div
key={i}
className="apple-glass-card w-[min(460px,95%)] rounded-[22px] px-5 py-4 transition-all"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5 min-w-0">
<div
className={`relative flex h-2.5 w-2.5 shrink-0 ${running ? "animate-pulse" : ""}`}
>
<span
className={`relative inline-flex rounded-full h-2.5 w-2.5 ${failed ? "bg-red-500" : running ? "bg-amber-400" : "bg-emerald-400"}`}
></span>
</div>
<span className="text-ui-body font-semibold text-foreground truncate" dir="auto">
{block.name}
</span>
</div>
<span
className={`rounded-full px-3 py-0.5 text-ui-body-sm font-medium border ${
failed
? "border-destructive/30 bg-destructive/15 text-destructive"
: running
? "border-warning/30 bg-warning/15 text-warning"
: "border-success/30 bg-success/15 text-success"
}`}
style={{
animation: running ? "rkPulse 1.2s ease-in-out infinite" : undefined,
}}
>
{running ? t`subagent` : block.status}
</span>
</div>
<div className="mt-2 text-ui-body text-muted-foreground">{block.task}</div>
{block.progress || block.result ? (
<div className="mt-2.5 text-[14.5px] leading-[1.5] text-foreground/75">
<ChatMarkdown streaming={running}>
{block.result || block.progress || ""}
</ChatMarkdown>
</div>
) : null}
</div>
);
}
if (block.kind === "project_created") {
return (
<ProjectCreatedCard
key={i}
name={block.name}
memberCount={block.memberCount}
onClick={() => onOpenGroup(block.groupId)}
/>
);
}
if (block.kind === "child_bot") {
const removed = block.status === "deleted" || block.status === "archived";
return (
<button
key={i}
type="button"
disabled={removed}
onClick={() => onOpenBot(block.botId)}
className="w-[min(340px,90%)] rounded-[18px] border border-border bg-muted px-[18px] py-4 text-start disabled:opacity-60"
>
<div className="flex items-center justify-between">
<span className="text-ui-body font-medium text-foreground" dir="auto">
{block.name}
</span>
<span
className={`rounded-full px-[11px] py-1 text-ui-body-sm ${
removed ? "bg-destructive/15 text-destructive" : "bg-success/15 text-success"
}`}
>
{block.status === "archived" ? (
<Trans>archived</Trans>
) : block.status === "deleted" ? (
<Trans>deleted</Trans>
) : (
<Trans>bot</Trans>
)}
</span>
</div>
<div className="mt-2 text-[14.5px] leading-[1.5] text-foreground/75" dir="auto">
{removed
? block.status === "archived"
? t`Archived. Chat, memory, and files kept.`
: t`Removed with chat, computer, and memory.`
: block.title || t`Opened its thread.`}
</div>
</button>
);
}
if (block.kind === "choice") {
const botId = "botId" in artifactTarget ? artifactTarget.botId : message.botId;
if (!botId) return null;
return <ChoiceCard key={i} botId={botId} block={block} onBotChanged={onBotChanged} />;
}
if (block.kind === "app_connect") {
const botId = "botId" in artifactTarget ? artifactTarget.botId : message.botId;
if (!botId) return null;
return (
<div key={i} className="flex justify-start py-1">
<AppConnectCard botId={botId} block={block} />
</div>
);
}
if (block.kind === "chart") {
return (
<div key={i} className="flex justify-start">
<ChartBlockView name={block.name} spec={block.spec} data={block.data} />
</div>
);
}
if (block.kind === "mcp_approval") {
return (
<div key={i} className="flex justify-start">
<McpApprovalCard
botId={"botId" in artifactTarget ? artifactTarget.botId : message.botId}
name={block.name}
serverId={block.serverId}
transport={block.transport}
endpoint={block.endpoint}
needsOAuth={block.needsOAuth}
/>
</div>
);
}
if (block.kind === "image") {
return (
<div
key={i}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<ArtifactImage
target={artifactTarget}
artifactId={block.artifactId}
name={block.name}
/>
</div>
);
}
if (block.kind === "file") {
return (
<div
key={i}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<ArtifactFileCard
target={artifactTarget}
artifactId={block.artifactId}
name={block.name}
mimeType={block.mimeType}
size={block.size}
/>
</div>
);
}
if (block.kind === "text" && message.role === "user") {
return (
<div key={i} className="flex w-fit max-w-full justify-end">
<div
data-testid="message-user-bubble"
className={
encounterMode
? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl bg-chat-user px-3.5 py-2 text-ui-body-sm leading-relaxed text-chat-user-foreground"
: "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-[17px] leading-[1.62] text-chat-user-foreground shadow-md"
}
dir="auto"
>
{block.text}
</div>
</div>
);
}
if (block.kind === "text") {
return (
<div key={i} className="flex w-fit max-w-full justify-start">
<div
data-testid="message-bot-bubble"
className={
encounterMode
? "max-w-full space-y-2 border-s-2 border-emerald-500/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-6 py-4 text-[17.5px] leading-[1.85] text-foreground/95"
}
dir="auto"
>
<ChatMarkdown>{block.text}</ChatMarkdown>
<div className="mt-3 pt-2.5 flex items-center justify-between border-t border-border text-ui-label text-muted-foreground/75">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(block.text);
}
}}
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
title={t`Antwort kopieren`}
>
<Copy size={13.5} />
<span>{t`Kopieren`}</span>
</button>
{voiceReady ? (
<button
type="button"
aria-label={speaking ? t`Stop speaking` : t`Speak this reply`}
onClick={onSpeak}
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
>
<Volume2 size={13.5} />
<span>{speaking ? t`Stop` : t`Vorlesen`}</span>
</button>
) : null}
</div>
<span className="tabular-nums font-mono text-ui-label text-muted-foreground">
{new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
</div>
</div>
);
}
if (block.kind === "card") {
return (
<div key={i} className="flex justify-start">
<div className="flex flex-col gap-2 rounded-[20px] bg-muted px-5 py-4">
{block.lines.map((line) => (
<div key={line.k} className="flex items-baseline gap-2.5 text-ui-body">
<span className="text-success">✓</span>
<span className="font-semibold text-foreground">{line.k}</span>
<span className="text-muted-foreground">→</span>
<span>{line.v}</span>
</div>
))}
</div>
</div>
);
}
if (block.kind === "ask") {
return (
<AskCard
key={i}
block={block}
canAnswer={canAnswer}
onAnswer={(text) => onAnswer(message, text)}
/>
);
}
if (block.kind === "cloud_agent") return <CloudAgentCard key={i} block={block} />;
if (block.kind === "skill_draft") {
return (
<div key={i} className="flex justify-start">
<SkillDraftCard block={block} onRefresh={onRefresh} onAddRoutine={onAddRoutine} />
</div>
);
}
if (block.kind === "computer") {
return (
<div
key={i}
className="w-[340px] rounded-[18px] border border-border bg-muted px-[18px] py-4"
>
<div className="flex items-center justify-between">
<span className="text-ui-body font-medium text-foreground">
<Trans>Computer</Trans>
</span>
<span
className={
block.state === "Needs you"
? "rounded-full bg-warning/15 px-[11px] py-1 text-ui-body-sm text-warning"
: "rounded-full bg-success/15 px-[11px] py-1 text-ui-body-sm text-success"
}
>
{block.state}
</span>
</div>
<div className="my-2.5 text-[14.5px] leading-[1.5] text-foreground/75">
<ChatMarkdown>{block.text}</ChatMarkdown>
</div>
</div>
);
}
return null;
})}
</>
);
});
function embeddableScreenUrl(url: string | null): string | null {
if (!url) return null;
try {
const parsed = new URL(url, window.location.href);
const page = new URL(window.location.href);
const local = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
const pagePort = page.port || (page.protocol === "https:" ? "443" : "80");
if (local && parsed.port && parsed.port !== pagePort) {
return null;
}
return parsed.toString();
} catch {
return url;
}
}
function screenIframeSandbox(url: string | null) {
if (!url) return undefined;
try {
return new URL(url, window.location.href).pathname.startsWith("/novnc/")
? "allow-scripts allow-pointer-lock"
: undefined;
} catch {
return undefined;
}
}
function DesktopKindEmptyState({ className }: { className?: string }) {
return (
<div className={className}>
<Trans>
This bot runs on this computer, not a Linux desktop. Shell and files use your home folder.
</Trans>
</div>
);
}
function computerPlaceholder(
state: ComputerStatus["state"] | undefined,
booting: boolean,
label: string,
) {
if (state === "booting" || booting) return t`Booting live desktop…`;
if (state === "running") return label;
if (state === "suspended") return t`Computer is asleep. Open it to wake.`;
if (state === "error") return t`Computer failed to boot`;
return t`Computer is stopped`;
}
function computerLabel(mode: ComputerStatus["mode"] | undefined, botName: string) {
return mode === "dedicated" ? t`${botName}’s computer` : t`Team Computer`;
}
function newClientNonce(): string {
const webCrypto = globalThis.crypto;
if (webCrypto && typeof webCrypto.randomUUID === "function") {
return webCrypto.randomUUID();
}
return `m-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === "string" ? reader.result : "";
const base64 = result.includes(",") ? (result.split(",")[1] ?? "") : result;
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}