From cf3f1ecc3fd47faf4952faa388cad88c7db70d9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 10:04:41 +0000 Subject: [PATCH] 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 }; +}