diff --git a/apps/web/src/ai/tools/create-page/client.ts b/apps/web/src/ai/tools/create-page/client.ts index 132a8a23..1399b6c0 100644 --- a/apps/web/src/ai/tools/create-page/client.ts +++ b/apps/web/src/ai/tools/create-page/client.ts @@ -52,11 +52,13 @@ export function useCreatePageTool() { eventEmitter.buffer( new PageCreatedEvent({ - chat, id: newPage.page.id, title: toolCall.input.title, - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, + tool: { + chat, + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + }, }), ); diff --git a/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-tree-actions.tsx b/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-tree-actions.tsx index ec008858..cbcb655e 100644 --- a/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-tree-actions.tsx +++ b/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-tree-actions.tsx @@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { FilePlus2, FolderPlus, Plus, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useRef, useState, useTransition } from "react"; +import { useAppEventEmitter } from "~/components/events/app-event-context"; import { Button } from "~/components/ui/button"; import { DropdownMenu, @@ -13,6 +14,7 @@ import { DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; import { useSidebar } from "~/components/ui/sidebar"; +import { PageCreatedEvent } from "~/events/page-created-event"; import { cn } from "~/lib/cn"; import { findNodeInQueries, @@ -65,6 +67,7 @@ export function AppSidebarTreeActions({ const createPageContextRef = useRef(null); const { isMobile, setOpenMobile } = useSidebar(); const treeQueryFilter = trpc.tree.getChildrenPaginated.infiniteQueryFilter(); + const eventEmitter = useAppEventEmitter(); const getContainerQueryKey = (targetParentNodeId: string | null) => { return trpc.tree.getChildrenPaginated.infiniteQueryKey( @@ -260,6 +263,13 @@ export function AppSidebarTreeActions({ ), }); } + + eventEmitter.buffer( + new PageCreatedEvent({ + id: newPage.page.id, + title: newPage.page.title, + }), + ); } createPageContextRef.current = null; }, diff --git a/apps/web/src/app/(app)/@appSidebar/_components/delete-page-button.tsx b/apps/web/src/app/(app)/@appSidebar/_components/delete-page-button.tsx index eadd2f22..823abdf0 100644 --- a/apps/web/src/app/(app)/@appSidebar/_components/delete-page-button.tsx +++ b/apps/web/src/app/(app)/@appSidebar/_components/delete-page-button.tsx @@ -154,6 +154,15 @@ export function DeletePageDialog({ treeSnapshots, }; }, + onSuccess: () => { + deletePageContextRef.current = null; + + if (pathname === `/pages/${page.id}`) { + router.push("/journal"); + } + + setDialogOpen(false); + }, }), ); @@ -178,22 +187,9 @@ export function DeletePageDialog({ const confirmDelete = useCallback(() => { startTransition(() => { - deletePage( - { id: page.document_id }, - { - onSuccess: () => { - deletePageContextRef.current = null; - - if (pathname === `/pages/${page.id}`) { - router.push("/journal"); - } - - setDialogOpen(false); - }, - }, - ); + deletePage({ id: page.document_id }); }); - }, [deletePage, page.document_id, page.id, pathname, router, setDialogOpen]); + }, [deletePage, page.document_id]); return ( diff --git a/apps/web/src/app/(app)/pages/[id]/page.tsx b/apps/web/src/app/(app)/pages/[id]/page.tsx index a6fd6e92..3d401db9 100644 --- a/apps/web/src/app/(app)/pages/[id]/page.tsx +++ b/apps/web/src/app/(app)/pages/[id]/page.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import { Suspense } from "react"; import { api } from "~/trpc/server"; import { DynamicPageEditor } from "../_components/page-editor.dynamic"; +import { PageShell } from "../_components/page-shell"; import { PageSkeleton } from "../_components/page-skeleton"; import { PageTitleTextarea } from "../_components/page-title-textarea"; @@ -19,8 +20,19 @@ export default async function Page({ } return ( -
- }> + + +
+ } + > + - - + + ); } diff --git a/apps/web/src/app/(app)/pages/_components/page-editor.tsx b/apps/web/src/app/(app)/pages/_components/page-editor.tsx index 9fde2ac9..ddc46530 100644 --- a/apps/web/src/app/(app)/pages/_components/page-editor.tsx +++ b/apps/web/src/app/(app)/pages/_components/page-editor.tsx @@ -100,11 +100,16 @@ export function PageEditor({ useAppEventHandler( ({ payload }) => { - void payload.chat.addToolOutput({ - output: `Opened new page: ${payload.title}`, - tool: payload.toolName, - toolCallId: payload.toolCallId, - }); + if (payload.tool) { + void payload.tool.chat.addToolOutput({ + output: `Opened new page: ${payload.title}`, + tool: payload.tool.toolName, + toolCallId: payload.tool.toolCallId, + }); + } + if (payload.title) { + editor.focus(); + } }, [PageCreatedEvent, page.id], ); diff --git a/apps/web/src/app/(app)/pages/_components/page-shell.tsx b/apps/web/src/app/(app)/pages/_components/page-shell.tsx new file mode 100644 index 00000000..3f16beb7 --- /dev/null +++ b/apps/web/src/app/(app)/pages/_components/page-shell.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { Page } from "@acme/db/schema"; +import { useEffect } from "react"; +import { useAppEventEmitter } from "~/components/events/app-event-context"; +import { PageCreatedEvent } from "~/events/page-created-event"; +import { cn } from "~/lib/cn"; + +type PageShellProps = React.ComponentProps<"div"> & { + page: Pick; +}; + +export async function PageShell({ + className, + children, + page, + ...props +}: PageShellProps) { + const eventEmitter = useAppEventEmitter(); + + // Listen for page created events and drain them if they match the current page ID. + useEffect(() => { + const _events = eventEmitter.drain(PageCreatedEvent.eventType, page.id); + }, [eventEmitter, page.id]); + + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx b/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx index c0b57da1..58227f1c 100644 --- a/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx +++ b/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx @@ -2,9 +2,11 @@ import type { Page } from "@acme/db/schema"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useDebouncedCallback } from "use-debounce"; import { ExpandingTextarea } from "~/components/ui/expanding-textarea"; +import { PageCreatedEvent } from "~/events/page-created-event"; +import { useAppEventHandler } from "~/hooks/use-app-event-handler"; import { cn } from "~/lib/cn"; import { updateNode, @@ -32,9 +34,9 @@ export function PageTitleTextarea({ debounceTime = DEFAULT_DEBOUNCE_TIME, onTitleChange, onKeyDown, - ref, ...rest }: PageEditorTitleProps) { + const ref = useRef(null); const trpc = useTRPC(); const queryClient = useQueryClient(); const { mutate: updatePageTitle } = useMutation( @@ -112,6 +114,15 @@ export function PageTitleTextarea({ onKeyDown?.(e); } + useAppEventHandler( + ({ payload }) => { + if (!payload.title && ref.current) { + ref.current.focus(); + } + }, + [PageCreatedEvent, page.id], + ); + return ( { /** * Removes and optionally returns a buffered event if it exists. * Automatically cleans up empty event type maps. + * + * Note: this only operates on buffer storage. It does not notify + * listeners. Use AppEventEmitter.drain() when you need fan-out delivery. */ flush>(eventType: string, id: string): E | null { const events = this.buffer.get(eventType); diff --git a/apps/web/src/events/app-event-emitter.ts b/apps/web/src/events/app-event-emitter.ts index a00f20b4..298ab00f 100644 --- a/apps/web/src/events/app-event-emitter.ts +++ b/apps/web/src/events/app-event-emitter.ts @@ -23,21 +23,35 @@ export class AppEventEmitter { } /** - * Smart buffering: emits immediately if listeners exist, otherwise buffers for later. + * Buffers for later consumption. * Use this to handle race conditions where listeners might not be registered yet. */ buffer>(event: E): void { - const eventListeners = this.eventListeners.get(event.eventType) || []; + this.eventBuffer.store(event); + } - if (eventListeners.length > 0) { - // If there are active listeners, emit immediately - eventListeners.forEach((listener) => { - listener(event); - }); - } else { - // If no listeners, buffer the event for later consumption - this.eventBuffer.store(event); + /** + * Drains a buffered event by delivering it to all current listeners, + * then consuming it from the buffer. + * + * This is useful for orchestrated "sticky event" delivery where + * buffered events should only be played once, but fan out to all + * listeners that are currently mounted. + * + * Returns the drained event if found, otherwise null. + */ + drain>(eventType: string, id: string): E | null { + const bufferedEvent = this.eventBuffer.flush(eventType, id); + if (!bufferedEvent) { + return null; } + + const eventListeners = this.eventListeners.get(eventType) || []; + eventListeners.forEach((listener) => { + listener(bufferedEvent); + }); + + return bufferedEvent; } /** diff --git a/apps/web/src/events/page-created-event.ts b/apps/web/src/events/page-created-event.ts index 86976f1b..976e4017 100644 --- a/apps/web/src/events/page-created-event.ts +++ b/apps/web/src/events/page-created-event.ts @@ -4,9 +4,11 @@ import { AppEvent, type AppEventPayload } from "./app-event"; export interface PageCreatedPayload extends AppEventPayload { id: string; title: string; - toolName: string; - toolCallId: string; - chat: Chat; + tool?: { + toolName: string; + toolCallId: string; + chat: Chat; + }; } export class PageCreatedEvent extends AppEvent { diff --git a/apps/web/src/hooks/use-app-event-handler.ts b/apps/web/src/hooks/use-app-event-handler.ts index dd417525..14e3e86e 100644 --- a/apps/web/src/hooks/use-app-event-handler.ts +++ b/apps/web/src/hooks/use-app-event-handler.ts @@ -17,11 +17,6 @@ export function useAppEventHandler< const eventType: string = EventClass.eventType; useEffect(() => { - const bufferedEvent = eventEmitter.flush(eventType, id); - if (bufferedEvent && bufferedEvent instanceof EventClass) { - handler(bufferedEvent); - } - const handleLiveEvent = (event: AppEvent): void => { if ( event.type === eventType &&