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 }; +}