Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/web/src/ai/tools/create-page/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -65,6 +67,7 @@ export function AppSidebarTreeActions({
const createPageContextRef = useRef<CreateMutationContext | null>(null);
const { isMobile, setOpenMobile } = useSidebar();
const treeQueryFilter = trpc.tree.getChildrenPaginated.infiniteQueryFilter();
const eventEmitter = useAppEventEmitter();

const getContainerQueryKey = (targetParentNodeId: string | null) => {
return trpc.tree.getChildrenPaginated.infiniteQueryKey(
Expand Down Expand Up @@ -260,6 +263,13 @@ export function AppSidebarTreeActions({
),
});
}

eventEmitter.buffer(
new PageCreatedEvent({
id: newPage.page.id,
title: newPage.page.title,
}),
);
}
createPageContextRef.current = null;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ export function DeletePageDialog({
treeSnapshots,
};
},
onSuccess: () => {
deletePageContextRef.current = null;

if (pathname === `/pages/${page.id}`) {
router.push("/journal");
}

setDialogOpen(false);
},
}),
);

Expand All @@ -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 (
<Dialog open={isDialogOpen} onOpenChange={setDialogOpen}>
Expand Down
22 changes: 17 additions & 5 deletions apps/web/src/app/(app)/pages/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -19,8 +20,19 @@ export default async function Page({
}

return (
<div className="mx-auto flex min-h-full max-w-5xl flex-col gap-4 pt-8 pb-52">
<Suspense fallback={<PageSkeleton />}>
<Suspense
fallback={
<div className="mx-auto flex min-h-full max-w-5xl flex-col gap-4 pt-8 pb-52">
<PageSkeleton />
</div>
}
>
<PageShell
page={{
id,
}}
className="mx-auto flex min-h-full max-w-5xl flex-col gap-4 pt-8 pb-52"
>
<DynamicPageEditor
page={{
document_id: page.document_id,
Expand All @@ -36,10 +48,10 @@ export default async function Page({
parent_node_id: page.parent_node_id,
title: page.title,
}}
className="px-8 py-0"
className="px-8 py-2"
/>
</DynamicPageEditor>
</Suspense>
</div>
</PageShell>
</Suspense>
);
}
15 changes: 10 additions & 5 deletions apps/web/src/app/(app)/pages/_components/page-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/app/(app)/pages/_components/page-shell.tsx
Original file line number Diff line number Diff line change
@@ -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<Page, "id">;
};

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 (
<div
className={cn(
"mx-auto flex min-h-full max-w-5xl flex-col gap-4 pt-8 pb-52",
className,
)}
{...props}
>
{children}
</div>
);
}
15 changes: 13 additions & 2 deletions apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -32,9 +34,9 @@ export function PageTitleTextarea({
debounceTime = DEFAULT_DEBOUNCE_TIME,
onTitleChange,
onKeyDown,
ref,
...rest
}: PageEditorTitleProps) {
const ref = useRef<HTMLTextAreaElement | null>(null);
const trpc = useTRPC();
const queryClient = useQueryClient();
const { mutate: updatePageTitle } = useMutation(
Expand Down Expand Up @@ -112,6 +114,15 @@ export function PageTitleTextarea({
onKeyDown?.(e);
}

useAppEventHandler(
({ payload }) => {
if (!payload.title && ref.current) {
ref.current.focus();
}
},
[PageCreatedEvent, page.id],
);

return (
<ExpandingTextarea
ref={ref}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/events/app-event-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export class AppEventBuffer<T extends AppEventPayload = AppEventPayload> {
/**
* 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<E extends AppEvent<T>>(eventType: string, id: string): E | null {
const events = this.buffer.get(eventType);
Expand Down
34 changes: 24 additions & 10 deletions apps/web/src/events/app-event-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,35 @@ export class AppEventEmitter<T extends AppEventPayload = AppEventPayload> {
}

/**
* 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<E extends AppEvent<T>>(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<E extends AppEvent<T>>(eventType: string, id: string): E | null {
const bufferedEvent = this.eventBuffer.flush<E>(eventType, id);
if (!bufferedEvent) {
return null;
}

const eventListeners = this.eventListeners.get(eventType) || [];
eventListeners.forEach((listener) => {
listener(bufferedEvent);
});

return bufferedEvent;
}

/**
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/events/page-created-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UIMessage>;
tool?: {
toolName: string;
toolCallId: string;
chat: Chat<UIMessage>;
};
}

export class PageCreatedEvent extends AppEvent<PageCreatedPayload> {
Expand Down
5 changes: 0 additions & 5 deletions apps/web/src/hooks/use-app-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppEventPayload>): void => {
if (
event.type === eventType &&
Expand Down
Loading