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:
commit
91e560a0e6
File diff suppressed because it is too large
Load Diff
31
apps/web/src/pages/shell/use-agent-skills.ts
Normal file
31
apps/web/src/pages/shell/use-agent-skills.ts
Normal 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 };
|
||||||
|
}
|
||||||
67
apps/web/src/pages/shell/use-bots-sidebar-collapse.ts
Normal file
67
apps/web/src/pages/shell/use-bots-sidebar-collapse.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
86
apps/web/src/pages/shell/use-browser-notifications.ts
Normal file
86
apps/web/src/pages/shell/use-browser-notifications.ts
Normal 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 };
|
||||||
|
}
|
||||||
43
apps/web/src/pages/shell/use-computer-viewport.ts
Normal file
43
apps/web/src/pages/shell/use-computer-viewport.ts
Normal 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;
|
||||||
|
}
|
||||||
45
apps/web/src/pages/shell/use-heute-action-count.ts
Normal file
45
apps/web/src/pages/shell/use-heute-action-count.ts
Normal 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;
|
||||||
|
}
|
||||||
159
apps/web/src/pages/shell/use-mention-targets.ts
Normal file
159
apps/web/src/pages/shell/use-mention-targets.ts
Normal 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;
|
||||||
|
}
|
||||||
27
apps/web/src/pages/shell/use-messaging-status.ts
Normal file
27
apps/web/src/pages/shell/use-messaging-status.ts
Normal 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 };
|
||||||
|
}
|
||||||
66
apps/web/src/pages/shell/use-mobile-sidebar.ts
Normal file
66
apps/web/src/pages/shell/use-mobile-sidebar.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
99
apps/web/src/pages/shell/use-shell-sounds.ts
Normal file
99
apps/web/src/pages/shell/use-shell-sounds.ts
Normal 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]);
|
||||||
|
}
|
||||||
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