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:
parent
a0060b8df7
commit
307c095bb6
@ -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);
|
||||
|
||||
259
apps/web/src/pages/shell/use-sidebar-model.ts
Normal file
259
apps/web/src/pages/shell/use-sidebar-model.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
64
apps/web/src/pages/shell/use-space-search.ts
Normal file
64
apps/web/src/pages/shell/use-space-search.ts
Normal 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user