From 307c095bb6021539bab30019359279e331227019 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:59:38 +0000 Subject: [PATCH 1/4] 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 }; +} From a1bf3922988c85f4dd34e0019d9b761366915cdd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 10:01:09 +0000 Subject: [PATCH 2/4] refactor(web): extract sound, Today count and browser notification hooks Behaviour-preserving move of ShellPage side effects into named hooks: - useShellSounds: audio ducking, the app-start sound and the sounds for bots that need the user, finished work, run errors, handoffs and group sparks. - useHeuteActionCount: the 15-second Today brief poll behind the sidebar badge. - useBrowserNotifications: pending and delivered browser notification bookkeeping plus notifyBrowserForEvent / flushPendingBrowserNotifications. The hooks run where the code was. The Today poll effect sat between the app-start sound and the bot status sound; it now runs just before the sound hook, ahead of the two mount-only audio effects it does not interact with. Effect bodies, refs 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 | 207 ++---------------- .../pages/shell/use-browser-notifications.ts | 86 ++++++++ .../src/pages/shell/use-heute-action-count.ts | 45 ++++ apps/web/src/pages/shell/use-shell-sounds.ts | 99 +++++++++ 4 files changed, 245 insertions(+), 192 deletions(-) create mode 100644 apps/web/src/pages/shell/use-browser-notifications.ts create mode 100644 apps/web/src/pages/shell/use-heute-action-count.ts create mode 100644 apps/web/src/pages/shell/use-shell-sounds.ts diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index ab905ee..9665eb2 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -18,7 +18,6 @@ import type { Group, MainConversationRef, Me, - ProductEvent, Routine, Space, SpaceMemoryConfig, @@ -141,11 +140,7 @@ import { 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 { desktopBridge } from "../lib/desktop"; import { scheduleFocusPrompt } from "../lib/focus-prompt"; @@ -155,14 +150,11 @@ 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"; @@ -248,6 +240,9 @@ import { threadSnapshotSignal, } from "./shell/thread-state"; import { Transcript } from "./shell/transcript"; +import { useBrowserNotifications } from "./shell/use-browser-notifications"; +import { useHeuteActionCount } from "./shell/use-heute-action-count"; +import { useShellSounds } from "./shell/use-shell-sounds"; import { useSidebarModel } from "./shell/use-sidebar-model"; import { useSpaceSearch } from "./shell/use-space-search"; import { WindowChrome } from "./WindowChrome"; @@ -287,13 +282,6 @@ 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; @@ -380,7 +368,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()); @@ -699,8 +686,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); @@ -787,71 +772,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) => { @@ -1966,114 +1888,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) => 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-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-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]); +} From 2b27d5c5da69514e864b62f7919f62b5e5c7ab7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 10:03:23 +0000 Subject: [PATCH 3/4] refactor(web): extract mobile sidebar, bots sidebar and computer viewport hooks Behaviour-preserving move of layout state out of ShellPage: - useMobileSidebar: the mobile navigation open state, closing it at the desktop breakpoint, and the edge-swipe touch handlers that were inline on the shell root. - useBotsSidebarCollapse: the per-user collapsed preference and the edge-drag pointer handlers that were inline on the sidebar edge button. - useComputerViewport: the on-screen-keyboard viewport tracking for the full-screen computer. Each hook is called where its effect was, so effect order is unchanged. The inline JSX handlers become named functions with identical bodies, still recreated on every render as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/src/pages/Shell.tsx | 164 +++--------------- .../pages/shell/use-bots-sidebar-collapse.ts | 67 +++++++ .../src/pages/shell/use-computer-viewport.ts | 43 +++++ .../web/src/pages/shell/use-mobile-sidebar.ts | 66 +++++++ 4 files changed, 202 insertions(+), 138 deletions(-) create mode 100644 apps/web/src/pages/shell/use-bots-sidebar-collapse.ts create mode 100644 apps/web/src/pages/shell/use-computer-viewport.ts create mode 100644 apps/web/src/pages/shell/use-mobile-sidebar.ts diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 9665eb2..01a5beb 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -135,11 +135,6 @@ 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 { requestBrowserNotificationPermission } from "../lib/browser-notifications"; import { loadComputerScreen } from "../lib/computer-screen"; import { desktopBridge } from "../lib/desktop"; @@ -240,8 +235,11 @@ import { threadSnapshotSignal, } from "./shell/thread-state"; import { Transcript } from "./shell/transcript"; +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 { 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"; @@ -284,8 +282,6 @@ type Panel = /** 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. */ @@ -375,9 +371,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. */ @@ -499,17 +500,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") => { @@ -519,15 +521,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) => { @@ -597,44 +590,11 @@ 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]); + const computerViewport = useComputerViewport(computerOpen); useEffect(() => { if (!session.data?.user) return; let cancelled = false; @@ -2580,11 +2540,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); @@ -2871,47 +2826,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} > { @@ -3829,39 +3746,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} />
( + 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-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-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, + }; +} From cf3f1ecc3fd47faf4952faa388cad88c7db70d9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 10:04:41 +0000 Subject: [PATCH 4/4] refactor(web): extract messaging status, agent skills and mention target hooks Behaviour-preserving move of ShellPage data loaders into named hooks: - useMessagingStatus: the messaging surface flag and providers, loaded once a session user exists. - useAgentSkills: the agent skill catalog for slash commands, its refresh, and the setter bot settings use after editing skills. - useMentionTargets: composer mention options built from bots, group members, routines and connectors, including the routine/connector loader. Each hook is called where its effect was, so effect order is unchanged, and the hooks take the same objects the effects read so bodies and dependency arrays move verbatim. The mention loader's bots ref stays private to the hook; nothing else read it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/src/pages/Shell.tsx | 175 ++---------------- apps/web/src/pages/shell/use-agent-skills.ts | 31 ++++ .../src/pages/shell/use-mention-targets.ts | 159 ++++++++++++++++ .../src/pages/shell/use-messaging-status.ts | 27 +++ 4 files changed, 228 insertions(+), 164 deletions(-) create mode 100644 apps/web/src/pages/shell/use-agent-skills.ts create mode 100644 apps/web/src/pages/shell/use-mention-targets.ts create mode 100644 apps/web/src/pages/shell/use-messaging-status.ts diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 01a5beb..04b811e 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -7,14 +7,11 @@ 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, @@ -34,7 +31,6 @@ import { import { applyWorkspaceFocus, attachmentsForThread, - buildComposerMentionOptions, type ComposerMention, cronFromPreset, deriveWorkspaceFocus, @@ -235,10 +231,13 @@ 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"; @@ -421,16 +420,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); @@ -488,8 +477,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); @@ -595,22 +582,7 @@ export function ShellPage() { // takeover failures must stay visible even when a URL remains. const [computerErrorFromScreen, setComputerErrorFromScreen] = useState(false); const computerViewport = useComputerViewport(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 { messagingSurfaceEnabled, messagingProviders } = useMessagingStatus(session); const [usage, setUsage] = useState<{ inputTokens: number; outputTokens: number; @@ -1093,27 +1065,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 @@ -1869,48 +1821,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 @@ -1925,78 +1844,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"); diff --git a/apps/web/src/pages/shell/use-agent-skills.ts b/apps/web/src/pages/shell/use-agent-skills.ts new file mode 100644 index 0000000..84cd7c4 --- /dev/null +++ b/apps/web/src/pages/shell/use-agent-skills.ts @@ -0,0 +1,31 @@ +import type { AgentSkillCatalogEntry } from "@rakazo/contracts"; +import { useCallback, useEffect, useState } from "react"; +import { rpc } from "../../lib/rpc"; + +export function useAgentSkills() { + const [agentSkills, setAgentSkills] = useState([]); + + 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-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 }; +}