From e4051a4a52233910c0d8f0ba01b7f557e1aaef96 Mon Sep 17 00:00:00 2001 From: Robert Molina Date: Sun, 29 Mar 2026 17:48:14 -0400 Subject: [PATCH 1/3] feat: when users create a page, autofocus on the page title --- apps/web/src/ai/tools/create-page/client.ts | 8 ++-- .../_components/app-sidebar-tree-actions.tsx | 10 +++++ .../_components/delete-page-button.tsx | 26 +++++------- apps/web/src/app/(app)/pages/[id]/page.tsx | 42 ++++++++++--------- .../(app)/pages/_components/page-editor.tsx | 16 +++++-- .../(app)/pages/_components/page-shell.tsx | 41 ++++++++++++++++++ .../pages/_components/page-title-textarea.tsx | 15 ++++++- apps/web/src/events/app-event-buffer.ts | 3 ++ apps/web/src/events/app-event-emitter.ts | 34 ++++++++++----- apps/web/src/events/page-created-event.ts | 8 ++-- apps/web/src/hooks/use-app-event-handler.ts | 5 --- 11 files changed, 146 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/app/(app)/pages/_components/page-shell.tsx 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..770e85fc 100644 --- a/apps/web/src/app/(app)/pages/[id]/page.tsx +++ b/apps/web/src/app/(app)/pages/[id]/page.tsx @@ -1,8 +1,7 @@ import { notFound } from "next/navigation"; -import { Suspense } from "react"; import { api } from "~/trpc/server"; import { DynamicPageEditor } from "../_components/page-editor.dynamic"; -import { PageSkeleton } from "../_components/page-skeleton"; +import { PageShell } from "../_components/page-shell"; import { PageTitleTextarea } from "../_components/page-title-textarea"; export default async function Page({ @@ -19,27 +18,30 @@ export default async function Page({ } return ( -
- }> - + + - - - -
+ className="px-8 py-2" + /> + + ); } 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..29f01317 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,19 @@ export function PageEditor({ useAppEventHandler( ({ payload }) => { - void payload.chat.addToolOutput({ - output: `Opened new page: ${payload.title}`, - tool: payload.toolName, - toolCallId: payload.toolCallId, + console.log("[PageEditor] useAppEventHandler", { + payload, }); + 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..afcae65e --- /dev/null +++ b/apps/web/src/app/(app)/pages/_components/page-shell.tsx @@ -0,0 +1,41 @@ +"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); + console.log("[PageShell] drain", { + events, + page: 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 && From 6590bb1cf50384750e09aa695d6aad0e22de5ecc Mon Sep 17 00:00:00 2001 From: Robert Molina Date: Sun, 29 Mar 2026 17:51:35 -0400 Subject: [PATCH 2/3] chore: format --- apps/web/src/app/(app)/pages/_components/page-editor.tsx | 3 --- apps/web/src/app/(app)/pages/_components/page-shell.tsx | 6 +----- 2 files changed, 1 insertion(+), 8 deletions(-) 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 29f01317..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,9 +100,6 @@ export function PageEditor({ useAppEventHandler( ({ payload }) => { - console.log("[PageEditor] useAppEventHandler", { - payload, - }); if (payload.tool) { void payload.tool.chat.addToolOutput({ output: `Opened new page: ${payload.title}`, diff --git a/apps/web/src/app/(app)/pages/_components/page-shell.tsx b/apps/web/src/app/(app)/pages/_components/page-shell.tsx index afcae65e..3f16beb7 100644 --- a/apps/web/src/app/(app)/pages/_components/page-shell.tsx +++ b/apps/web/src/app/(app)/pages/_components/page-shell.tsx @@ -20,11 +20,7 @@ export async function PageShell({ // Listen for page created events and drain them if they match the current page ID. useEffect(() => { - const events = eventEmitter.drain(PageCreatedEvent.eventType, page.id); - console.log("[PageShell] drain", { - events, - page: page.id, - }); + const _events = eventEmitter.drain(PageCreatedEvent.eventType, page.id); }, [eventEmitter, page.id]); return ( From 2c768db208a17be83486046c27d6ed9c32db5788 Mon Sep 17 00:00:00 2001 From: Robert Molina Date: Sun, 29 Mar 2026 17:57:32 -0400 Subject: [PATCH 3/3] fix: added suspense while dynamic editor loads --- apps/web/src/app/(app)/pages/[id]/page.tsx | 44 +++++++++++++--------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/apps/web/src/app/(app)/pages/[id]/page.tsx b/apps/web/src/app/(app)/pages/[id]/page.tsx index 770e85fc..3d401db9 100644 --- a/apps/web/src/app/(app)/pages/[id]/page.tsx +++ b/apps/web/src/app/(app)/pages/[id]/page.tsx @@ -1,7 +1,9 @@ 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"; export default async function Page({ @@ -18,30 +20,38 @@ export default async function Page({ } return ( - + + + } > - - - - + initialBlocks={page.blocks} + > + + + + ); }