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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-04 10:04:41 +00:00
parent 2b27d5c5da
commit cf3f1ecc3f
No known key found for this signature in database
4 changed files with 228 additions and 164 deletions

View File

@ -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<string | null>(null);
const [taughtSkills, setTaughtSkills] = useState<TaughtSkill[]>([]);
const [taughtSkillsBotId, setTaughtSkillsBotId] = useState<string | null>(null);
const [agentSkills, setAgentSkills] = useState<AgentSkillCatalogEntry[]>([]);
const [mentionRoutines, setMentionRoutines] = useState<Array<Routine & { botName?: string }>>([]);
const [mentionConnectors, setMentionConnectors] = useState<
Array<{
id: string;
name: string;
authStatus: "connected" | "needs_auth";
connectionId?: string;
}>
>([]);
const [teachBusy, setTeachBusy] = useState(false);
const [computer, setComputer] = useState<ComputerStatus | null>(null);
const computerRef = useRef<ComputerStatus | null>(null);
@ -488,8 +477,6 @@ export function ShellPage() {
const [settingsSection, setSettingsSection] = useState<SettingsSection>("general");
const [messagingSettingsOpen, setMessagingSettingsOpen] = useState(false);
const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false);
const [messagingProviders, setMessagingProviders] = useState<string[]>([]);
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<Routine & { botName?: string }>),
),
).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");

View File

@ -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<AgentSkillCatalogEntry[]>([]);
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 };
}

View File

@ -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<Array<Routine & { botName?: string }>>([]);
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<Routine & { botName?: string }>),
),
).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;
}

View File

@ -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<typeof authClient.useSession>) {
const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false);
const [messagingProviders, setMessagingProviders] = useState<string[]>([]);
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 };
}