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