Merge d2/hooks: ShellPage hooks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

# Conflicts:
#	apps/web/src/pages/Shell.tsx
This commit is contained in:
Claude 2026-10-04 10:27:11 +00:00
commit 91e560a0e6
No known key found for this signature in database
12 changed files with 1023 additions and 770 deletions

File diff suppressed because it is too large Load Diff

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,67 @@
import { useEffect, useRef, useState } from "react";
import {
BOTS_SIDEBAR_EDGE_DRAG_PX,
readBotsSidebarCollapsed,
writeBotsSidebarCollapsed,
} from "../../lib/bots-sidebar-pref";
export function useBotsSidebarCollapse(userId: string | undefined) {
const [botsSidebarCollapsed, setBotsSidebarCollapsed] = useState(false);
const botsSidebarEdgeDragRef = useRef<{ startX: number; mode: "expand" | "collapse" } | null>(
null,
);
useEffect(() => {
setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId));
}, [userId]);
function setBotsSidebarCollapsedPref(collapsed: boolean) {
setBotsSidebarCollapsed(collapsed);
writeBotsSidebarCollapsed(userId, collapsed);
}
function onBotsSidebarEdgePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
event.currentTarget.setPointerCapture(event.pointerId);
botsSidebarEdgeDragRef.current = {
startX: event.clientX,
mode: botsSidebarCollapsed ? "expand" : "collapse",
};
}
function onBotsSidebarEdgePointerMove(event: React.PointerEvent<HTMLButtonElement>) {
const drag = botsSidebarEdgeDragRef.current;
if (!drag) return;
const rtl =
typeof document !== "undefined" && document.documentElement.getAttribute("dir") === "rtl";
const delta = rtl ? drag.startX - event.clientX : event.clientX - drag.startX;
if (drag.mode === "expand" && delta >= BOTS_SIDEBAR_EDGE_DRAG_PX) {
botsSidebarEdgeDragRef.current = null;
setBotsSidebarCollapsedPref(false);
} else if (drag.mode === "collapse" && delta <= -BOTS_SIDEBAR_EDGE_DRAG_PX) {
botsSidebarEdgeDragRef.current = null;
setBotsSidebarCollapsedPref(true);
}
}
function onBotsSidebarEdgePointerUp(event: React.PointerEvent<HTMLButtonElement>) {
const drag = botsSidebarEdgeDragRef.current;
botsSidebarEdgeDragRef.current = null;
if (!drag) return;
if (Math.abs(event.clientX - drag.startX) < BOTS_SIDEBAR_EDGE_DRAG_PX) {
setBotsSidebarCollapsedPref(!botsSidebarCollapsed);
}
}
function onBotsSidebarEdgePointerCancel() {
botsSidebarEdgeDragRef.current = null;
}
return {
botsSidebarCollapsed,
setBotsSidebarCollapsedPref,
onBotsSidebarEdgePointerDown,
onBotsSidebarEdgePointerMove,
onBotsSidebarEdgePointerUp,
onBotsSidebarEdgePointerCancel,
};
}

View File

@ -0,0 +1,86 @@
import type { Bot, ProductEvent } from "@rakazo/contracts";
import type { RefObject } from "react";
import { useCallback, useRef } from "react";
import {
deliverBrowserNotification as deliverNativeBrowserNotification,
shouldNotifyBrowser,
} from "../../lib/browser-notifications";
type PendingBrowserNotification = {
event: Pick<ProductEvent, "id" | "type" | "threadId" | "botId" | "payload">;
botId: string;
botName: string;
groupNotification: boolean;
};
export function useBrowserNotifications(botsRef: RefObject<Bot[]>) {
const notifiedBrowserEvents = useRef(new Set<string>());
const pendingBrowserNotifications = useRef(new Map<string, PendingBrowserNotification>());
const deliverBrowserNotification = useCallback((pending: PendingBrowserNotification): boolean => {
const currentBot = botsRef.current.find((bot) => bot.id === pending.botId);
if (!currentBot || typeof Notification === "undefined") return true;
const result = deliverNativeBrowserNotification(
pending.event,
currentBot.name || pending.botName,
{
enabled: pending.groupNotification || currentBot.notifyOnFinish,
pageVisible: document.visibilityState === "visible",
windowFocused: document.hasFocus(),
permission: Notification.permission,
notifiedEventIds: notifiedBrowserEvents.current,
show: (title, body, tag) => new Notification(title, { body, tag }),
},
);
return result !== "pending";
}, []);
const flushPendingBrowserNotifications = useCallback(() => {
for (const [threadId, pending] of pendingBrowserNotifications.current) {
if (deliverBrowserNotification(pending)) {
pendingBrowserNotifications.current.delete(threadId);
}
}
}, [deliverBrowserNotification]);
const notifyBrowserForEvent = useCallback(
(
event: Pick<ProductEvent, "id" | "type" | "threadId" | "seq" | "botId" | "payload">,
subscribedThreadId: string | undefined,
initialCursor: number,
streamReady: boolean,
botName: string,
enabled: boolean,
groupNotification: boolean,
) => {
const botId = event.botId;
if (typeof botId !== "string") return;
const eligible = shouldNotifyBrowser(event, {
subscribedThreadId: subscribedThreadId ?? "",
initialCursor,
streamReady,
pageVisible: document.visibilityState === "visible",
windowFocused: document.hasFocus(),
permission: "granted",
notifiedEventIds: notifiedBrowserEvents.current,
});
if (!eligible || !enabled) return;
const pending = {
event,
botId,
botName,
groupNotification,
} satisfies PendingBrowserNotification;
if (typeof Notification === "undefined" || Notification.permission === "denied") return;
if (Notification.permission === "default") {
pendingBrowserNotifications.current.set(event.threadId, pending);
return;
}
if (deliverBrowserNotification(pending)) {
pendingBrowserNotifications.current.delete(event.threadId);
} else {
pendingBrowserNotifications.current.set(event.threadId, pending);
}
},
[deliverBrowserNotification],
);
return { flushPendingBrowserNotifications, notifyBrowserForEvent };
}

