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:
Claude
2026-03-09 18:12:11 +00:00
parent 4c9c816024
commit d2e04a151a
6 changed files with 134 additions and 49 deletions

View File

@@ -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>
);
}

View File

@@ -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)

View File

@@ -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}

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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) => {