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 {
|
import { closestCenter, DndContext, DragOverlay } from "@dnd-kit/core";
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
DragOverlay,
|
|
||||||
MouseSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
@ -28,7 +20,6 @@ import type {
|
|||||||
Me,
|
Me,
|
||||||
ProductEvent,
|
ProductEvent,
|
||||||
Routine,
|
Routine,
|
||||||
SearchHit,
|
|
||||||
Space,
|
Space,
|
||||||
SpaceMemoryConfig,
|
SpaceMemoryConfig,
|
||||||
TaughtSkill,
|
TaughtSkill,
|
||||||
@ -48,7 +39,6 @@ import {
|
|||||||
type ComposerMention,
|
type ComposerMention,
|
||||||
cronFromPreset,
|
cronFromPreset,
|
||||||
deriveWorkspaceFocus,
|
deriveWorkspaceFocus,
|
||||||
groupBotsForSidebar,
|
|
||||||
inferAttachmentMimeType,
|
inferAttachmentMimeType,
|
||||||
isActive,
|
isActive,
|
||||||
isRunTerminalEvent,
|
isRunTerminalEvent,
|
||||||
@ -176,7 +166,6 @@ import { ooVoiceMark } from "../lib/oo-voice-mark";
|
|||||||
import { revokePendingAttachmentPreviews } from "../lib/pending-attachments";
|
import { revokePendingAttachmentPreviews } from "../lib/pending-attachments";
|
||||||
import { markAfterPaint, markOnce } from "../lib/performance";
|
import { markAfterPaint, markOnce } from "../lib/performance";
|
||||||
import { isEmptyProjectSpace } from "../lib/project-empty-state";
|
import { isEmptyProjectSpace } from "../lib/project-empty-state";
|
||||||
import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order";
|
|
||||||
import { routineSaveName } from "../lib/routine-name";
|
import { routineSaveName } from "../lib/routine-name";
|
||||||
import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc";
|
import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc";
|
||||||
import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage";
|
import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage";
|
||||||
@ -242,7 +231,7 @@ import {
|
|||||||
RenameBotSectionDialog,
|
RenameBotSectionDialog,
|
||||||
} from "./shell/dialogs";
|
} from "./shell/dialogs";
|
||||||
import { MainConversationRow } from "./shell/main-conversation-row";
|
import { MainConversationRow } from "./shell/main-conversation-row";
|
||||||
import { cleanRosterPreview, formatRosterTime, stripSpaceTitlePrefix } from "./shell/roster-format";
|
import { cleanRosterPreview, formatRosterTime } from "./shell/roster-format";
|
||||||
import {
|
import {
|
||||||
rosterNameClass,
|
rosterNameClass,
|
||||||
rosterPreviewClass,
|
rosterPreviewClass,
|
||||||
@ -259,6 +248,8 @@ import {
|
|||||||
threadSnapshotSignal,
|
threadSnapshotSignal,
|
||||||
} from "./shell/thread-state";
|
} from "./shell/thread-state";
|
||||||
import { Transcript } from "./shell/transcript";
|
import { Transcript } from "./shell/transcript";
|
||||||
|
import { useSidebarModel } from "./shell/use-sidebar-model";
|
||||||
|
import { useSpaceSearch } from "./shell/use-space-search";
|
||||||
import { WindowChrome } from "./WindowChrome";
|
import { WindowChrome } from "./WindowChrome";
|
||||||
|
|
||||||
const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
|
const BotContextMenu = lazyWithRecovery("BotContextMenu", () =>
|
||||||
@ -414,21 +405,6 @@ export function ShellPage() {
|
|||||||
|
|
||||||
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
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 [snapshot, setSnapshot] = useState<ThreadSnapshot | null>(null);
|
||||||
const snapshotRef = useRef<ThreadSnapshot | null>(null);
|
const snapshotRef = useRef<ThreadSnapshot | null>(null);
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||||
@ -1532,213 +1508,27 @@ export function ShellPage() {
|
|||||||
};
|
};
|
||||||
}, [activeGroup?.id, groupId, notifyBrowserForEvent]);
|
}, [activeGroup?.id, groupId, notifyBrowserForEvent]);
|
||||||
|
|
||||||
const sidebarGroups = useMemo(() => {
|
const { query, setQuery, searchHits, searchLoading, showSpaceSearch, jumpToSearchHit } =
|
||||||
const needle = query.toLowerCase();
|
useSpaceSearch(navigate);
|
||||||
const sidebarSpaces =
|
const {
|
||||||
spaces.length > 0
|
homeSpaceBots,
|
||||||
? spaces.map((space) =>
|
conversationSidebarGroups,
|
||||||
space.id === bootstrapMe?.spaceId ? { ...space, bots, groups, botSections } : space,
|
mainConversationEntry,
|
||||||
)
|
projectSidebarGroups,
|
||||||
: bootstrapMe
|
sensors,
|
||||||
? [
|
setDraggedProjectId,
|
||||||
{
|
handleProjectDragEnd,
|
||||||
id: bootstrapMe.spaceId,
|
handleProjectDragStart,
|
||||||
name: "Personal",
|
} = useSidebarModel({
|
||||||
isDefault: true,
|
query,
|
||||||
hasContent: true,
|
spaces,
|
||||||
canDelete: false,
|
bots,
|
||||||
bots,
|
groups,
|
||||||
groups,
|
botSections,
|
||||||
botSections,
|
bootstrapMe,
|
||||||
},
|
homeSpaceId,
|
||||||
]
|
mainConversation,
|
||||||
: [];
|
});
|
||||||
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 openSpaceChat = useCallback(
|
const openSpaceChat = useCallback(
|
||||||
(spaceId: string, path: string) => {
|
(spaceId: string, path: string) => {
|
||||||
@ -1856,47 +1646,6 @@ export function ShellPage() {
|
|||||||
},
|
},
|
||||||
[userId],
|
[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 }) {
|
async function jumpToMessage(target: { botId?: string; groupId?: string; messageId: string }) {
|
||||||
const threadTarget = searchHitThreadTarget(target);
|
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