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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-04 09:59:38 +00:00
parent a0060b8df7
commit 307c095bb6
No known key found for this signature in database
3 changed files with 348 additions and 276 deletions

View File

@ -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<string[]>(() => readProjectSidebarOrder());
const handleReorderProjects = useCallback((orderedIds: string[]) => {
setProjectOrder(orderedIds);
writeProjectSidebarOrder(orderedIds);
}, []);
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { distance: 8 } }),
);
const [query, setQuery] = useState("");
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [snapshot, setSnapshot] = useState<ThreadSnapshot | null>(null);
const snapshotRef = useRef<ThreadSnapshot | null>(null);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
@ -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<string>();
const seenProjectGroupChatIds = new Set<string>();
const dedupeSidebarBots = (bots: (typeof sidebarGroups)[number]["bots"]) => {
const seen = new Set<string>();
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);

View File

@ -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<string[]>(() => readProjectSidebarOrder());
const handleReorderProjects = useCallback((orderedIds: string[]) => {
setProjectOrder(orderedIds);
writeProjectSidebarOrder(orderedIds);
}, []);
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(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<string>();
const seenProjectGroupChatIds = new Set<string>();
const dedupeSidebarBots = (bots: (typeof sidebarGroups)[number]["bots"]) => {
const seen = new Set<string>();
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,
};
}

View File

@ -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<SetStateAction<string>>;
searchHits: SearchHit[];
searchLoading: boolean;
showSpaceSearch: boolean;
jumpToSearchHit: (hit: SearchHit) => Promise<void>;
};
export function useSpaceSearch(navigate: NavigateFunction): SpaceSearch {
const [query, setQuery] = useState("");
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
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 };
}