Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
8085 lines
318 KiB
TypeScript
8085 lines
318 KiB
TypeScript
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,
|
||
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 { CloudAgentCard } from "../components/CloudAgentCard";
|
||
import { ComputerMaintenanceActions } from "../components/ComputerMaintenanceActions";
|
||
import {
|
||
ComputersUnavailableHint,
|
||
computersAreUnavailable,
|
||
} from "../components/ComputersUnavailableHint";
|
||
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
|
||
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 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 type { BrowserPreviewCursor } from "../lib/browser-preview-context";
|
||
import { browserPreviewActivity } from "../lib/browser-preview-context";
|
||
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 { disableWebPush } from "../lib/web-push";
|
||
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();
|
||
}
|
||
}
|
||
|
||
function copyBrowserPreviewActivities(
|
||
activities: ReadonlyMap<string, BrowserPreviewCursor>,
|
||
): Map<string, BrowserPreviewCursor> {
|
||
return new Map(activities);
|
||
}
|
||
|
||
function sameBrowserPreviewActivities(
|
||
current: ReadonlyMap<string, BrowserPreviewCursor>,
|
||
next: ReadonlyMap<string, BrowserPreviewCursor>,
|
||
): boolean {
|
||
if (current.size !== next.size) return false;
|
||
for (const [id, cursor] of next) {
|
||
const existing = current.get(id);
|
||
if (
|
||
!existing ||
|
||
existing.x !== cursor.x ||
|
||
existing.y !== cursor.y ||
|
||
existing.label !== cursor.label
|
||
) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
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]);
|
||
|
||
// ── Browser Preview ──────────────────────────────────────────────
|
||
const bridge = desktopBridge();
|
||
const [isDesktop] = useState(Boolean(bridge?.browserPreview));
|
||
const [panel, setPanel] = useState<Panel>(null);
|
||
const [browserUrl, setBrowserUrl] = useState("http://localhost:5173");
|
||
const [devServerRunning, setDevServerRunning] = useState(false);
|
||
const [browserLoading, setBrowserLoading] = useState(false);
|
||
const [agentActivities, setAgentActivities] = useState<
|
||
Map<string, { x: number; y: number; label: string }>
|
||
>(new Map());
|
||
|
||
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 } }),
|
||
);
|
||
|
||
// Browser Preview lifecycle
|
||
useEffect(() => {
|
||
const bp = bridge?.browserPreview;
|
||
if (!bp) return;
|
||
const unsubNav = bp.onNavigate((url) => setBrowserUrl(url));
|
||
const unsubLoading = bp.onLoadingProgress((p) => setBrowserLoading(p < 1));
|
||
bp.getState()
|
||
.then((s) => {
|
||
setBrowserUrl(s.url);
|
||
setDevServerRunning(s.devServerRunning);
|
||
})
|
||
.catch(() => {});
|
||
return () => {
|
||
unsubNav();
|
||
unsubLoading();
|
||
};
|
||
}, [bridge]);
|
||
|
||
const startDevServer = useCallback(async () => {
|
||
try {
|
||
await bridge?.browserPreview?.devServer.start();
|
||
setDevServerRunning(true);
|
||
} catch {
|
||
/* */
|
||
}
|
||
}, [bridge]);
|
||
const stopDevServer = useCallback(async () => {
|
||
try {
|
||
await bridge?.browserPreview?.devServer.stop();
|
||
setDevServerRunning(false);
|
||
} catch {
|
||
/* */
|
||
}
|
||
}, [bridge]);
|
||
const navigateBrowser = useCallback(
|
||
(url: string) => {
|
||
bridge?.browserPreview?.navigate(url).catch(() => {});
|
||
},
|
||
[bridge],
|
||
);
|
||
const reloadBrowser = useCallback(() => {
|
||
bridge?.browserPreview?.reload().catch(() => {});
|
||
}, [bridge]);
|
||
|
||
// Agent activity → browser preview cursors
|
||
useEffect(() => {
|
||
const unsub = browserPreviewActivity.subscribe((acts) => {
|
||
setAgentActivities((current) =>
|
||
sameBrowserPreviewActivities(current, acts) ? current : copyBrowserPreviewActivities(acts),
|
||
);
|
||
});
|
||
return unsub;
|
||
}, []);
|
||
|
||
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);
|
||
}
|
||
},
|
||
});
|
||
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]);
|
||
|
||
// Working bots → browser preview activity cursors
|
||
useEffect(() => {
|
||
if (workingBots.length === 0) return;
|
||
for (const bot of workingBots) {
|
||
if (!bot.botId) continue;
|
||
const label = bot.name ?? bot.botId.slice(0, 8);
|
||
browserPreviewActivity.set(bot.botId, 50, 40, label);
|
||
}
|
||
const activeBotIds = new Set(workingBots.map((b) => b.botId).filter(Boolean));
|
||
for (const id of browserPreviewActivity.snapshot().keys()) {
|
||
if (!activeBotIds.has(id)) browserPreviewActivity.clear(id);
|
||
}
|
||
}, [workingBots]);
|
||
|
||
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-black bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-zinc-900 via-black to-black 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-white/10 md:bg-[#0A0A0C] 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"
|
||
: "text-muted-foreground/70 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] text-muted-foreground/70 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={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
|
||
activeNavPillar === "heute"
|
||
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
||
: "hover:bg-white/[0.04]"
|
||
}`}
|
||
>
|
||
<div className="relative grid size-control-lg shrink-0 place-items-center rounded-full bg-emerald-500/10 text-emerald-400">
|
||
<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="text-muted-foreground/60 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 text-muted-foreground">
|
||
<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-muted-foreground/60 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-[#0b0c10] ${
|
||
rosterStatusDot(item.chat.status) === "needs-you"
|
||
? "bg-warning animate-pulse"
|
||
: rosterStatusDot(item.chat.status) === "working"
|
||
? "bg-amber-400 animate-pulse"
|
||
: "bg-emerald-400"
|
||
}`}
|
||
/>
|
||
</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-warning">
|
||
{needsYouChipLabel(item.chat.status)}
|
||
</span>
|
||
) : item.chat.title ? (
|
||
<span className="min-w-0 truncate rounded-full border border-emerald-500/25 bg-emerald-500/10 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
|
||
{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 text-muted-foreground 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-white/5 px-2.5">
|
||
<div>
|
||
<div className="rk-shell-section-label">{t`Projekte`}</div>
|
||
<p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70">
|
||
{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="text-muted-foreground/60 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 text-muted-foreground">
|
||
<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-muted-foreground/60 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={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
|
||
item.kind === "bot" && !inGroup && active?.id === item.chat.id
|
||
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
||
: item.kind === "group" &&
|
||
inGroup &&
|
||
activeGroup?.id === item.chat.id
|
||
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
||
: "hover:bg-white/[0.05] border border-transparent"
|
||
}`}
|
||
>
|
||
{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 ${
|
||
item.chat.unread
|
||
? "font-bold text-white"
|
||
: "font-semibold text-white/95"
|
||
}`}
|
||
>
|
||
{item.chat.name}
|
||
</span>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1.5">
|
||
<span className="text-ui-body-sm text-zinc-400 tabular-nums font-medium">
|
||
{formatRosterTime(item.chat.updatedAt)}
|
||
</span>
|
||
{item.chat.unread ? (
|
||
<span
|
||
aria-hidden="true"
|
||
className="inline-block h-2 w-2 rounded-full bg-emerald-400"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div
|
||
dir="auto"
|
||
className={`mt-0.5 truncate text-ui-body leading-snug ${
|
||
item.chat.unread
|
||
? "font-medium text-white"
|
||
: "text-zinc-300"
|
||
}`}
|
||
>
|
||
{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 text-muted-foreground 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={() =>
|
||
// This browser stops receiving the account's pushes once it signs out.
|
||
void disableWebPush()
|
||
.catch(() => undefined)
|
||
.then(() => 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">
|
||
{!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}
|
||
onOpenInSpace={openSpaceChat}
|
||
/>
|
||
) : null}
|
||
{recordingSkill ? (
|
||
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">
|
||
<Trans>Teaching in progress. Stop teaching before sending a new message.</Trans>
|
||
</div>
|
||
) : null}
|
||
{(active || activeGroup) && !showProjectTimeline ? (
|
||
<Composer
|
||
key={inGroup ? `group:${groupId}` : `bot:${active?.id}`}
|
||
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-white/10 md:bg-[#0A0A0C] 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 && (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}
|
||
{!panel && active ? (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={t`Browser preview`}
|
||
onClick={() => setPanel("browser")}
|
||
className="text-muted-foreground"
|
||
>
|
||
<PanelRight 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 === "browser" ? (
|
||
<div className="flex h-full flex-col">
|
||
<div className="flex items-center gap-1.5 border-b px-3 py-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={reloadBrowser}
|
||
aria-label={t`Reload`}
|
||
className="size-icon-sm text-muted-foreground"
|
||
>
|
||
<RefreshCw strokeWidth={1.7} className="size-4" />
|
||
</Button>
|
||
<input
|
||
type="url"
|
||
value={browserUrl}
|
||
onChange={(e) => setBrowserUrl(e.target.value)}
|
||
onBlur={() => navigateBrowser(browserUrl)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") navigateBrowser(browserUrl);
|
||
}}
|
||
placeholder="http://localhost:5173"
|
||
className="flex-1 rounded-md bg-muted/60 px-2.5 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:ring-1 focus:ring-ring/50"
|
||
/>
|
||
{isDesktop ? (
|
||
devServerRunning ? (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
|
||
<span className="size-1.5 rounded-full bg-emerald-400" />
|
||
<Trans>Dev</Trans>
|
||
</span>
|
||
) : (
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
onClick={startDevServer}
|
||
className="text-ui-label text-muted-foreground hover:text-foreground"
|
||
>
|
||
<Trans>Start Dev</Trans>
|
||
</Button>
|
||
)
|
||
) : null}
|
||
{browserLoading && (
|
||
<div className="h-0.5 w-full rounded-full bg-muted/30">
|
||
<div className="h-full w-2/3 animate-pulse rounded-full bg-primary/60" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="relative flex-1 overflow-hidden bg-background">
|
||
<div className="relative h-full w-full" data-testid="browser-preview">
|
||
{agentActivities.size > 0 && (
|
||
<div className="pointer-events-none absolute inset-0">
|
||
{Array.from(agentActivities.entries()).map(([agentId, activity]) => {
|
||
const cx = Math.min(100, Math.max(0, activity.x)) + "%";
|
||
const cy = Math.min(100, Math.max(0, activity.y)) + "%";
|
||
return (
|
||
<div
|
||
key={agentId}
|
||
className="absolute transition-[left,top] duration-300 ease-out"
|
||
style={{ left: cx, top: cy }}
|
||
>
|
||
<div className="relative">
|
||
<div className="size-3 rounded-full bg-primary/60 ring-2 ring-primary" />
|
||
<div className="absolute -right-1 -top-4 whitespace-nowrap rounded bg-overlay px-1.5 py-0.5 text-ui-label text-foreground shadow-sm">
|
||
{activity.label}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</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" }}
|
||
/>
|
||
) : (
|
||
<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-black/60"
|
||
aria-label={t`Close`}
|
||
onClick={() => setShowCreateBotModal(false)}
|
||
/>
|
||
<div className="relative z-10 max-h-[90vh] w-[480px] overflow-y-auto rounded-2xl border border-white/10 bg-[#0A0A0C] p-6 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}
|
||
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 ? (
|
||
<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-white mb-2">
|
||
{t`Guten Tag.`} <span className="text-emerald-400">evOo</span> {t`ist bereit.`}
|
||
</h2>
|
||
<p className="max-w-md text-ui-body leading-relaxed text-zinc-300 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 group flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] hover:border-emerald-500/40 cursor-pointer"
|
||
>
|
||
<span className="text-xl mb-2">⚡</span>
|
||
<span className="text-[14.5px] font-semibold text-white group-hover:text-emerald-400 transition-colors">
|
||
{t`Portfolio-Check`}
|
||
</span>
|
||
<span className="text-ui-body-sm text-zinc-400 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 group flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] hover:border-sky-500/40 cursor-pointer"
|
||
>
|
||
<span className="text-xl mb-2">📈</span>
|
||
<span className="text-[14.5px] font-semibold text-white group-hover:text-sky-400 transition-colors">
|
||
{t`Alpha-Signale`}
|
||
</span>
|
||
<span className="text-ui-body-sm text-zinc-400 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 group flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] hover:border-purple-500/40 cursor-pointer"
|
||
>
|
||
<span className="text-xl mb-2">💎</span>
|
||
<span className="text-[14.5px] font-semibold text-white group-hover:text-purple-400 transition-colors">
|
||
{t`Steve Jobs Audit`}
|
||
</span>
|
||
<span className="text-ui-body-sm text-zinc-400 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 focus-within:border-emerald-500/40 focus-within:shadow-[0_20px_50px_-10px_rgba(0,0,0,0.85),inset_0_1px_0_rgba(255,255,255,0.22),0_0_0_1px_rgba(16,185,129,0.35)] shadow-[0_16px_40px_-8px_rgba(0,0,0,0.7),inset_0_1px_0_rgba(255,255,255,0.14)]"
|
||
>
|
||
<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-white/10 bg-white/[0.06] text-foreground/80 transition-all hover:bg-white/[0.12] hover:text-white 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-white/10 bg-white/[0.08] px-3 py-1 text-ui-body font-medium text-foreground backdrop-blur-md"
|
||
>
|
||
<Box strokeWidth={1.8} className="shrink-0 text-blue-400 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-white/10 bg-white/[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 selection:bg-emerald-500/30"
|
||
/>
|
||
</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 bg-emerald-400 text-slate-950 hover:bg-emerald-300 shadow-[0_0_16px_rgba(16,185,129,0.35)] 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-white/15 bg-white/[0.08] text-foreground/90 shadow-sm transition-colors hover:bg-white/[0.15] hover:text-white 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 bg-emerald-400 text-slate-950 hover:bg-emerald-300 shadow-[0_0_16px_rgba(16,185,129,0.35)] transition-all active:scale-90 disabled:bg-white/[0.08] disabled:text-white/20 disabled:shadow-none 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-white/[0.08] border border-white/[0.12] 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-white tracking-tight" dir="auto">
|
||
{effectiveSpeakerName}
|
||
</span>
|
||
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
|
||
Oo
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span className="text-ui-label font-mono text-zinc-400 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);
|
||
});
|
||
}
|