View File

@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
/** Visible area above an on-screen keyboard while the full-screen computer is open. */
export function useComputerViewport(
computerOpen: boolean,
): { height: number; offsetTop: number } | null {
const [computerViewport, setComputerViewport] = useState<{
height: number;
offsetTop: number;
} | null>(null);
useEffect(() => {
if (!computerOpen) {
setComputerViewport(null);
return;
}
const viewport = window.visualViewport;
if (!viewport) return;
const sync = () => {
const height = Math.round(viewport.height);
const offsetTop = Math.round(viewport.offsetTop);
// Desktop scrollbars and sub-pixel viewport events must not rewrite the
// overlay height: that feedback loop moves the maintenance menu forever.
const keyboardInset = Math.round(window.innerHeight) - height - offsetTop;
if (keyboardInset < 80) {
setComputerViewport((current) => (current === null ? current : null));
return;
}
setComputerViewport((current) => {
if (current && current.height === height && current.offsetTop === offsetTop) return current;
return { height, offsetTop };
});
};
sync();
viewport.addEventListener("resize", sync);
viewport.addEventListener("scroll", sync);
return () => {
viewport.removeEventListener("resize", sync);
viewport.removeEventListener("scroll", sync);
};
}, [computerOpen]);
return computerViewport;
}

View File

