11"use client" ;
22
33import { useState , useEffect , useCallback , useMemo } from "react" ;
4- import { usePathname } from "next/navigation" ;
4+ import { usePathname , useRouter } from "next/navigation" ;
55import { TooltipProvider } from "@/components/ui/tooltip" ;
66import { NavRail } from "./NavRail" ;
77import { ChatListPanel } from "./ChatListPanel" ;
@@ -14,9 +14,39 @@ import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/u
1414import { UpdateContext , type UpdateInfo } from "@/hooks/useUpdate" ;
1515import { ImageGenContext , useImageGenState } from "@/hooks/useImageGen" ;
1616import { BatchImageGenContext , useBatchImageGenState } from "@/hooks/useBatchImageGen" ;
17+ import { SplitContext , type SplitSession } from "@/hooks/useSplit" ;
18+ import { SplitChatContainer } from "./SplitChatContainer" ;
1719import { ErrorBoundary } from "./ErrorBoundary" ;
1820import { getActiveSessionIds , getSnapshot } from "@/lib/stream-session-manager" ;
1921
22+ const SPLIT_SESSIONS_KEY = "codepilot:split-sessions" ;
23+ const SPLIT_ACTIVE_COLUMN_KEY = "codepilot:split-active-column" ;
24+
25+ function loadSplitSessions ( ) : SplitSession [ ] {
26+ if ( typeof window === "undefined" ) return [ ] ;
27+ try {
28+ const raw = localStorage . getItem ( SPLIT_SESSIONS_KEY ) ;
29+ if ( raw ) return JSON . parse ( raw ) ;
30+ } catch {
31+ // ignore
32+ }
33+ return [ ] ;
34+ }
35+
36+ function saveSplitSessions ( sessions : SplitSession [ ] ) {
37+ if ( sessions . length >= 2 ) {
38+ localStorage . setItem ( SPLIT_SESSIONS_KEY , JSON . stringify ( sessions ) ) ;
39+ } else {
40+ localStorage . removeItem ( SPLIT_SESSIONS_KEY ) ;
41+ localStorage . removeItem ( SPLIT_ACTIVE_COLUMN_KEY ) ;
42+ }
43+ }
44+
45+ function loadActiveColumn ( ) : string {
46+ if ( typeof window === "undefined" ) return "" ;
47+ return localStorage . getItem ( SPLIT_ACTIVE_COLUMN_KEY ) || "" ;
48+ }
49+
2050const EMPTY_SET = new Set < string > ( ) ;
2151const CHATLIST_MIN = 180 ;
2252const CHATLIST_MAX = 400 ;
@@ -40,6 +70,7 @@ const DISMISSED_VERSION_KEY = "codepilot_dismissed_update_version";
4070
4171export function AppShell ( { children } : { children : React . ReactNode } ) {
4272 const pathname = usePathname ( ) ;
73+ const router = useRouter ( ) ;
4374
4475 const [ chatListOpen , setChatListOpenRaw ] = useState ( false ) ;
4576
@@ -75,7 +106,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
75106
76107 // Panel state
77108 const isChatRoute = pathname . startsWith ( "/chat/" ) || pathname === "/chat" ;
78- const isChatDetailRoute = pathname . startsWith ( "/chat/" ) ;
79109
80110 // Auto-close chat list when leaving chat routes
81111 const setChatListOpen = useCallback ( ( open : boolean ) => {
@@ -118,6 +148,133 @@ export function AppShell({ children }: { children: React.ReactNode }) {
118148 return ( ) => window . removeEventListener ( 'stream-session-event' , handler ) ;
119149 } , [ ] ) ;
120150
151+ // --- Split-screen state ---
152+ const [ splitSessions , setSplitSessions ] = useState < SplitSession [ ] > ( ( ) => loadSplitSessions ( ) ) ;
153+ const [ activeColumnId , setActiveColumnIdRaw ] = useState < string > ( ( ) => loadActiveColumn ( ) ) ;
154+ const isSplitActive = splitSessions . length >= 2 ;
155+ const isChatDetailRoute = pathname . startsWith ( "/chat/" ) || isSplitActive ;
156+
157+ // Persist split sessions to localStorage
158+ useEffect ( ( ) => {
159+ saveSplitSessions ( splitSessions ) ;
160+ if ( activeColumnId ) {
161+ localStorage . setItem ( SPLIT_ACTIVE_COLUMN_KEY , activeColumnId ) ;
162+ }
163+ } , [ splitSessions , activeColumnId ] ) ;
164+
165+ // URL sync: when activeColumn changes, update router
166+ useEffect ( ( ) => {
167+ if ( isSplitActive && activeColumnId ) {
168+ const target = `/chat/${ activeColumnId } ` ;
169+ if ( pathname !== target ) {
170+ router . replace ( target ) ;
171+ }
172+ }
173+ } , [ isSplitActive , activeColumnId , pathname , router ] ) ;
174+
175+ const setActiveColumn = useCallback ( ( sessionId : string ) => {
176+ setActiveColumnIdRaw ( sessionId ) ;
177+ } , [ ] ) ;
178+
179+ const addToSplit = useCallback ( ( session : SplitSession ) => {
180+ setSplitSessions ( ( prev ) => {
181+ // If already in split, don't add again
182+ if ( prev . some ( ( s ) => s . sessionId === session . sessionId ) ) return prev ;
183+
184+ if ( prev . length < 2 ) {
185+ // First time entering split: add current active session + new session
186+ // The current session info comes from PanelContext
187+ const currentSessionId = sessionId ;
188+ if ( currentSessionId && currentSessionId !== session . sessionId ) {
189+ const currentSession : SplitSession = {
190+ sessionId : currentSessionId ,
191+ title : sessionTitle || "New Conversation" ,
192+ workingDirectory : workingDirectory || "" ,
193+ projectName : "" ,
194+ mode : "code" ,
195+ } ;
196+ // Check if current is already in the list
197+ const hasCurrentAlready = prev . some ( ( s ) => s . sessionId === currentSessionId ) ;
198+ const next = hasCurrentAlready ? [ ...prev , session ] : [ ...prev , currentSession , session ] ;
199+ setActiveColumnIdRaw ( session . sessionId ) ;
200+ return next ;
201+ }
202+ }
203+
204+ // Append to existing split
205+ const next = [ ...prev , session ] ;
206+ setActiveColumnIdRaw ( session . sessionId ) ;
207+ return next ;
208+ } ) ;
209+ } , [ sessionId , sessionTitle , workingDirectory ] ) ;
210+
211+ const removeFromSplit = useCallback ( ( removeId : string ) => {
212+ setSplitSessions ( ( prev ) => {
213+ const next = prev . filter ( ( s ) => s . sessionId !== removeId ) ;
214+ if ( next . length <= 1 ) {
215+ // Exit split mode
216+ if ( next . length === 1 ) {
217+ // Navigate to the remaining session
218+ router . replace ( `/chat/${ next [ 0 ] . sessionId } ` ) ;
219+ }
220+ return [ ] ;
221+ }
222+ // If removing active column, switch to first remaining
223+ setActiveColumnIdRaw ( ( currentActive ) =>
224+ currentActive === removeId ? next [ 0 ] . sessionId : currentActive
225+ ) ;
226+ return next ;
227+ } ) ;
228+ } , [ router ] ) ;
229+
230+ const exitSplit = useCallback ( ( ) => {
231+ const firstSession = splitSessions [ 0 ] ;
232+ setSplitSessions ( [ ] ) ;
233+ setActiveColumnIdRaw ( "" ) ;
234+ if ( firstSession ) {
235+ router . replace ( `/chat/${ firstSession . sessionId } ` ) ;
236+ }
237+ } , [ splitSessions , router ] ) ;
238+
239+ const isInSplit = useCallback ( ( sid : string ) => {
240+ return splitSessions . some ( ( s ) => s . sessionId === sid ) ;
241+ } , [ splitSessions ] ) ;
242+
243+ // Handle delete of a session that's in split
244+ useEffect ( ( ) => {
245+ const handler = ( ) => {
246+ // Re-validate split sessions exist
247+ setSplitSessions ( ( prev ) => {
248+ // We don't remove here; deletion handler in ChatListPanel will call removeFromSplit
249+ return prev ;
250+ } ) ;
251+ } ;
252+ window . addEventListener ( "session-deleted" , handler ) ;
253+ return ( ) => window . removeEventListener ( "session-deleted" , handler ) ;
254+ } , [ ] ) ;
255+
256+ // Exit split when navigating to non-chat routes
257+ useEffect ( ( ) => {
258+ if ( isSplitActive && ! pathname . startsWith ( "/chat" ) ) {
259+ setSplitSessions ( [ ] ) ;
260+ setActiveColumnIdRaw ( "" ) ;
261+ }
262+ } , [ isSplitActive , pathname ] ) ;
263+
264+ const splitContextValue = useMemo (
265+ ( ) => ( {
266+ splitSessions,
267+ activeColumnId,
268+ isSplitActive,
269+ addToSplit,
270+ removeFromSplit,
271+ setActiveColumn,
272+ exitSplit,
273+ isInSplit,
274+ } ) ,
275+ [ splitSessions , activeColumnId , isSplitActive , addToSplit , removeFromSplit , setActiveColumn , exitSplit , isInSplit ]
276+ ) ;
277+
121278 // Warn before closing window/tab while any session is streaming
122279 useEffect ( ( ) => {
123280 if ( activeStreamingSessions . size === 0 ) return ;
@@ -414,6 +571,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
414571 return (
415572 < UpdateContext . Provider value = { updateContextValue } >
416573 < PanelContext . Provider value = { panelContextValue } >
574+ < SplitContext . Provider value = { splitContextValue } >
417575 < ImageGenContext . Provider value = { imageGenValue } >
418576 < BatchImageGenContext . Provider value = { batchImageGenValue } >
419577 < TooltipProvider delayDuration = { 300 } >
@@ -439,7 +597,11 @@ export function AppShell({ children }: { children: React.ReactNode }) {
439597 />
440598 < UpdateBanner />
441599 < main className = "relative flex-1 overflow-hidden" >
442- < ErrorBoundary > { children } </ ErrorBoundary >
600+ { isSplitActive ? (
601+ < SplitChatContainer />
602+ ) : (
603+ < ErrorBoundary > { children } </ ErrorBoundary >
604+ ) }
443605 </ main >
444606 </ div >
445607 { isChatDetailRoute && previewFile && (
@@ -469,6 +631,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
469631 </ TooltipProvider >
470632 </ BatchImageGenContext . Provider >
471633 </ ImageGenContext . Provider >
634+ </ SplitContext . Provider >
472635 </ PanelContext . Provider >
473636 </ UpdateContext . Provider >
474637 ) ;
0 commit comments