mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(copilot): add fork chat into split panel
Allow users to fork an existing chat session into a new split pane, opening the same conversation side-by-side for reference or comparison. - Extend splitPane() to accept optional sessionId for the new pane - Hoist SplitPaneProvider above ChatSidebar so sidebar can access pane context on desktop - Add "Open in horizontal/vertical split" to sidebar session dropdown - Sidebar click now loads session in the focused pane on desktop - Add fork button (GitFork icon) to PaneToolbar for forking the current chat into a new adjacent pane - Add useSplitPaneContextOptional() for safe usage outside provider https://claude.ai/code/session_0176uT7NXqgd5vdgTLdANLTR
This commit is contained in:
@@ -15,9 +15,18 @@ 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 } from "./components/SplitPane/SplitPaneContext";
|
||||
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 {
|
||||
sessionId,
|
||||
@@ -59,16 +68,15 @@ 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">
|
||||
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{isMobile ? (
|
||||
if (isMobile) {
|
||||
return (
|
||||
<SidebarProvider
|
||||
defaultOpen={true}
|
||||
className="h-[calc(100vh-72px)] min-h-0"
|
||||
>
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0">
|
||||
<MobileHeader onOpenDrawer={handleOpenDrawer} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatContainer
|
||||
messages={messages}
|
||||
status={status}
|
||||
@@ -114,14 +122,8 @@ export function CopilotPage() {
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SplitPaneProvider>
|
||||
{(tree) => <PaneTree node={tree} />}
|
||||
</SplitPaneProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<MobileDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
sessions={sessions}
|
||||
@@ -132,15 +134,29 @@ export function CopilotPage() {
|
||||
onClose={handleCloseDrawer}
|
||||
onOpenChange={handleDrawerOpenChange}
|
||||
/>
|
||||
)}
|
||||
{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,12 +23,19 @@ 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 { motion } from "framer-motion";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { useState } from "react";
|
||||
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
|
||||
import { useSplitPaneContextOptional } from "../SplitPane/SplitPaneContext";
|
||||
|
||||
export function ChatSidebar() {
|
||||
const { state } = useSidebar();
|
||||
@@ -39,6 +46,7 @@ export function ChatSidebar() {
|
||||
title: string | null | undefined;
|
||||
} | null>(null);
|
||||
|
||||
const splitPaneCtx = useSplitPaneContextOptional();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: sessionsResponse, isLoading: isLoadingSessions } =
|
||||
@@ -74,13 +82,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 handleDeleteClick(
|
||||
e: React.MouseEvent,
|
||||
id: string,
|
||||
@@ -242,6 +261,26 @@ export function ChatSidebar() {
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{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)
|
||||
|
||||
@@ -47,7 +47,7 @@ export function ChatPane({ paneId, sessionId: externalSessionId }: Props) {
|
||||
onFocus={() => setFocusedPaneId(paneId)}
|
||||
onMouseDown={() => setFocusedPaneId(paneId)}
|
||||
>
|
||||
<PaneToolbar paneId={paneId} title={title} />
|
||||
<PaneToolbar paneId={paneId} title={title} sessionId={sessionId} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatContainer
|
||||
messages={messages}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
GitForkIcon,
|
||||
SplitHorizontalIcon,
|
||||
SplitVerticalIcon,
|
||||
XIcon,
|
||||
@@ -10,9 +11,10 @@ import { useSplitPaneContext } from "./SplitPaneContext";
|
||||
interface Props {
|
||||
paneId: string;
|
||||
title: string | null;
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export function PaneToolbar({ paneId, title }: Props) {
|
||||
export function PaneToolbar({ paneId, title, sessionId }: Props) {
|
||||
const { splitPane, closePane, leafCount, focusedPaneId } =
|
||||
useSplitPaneContext();
|
||||
|
||||
@@ -32,6 +34,16 @@ export function PaneToolbar({ paneId, title }: Props) {
|
||||
{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"
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, ReactNode, useContext } from "react";
|
||||
import type { SplitDirection } from "./types";
|
||||
import type { PaneNode, SplitDirection } from "./types";
|
||||
import { usePaneTree } from "./usePaneTree";
|
||||
|
||||
interface SplitPaneContextValue {
|
||||
splitPane: (paneId: string, direction: SplitDirection) => void;
|
||||
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);
|
||||
@@ -25,8 +30,13 @@ export function useSplitPaneContext() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Safe version that returns null when outside the provider (e.g. mobile). */
|
||||
export function useSplitPaneContextOptional() {
|
||||
return useContext(SplitPaneContext);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: (tree: ReturnType<typeof usePaneTree>["tree"]) => ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SplitPaneProvider({ children }: Props) {
|
||||
@@ -49,9 +59,10 @@ export function SplitPaneProvider({ children }: Props) {
|
||||
focusedPaneId,
|
||||
setFocusedPaneId,
|
||||
leafCount,
|
||||
tree,
|
||||
}}
|
||||
>
|
||||
{children(tree)}
|
||||
{children}
|
||||
</SplitPaneContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,29 +77,36 @@ export function usePaneTree() {
|
||||
() => (tree as LeafPane).id,
|
||||
);
|
||||
|
||||
const splitPane = useCallback((paneId: string, direction: SplitDirection) => {
|
||||
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 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();
|
||||
const newLeaf = createLeaf(sessionIdForNewPane);
|
||||
|
||||
const splitNode: PaneNode = {
|
||||
type: "split",
|
||||
id: `split-${nextPaneId++}`,
|
||||
direction,
|
||||
children: [existingLeaf, newLeaf],
|
||||
};
|
||||
const splitNode: PaneNode = {
|
||||
type: "split",
|
||||
id: `split-${nextPaneId++}`,
|
||||
direction,
|
||||
children: [existingLeaf, newLeaf],
|
||||
};
|
||||
|
||||
setFocusedPaneId(newLeaf.id);
|
||||
return replaceNode(prev, paneId, splitNode);
|
||||
});
|
||||
}, []);
|
||||
setFocusedPaneId(newLeaf.id);
|
||||
return replaceNode(prev, paneId, splitNode);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closePane = useCallback(
|
||||
(paneId: string) => {
|
||||
|
||||
Reference in New Issue
Block a user