@ -0,0 +1,45 @@
import type { Bot, Group } from "@rakazo/contracts";
import type { RefObject } from "react";
import { useEffect, useState } from "react";
import { buildHeuteBrief } from "../../lib/heute-brief";
import { rpc } from "../../lib/rpc";
export function useHeuteActionCount(groups: Group[], botsRef: RefObject<Bot[]>): number {
const [heuteActionCount, setHeuteActionCount] = useState(0);
useEffect(() => {
let cancelled = false;
let timer: number | undefined;
const tick = async () => {
try {
const [active, recent] = await Promise.all([
rpc.runs.list({ filter: "active" }),
rpc.runs.list({ filter: "recent" }),
]);
if (cancelled) return;
const brief = buildHeuteBrief({
bots: botsRef.current,
groups,
activeRuns: active.runs,
recentRuns: recent.runs,
});
setHeuteActionCount(brief.actionCount);
} catch {
if (cancelled) return;
} finally {
if (!cancelled) {
timer = window.setTimeout(() => void tick(), 15_000);
}
}
};
void tick();
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [groups]);
return heuteActionCount;
}

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

View File

@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from "react";
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56;
export function useMobileSidebar() {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const mobileSidebarSwipeRef = useRef<{ startX: number; startY: number } | null>(null);
useEffect(() => {
const desktop = window.matchMedia("(min-width: 768px)");
function closeMobileSidebar() {
if (desktop.matches) setMobileSidebarOpen(false);
}
closeMobileSidebar();
desktop.addEventListener("change", closeMobileSidebar);
return () => desktop.removeEventListener("change", closeMobileSidebar);
}, []);
function onMobileSidebarTouchStart(event: React.TouchEvent<HTMLDivElement>) {
if (
mobileSidebarOpen ||
event.touches.length !== 1 ||
window.matchMedia("(min-width: 768px)").matches
) {
mobileSidebarSwipeRef.current = null;
return;
}
const touch = event.touches[0];
if (!touch) return;
const rtl = document.documentElement.getAttribute("dir") === "rtl";
const startsAtEdge = rtl
? touch.clientX >= window.innerWidth - MOBILE_SIDEBAR_SWIPE_EDGE_PX
: touch.clientX <= MOBILE_SIDEBAR_SWIPE_EDGE_PX;
mobileSidebarSwipeRef.current = startsAtEdge
? { startX: touch.clientX, startY: touch.clientY }
: null;
}
function onMobileSidebarTouchEnd(event: React.TouchEvent<HTMLDivElement>) {
const swipe = mobileSidebarSwipeRef.current;
mobileSidebarSwipeRef.current = null;
const touch = event.changedTouches[0];
if (!swipe || !touch || mobileSidebarOpen || window.matchMedia("(min-width: 768px)").matches) {
return;
}
const rtl = document.documentElement.getAttribute("dir") === "rtl";
const horizontal = rtl ? swipe.startX - touch.clientX : touch.clientX - swipe.startX;
const vertical = Math.abs(touch.clientY - swipe.startY);
if (horizontal >= MOBILE_SIDEBAR_SWIPE_DISTANCE_PX && horizontal > vertical * 1.25) {
setMobileSidebarOpen(true);
}
}
function onMobileSidebarTouchCancel() {
mobileSidebarSwipeRef.current = null;
}
return {
mobileSidebarOpen,
setMobileSidebarOpen,
onMobileSidebarTouchStart,
onMobileSidebarTouchEnd,
onMobileSidebarTouchCancel,
};
}

View File

@ -0,0 +1,99 @@
import type { Bot, ThreadSnapshot } from "@rakazo/contracts";
import { useEffect, useRef } from "react";
import { wireOoAudioVoiceDucking } from "../../lib/oo-audio-voice-wire";
import { ooSound } from "../../lib/oo-sound";
import { ooVocals } from "../../lib/oo-vocals";
import { ooVoiceMark } from "../../lib/oo-voice-mark";
export type ShellSoundsInput = {
bots: Bot[];
transcriptRunning: boolean;
callOpen: boolean;
displayedRunErrorId: string | null;
activeSnapshot: ThreadSnapshot | null;
inGroup: boolean;
};
export function useShellSounds({
bots,
transcriptRunning,
callOpen,
displayedRunErrorId,
activeSnapshot,
inGroup,
}: ShellSoundsInput): void {
const prevTranscriptRunning = useRef(false);
const prevRunErrorId = useRef<string | null>(null);
const seenHandoffKeys = useRef(new Set<string>());
const appSoundStarted = useRef(false);
const prevBotStatuses = useRef(new Map<string, string>());
useEffect(() => wireOoAudioVoiceDucking(), []);
useEffect(() => {
if (appSoundStarted.current) return;
appSoundStarted.current = true;
ooSound.play("app-start");
window.setTimeout(() => ooVoiceMark.maybePlayColdOpen(), 280);
}, []);
useEffect(() => {
for (const bot of bots) {
const prev = prevBotStatuses.current.get(bot.id);
if (
prev !== bot.status &&
(bot.status === "waiting_input" || bot.status === "waiting_takeover")
) {
ooSound.play("need-you");
ooVocals.reactForEvent("need-you");
}
prevBotStatuses.current.set(bot.id, bot.status);
}
}, [bots]);
useEffect(() => {
if (prevTranscriptRunning.current && !transcriptRunning && !callOpen) {
ooSound.play("success-resolve");
ooVocals.reactForEvent("success");
}
prevTranscriptRunning.current = transcriptRunning;
}, [transcriptRunning, callOpen]);
useEffect(() => {
const runId = displayedRunErrorId;
if (runId && runId !== prevRunErrorId.current) {
ooSound.play("attention");
}
prevRunErrorId.current = runId;
}, [displayedRunErrorId]);
const seenGroupSparkKeys = useRef(new Set<string>());
useEffect(() => {
for (const message of activeSnapshot?.messages ?? []) {
for (const block of message.blocks) {
if (block.kind === "handoff") {
const key = `${message.id}:handoff:${block.toBotId}`;
if (seenHandoffKeys.current.has(key)) continue;
seenHandoffKeys.current.add(key);
if (seenHandoffKeys.current.size > 80) {
const oldest = seenHandoffKeys.current.values().next().value;
if (oldest !== undefined) seenHandoffKeys.current.delete(oldest);
}
ooSound.play("handoff");
continue;
}
if (inGroup && block.kind === "bot_message_received") {
const key = `${message.id}:peer:${block.fromBotId}`;
if (seenGroupSparkKeys.current.has(key)) continue;
seenGroupSparkKeys.current.add(key);
if (seenGroupSparkKeys.current.size > 80) {
const oldest = seenGroupSparkKeys.current.values().next().value;
if (oldest !== undefined) seenGroupSparkKeys.current.delete(oldest);
}
ooSound.play("group-spark");
}
}
}
}, [activeSnapshot?.messages, inGroup]);
}

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