mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-03-17 03:00:27 -04:00
Compare commits
5 Commits
dev
...
claude/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6642b5e41a | ||
|
|
ddd588ed05 | ||
|
|
df71b68abb | ||
|
|
d2e04a151a | ||
|
|
4c9c816024 |
@@ -106,6 +106,7 @@
|
||||
"react-icons": "5.5.0",
|
||||
"react-markdown": "9.0.3",
|
||||
"react-modal": "3.16.3",
|
||||
"react-resizable-panels": "4.6.5",
|
||||
"react-shepherd": "6.1.9",
|
||||
"react-window": "2.2.0",
|
||||
"recharts": "3.3.0",
|
||||
|
||||
14
autogpt_platform/frontend/pnpm-lock.yaml
generated
14
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -240,6 +240,9 @@ importers:
|
||||
react-modal:
|
||||
specifier: 3.16.3
|
||||
version: 3.16.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-resizable-panels:
|
||||
specifier: 4.6.5
|
||||
version: 4.6.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-shepherd:
|
||||
specifier: 6.1.9
|
||||
version: 6.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
|
||||
@@ -7321,6 +7324,12 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-resizable-panels@4.6.5:
|
||||
resolution: {integrity: sha512-pmQP6qv9KmsesNMvWVNvVfVJAwYSOWWbAOAtrPR8Cre20+j1NWIlyft0btjtDQE+OepXmI6g3VPrCXQY0oD7+Q==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-shepherd@6.1.9:
|
||||
resolution: {integrity: sha512-kSFs7ER9+tDAQ9a80CGTaWHpuNf/6RNnnAqtPxFqZSt5NnlKi6T8/E93sYMPOibhvdtpG5pIZpeT3JI1+Ppqiw==}
|
||||
peerDependencies:
|
||||
@@ -16697,6 +16706,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.17
|
||||
|
||||
react-resizable-panels@4.6.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-shepherd@6.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
@@ -16,8 +16,19 @@ import { DeleteChatDialog } from "./components/DeleteChatDialog/DeleteChatDialog
|
||||
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
|
||||
import { MobileHeader } from "./components/MobileHeader/MobileHeader";
|
||||
import { ScaleLoader } from "./components/ScaleLoader/ScaleLoader";
|
||||
import { PaneTree } from "./components/SplitPane/PaneTree";
|
||||
import {
|
||||
SplitPaneProvider,
|
||||
useSplitPaneContext,
|
||||
} from "./components/SplitPane/SplitPaneContext";
|
||||
import { useCopilotPage } from "./useCopilotPage";
|
||||
|
||||
/** Reads the pane tree from context and renders it. */
|
||||
function DesktopPaneContent() {
|
||||
const { tree } = useSplitPaneContext();
|
||||
return <PaneTree node={tree} />;
|
||||
}
|
||||
|
||||
export function CopilotPage() {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
|
||||
@@ -103,104 +114,116 @@ export function CopilotPage() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
defaultOpen={true}
|
||||
className="h-[calc(100vh-72px)] min-h-0"
|
||||
>
|
||||
{!isMobile && <ChatSidebar />}
|
||||
<div
|
||||
className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
if (isMobile) {
|
||||
return (
|
||||
<SidebarProvider
|
||||
defaultOpen={true}
|
||||
className="h-[calc(100vh-72px)] min-h-0"
|
||||
>
|
||||
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
|
||||
{/* Drop overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-violet-400 bg-violet-500/10 transition-opacity duration-150",
|
||||
isDragging ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<UploadSimple className="h-10 w-10 text-violet-500" weight="bold" />
|
||||
<span className="text-lg font-medium text-violet-600">
|
||||
Drop files here
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatContainer
|
||||
messages={messages}
|
||||
status={status}
|
||||
error={error}
|
||||
sessionId={sessionId}
|
||||
isLoadingSession={isLoadingSession}
|
||||
isSessionError={isSessionError}
|
||||
isCreatingSession={isCreatingSession}
|
||||
isReconnecting={isReconnecting}
|
||||
onCreateSession={createSession}
|
||||
onSend={onSend}
|
||||
onStop={stop}
|
||||
isUploadingFiles={isUploadingFiles}
|
||||
droppedFiles={droppedFiles}
|
||||
onDroppedFilesConsumed={handleDroppedFilesConsumed}
|
||||
headerSlot={
|
||||
isMobile && sessionId ? (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded p-1.5 hover:bg-neutral-100"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<DotsThree className="h-5 w-5 text-neutral-600" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const session = sessions.find(
|
||||
(s) => s.id === sessionId,
|
||||
);
|
||||
if (session) {
|
||||
handleDeleteClick(session.id, session.title);
|
||||
}
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="text-red-600 focus:bg-red-50 focus:text-red-600"
|
||||
>
|
||||
Delete chat
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
<MobileHeader onOpenDrawer={handleOpenDrawer} />
|
||||
{/* Drop overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-violet-400 bg-violet-500/10 transition-opacity duration-150",
|
||||
isDragging ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<UploadSimple className="h-10 w-10 text-violet-500" weight="bold" />
|
||||
<span className="text-lg font-medium text-violet-600">
|
||||
Drop files here
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatContainer
|
||||
messages={messages}
|
||||
status={status}
|
||||
error={error}
|
||||
sessionId={sessionId}
|
||||
isLoadingSession={isLoadingSession}
|
||||
isSessionError={isSessionError}
|
||||
isCreatingSession={isCreatingSession}
|
||||
isReconnecting={isReconnecting}
|
||||
onCreateSession={createSession}
|
||||
onSend={onSend}
|
||||
onStop={stop}
|
||||
isUploadingFiles={isUploadingFiles}
|
||||
droppedFiles={droppedFiles}
|
||||
onDroppedFilesConsumed={handleDroppedFilesConsumed}
|
||||
headerSlot={
|
||||
sessionId ? (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded p-1.5 hover:bg-neutral-100"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<DotsThree className="h-5 w-5 text-neutral-600" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const session = sessions.find(
|
||||
(s) => s.id === sessionId,
|
||||
);
|
||||
if (session) {
|
||||
handleDeleteClick(session.id, session.title);
|
||||
}
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="text-red-600 focus:bg-red-50 focus:text-red-600"
|
||||
>
|
||||
Delete chat
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<MobileDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
sessions={sessions}
|
||||
currentSessionId={sessionId}
|
||||
isLoading={isLoadingSessions}
|
||||
onSelectSession={handleSelectSession}
|
||||
onNewChat={handleNewChat}
|
||||
onClose={handleCloseDrawer}
|
||||
onOpenChange={handleDrawerOpenChange}
|
||||
/>
|
||||
<DeleteChatDialog
|
||||
session={sessionToDelete}
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<MobileDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
sessions={sessions}
|
||||
currentSessionId={sessionId}
|
||||
isLoading={isLoadingSessions}
|
||||
onSelectSession={handleSelectSession}
|
||||
onNewChat={handleNewChat}
|
||||
onClose={handleCloseDrawer}
|
||||
onOpenChange={handleDrawerOpenChange}
|
||||
/>
|
||||
)}
|
||||
{/* Delete confirmation dialog - rendered at top level for proper z-index on mobile */}
|
||||
{isMobile && (
|
||||
<DeleteChatDialog
|
||||
session={sessionToDelete}
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SplitPaneProvider>
|
||||
<SidebarProvider
|
||||
defaultOpen={true}
|
||||
className="h-[calc(100vh-72px)] min-h-0"
|
||||
>
|
||||
<ChatSidebar />
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DesktopPaneContent />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</SplitPaneProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,13 +23,20 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DotsThree, PlusCircleIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
DotsThree,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
SplitHorizontalIcon,
|
||||
SplitVerticalIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCopilotUIStore } from "../../store";
|
||||
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
|
||||
import { useSplitPaneContextOptional } from "../SplitPane/SplitPaneContext";
|
||||
|
||||
export function ChatSidebar() {
|
||||
const { state } = useSidebar();
|
||||
@@ -37,6 +44,7 @@ export function ChatSidebar() {
|
||||
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
|
||||
const { sessionToDelete, setSessionToDelete } = useCopilotUIStore();
|
||||
|
||||
const splitPaneCtx = useSplitPaneContextOptional();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: sessionsResponse, isLoading: isLoadingSessions } =
|
||||
@@ -103,13 +111,24 @@ export function ChatSidebar() {
|
||||
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
|
||||
|
||||
function handleNewChat() {
|
||||
if (splitPaneCtx) {
|
||||
splitPaneCtx.setPaneSession(splitPaneCtx.focusedPaneId, null);
|
||||
}
|
||||
setSessionId(null);
|
||||
}
|
||||
|
||||
function handleSelectSession(id: string) {
|
||||
if (splitPaneCtx) {
|
||||
splitPaneCtx.setPaneSession(splitPaneCtx.focusedPaneId, id);
|
||||
}
|
||||
setSessionId(id);
|
||||
}
|
||||
|
||||
function handleForkSession(id: string, direction: "horizontal" | "vertical") {
|
||||
if (!splitPaneCtx) return;
|
||||
splitPaneCtx.splitPane(splitPaneCtx.focusedPaneId, direction, id);
|
||||
}
|
||||
|
||||
function handleRenameClick(
|
||||
e: React.MouseEvent,
|
||||
id: string,
|
||||
@@ -355,6 +374,26 @@ export function ChatSidebar() {
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
{splitPaneCtx && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleForkSession(session.id, "horizontal")
|
||||
}
|
||||
>
|
||||
<SplitHorizontalIcon className="mr-2 h-4 w-4" />
|
||||
Open in horizontal split
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleForkSession(session.id, "vertical")
|
||||
}
|
||||
>
|
||||
<SplitVerticalIcon className="mr-2 h-4 w-4" />
|
||||
Open in vertical split
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) =>
|
||||
handleDeleteClick(e, session.id, session.title)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { ChatContainer } from "../ChatContainer/ChatContainer";
|
||||
import { PaneToolbar } from "./PaneToolbar";
|
||||
import { useSplitPaneContext } from "./SplitPaneContext";
|
||||
import { usePaneChat } from "./usePaneChat";
|
||||
|
||||
interface Props {
|
||||
paneId: string;
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export function ChatPane({ paneId, sessionId: externalSessionId }: Props) {
|
||||
const { setPaneSession, setFocusedPaneId } = useSplitPaneContext();
|
||||
|
||||
const {
|
||||
sessionId,
|
||||
messages,
|
||||
status,
|
||||
error,
|
||||
stop,
|
||||
isReconnecting,
|
||||
isLoadingSession,
|
||||
isSessionError,
|
||||
isCreatingSession,
|
||||
createSession,
|
||||
onSend,
|
||||
} = usePaneChat({
|
||||
paneId,
|
||||
sessionId: externalSessionId,
|
||||
onSessionChange: setPaneSession,
|
||||
});
|
||||
|
||||
// Derive a title from the first user message
|
||||
const firstUserMessage = messages.find((m) => m.role === "user");
|
||||
const title = firstUserMessage
|
||||
? firstUserMessage.parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => ("text" in p ? p.text : ""))
|
||||
.join("")
|
||||
.slice(0, 40)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
onFocus={() => setFocusedPaneId(paneId)}
|
||||
onMouseDown={() => setFocusedPaneId(paneId)}
|
||||
>
|
||||
<PaneToolbar paneId={paneId} title={title} sessionId={sessionId} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatContainer
|
||||
messages={messages}
|
||||
status={status}
|
||||
error={error}
|
||||
sessionId={sessionId}
|
||||
isLoadingSession={isLoadingSession}
|
||||
isSessionError={isSessionError}
|
||||
isCreatingSession={isCreatingSession}
|
||||
isReconnecting={isReconnecting}
|
||||
onCreateSession={createSession}
|
||||
onSend={onSend}
|
||||
onStop={stop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
GitForkIcon,
|
||||
SplitHorizontalIcon,
|
||||
SplitVerticalIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useSplitPaneContext } from "./SplitPaneContext";
|
||||
|
||||
interface Props {
|
||||
paneId: string;
|
||||
title: string | null;
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export function PaneToolbar({ paneId, title, sessionId }: Props) {
|
||||
const { splitPane, closePane, leafCount, focusedPaneId } =
|
||||
useSplitPaneContext();
|
||||
|
||||
const isFocused = focusedPaneId === paneId;
|
||||
const canClose = leafCount > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex h-8 shrink-0 items-center justify-between border-b px-2 text-xs " +
|
||||
(isFocused
|
||||
? "border-violet-200 bg-violet-50 text-violet-700"
|
||||
: "border-zinc-200 bg-zinc-50 text-zinc-500")
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{title || "New chat"}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{sessionId && (
|
||||
<button
|
||||
onClick={() => splitPane(paneId, "horizontal", sessionId)}
|
||||
className="rounded p-1 hover:bg-black/5"
|
||||
aria-label="Fork chat into split"
|
||||
title="Fork this chat into a new split pane"
|
||||
>
|
||||
<GitForkIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => splitPane(paneId, "horizontal")}
|
||||
className="rounded p-1 hover:bg-black/5"
|
||||
aria-label="Split horizontally"
|
||||
title="Split horizontal (side by side)"
|
||||
>
|
||||
<SplitHorizontalIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => splitPane(paneId, "vertical")}
|
||||
className="rounded p-1 hover:bg-black/5"
|
||||
aria-label="Split vertically"
|
||||
title="Split vertical (top/bottom)"
|
||||
>
|
||||
<SplitVerticalIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{canClose && (
|
||||
<button
|
||||
onClick={() => closePane(paneId)}
|
||||
className="rounded p-1 hover:bg-red-100 hover:text-red-600"
|
||||
aria-label="Close pane"
|
||||
title="Close pane"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Panel, Separator } from "react-resizable-panels";
|
||||
import { ChatPane } from "./ChatPane";
|
||||
import type { PaneNode } from "./types";
|
||||
|
||||
interface Props {
|
||||
node: PaneNode;
|
||||
}
|
||||
|
||||
export function PaneTree({ node }: Props) {
|
||||
if (node.type === "leaf") {
|
||||
return <ChatPane paneId={node.id} sessionId={node.sessionId} />;
|
||||
}
|
||||
|
||||
const orientation =
|
||||
node.direction === "horizontal" ? "horizontal" : "vertical";
|
||||
|
||||
return (
|
||||
<Group orientation={orientation} id={node.id}>
|
||||
<Panel defaultSize={50} minSize={15} id={`${node.id}-left`}>
|
||||
<PaneTree node={node.children[0]} />
|
||||
</Panel>
|
||||
|
||||
<Separator
|
||||
className={
|
||||
"relative flex items-center justify-center bg-zinc-200 transition-colors hover:bg-violet-300 active:bg-violet-400 " +
|
||||
(orientation === "horizontal" ? "w-1.5" : "h-1.5")
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"rounded-full bg-zinc-400 " +
|
||||
(orientation === "horizontal" ? "h-8 w-1" : "h-1 w-8")
|
||||
}
|
||||
/>
|
||||
</Separator>
|
||||
|
||||
<Panel defaultSize={50} minSize={15} id={`${node.id}-right`}>
|
||||
<PaneTree node={node.children[1]} />
|
||||
</Panel>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, ReactNode, useContext } from "react";
|
||||
import type { PaneNode, SplitDirection } from "./types";
|
||||
import { usePaneTree } from "./usePaneTree";
|
||||
|
||||
interface SplitPaneContextValue {
|
||||
splitPane: (
|
||||
paneId: string,
|
||||
direction: SplitDirection,
|
||||
sessionIdForNewPane?: string | null,
|
||||
) => void;
|
||||
closePane: (paneId: string) => void;
|
||||
setPaneSession: (paneId: string, sessionId: string | null) => void;
|
||||
focusedPaneId: string;
|
||||
setFocusedPaneId: (id: string) => void;
|
||||
leafCount: number;
|
||||
tree: PaneNode;
|
||||
}
|
||||
|
||||
const SplitPaneContext = createContext<SplitPaneContextValue | null>(null);
|
||||
|
||||
export function useSplitPaneContext() {
|
||||
const ctx = useContext(SplitPaneContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useSplitPaneContext must be used within a SplitPaneProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Safe version that returns null when outside the provider (e.g. mobile). */
|
||||
export function useSplitPaneContextOptional() {
|
||||
return useContext(SplitPaneContext);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SplitPaneProvider({ children }: Props) {
|
||||
const {
|
||||
tree,
|
||||
focusedPaneId,
|
||||
setFocusedPaneId,
|
||||
splitPane,
|
||||
closePane,
|
||||
setPaneSession,
|
||||
leafCount,
|
||||
} = usePaneTree();
|
||||
|
||||
return (
|
||||
<SplitPaneContext.Provider
|
||||
value={{
|
||||
splitPane,
|
||||
closePane,
|
||||
setPaneSession,
|
||||
focusedPaneId,
|
||||
setFocusedPaneId,
|
||||
leafCount,
|
||||
tree,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SplitPaneContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Pane tree data structures for tmux-style split windowing.
|
||||
*
|
||||
* The tree is a binary tree where each internal node is a split (horizontal or
|
||||
* vertical) and each leaf node is an independent chat pane.
|
||||
*/
|
||||
|
||||
export type SplitDirection = "horizontal" | "vertical";
|
||||
|
||||
export interface LeafPane {
|
||||
type: "leaf";
|
||||
id: string;
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export interface SplitPane {
|
||||
type: "split";
|
||||
id: string;
|
||||
direction: SplitDirection;
|
||||
children: [PaneNode, PaneNode];
|
||||
}
|
||||
|
||||
export type PaneNode = LeafPane | SplitPane;
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Per-pane chat hook. This is a simplified version of useCopilotPage
|
||||
* that manages a single chat session for one pane in the split view.
|
||||
*/
|
||||
|
||||
import {
|
||||
getGetV2GetSessionQueryKey,
|
||||
postV2CancelSessionTask,
|
||||
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { toast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import type { UIMessage } from "ai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePaneChatSession } from "./usePaneChatSession";
|
||||
|
||||
const RECONNECT_BASE_DELAY_MS = 1_000;
|
||||
const RECONNECT_MAX_DELAY_MS = 30_000;
|
||||
const RECONNECT_MAX_ATTEMPTS = 5;
|
||||
|
||||
function resolveInProgressTools(
|
||||
messages: UIMessage[],
|
||||
outcome: "completed" | "cancelled",
|
||||
): UIMessage[] {
|
||||
return messages.map((msg) => ({
|
||||
...msg,
|
||||
parts: msg.parts.map((part) =>
|
||||
"state" in part &&
|
||||
(part.state === "input-streaming" || part.state === "input-available")
|
||||
? outcome === "cancelled"
|
||||
? { ...part, state: "output-error" as const, errorText: "Cancelled" }
|
||||
: { ...part, state: "output-available" as const, output: "" }
|
||||
: part,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function deduplicateMessages(messages: UIMessage[]): UIMessage[] {
|
||||
const seenIds = new Set<string>();
|
||||
return messages.filter((msg) => {
|
||||
if (seenIds.has(msg.id)) return false;
|
||||
seenIds.add(msg.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
interface UsePaneChatArgs {
|
||||
paneId: string;
|
||||
sessionId: string | null;
|
||||
onSessionChange: (paneId: string, sessionId: string | null) => void;
|
||||
}
|
||||
|
||||
export function usePaneChat({
|
||||
paneId,
|
||||
sessionId: externalSessionId,
|
||||
onSessionChange,
|
||||
}: UsePaneChatArgs) {
|
||||
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setSessionId = useCallback(
|
||||
(id: string | null) => {
|
||||
onSessionChange(paneId, id);
|
||||
},
|
||||
[paneId, onSessionChange],
|
||||
);
|
||||
|
||||
const {
|
||||
sessionId,
|
||||
hydratedMessages,
|
||||
hasActiveStream,
|
||||
isLoadingSession,
|
||||
isSessionError,
|
||||
createSession,
|
||||
isCreatingSession,
|
||||
refetchSession,
|
||||
} = usePaneChatSession({
|
||||
sessionId: externalSessionId,
|
||||
setSessionId,
|
||||
});
|
||||
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
sessionId
|
||||
? new DefaultChatTransport({
|
||||
api: `/api/chat/sessions/${sessionId}/stream`,
|
||||
prepareSendMessagesRequest: ({ messages }) => {
|
||||
const last = messages[messages.length - 1];
|
||||
return {
|
||||
body: {
|
||||
message: (
|
||||
last.parts?.map((p) => (p.type === "text" ? p.text : "")) ??
|
||||
[]
|
||||
).join(""),
|
||||
is_user_message: last.role === "user",
|
||||
context: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
prepareReconnectToStreamRequest: () => ({
|
||||
api: `/api/chat/sessions/${sessionId}/stream`,
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
// Reconnect state
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
const [isReconnectScheduled, setIsReconnectScheduled] = useState(false);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const hasShownDisconnectToast = useRef(false);
|
||||
|
||||
function handleReconnect(sid: string) {
|
||||
if (isReconnectScheduled || !sid) return;
|
||||
|
||||
const nextAttempt = reconnectAttempts + 1;
|
||||
if (nextAttempt > RECONNECT_MAX_ATTEMPTS) {
|
||||
toast({
|
||||
title: "Connection lost",
|
||||
description: "Unable to reconnect. Please refresh the page.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReconnectScheduled(true);
|
||||
setReconnectAttempts(nextAttempt);
|
||||
|
||||
if (!hasShownDisconnectToast.current) {
|
||||
hasShownDisconnectToast.current = true;
|
||||
toast({
|
||||
title: "Connection lost",
|
||||
description: "Reconnecting...",
|
||||
});
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_DELAY_MS * 2 ** reconnectAttempts,
|
||||
RECONNECT_MAX_DELAY_MS,
|
||||
);
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
setIsReconnectScheduled(false);
|
||||
resumeStream();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
const {
|
||||
messages: rawMessages,
|
||||
sendMessage,
|
||||
stop: sdkStop,
|
||||
status,
|
||||
error,
|
||||
setMessages,
|
||||
resumeStream,
|
||||
} = useChat({
|
||||
id: sessionId ? `${paneId}-${sessionId}` : undefined,
|
||||
transport: transport ?? undefined,
|
||||
onFinish: async ({ isDisconnect, isAbort }) => {
|
||||
if (isAbort || !sessionId) return;
|
||||
|
||||
if (isDisconnect) {
|
||||
handleReconnect(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await refetchSession();
|
||||
const backendActive =
|
||||
result.data?.status === 200 && !!result.data.data.active_stream;
|
||||
|
||||
if (backendActive) {
|
||||
handleReconnect(sessionId);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
if (!sessionId) return;
|
||||
const isNetworkError =
|
||||
error.name === "TypeError" || error.name === "AbortError";
|
||||
if (isNetworkError) {
|
||||
handleReconnect(sessionId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const messages = useMemo(
|
||||
() => deduplicateMessages(rawMessages),
|
||||
[rawMessages],
|
||||
);
|
||||
|
||||
async function stop() {
|
||||
sdkStop();
|
||||
setMessages((prev) => resolveInProgressTools(prev, "cancelled"));
|
||||
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const res = await postV2CancelSessionTask(sessionId);
|
||||
if (
|
||||
res.status === 200 &&
|
||||
"reason" in res.data &&
|
||||
res.data.reason === "cancel_published_not_confirmed"
|
||||
) {
|
||||
toast({
|
||||
title: "Stop may take a moment",
|
||||
description:
|
||||
"The cancel was sent but not yet confirmed. The task should stop shortly.",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: "Could not stop the task",
|
||||
description: "The task may still be running in the background.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate messages from REST API when not actively streaming
|
||||
useEffect(() => {
|
||||
if (!hydratedMessages || hydratedMessages.length === 0) return;
|
||||
if (status === "streaming" || status === "submitted") return;
|
||||
if (isReconnectScheduled) return;
|
||||
setMessages((prev) => {
|
||||
if (prev.length >= hydratedMessages.length) return prev;
|
||||
return deduplicateMessages(hydratedMessages);
|
||||
});
|
||||
}, [hydratedMessages, setMessages, status, isReconnectScheduled]);
|
||||
|
||||
// Track resume state per session
|
||||
const hasResumedRef = useRef<Map<string, boolean>>(new Map());
|
||||
|
||||
// Clean up reconnect state on session switch
|
||||
useEffect(() => {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = undefined;
|
||||
setReconnectAttempts(0);
|
||||
setIsReconnectScheduled(false);
|
||||
hasShownDisconnectToast.current = false;
|
||||
prevStatusRef.current = status;
|
||||
}, [sessionId, status]);
|
||||
|
||||
// Invalidate session cache when stream completes
|
||||
const prevStatusRef = useRef(status);
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current;
|
||||
prevStatusRef.current = status;
|
||||
|
||||
const wasActive = prev === "streaming" || prev === "submitted";
|
||||
const isIdle = status === "ready" || status === "error";
|
||||
|
||||
if (wasActive && isIdle && sessionId && !isReconnectScheduled) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetSessionQueryKey(sessionId),
|
||||
});
|
||||
if (status === "ready") {
|
||||
setReconnectAttempts(0);
|
||||
hasShownDisconnectToast.current = false;
|
||||
}
|
||||
}
|
||||
}, [status, sessionId, queryClient, isReconnectScheduled]);
|
||||
|
||||
// Resume an active stream after hydration
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (!hasActiveStream) return;
|
||||
if (!hydratedMessages || hydratedMessages.length === 0) return;
|
||||
if (status === "streaming" || status === "submitted") return;
|
||||
if (hasResumedRef.current.get(sessionId)) return;
|
||||
|
||||
hasResumedRef.current.set(sessionId, true);
|
||||
resumeStream();
|
||||
}, [sessionId, hasActiveStream, hydratedMessages, status, resumeStream]);
|
||||
|
||||
// Clear messages when session is null
|
||||
useEffect(() => {
|
||||
if (!sessionId) setMessages([]);
|
||||
}, [sessionId, setMessages]);
|
||||
|
||||
// Send pending message once session is created
|
||||
useEffect(() => {
|
||||
if (!sessionId || !pendingMessage) return;
|
||||
const msg = pendingMessage;
|
||||
setPendingMessage(null);
|
||||
sendMessage({ text: msg });
|
||||
}, [sessionId, pendingMessage, sendMessage]);
|
||||
|
||||
async function onSend(message: string) {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (sessionId) {
|
||||
sendMessage({ text: trimmed });
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingMessage(trimmed);
|
||||
await createSession();
|
||||
}
|
||||
|
||||
const isReconnecting =
|
||||
isReconnectScheduled ||
|
||||
(hasActiveStream && status !== "streaming" && status !== "submitted");
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
messages,
|
||||
status,
|
||||
error: isReconnecting ? undefined : error,
|
||||
stop,
|
||||
isReconnecting,
|
||||
isLoadingSession,
|
||||
isSessionError,
|
||||
isCreatingSession,
|
||||
createSession,
|
||||
onSend,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* A version of useChatSession that takes sessionId/setSessionId as props
|
||||
* instead of syncing to URL query state. This allows multiple independent
|
||||
* chat sessions in a split-pane layout.
|
||||
*/
|
||||
|
||||
import {
|
||||
getGetV2GetSessionQueryKey,
|
||||
getGetV2ListSessionsQueryKey,
|
||||
useGetV2GetSession,
|
||||
usePostV2CreateSession,
|
||||
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { toast } from "@/components/molecules/Toast/use-toast";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { convertChatSessionMessagesToUiMessages } from "../../helpers/convertChatSessionToUiMessages";
|
||||
|
||||
interface UsePaneChatSessionArgs {
|
||||
sessionId: string | null;
|
||||
setSessionId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export function usePaneChatSession({
|
||||
sessionId,
|
||||
setSessionId,
|
||||
}: UsePaneChatSessionArgs) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const sessionQuery = useGetV2GetSession(sessionId ?? "", {
|
||||
query: {
|
||||
enabled: !!sessionId,
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnMount: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cache when navigating away from a session
|
||||
const prevSessionIdRef = useRef(sessionId);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevSessionIdRef.current;
|
||||
prevSessionIdRef.current = sessionId;
|
||||
if (prev && prev !== sessionId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetSessionQueryKey(prev),
|
||||
});
|
||||
}
|
||||
}, [sessionId, queryClient]);
|
||||
|
||||
const hasActiveStream = useMemo(() => {
|
||||
if (sessionQuery.data?.status !== 200) return false;
|
||||
return !!sessionQuery.data.data.active_stream;
|
||||
}, [sessionQuery.data]);
|
||||
|
||||
const hydratedMessages = useMemo(() => {
|
||||
if (sessionQuery.data?.status !== 200 || !sessionId) return undefined;
|
||||
return convertChatSessionMessagesToUiMessages(
|
||||
sessionId,
|
||||
sessionQuery.data.data.messages ?? [],
|
||||
{ isComplete: !hasActiveStream },
|
||||
);
|
||||
}, [sessionQuery.data, sessionId, hasActiveStream]);
|
||||
|
||||
const { mutateAsync: createSessionMutation, isPending: isCreatingSession } =
|
||||
usePostV2CreateSession({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
if (response.status === 200 && response.data?.id) {
|
||||
setSessionId(response.data.id);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListSessionsQueryKey(),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function createSession() {
|
||||
if (sessionId) return sessionId;
|
||||
try {
|
||||
const response = await createSessionMutation();
|
||||
if (response.status !== 200 || !response.data?.id) {
|
||||
const error = new Error("Failed to create session");
|
||||
Sentry.captureException(error, {
|
||||
extra: { status: response.status },
|
||||
});
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not start a new chat session",
|
||||
description: "Please try again.",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return response.data.id;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === "Failed to create session"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
Sentry.captureException(error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not start a new chat session",
|
||||
description: "Please try again.",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
setSessionId,
|
||||
hydratedMessages,
|
||||
hasActiveStream,
|
||||
isLoadingSession: sessionQuery.isLoading,
|
||||
isSessionError: sessionQuery.isError,
|
||||
createSession,
|
||||
isCreatingSession,
|
||||
refetchSession: sessionQuery.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { LeafPane, PaneNode, SplitDirection } from "./types";
|
||||
|
||||
let nextPaneId = 1;
|
||||
|
||||
function createLeaf(sessionId: string | null = null): LeafPane {
|
||||
return { type: "leaf", id: `pane-${nextPaneId++}`, sessionId };
|
||||
}
|
||||
|
||||
/** Replace a node in the tree by id, returning a new tree. */
|
||||
function replaceNode(
|
||||
tree: PaneNode,
|
||||
targetId: string,
|
||||
replacement: PaneNode,
|
||||
): PaneNode {
|
||||
if (tree.id === targetId) return replacement;
|
||||
if (tree.type === "leaf") return tree;
|
||||
return {
|
||||
...tree,
|
||||
children: [
|
||||
replaceNode(tree.children[0], targetId, replacement),
|
||||
replaceNode(tree.children[1], targetId, replacement),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove a leaf from the tree, promoting its sibling. */
|
||||
function removeLeaf(tree: PaneNode, targetId: string): PaneNode | null {
|
||||
if (tree.type === "leaf") {
|
||||
return tree.id === targetId ? null : tree;
|
||||
}
|
||||
|
||||
const [left, right] = tree.children;
|
||||
|
||||
// If the target is a direct child, return the sibling
|
||||
if (left.id === targetId) return right;
|
||||
if (right.id === targetId) return left;
|
||||
|
||||
// Recurse into children
|
||||
const newLeft = removeLeaf(left, targetId);
|
||||
const newRight = removeLeaf(right, targetId);
|
||||
|
||||
if (!newLeft) return newRight;
|
||||
if (!newRight) return newLeft;
|
||||
|
||||
return { ...tree, children: [newLeft, newRight] };
|
||||
}
|
||||
|
||||
/** Count leaf nodes in the tree. */
|
||||
function countLeaves(tree: PaneNode): number {
|
||||
if (tree.type === "leaf") return 1;
|
||||
return countLeaves(tree.children[0]) + countLeaves(tree.children[1]);
|
||||
}
|
||||
|
||||
/** Update the sessionId of a specific leaf pane. */
|
||||
function updateLeafSession(
|
||||
tree: PaneNode,
|
||||
paneId: string,
|
||||
sessionId: string | null,
|
||||
): PaneNode {
|
||||
if (tree.type === "leaf") {
|
||||
if (tree.id === paneId) return { ...tree, sessionId };
|
||||
return tree;
|
||||
}
|
||||
return {
|
||||
...tree,
|
||||
children: [
|
||||
updateLeafSession(tree.children[0], paneId, sessionId),
|
||||
updateLeafSession(tree.children[1], paneId, sessionId),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function usePaneTree() {
|
||||
const [tree, setTree] = useState<PaneNode>(() => createLeaf());
|
||||
const [focusedPaneId, setFocusedPaneId] = useState<string>(
|
||||
() => (tree as LeafPane).id,
|
||||
);
|
||||
|
||||
const splitPane = useCallback(
|
||||
(
|
||||
paneId: string,
|
||||
direction: SplitDirection,
|
||||
sessionIdForNewPane: string | null = null,
|
||||
) => {
|
||||
setTree((prev) => {
|
||||
// Find the target leaf to preserve its session
|
||||
const targetLeaf = findLeaf(prev, paneId);
|
||||
const existingLeaf: LeafPane = targetLeaf
|
||||
? { ...targetLeaf }
|
||||
: createLeaf();
|
||||
// Give the existing leaf a new id so React re-keys properly
|
||||
existingLeaf.id = `pane-${nextPaneId++}`;
|
||||
|
||||
const newLeaf = createLeaf(sessionIdForNewPane);
|
||||
|
||||
const splitNode: PaneNode = {
|
||||
type: "split",
|
||||
id: `split-${nextPaneId++}`,
|
||||
direction,
|
||||
children: [existingLeaf, newLeaf],
|
||||
};
|
||||
|
||||
setFocusedPaneId(newLeaf.id);
|
||||
return replaceNode(prev, paneId, splitNode);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closePane = useCallback(
|
||||
(paneId: string) => {
|
||||
setTree((prev) => {
|
||||
if (countLeaves(prev) <= 1) return prev; // Don't close the last pane
|
||||
const newTree = removeLeaf(prev, paneId);
|
||||
if (!newTree) return prev;
|
||||
|
||||
// If the focused pane was closed, focus the first available leaf
|
||||
if (focusedPaneId === paneId) {
|
||||
const firstLeaf = findFirstLeaf(newTree);
|
||||
if (firstLeaf) setFocusedPaneId(firstLeaf.id);
|
||||
}
|
||||
|
||||
return newTree;
|
||||
});
|
||||
},
|
||||
[focusedPaneId],
|
||||
);
|
||||
|
||||
const setPaneSession = useCallback(
|
||||
(paneId: string, sessionId: string | null) => {
|
||||
setTree((prev) => updateLeafSession(prev, paneId, sessionId));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
tree,
|
||||
focusedPaneId,
|
||||
setFocusedPaneId,
|
||||
splitPane,
|
||||
closePane,
|
||||
setPaneSession,
|
||||
leafCount: countLeaves(tree),
|
||||
};
|
||||
}
|
||||
|
||||
function findLeaf(tree: PaneNode, id: string): LeafPane | null {
|
||||
if (tree.type === "leaf") return tree.id === id ? tree : null;
|
||||
return findLeaf(tree.children[0], id) || findLeaf(tree.children[1], id);
|
||||
}
|
||||
|
||||
function findFirstLeaf(tree: PaneNode): LeafPane | null {
|
||||
if (tree.type === "leaf") return tree;
|
||||
return findFirstLeaf(tree.children[0]);
|
||||
}
|
||||
Reference in New Issue
Block a user