diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index e846e08..951c3ad 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -1,12 +1,4 @@ -import { - closestCenter, - DndContext, - DragOverlay, - MouseSensor, - TouchSensor, - useSensor, - useSensors, -} from "@dnd-kit/core"; +import { closestCenter, DndContext, DragOverlay } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, @@ -15,20 +7,15 @@ import { import { CSS as DndCSS } from "@dnd-kit/utilities"; import { Trans, useLingui } from "@lingui/react/macro"; import type { - AgentSkillCatalogEntry, Bot, BotSection, ComputerMode, ComputerReleaseReason, ComputerStatus, - Connection, - ConnectionCatalogItem, Group, MainConversationRef, Me, - ProductEvent, Routine, - SearchHit, Space, SpaceMemoryConfig, TaughtSkill, @@ -44,11 +31,9 @@ import { import { applyWorkspaceFocus, attachmentsForThread, - buildComposerMentionOptions, type ComposerMention, cronFromPreset, deriveWorkspaceFocus, - groupBotsForSidebar, inferAttachmentMimeType, isActive, isRunTerminalEvent, @@ -121,16 +106,7 @@ import type { ArtifactTarget } from "../lib/artifact-open"; import { attachmentMaxBytes, uploadAttachment } from "../lib/artifact-upload"; import { authClient } from "../lib/auth"; import { takeInitialBootstrap } from "../lib/bootstrap"; -import { - BOTS_SIDEBAR_EDGE_DRAG_PX, - readBotsSidebarCollapsed, - writeBotsSidebarCollapsed, -} from "../lib/bots-sidebar-pref"; -import { - deliverBrowserNotification as deliverNativeBrowserNotification, - requestBrowserNotificationPermission, - shouldNotifyBrowser, -} from "../lib/browser-notifications"; +import { requestBrowserNotificationPermission } from "../lib/browser-notifications"; import { loadComputerScreen } from "../lib/computer-screen"; import { scheduleFocusPrompt } from "../lib/focus-prompt"; import { formatBytes } from "../lib/format-bytes"; @@ -139,18 +115,14 @@ import { 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 { localizedPreview } from "../lib/onboarding-copy"; -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 { 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 { routineSaveName } from "../lib/routine-name"; import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc"; import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage"; @@ -212,7 +184,7 @@ import { import { FullscreenComputerOverlay } from "./shell/fullscreen-computer-overlay"; import { MainConversationRow } from "./shell/main-conversation-row"; import { PointerMenu } from "./shell/pointer-menu"; -import { cleanRosterPreview, formatRosterTime, stripSpaceTitlePrefix } from "./shell/roster-format"; +import { cleanRosterPreview, formatRosterTime } from "./shell/roster-format"; import { rosterRowClass } from "./shell/roster-row-style"; import { ProjectRosterRow, TeamRosterRow } from "./shell/roster-rows"; import type { PendingAttachment } from "./shell/shell-types"; @@ -228,6 +200,17 @@ import { threadSnapshotSignal, } from "./shell/thread-state"; import { Transcript } from "./shell/transcript"; +import { useAgentSkills } from "./shell/use-agent-skills"; +import { useBotsSidebarCollapse } from "./shell/use-bots-sidebar-collapse"; +import { useBrowserNotifications } from "./shell/use-browser-notifications"; +import { useComputerViewport } from "./shell/use-computer-viewport"; +import { useHeuteActionCount } from "./shell/use-heute-action-count"; +import { useMentionTargets } from "./shell/use-mention-targets"; +import { useMessagingStatus } from "./shell/use-messaging-status"; +import { useMobileSidebar } from "./shell/use-mobile-sidebar"; +import { useShellSounds } from "./shell/use-shell-sounds"; +import { useSidebarModel } from "./shell/use-sidebar-model"; +import { useSpaceSearch } from "./shell/use-space-search"; import { UserMenu } from "./shell/user-menu"; const BotContextMenu = lazyWithRecovery("BotContextMenu", () => @@ -265,17 +248,8 @@ type Panel = | "artifact" | null; -type PendingBrowserNotification = { - event: Pick; - botId: string; - botName: string; - groupNotification: boolean; -}; - /** 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; /** The screen lingers after the work so its final state and the bot's reply land first. */ const AUTO_SCREEN_CLOSE_DELAY_MS = 4_000; /** How long one pointer announcement counts as the bot working on its screen. */ @@ -358,7 +332,6 @@ export function ShellPage() { "rakazo:workspace-pane-width", ); - const [heuteActionCount, setHeuteActionCount] = useState(0); const [archivedOpen, setArchivedOpen] = useState(false); const [expandedTeamGroups, setExpandedTeamGroups] = useState>({}); const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set()); @@ -366,9 +339,14 @@ export function ShellPage() { useEffect(() => { setCollapsedSidebarSections(readCollapsedSidebarSections(userId)); }, [userId]); - useEffect(() => { - setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId)); - }, [userId]); + const { + botsSidebarCollapsed, + setBotsSidebarCollapsedPref, + onBotsSidebarEdgePointerDown, + onBotsSidebarEdgePointerMove, + onBotsSidebarEdgePointerUp, + onBotsSidebarEdgePointerCancel, + } = useBotsSidebarCollapse(userId); const [panel, setPanel] = useState(null); /** The pane the conversation's focus opened; cleared as soon as the user changes the pane. */ @@ -383,21 +361,6 @@ export function ShellPage() { const [showCreateBotModal, setShowCreateBotModal] = useState(false); - // Persisted project (space) sort order - const [projectOrder, setProjectOrder] = useState(() => readProjectSidebarOrder()); - const handleReorderProjects = useCallback((orderedIds: string[]) => { - setProjectOrder(orderedIds); - writeProjectSidebarOrder(orderedIds); - }, []); - const [draggedProjectId, setDraggedProjectId] = useState(null); - const sensors = useSensors( - useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), - useSensor(TouchSensor, { activationConstraint: { distance: 8 } }), - ); - - const [query, setQuery] = useState(""); - const [searchHits, setSearchHits] = useState([]); - const [searchLoading, setSearchLoading] = useState(false); const [snapshot, setSnapshot] = useState(null); const snapshotRef = useRef(null); const [pendingAttachments, setPendingAttachments] = useState([]); @@ -426,16 +389,6 @@ export function ShellPage() { const [routinesBotId, setRoutinesBotId] = useState(null); const [taughtSkills, setTaughtSkills] = useState([]); const [taughtSkillsBotId, setTaughtSkillsBotId] = useState(null); - const [agentSkills, setAgentSkills] = useState([]); - const [mentionRoutines, setMentionRoutines] = useState>([]); - const [mentionConnectors, setMentionConnectors] = useState< - Array<{ - id: string; - name: string; - authStatus: "connected" | "needs_auth"; - connectionId?: string; - }> - >([]); const [teachBusy, setTeachBusy] = useState(false); const [computer, setComputer] = useState(null); const computerRef = useRef(null); @@ -493,8 +446,6 @@ export function ShellPage() { const [settingsSection, setSettingsSection] = useState("general"); const [messagingSettingsOpen, setMessagingSettingsOpen] = useState(false); - const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false); - const [messagingProviders, setMessagingProviders] = useState([]); const [memoryProviderConfig, setMemoryProviderConfig] = useState< SpaceMemoryConfig | null | undefined >(undefined); @@ -505,17 +456,18 @@ export function ShellPage() { const [dismissedRunErrorIds, setDismissedRunErrorIds] = useState>(readSeenRunErrorIds); const [menuOpen, setMenuOpen] = useState(false); - const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const mobileSidebarSwipeRef = useRef<{ startX: number; startY: number } | null>(null); + const { + mobileSidebarOpen, + setMobileSidebarOpen, + onMobileSidebarTouchStart, + onMobileSidebarTouchEnd, + onMobileSidebarTouchCancel, + } = useMobileSidebar(); const [draggedBotId, setDraggedBotId] = useState(null); const [createMenuOpen, setCreateMenuOpen] = useState(false); const [conversationPickerOpen, setConversationPickerOpen] = useState(false); - const [botsSidebarCollapsed, setBotsSidebarCollapsed] = useState(false); const focusPromptAbortRef = useRef(null); const focusPromptBotIdRef = useRef(null); - const botsSidebarEdgeDragRef = useRef<{ startX: number; mode: "expand" | "collapse" } | null>( - null, - ); const [newSpaceOpen, setNewSpaceOpen] = useState(false); const [newSpaceDialogVariant, setNewSpaceDialogVariant] = useState<"space" | "project">("space"); const openNewSpaceDialog = useCallback((variant: "space" | "project") => { @@ -525,15 +477,6 @@ export function ShellPage() { 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) => { @@ -603,60 +546,12 @@ export function ShellPage() { const [routineError, setRoutineError] = useState(null); const [screenUrl, setScreenUrl] = useState(null); const [computerOpen, setComputerOpen] = useState(false); - const [computerViewport, setComputerViewport] = useState<{ - height: number; - offsetTop: number; - } | null>(null); const [computerError, setComputerError] = useState(null); // Screen-load failures can sit beside a still-valid embed URL; boot and // takeover failures must stay visible even when a URL remains. const [computerErrorFromScreen, setComputerErrorFromScreen] = useState(false); - useEffect(() => { - if (!computerOpen) { - setComputerViewport(null); - return; - } - const viewport = window.visualViewport; - if (!viewport) return; - const sync = () => { - const height = Math.round(viewport.height); - const offsetTop = Math.round(viewport.offsetTop); - // Desktop scrollbars and sub-pixel viewport events must not rewrite the - // overlay height: that feedback loop moves the maintenance menu forever. - const keyboardInset = Math.round(window.innerHeight) - height - offsetTop; - if (keyboardInset < 80) { - setComputerViewport((current) => (current === null ? current : null)); - return; - } - setComputerViewport((current) => { - if (current && current.height === height && current.offsetTop === offsetTop) return current; - return { height, offsetTop }; - }); - }; - sync(); - viewport.addEventListener("resize", sync); - viewport.addEventListener("scroll", sync); - return () => { - viewport.removeEventListener("resize", sync); - viewport.removeEventListener("scroll", sync); - }; - }, [computerOpen]); - useEffect(() => { - if (!session.data?.user) return; - let cancelled = false; - void rpc.messaging - .status() - .then((status) => { - if (!cancelled) { - setMessagingSurfaceEnabled(status.enabled); - setMessagingProviders(status.providers); - } - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, [session.data?.user]); + const computerViewport = useComputerViewport(computerOpen); + const { messagingSurfaceEnabled, messagingProviders } = useMessagingStatus(session); const [usage, setUsage] = useState<{ inputTokens: number; outputTokens: number; @@ -692,8 +587,6 @@ export function ShellPage() { } | null>(null); const manuallyUnread = useRef(new Set()); const readVisibleGroups = useRef(new Set()); - const notifiedBrowserEvents = useRef(new Set()); - const pendingBrowserNotifications = useRef(new Map()); const computerVisible = useRef(false); computerVisible.current = panel === "computer" || computerOpen; const autoSpoken = useRef(null); @@ -780,71 +673,8 @@ export function ShellPage() { }, [markBotRead], ); - const deliverBrowserNotification = useCallback((pending: PendingBrowserNotification): boolean => { - const currentBot = botsRef.current.find((bot) => bot.id === pending.botId); - if (!currentBot || typeof Notification === "undefined") return true; - const result = deliverNativeBrowserNotification( - pending.event, - currentBot.name || pending.botName, - { - enabled: pending.groupNotification || currentBot.notifyOnFinish, - pageVisible: document.visibilityState === "visible", - windowFocused: document.hasFocus(), - permission: Notification.permission, - notifiedEventIds: notifiedBrowserEvents.current, - show: (title, body, tag) => new Notification(title, { body, tag }), - }, - ); - return result !== "pending"; - }, []); - const flushPendingBrowserNotifications = useCallback(() => { - for (const [threadId, pending] of pendingBrowserNotifications.current) { - if (deliverBrowserNotification(pending)) { - pendingBrowserNotifications.current.delete(threadId); - } - } - }, [deliverBrowserNotification]); - const notifyBrowserForEvent = useCallback( - ( - event: Pick, - subscribedThreadId: string | undefined, - initialCursor: number, - streamReady: boolean, - botName: string, - enabled: boolean, - groupNotification: boolean, - ) => { - const botId = event.botId; - if (typeof botId !== "string") return; - const eligible = shouldNotifyBrowser(event, { - subscribedThreadId: subscribedThreadId ?? "", - initialCursor, - streamReady, - pageVisible: document.visibilityState === "visible", - windowFocused: document.hasFocus(), - permission: "granted", - notifiedEventIds: notifiedBrowserEvents.current, - }); - if (!eligible || !enabled) return; - const pending = { - event, - botId, - botName, - groupNotification, - } satisfies PendingBrowserNotification; - if (typeof Notification === "undefined" || Notification.permission === "denied") return; - if (Notification.permission === "default") { - pendingBrowserNotifications.current.set(event.threadId, pending); - return; - } - if (deliverBrowserNotification(pending)) { - pendingBrowserNotifications.current.delete(event.threadId); - } else { - pendingBrowserNotifications.current.set(event.threadId, pending); - } - }, - [deliverBrowserNotification], - ); + const { flushPendingBrowserNotifications, notifyBrowserForEvent } = + useBrowserNotifications(botsRef); const refreshBots = useCallback( async (includeArchived = false, replaceBotOrder = false) => { @@ -1204,27 +1034,7 @@ export function ShellPage() { }; }, []); - 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); - }, []); + const { agentSkills, setAgentSkills, refreshAgentSkills } = useAgentSkills(); useEffect(() => { void rpc.voice @@ -1501,213 +1311,27 @@ export function ShellPage() { }; }, [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, - /** A project without its main conversation yet offers to start it. */ - startSpaceId: 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, - startSpaceId: undefined, - spaceId: space.id, - spaceName: space.name, - canDeleteSpace: space.canDelete === true, - }, - ]; - }); - }, [bootstrapMe, botSections, bots, groups, spaces, query]); - - const homeSpaceBots = useMemo(() => { - if (!homeSpaceId) return bots; - const home = spaces.find((space) => space.id === homeSpaceId); - return home?.bots ?? bots; - }, [bots, homeSpaceId, spaces]); - - const conversationSidebarGroups = useMemo(() => { - if (!homeSpaceId) return []; - return sidebarGroups - .filter((group) => group.spaceId === homeSpaceId) - .map((group) => ({ - ...group, - title: stripSpaceTitlePrefix(group.title, group.spaceName), - showLock: false, - bots: group.bots - .filter((item) => item.kind === "bot") - .filter((item) => item.chat.kind !== "main"), - })) - .filter((group) => group.bots.length > 0); - }, [homeSpaceId, sidebarGroups]); - - const mainConversationEntry = useMemo(() => { - if (!mainConversation) return null; - const inCurrentSpace = mainConversation.spaceId === bootstrapMe?.spaceId; - const space = spaces.find((item) => item.id === mainConversation.spaceId); - const bot = (inCurrentSpace ? bots : space?.bots)?.find( - (item) => item.id === mainConversation.botId, - ); - const group = (inCurrentSpace ? groups : space?.groups)?.find( - (item) => item.id === mainConversation.groupId, - ); - if (!bot || !group) return null; - return { ...mainConversation, bot, group }; - }, [bootstrapMe?.spaceId, bots, groups, mainConversation, spaces]); - - const projectSidebarGroups = useMemo(() => { - if (!homeSpaceId) return []; - const entries: typeof sidebarGroups = []; - const seenProjectSpaceIds = new Set(); - const seenProjectGroupChatIds = new Set(); - - const dedupeSidebarBots = (bots: (typeof sidebarGroups)[number]["bots"]) => { - const seen = new Set(); - return bots.filter((item) => { - const key = `${item.kind}:${item.chat.id}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - }; - - for (const space of spaces) { - if (space.id === homeSpaceId || seenProjectSpaceIds.has(space.id)) continue; - seenProjectSpaceIds.add(space.id); - const groupsInSpace = sidebarGroups.flatMap((group) => - group.spaceId === space.id ? group.bots.filter((item) => item.kind === "group") : [], - ); - // The project's main conversation, where the user talks to its main agent, comes first. - const projectBots = dedupeSidebarBots(groupsInSpace).sort( - (left, right) => Number(right.chat.kind === "main") - Number(left.chat.kind === "main"), - ); - const hasMain = projectBots.some((item) => item.chat.kind === "main"); - 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, - startSpaceId: hasMain ? 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 { query, setQuery, searchHits, searchLoading, showSpaceSearch, jumpToSearchHit } = + useSpaceSearch(navigate); + const { + homeSpaceBots, + conversationSidebarGroups, + mainConversationEntry, + projectSidebarGroups, + sensors, + setDraggedProjectId, + handleProjectDragEnd, + handleProjectDragStart, + } = useSidebarModel({ + query, + spaces, + bots, + groups, + botSections, + bootstrapMe, + homeSpaceId, + mainConversation, + }); const openSpaceChat = useCallback( (spaceId: string, path: string) => { @@ -1825,47 +1449,6 @@ export function ShellPage() { }, [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); @@ -2186,114 +1769,15 @@ export function ShellPage() { openSettings("voice"); }, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]); - const prevTranscriptRunning = useRef(false); - const prevRunErrorId = useRef(null); - const seenHandoffKeys = useRef(new Set()); - const appSoundStarted = useRef(false); - const prevBotStatuses = useRef(new Map()); - - useEffect(() => wireOoAudioVoiceDucking(), []); - - useEffect(() => { - if (appSoundStarted.current) return; - appSoundStarted.current = true; - ooSound.play("app-start"); - window.setTimeout(() => ooVoiceMark.maybePlayColdOpen(), 280); - }, []); - - useEffect(() => { - let cancelled = false; - let timer: number | undefined; - - const tick = async () => { - try { - const [active, recent] = await Promise.all([ - rpc.runs.list({ filter: "active" }), - rpc.runs.list({ filter: "recent" }), - ]); - if (cancelled) return; - const brief = buildHeuteBrief({ - bots: botsRef.current, - groups, - activeRuns: active.runs, - recentRuns: recent.runs, - }); - setHeuteActionCount(brief.actionCount); - } catch { - if (cancelled) return; - } finally { - if (!cancelled) { - timer = window.setTimeout(() => void tick(), 15_000); - } - } - }; - - void tick(); - return () => { - cancelled = true; - if (timer !== undefined) window.clearTimeout(timer); - }; - }, [groups]); - - useEffect(() => { - for (const bot of bots) { - const prev = prevBotStatuses.current.get(bot.id); - if ( - prev !== bot.status && - (bot.status === "waiting_input" || bot.status === "waiting_takeover") - ) { - ooSound.play("need-you"); - ooVocals.reactForEvent("need-you"); - } - prevBotStatuses.current.set(bot.id, bot.status); - } - }, [bots]); - - useEffect(() => { - if (prevTranscriptRunning.current && !transcriptRunning && !callOpen) { - ooSound.play("success-resolve"); - ooVocals.reactForEvent("success"); - } - prevTranscriptRunning.current = transcriptRunning; - }, [transcriptRunning, callOpen]); - - useEffect(() => { - const runId = displayedRunErrorId; - if (runId && runId !== prevRunErrorId.current) { - ooSound.play("attention"); - } - prevRunErrorId.current = runId; - }, [displayedRunErrorId]); - - const seenGroupSparkKeys = useRef(new Set()); - - useEffect(() => { - for (const message of activeSnapshot?.messages ?? []) { - for (const block of message.blocks) { - if (block.kind === "handoff") { - const key = `${message.id}:handoff:${block.toBotId}`; - if (seenHandoffKeys.current.has(key)) continue; - seenHandoffKeys.current.add(key); - if (seenHandoffKeys.current.size > 80) { - const oldest = seenHandoffKeys.current.values().next().value; - if (oldest !== undefined) seenHandoffKeys.current.delete(oldest); - } - ooSound.play("handoff"); - continue; - } - if (inGroup && block.kind === "bot_message_received") { - const key = `${message.id}:peer:${block.fromBotId}`; - if (seenGroupSparkKeys.current.has(key)) continue; - seenGroupSparkKeys.current.add(key); - if (seenGroupSparkKeys.current.size > 80) { - const oldest = seenGroupSparkKeys.current.values().next().value; - if (oldest !== undefined) seenGroupSparkKeys.current.delete(oldest); - } - ooSound.play("group-spark"); - } - } - } - }, [activeSnapshot?.messages, inGroup]); + const heuteActionCount = useHeuteActionCount(groups, botsRef); + useShellSounds({ + bots, + transcriptRunning, + callOpen, + displayedRunErrorId, + activeSnapshot, + inGroup, + }); const resolveTranscriptMemberName = useCallback( (botId: string | undefined) => @@ -2306,48 +1790,15 @@ export function ShellPage() { ? 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, + const composerMentionTargets = useMentionTargets({ bots, - groupId, groups, + groupId, inGroup, - mentionConnectors, - mentionRoutines, - ]); + activeGroup, + activeSnapshot, + initialBotsLoaded, + }); const shellReady = initialBotsLoaded && (inGroup @@ -2362,78 +1813,6 @@ export function ShellPage() { const jumpToMessageRef = useRef(jumpToMessage); jumpToMessageRef.current = jumpToMessage; - const mentionBotsKey = useMemo( - () => bots.map((bot) => `${bot.id}:${bot.name}`).join(","), - [bots], - ); - const botsForMentionsRef = useRef(bots); - botsForMentionsRef.current = bots; - - useEffect(() => { - const bots = botsForMentionsRef.current; - if (!initialBotsLoaded || bots.length === 0) { - setMentionRoutines([]); - setMentionConnectors([]); - return; - } - let cancelled = false; - const botNameById = new Map(bots.map((bot) => [bot.id, bot.name])); - void Promise.all( - bots.map((bot) => - rpc.routines - .list({ botId: bot.id }) - .then((rows) => - rows.map((routine) => ({ - ...routine, - botName: botNameById.get(bot.id) ?? bot.name, - })), - ) - .catch(() => [] as Array), - ), - ).then((lists) => { - if (!cancelled) setMentionRoutines(lists.flat()); - }); - void Promise.all([ - rpc.connections.list().catch(() => [] as Connection[]), - rpc.connections.catalog({}).catch(() => [] as ConnectionCatalogItem[]), - ]).then(([connections, catalog]) => { - if (cancelled) return; - const connected = connections.filter((row) => row.status === "connected"); - const options: Array<{ - id: string; - name: string; - authStatus: "connected" | "needs_auth"; - connectionId?: string; - }> = connected.map((row) => ({ - id: row.id, - name: row.displayName, - authStatus: "connected" as const, - connectionId: row.id, - })); - for (const item of catalog) { - if (item.connected || item.noAuth) continue; - if ( - connected.some( - (row) => - row.provider.toLowerCase() === item.slug.toLowerCase() || - row.displayName.toLowerCase() === item.name.toLowerCase(), - ) - ) { - continue; - } - options.push({ - id: `catalog:${item.connectorId}:${item.slug}`, - name: item.name, - authStatus: "needs_auth", - }); - } - setMentionConnectors(options); - }); - return () => { - cancelled = true; - }; - }, [initialBotsLoaded, mentionBotsKey]); - useLayoutEffect(() => { if (initialBotsLoaded) { markOnce("rk:renderer:bots-committed"); @@ -2977,11 +2356,6 @@ export function ShellPage() { focusPromptBotIdRef.current = null; } - function setBotsSidebarCollapsedPref(collapsed: boolean) { - setBotsSidebarCollapsed(collapsed); - writeBotsSidebarCollapsed(userId, collapsed); - } - function openSettings(section: SettingsSection = "general") { setSettingsSection(section); setSettingsOpen(true); @@ -3268,47 +2642,9 @@ export function ShellPage() { data-testid="shell-root" data-ready={shellReady} className="relative flex h-full min-w-0 overflow-hidden bg-background text-foreground/90 p-0 md:p-3 gap-0 md:gap-3" - onTouchStartCapture={(event) => { - if ( - mobileSidebarOpen || - event.touches.length !== 1 || - window.matchMedia("(min-width: 768px)").matches - ) { - mobileSidebarSwipeRef.current = null; - return; - } - const touch = event.touches[0]; - if (!touch) return; - const rtl = document.documentElement.getAttribute("dir") === "rtl"; - const startsAtEdge = rtl - ? touch.clientX >= window.innerWidth - MOBILE_SIDEBAR_SWIPE_EDGE_PX - : touch.clientX <= MOBILE_SIDEBAR_SWIPE_EDGE_PX; - mobileSidebarSwipeRef.current = startsAtEdge - ? { startX: touch.clientX, startY: touch.clientY } - : null; - }} - onTouchEndCapture={(event) => { - const swipe = mobileSidebarSwipeRef.current; - mobileSidebarSwipeRef.current = null; - const touch = event.changedTouches[0]; - if ( - !swipe || - !touch || - mobileSidebarOpen || - window.matchMedia("(min-width: 768px)").matches - ) { - return; - } - const rtl = document.documentElement.getAttribute("dir") === "rtl"; - const horizontal = rtl ? swipe.startX - touch.clientX : touch.clientX - swipe.startX; - const vertical = Math.abs(touch.clientY - swipe.startY); - if (horizontal >= MOBILE_SIDEBAR_SWIPE_DISTANCE_PX && horizontal > vertical * 1.25) { - setMobileSidebarOpen(true); - } - }} - onTouchCancelCapture={() => { - mobileSidebarSwipeRef.current = null; - }} + onTouchStartCapture={onMobileSidebarTouchStart} + onTouchEndCapture={onMobileSidebarTouchEnd} + onTouchCancelCapture={onMobileSidebarTouchCancel} > { @@ -3724,39 +3060,10 @@ export function ShellPage() { 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; - }} + onPointerDown={onBotsSidebarEdgePointerDown} + onPointerMove={onBotsSidebarEdgePointerMove} + onPointerUp={onBotsSidebarEdgePointerUp} + onPointerCancel={onBotsSidebarEdgePointerCancel} />
([]); + + 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); + }, []); + + return { agentSkills, setAgentSkills, refreshAgentSkills }; +} diff --git a/apps/web/src/pages/shell/use-bots-sidebar-collapse.ts b/apps/web/src/pages/shell/use-bots-sidebar-collapse.ts new file mode 100644 index 0000000..83f5e77 --- /dev/null +++ b/apps/web/src/pages/shell/use-bots-sidebar-collapse.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from "react"; +import { + BOTS_SIDEBAR_EDGE_DRAG_PX, + readBotsSidebarCollapsed, + writeBotsSidebarCollapsed, +} from "../../lib/bots-sidebar-pref"; + +export function useBotsSidebarCollapse(userId: string | undefined) { + const [botsSidebarCollapsed, setBotsSidebarCollapsed] = useState(false); + const botsSidebarEdgeDragRef = useRef<{ startX: number; mode: "expand" | "collapse" } | null>( + null, + ); + + useEffect(() => { + setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId)); + }, [userId]); + + function setBotsSidebarCollapsedPref(collapsed: boolean) { + setBotsSidebarCollapsed(collapsed); + writeBotsSidebarCollapsed(userId, collapsed); + } + + function onBotsSidebarEdgePointerDown(event: React.PointerEvent) { + event.currentTarget.setPointerCapture(event.pointerId); + botsSidebarEdgeDragRef.current = { + startX: event.clientX, + mode: botsSidebarCollapsed ? "expand" : "collapse", + }; + } + + function onBotsSidebarEdgePointerMove(event: React.PointerEvent) { + 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); + } + } + + function onBotsSidebarEdgePointerUp(event: React.PointerEvent) { + const drag = botsSidebarEdgeDragRef.current; + botsSidebarEdgeDragRef.current = null; + if (!drag) return; + if (Math.abs(event.clientX - drag.startX) < BOTS_SIDEBAR_EDGE_DRAG_PX) { + setBotsSidebarCollapsedPref(!botsSidebarCollapsed); + } + } + + function onBotsSidebarEdgePointerCancel() { + botsSidebarEdgeDragRef.current = null; + } + + return { + botsSidebarCollapsed, + setBotsSidebarCollapsedPref, + onBotsSidebarEdgePointerDown, + onBotsSidebarEdgePointerMove, + onBotsSidebarEdgePointerUp, + onBotsSidebarEdgePointerCancel, + }; +} diff --git a/apps/web/src/pages/shell/use-browser-notifications.ts b/apps/web/src/pages/shell/use-browser-notifications.ts new file mode 100644 index 0000000..497ab89 --- /dev/null +++ b/apps/web/src/pages/shell/use-browser-notifications.ts @@ -0,0 +1,86 @@ +import type { Bot, ProductEvent } from "@rakazo/contracts"; +import type { RefObject } from "react"; +import { useCallback, useRef } from "react"; +import { + deliverBrowserNotification as deliverNativeBrowserNotification, + shouldNotifyBrowser, +} from "../../lib/browser-notifications"; + +type PendingBrowserNotification = { + event: Pick; + botId: string; + botName: string; + groupNotification: boolean; +}; + +export function useBrowserNotifications(botsRef: RefObject) { + const notifiedBrowserEvents = useRef(new Set()); + const pendingBrowserNotifications = useRef(new Map()); + const deliverBrowserNotification = useCallback((pending: PendingBrowserNotification): boolean => { + const currentBot = botsRef.current.find((bot) => bot.id === pending.botId); + if (!currentBot || typeof Notification === "undefined") return true; + const result = deliverNativeBrowserNotification( + pending.event, + currentBot.name || pending.botName, + { + enabled: pending.groupNotification || currentBot.notifyOnFinish, + pageVisible: document.visibilityState === "visible", + windowFocused: document.hasFocus(), + permission: Notification.permission, + notifiedEventIds: notifiedBrowserEvents.current, + show: (title, body, tag) => new Notification(title, { body, tag }), + }, + ); + return result !== "pending"; + }, []); + const flushPendingBrowserNotifications = useCallback(() => { + for (const [threadId, pending] of pendingBrowserNotifications.current) { + if (deliverBrowserNotification(pending)) { + pendingBrowserNotifications.current.delete(threadId); + } + } + }, [deliverBrowserNotification]); + const notifyBrowserForEvent = useCallback( + ( + event: Pick, + subscribedThreadId: string | undefined, + initialCursor: number, + streamReady: boolean, + botName: string, + enabled: boolean, + groupNotification: boolean, + ) => { + const botId = event.botId; + if (typeof botId !== "string") return; + const eligible = shouldNotifyBrowser(event, { + subscribedThreadId: subscribedThreadId ?? "", + initialCursor, + streamReady, + pageVisible: document.visibilityState === "visible", + windowFocused: document.hasFocus(), + permission: "granted", + notifiedEventIds: notifiedBrowserEvents.current, + }); + if (!eligible || !enabled) return; + const pending = { + event, + botId, + botName, + groupNotification, + } satisfies PendingBrowserNotification; + if (typeof Notification === "undefined" || Notification.permission === "denied") return; + if (Notification.permission === "default") { + pendingBrowserNotifications.current.set(event.threadId, pending); + return; + } + if (deliverBrowserNotification(pending)) { + pendingBrowserNotifications.current.delete(event.threadId); + } else { + pendingBrowserNotifications.current.set(event.threadId, pending); + } + }, + [deliverBrowserNotification], + ); + + return { flushPendingBrowserNotifications, notifyBrowserForEvent }; +} diff --git a/apps/web/src/pages/shell/use-computer-viewport.ts b/apps/web/src/pages/shell/use-computer-viewport.ts new file mode 100644 index 0000000..c451792 --- /dev/null +++ b/apps/web/src/pages/shell/use-computer-viewport.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +/** Visible area above an on-screen keyboard while the full-screen computer is open. */ +export function useComputerViewport( + computerOpen: boolean, +): { height: number; offsetTop: number } | null { + const [computerViewport, setComputerViewport] = useState<{ + height: number; + offsetTop: number; + } | null>(null); + 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]); + + return computerViewport; +} diff --git a/apps/web/src/pages/shell/use-heute-action-count.ts b/apps/web/src/pages/shell/use-heute-action-count.ts new file mode 100644 index 0000000..1ea9f43 --- /dev/null +++ b/apps/web/src/pages/shell/use-heute-action-count.ts @@ -0,0 +1,45 @@ +import type { Bot, Group } from "@rakazo/contracts"; +import type { RefObject } from "react"; +import { useEffect, useState } from "react"; +import { buildHeuteBrief } from "../../lib/heute-brief"; +import { rpc } from "../../lib/rpc"; + +export function useHeuteActionCount(groups: Group[], botsRef: RefObject): number { + const [heuteActionCount, setHeuteActionCount] = useState(0); + + 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]); + + return heuteActionCount; +} diff --git a/apps/web/src/pages/shell/use-mention-targets.ts b/apps/web/src/pages/shell/use-mention-targets.ts new file mode 100644 index 0000000..0220337 --- /dev/null +++ b/apps/web/src/pages/shell/use-mention-targets.ts @@ -0,0 +1,159 @@ +import type { + Bot, + Connection, + ConnectionCatalogItem, + Group, + Routine, + ThreadSnapshot, +} from "@rakazo/contracts"; +import type { ComposerMention } from "@rakazo/core"; +import { buildComposerMentionOptions } from "@rakazo/core"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { rpc } from "../../lib/rpc"; + +export type MentionTargetsInput = { + bots: Bot[]; + groups: Group[]; + groupId: string | undefined; + inGroup: boolean; + activeGroup: Group | undefined; + activeSnapshot: ThreadSnapshot | null; + initialBotsLoaded: boolean; +}; + +export function useMentionTargets({ + bots, + groups, + groupId, + inGroup, + activeGroup, + activeSnapshot, + initialBotsLoaded, +}: MentionTargetsInput): ComposerMention[] { + const [mentionRoutines, setMentionRoutines] = useState>([]); + const [mentionConnectors, setMentionConnectors] = useState< + Array<{ + id: string; + name: string; + authStatus: "connected" | "needs_auth"; + connectionId?: string; + }> + >([]); + + 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 mentionBotsKey = useMemo( + () => bots.map((bot) => `${bot.id}:${bot.name}`).join(","), + [bots], + ); + const botsForMentionsRef = useRef(bots); + botsForMentionsRef.current = bots; + + useEffect(() => { + const bots = botsForMentionsRef.current; + if (!initialBotsLoaded || bots.length === 0) { + setMentionRoutines([]); + setMentionConnectors([]); + return; + } + let cancelled = false; + const botNameById = new Map(bots.map((bot) => [bot.id, bot.name])); + void Promise.all( + bots.map((bot) => + rpc.routines + .list({ botId: bot.id }) + .then((rows) => + rows.map((routine) => ({ + ...routine, + botName: botNameById.get(bot.id) ?? bot.name, + })), + ) + .catch(() => [] as Array), + ), + ).then((lists) => { + if (!cancelled) setMentionRoutines(lists.flat()); + }); + void Promise.all([ + rpc.connections.list().catch(() => [] as Connection[]), + rpc.connections.catalog({}).catch(() => [] as ConnectionCatalogItem[]), + ]).then(([connections, catalog]) => { + if (cancelled) return; + const connected = connections.filter((row) => row.status === "connected"); + const options: Array<{ + id: string; + name: string; + authStatus: "connected" | "needs_auth"; + connectionId?: string; + }> = connected.map((row) => ({ + id: row.id, + name: row.displayName, + authStatus: "connected" as const, + connectionId: row.id, + })); + for (const item of catalog) { + if (item.connected || item.noAuth) continue; + if ( + connected.some( + (row) => + row.provider.toLowerCase() === item.slug.toLowerCase() || + row.displayName.toLowerCase() === item.name.toLowerCase(), + ) + ) { + continue; + } + options.push({ + id: `catalog:${item.connectorId}:${item.slug}`, + name: item.name, + authStatus: "needs_auth", + }); + } + setMentionConnectors(options); + }); + return () => { + cancelled = true; + }; + }, [initialBotsLoaded, mentionBotsKey]); + + return composerMentionTargets; +} diff --git a/apps/web/src/pages/shell/use-messaging-status.ts b/apps/web/src/pages/shell/use-messaging-status.ts new file mode 100644 index 0000000..9baa27d --- /dev/null +++ b/apps/web/src/pages/shell/use-messaging-status.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; +import type { authClient } from "../../lib/auth"; +import { rpc } from "../../lib/rpc"; + +export function useMessagingStatus(session: ReturnType) { + const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false); + const [messagingProviders, setMessagingProviders] = useState([]); + + 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]); + + return { messagingSurfaceEnabled, messagingProviders }; +} diff --git a/apps/web/src/pages/shell/use-mobile-sidebar.ts b/apps/web/src/pages/shell/use-mobile-sidebar.ts new file mode 100644 index 0000000..8815752 --- /dev/null +++ b/apps/web/src/pages/shell/use-mobile-sidebar.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from "react"; + +const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32; +const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56; + +export function useMobileSidebar() { + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const mobileSidebarSwipeRef = useRef<{ startX: number; startY: number } | null>(null); + + 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); + }, []); + + function onMobileSidebarTouchStart(event: React.TouchEvent) { + 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; + } + + function onMobileSidebarTouchEnd(event: React.TouchEvent) { + 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); + } + } + + function onMobileSidebarTouchCancel() { + mobileSidebarSwipeRef.current = null; + } + + return { + mobileSidebarOpen, + setMobileSidebarOpen, + onMobileSidebarTouchStart, + onMobileSidebarTouchEnd, + onMobileSidebarTouchCancel, + }; +} diff --git a/apps/web/src/pages/shell/use-shell-sounds.ts b/apps/web/src/pages/shell/use-shell-sounds.ts new file mode 100644 index 0000000..66e89ec --- /dev/null +++ b/apps/web/src/pages/shell/use-shell-sounds.ts @@ -0,0 +1,99 @@ +import type { Bot, ThreadSnapshot } from "@rakazo/contracts"; +import { useEffect, useRef } from "react"; +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"; + +export type ShellSoundsInput = { + bots: Bot[]; + transcriptRunning: boolean; + callOpen: boolean; + displayedRunErrorId: string | null; + activeSnapshot: ThreadSnapshot | null; + inGroup: boolean; +}; + +export function useShellSounds({ + bots, + transcriptRunning, + callOpen, + displayedRunErrorId, + activeSnapshot, + inGroup, +}: ShellSoundsInput): void { + const prevTranscriptRunning = useRef(false); + const prevRunErrorId = useRef(null); + const seenHandoffKeys = useRef(new Set()); + const appSoundStarted = useRef(false); + const prevBotStatuses = useRef(new Map()); + + useEffect(() => wireOoAudioVoiceDucking(), []); + + useEffect(() => { + if (appSoundStarted.current) return; + appSoundStarted.current = true; + ooSound.play("app-start"); + window.setTimeout(() => ooVoiceMark.maybePlayColdOpen(), 280); + }, []); + + useEffect(() => { + for (const bot of bots) { + const prev = prevBotStatuses.current.get(bot.id); + if ( + prev !== bot.status && + (bot.status === "waiting_input" || bot.status === "waiting_takeover") + ) { + ooSound.play("need-you"); + ooVocals.reactForEvent("need-you"); + } + prevBotStatuses.current.set(bot.id, bot.status); + } + }, [bots]); + + useEffect(() => { + if (prevTranscriptRunning.current && !transcriptRunning && !callOpen) { + ooSound.play("success-resolve"); + ooVocals.reactForEvent("success"); + } + prevTranscriptRunning.current = transcriptRunning; + }, [transcriptRunning, callOpen]); + + useEffect(() => { + const runId = displayedRunErrorId; + if (runId && runId !== prevRunErrorId.current) { + ooSound.play("attention"); + } + prevRunErrorId.current = runId; + }, [displayedRunErrorId]); + + const seenGroupSparkKeys = useRef(new Set()); + + useEffect(() => { + for (const message of activeSnapshot?.messages ?? []) { + for (const block of message.blocks) { + if (block.kind === "handoff") { + const key = `${message.id}:handoff:${block.toBotId}`; + if (seenHandoffKeys.current.has(key)) continue; + seenHandoffKeys.current.add(key); + if (seenHandoffKeys.current.size > 80) { + const oldest = seenHandoffKeys.current.values().next().value; + if (oldest !== undefined) seenHandoffKeys.current.delete(oldest); + } + ooSound.play("handoff"); + continue; + } + if (inGroup && block.kind === "bot_message_received") { + const key = `${message.id}:peer:${block.fromBotId}`; + if (seenGroupSparkKeys.current.has(key)) continue; + seenGroupSparkKeys.current.add(key); + if (seenGroupSparkKeys.current.size > 80) { + const oldest = seenGroupSparkKeys.current.values().next().value; + if (oldest !== undefined) seenGroupSparkKeys.current.delete(oldest); + } + ooSound.play("group-spark"); + } + } + } + }, [activeSnapshot?.messages, inGroup]); +} diff --git a/apps/web/src/pages/shell/use-sidebar-model.ts b/apps/web/src/pages/shell/use-sidebar-model.ts new file mode 100644 index 0000000..d1f864c --- /dev/null +++ b/apps/web/src/pages/shell/use-sidebar-model.ts @@ -0,0 +1,259 @@ +import { MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core"; +import type { Bot, BotSection, Group, MainConversationRef, Me, Space } from "@rakazo/contracts"; +import { groupBotsForSidebar } from "@rakazo/core"; +import { useCallback, useMemo, useState } from "react"; +import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../../lib/project-sidebar-order"; +import { stripSpaceTitlePrefix } from "./roster-format"; + +export type SidebarModelInput = { + query: string; + spaces: Space[]; + bots: Bot[]; + groups: Group[]; + botSections: BotSection[]; + bootstrapMe: Me | null | undefined; + homeSpaceId: string | undefined; + mainConversation: MainConversationRef | null; +}; + +export function useSidebarModel({ + query, + spaces, + bots, + groups, + botSections, + bootstrapMe, + homeSpaceId, + mainConversation, +}: SidebarModelInput) { + // Persisted project (space) sort order + const [projectOrder, setProjectOrder] = useState(() => readProjectSidebarOrder()); + const handleReorderProjects = useCallback((orderedIds: string[]) => { + setProjectOrder(orderedIds); + writeProjectSidebarOrder(orderedIds); + }, []); + const [draggedProjectId, setDraggedProjectId] = useState(null); + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { distance: 8 } }), + ); + + const 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, + /** A project without its main conversation yet offers to start it. */ + startSpaceId: 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, + startSpaceId: undefined, + spaceId: space.id, + spaceName: space.name, + canDeleteSpace: space.canDelete === true, + }, + ]; + }); + }, [bootstrapMe, botSections, bots, groups, spaces, query]); + + const homeSpaceBots = useMemo(() => { + if (!homeSpaceId) return bots; + const home = spaces.find((space) => space.id === homeSpaceId); + return home?.bots ?? bots; + }, [bots, homeSpaceId, spaces]); + + const conversationSidebarGroups = useMemo(() => { + if (!homeSpaceId) return []; + return sidebarGroups + .filter((group) => group.spaceId === homeSpaceId) + .map((group) => ({ + ...group, + title: stripSpaceTitlePrefix(group.title, group.spaceName), + showLock: false, + bots: group.bots + .filter((item) => item.kind === "bot") + .filter((item) => item.chat.kind !== "main"), + })) + .filter((group) => group.bots.length > 0); + }, [homeSpaceId, sidebarGroups]); + + const mainConversationEntry = useMemo(() => { + if (!mainConversation) return null; + const inCurrentSpace = mainConversation.spaceId === bootstrapMe?.spaceId; + const space = spaces.find((item) => item.id === mainConversation.spaceId); + const bot = (inCurrentSpace ? bots : space?.bots)?.find( + (item) => item.id === mainConversation.botId, + ); + const group = (inCurrentSpace ? groups : space?.groups)?.find( + (item) => item.id === mainConversation.groupId, + ); + if (!bot || !group) return null; + return { ...mainConversation, bot, group }; + }, [bootstrapMe?.spaceId, bots, groups, mainConversation, spaces]); + + const projectSidebarGroups = useMemo(() => { + if (!homeSpaceId) return []; + const entries: typeof sidebarGroups = []; + const seenProjectSpaceIds = new Set(); + const seenProjectGroupChatIds = new Set(); + + const dedupeSidebarBots = (bots: (typeof sidebarGroups)[number]["bots"]) => { + const seen = new Set(); + return bots.filter((item) => { + const key = `${item.kind}:${item.chat.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }; + + for (const space of spaces) { + if (space.id === homeSpaceId || seenProjectSpaceIds.has(space.id)) continue; + seenProjectSpaceIds.add(space.id); + const groupsInSpace = sidebarGroups.flatMap((group) => + group.spaceId === space.id ? group.bots.filter((item) => item.kind === "group") : [], + ); + // The project's main conversation, where the user talks to its main agent, comes first. + const projectBots = dedupeSidebarBots(groupsInSpace).sort( + (left, right) => Number(right.chat.kind === "main") - Number(left.chat.kind === "main"), + ); + const hasMain = projectBots.some((item) => item.chat.kind === "main"); + 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, + startSpaceId: hasMain ? 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)); + }, []); + + return { + homeSpaceBots, + conversationSidebarGroups, + mainConversationEntry, + projectSidebarGroups, + sensors, + setDraggedProjectId, + handleProjectDragEnd, + handleProjectDragStart, + }; +} diff --git a/apps/web/src/pages/shell/use-space-search.ts b/apps/web/src/pages/shell/use-space-search.ts new file mode 100644 index 0000000..5b1ab5b --- /dev/null +++ b/apps/web/src/pages/shell/use-space-search.ts @@ -0,0 +1,64 @@ +import type { SearchHit } from "@rakazo/contracts"; +import { Calendar, Settings } from "lucide-react"; +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useState } from "react"; +import type { NavigateFunction } from "react-router-dom"; +import { rpc } from "../../lib/rpc"; + +export type SpaceSearch = { + query: string; + setQuery: Dispatch>; + searchHits: SearchHit[]; + searchLoading: boolean; + showSpaceSearch: boolean; + jumpToSearchHit: (hit: SearchHit) => Promise; +}; + +export function useSpaceSearch(navigate: NavigateFunction): SpaceSearch { + const [query, setQuery] = useState(""); + const [searchHits, setSearchHits] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); + 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, + }); + } + + return { query, setQuery, searchHits, searchLoading, showSpaceSearch, jumpToSearchHit }; +}