Compare commits

...

5 Commits

Author SHA1 Message Date
Nicholas Tindle
6642b5e41a Merge branch 'dev' into claude/add-split-screen-chat-9v6F4 2026-03-12 00:31:48 -05:00
Nicholas Tindle
ddd588ed05 Merge branch 'dev' into claude/add-split-screen-chat-9v6F4 2026-03-11 03:36:40 -05:00
Claude
df71b68abb merge: resolve conflicts with dev branch
Combine split-pane support (fork session, desktop pane content) with
drag-and-drop file upload and rename functionality from dev.

https://claude.ai/code/session_0176uT7NXqgd5vdgTLdANLTR
2026-03-09 23:02:24 +00:00
Claude
d2e04a151a 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
2026-03-09 18:12:11 +00:00
Claude
4c9c816024 feat(copilot): add tmux-style split screen windowing for chat
Add a recursive split pane system to the copilot chat interface that
allows users to open multiple independent chat sessions side by side,
splitting both horizontally and vertically (like tmux).

- Add SplitPane component tree (types, context, PaneTree, ChatPane,
  PaneToolbar) with recursive binary tree state management
- Each pane runs its own independent chat session via usePaneChat and
  usePaneChatSession hooks (decoupled from URL query state)
- Resizable dividers between panes using react-resizable-panels
- PaneToolbar on each pane with split-horizontal, split-vertical, and
  close controls
- Desktop only: mobile retains the existing single-pane layout

https://claude.ai/code/session_0176uT7NXqgd5vdgTLdANLTR
2026-02-28 04:26:45 +00:00
12 changed files with 1051 additions and 95 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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