From 307c095bb6021539bab30019359279e331227019 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:59:38 +0000 Subject: [PATCH] refactor(web): extract sidebar model and space search hooks from ShellPage ShellPage mixes the sidebar's derived roster with thread orchestration in one 3,000-line body, which makes both hard to review. Behaviour-preserving move: - useSpaceSearch owns the sidebar query, the debounced space search and jumpToSearchHit. - useSidebarModel owns the sidebar memos (roster groups, conversations, projects, main conversation entry) and the project drag-and-drop order. Both are called where the sidebar memos were, so the search effect keeps its place among ShellPage's effects; bodies and dependency arrays move verbatim. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/src/pages/Shell.tsx | 301 ++---------------- apps/web/src/pages/shell/use-sidebar-model.ts | 259 +++++++++++++++ apps/web/src/pages/shell/use-space-search.ts | 64 ++++ 3 files changed, 348 insertions(+), 276 deletions(-) create mode 100644 apps/web/src/pages/shell/use-sidebar-model.ts create mode 100644 apps/web/src/pages/shell/use-space-search.ts diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 57d0ed6..ab905ee 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, @@ -28,7 +20,6 @@ import type { Me, ProductEvent, Routine, - SearchHit, Space, SpaceMemoryConfig, TaughtSkill, @@ -48,7 +39,6 @@ import { type ComposerMention, cronFromPreset, deriveWorkspaceFocus, - groupBotsForSidebar, inferAttachmentMimeType, isActive, isRunTerminalEvent, @@ -176,7 +166,6 @@ 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"; @@ -242,7 +231,7 @@ import { RenameBotSectionDialog, } from "./shell/dialogs"; import { MainConversationRow } from "./shell/main-conversation-row"; -import { cleanRosterPreview, formatRosterTime, stripSpaceTitlePrefix } from "./shell/roster-format"; +import { cleanRosterPreview, formatRosterTime } from "./shell/roster-format"; import { rosterNameClass, rosterPreviewClass, @@ -259,6 +248,8 @@ import { threadSnapshotSignal, } from "./shell/thread-state"; import { Transcript } from "./shell/transcript"; +import { useSidebarModel } from "./shell/use-sidebar-model"; +import { useSpaceSearch } from "./shell/use-space-search"; import { WindowChrome } from "./WindowChrome"; const BotContextMenu = lazyWithRecovery("BotContextMenu", () => @@ -414,21 +405,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([]); @@ -1532,213 +1508,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) => { @@ -1856,47 +1646,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); 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 }; +}