diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 9bda39b..33a3810 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useMemo } from "react"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { TooltipProvider } from "@/components/ui/tooltip"; import { NavRail } from "./NavRail"; import { ChatListPanel } from "./ChatListPanel"; @@ -14,9 +14,39 @@ import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/u import { UpdateContext, type UpdateInfo } from "@/hooks/useUpdate"; import { ImageGenContext, useImageGenState } from "@/hooks/useImageGen"; import { BatchImageGenContext, useBatchImageGenState } from "@/hooks/useBatchImageGen"; +import { SplitContext, type SplitSession } from "@/hooks/useSplit"; +import { SplitChatContainer } from "./SplitChatContainer"; import { ErrorBoundary } from "./ErrorBoundary"; import { getActiveSessionIds, getSnapshot } from "@/lib/stream-session-manager"; +const SPLIT_SESSIONS_KEY = "codepilot:split-sessions"; +const SPLIT_ACTIVE_COLUMN_KEY = "codepilot:split-active-column"; + +function loadSplitSessions(): SplitSession[] { + if (typeof window === "undefined") return []; + try { + const raw = localStorage.getItem(SPLIT_SESSIONS_KEY); + if (raw) return JSON.parse(raw); + } catch { + // ignore + } + return []; +} + +function saveSplitSessions(sessions: SplitSession[]) { + if (sessions.length >= 2) { + localStorage.setItem(SPLIT_SESSIONS_KEY, JSON.stringify(sessions)); + } else { + localStorage.removeItem(SPLIT_SESSIONS_KEY); + localStorage.removeItem(SPLIT_ACTIVE_COLUMN_KEY); + } +} + +function loadActiveColumn(): string { + if (typeof window === "undefined") return ""; + return localStorage.getItem(SPLIT_ACTIVE_COLUMN_KEY) || ""; +} + const EMPTY_SET = new Set(); const CHATLIST_MIN = 180; const CHATLIST_MAX = 400; @@ -40,6 +70,7 @@ const DISMISSED_VERSION_KEY = "codepilot_dismissed_update_version"; export function AppShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const router = useRouter(); const [chatListOpen, setChatListOpenRaw] = useState(false); @@ -75,7 +106,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { // Panel state const isChatRoute = pathname.startsWith("/chat/") || pathname === "/chat"; - const isChatDetailRoute = pathname.startsWith("/chat/"); // Auto-close chat list when leaving chat routes const setChatListOpen = useCallback((open: boolean) => { @@ -118,6 +148,133 @@ export function AppShell({ children }: { children: React.ReactNode }) { return () => window.removeEventListener('stream-session-event', handler); }, []); + // --- Split-screen state --- + const [splitSessions, setSplitSessions] = useState(() => loadSplitSessions()); + const [activeColumnId, setActiveColumnIdRaw] = useState(() => loadActiveColumn()); + const isSplitActive = splitSessions.length >= 2; + const isChatDetailRoute = pathname.startsWith("/chat/") || isSplitActive; + + // Persist split sessions to localStorage + useEffect(() => { + saveSplitSessions(splitSessions); + if (activeColumnId) { + localStorage.setItem(SPLIT_ACTIVE_COLUMN_KEY, activeColumnId); + } + }, [splitSessions, activeColumnId]); + + // URL sync: when activeColumn changes, update router + useEffect(() => { + if (isSplitActive && activeColumnId) { + const target = `/chat/${activeColumnId}`; + if (pathname !== target) { + router.replace(target); + } + } + }, [isSplitActive, activeColumnId, pathname, router]); + + const setActiveColumn = useCallback((sessionId: string) => { + setActiveColumnIdRaw(sessionId); + }, []); + + const addToSplit = useCallback((session: SplitSession) => { + setSplitSessions((prev) => { + // If already in split, don't add again + if (prev.some((s) => s.sessionId === session.sessionId)) return prev; + + if (prev.length < 2) { + // First time entering split: add current active session + new session + // The current session info comes from PanelContext + const currentSessionId = sessionId; + if (currentSessionId && currentSessionId !== session.sessionId) { + const currentSession: SplitSession = { + sessionId: currentSessionId, + title: sessionTitle || "New Conversation", + workingDirectory: workingDirectory || "", + projectName: "", + mode: "code", + }; + // Check if current is already in the list + const hasCurrentAlready = prev.some((s) => s.sessionId === currentSessionId); + const next = hasCurrentAlready ? [...prev, session] : [...prev, currentSession, session]; + setActiveColumnIdRaw(session.sessionId); + return next; + } + } + + // Append to existing split + const next = [...prev, session]; + setActiveColumnIdRaw(session.sessionId); + return next; + }); + }, [sessionId, sessionTitle, workingDirectory]); + + const removeFromSplit = useCallback((removeId: string) => { + setSplitSessions((prev) => { + const next = prev.filter((s) => s.sessionId !== removeId); + if (next.length <= 1) { + // Exit split mode + if (next.length === 1) { + // Navigate to the remaining session + router.replace(`/chat/${next[0].sessionId}`); + } + return []; + } + // If removing active column, switch to first remaining + setActiveColumnIdRaw((currentActive) => + currentActive === removeId ? next[0].sessionId : currentActive + ); + return next; + }); + }, [router]); + + const exitSplit = useCallback(() => { + const firstSession = splitSessions[0]; + setSplitSessions([]); + setActiveColumnIdRaw(""); + if (firstSession) { + router.replace(`/chat/${firstSession.sessionId}`); + } + }, [splitSessions, router]); + + const isInSplit = useCallback((sid: string) => { + return splitSessions.some((s) => s.sessionId === sid); + }, [splitSessions]); + + // Handle delete of a session that's in split + useEffect(() => { + const handler = () => { + // Re-validate split sessions exist + setSplitSessions((prev) => { + // We don't remove here; deletion handler in ChatListPanel will call removeFromSplit + return prev; + }); + }; + window.addEventListener("session-deleted", handler); + return () => window.removeEventListener("session-deleted", handler); + }, []); + + // Exit split when navigating to non-chat routes + useEffect(() => { + if (isSplitActive && !pathname.startsWith("/chat")) { + setSplitSessions([]); + setActiveColumnIdRaw(""); + } + }, [isSplitActive, pathname]); + + const splitContextValue = useMemo( + () => ({ + splitSessions, + activeColumnId, + isSplitActive, + addToSplit, + removeFromSplit, + setActiveColumn, + exitSplit, + isInSplit, + }), + [splitSessions, activeColumnId, isSplitActive, addToSplit, removeFromSplit, setActiveColumn, exitSplit, isInSplit] + ); + // Warn before closing window/tab while any session is streaming useEffect(() => { if (activeStreamingSessions.size === 0) return; @@ -414,6 +571,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { return ( + @@ -439,7 +597,11 @@ export function AppShell({ children }: { children: React.ReactNode }) { />
- {children} + {isSplitActive ? ( + + ) : ( + {children} + )}
{isChatDetailRoute && previewFile && ( @@ -469,6 +631,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
+
); diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 4b5a18e..3246f46 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -15,6 +15,7 @@ import { PlusSignIcon, FolderOpenIcon, } from "@hugeicons/core-free-icons"; +import { Columns2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -25,6 +26,7 @@ import { } from "@/components/ui/tooltip"; import { cn, parseDBDate } from "@/lib/utils"; import { usePanel } from "@/hooks/usePanel"; +import { useSplit } from "@/hooks/useSplit"; import { useTranslation } from "@/hooks/useTranslation"; import { useNativeFolderPicker } from "@/hooks/useNativeFolderPicker"; import { ConnectionStatus } from "./ConnectionStatus"; @@ -110,15 +112,11 @@ function groupSessionsByProject(sessions: ChatSession[]): ProjectGroup[] { return groups; } -const MODE_BADGE_CONFIG = { - code: { label: "Code", className: "bg-blue-500/10 text-blue-500" }, - plan: { label: "Plan", className: "bg-sky-500/10 text-sky-500" }, -} as const; - export function ChatListPanel({ open, width }: ChatListPanelProps) { const pathname = usePathname(); const router = useRouter(); - const { streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, workingDirectory } = usePanel(); + const { streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, workingDirectory, sessionId: currentSessionId } = usePanel(); + const { splitSessions, isSplitActive, activeColumnId, addToSplit, removeFromSplit, setActiveColumn, isInSplit } = useSplit(); const { t } = useTranslation(); const { isElectron, openNativePicker } = useNativeFolderPicker(); const [sessions, setSessions] = useState([]); @@ -275,6 +273,10 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { }); if (res.ok) { setSessions((prev) => prev.filter((s) => s.id !== sessionId)); + // Remove from split if it's there + if (isInSplit(sessionId)) { + removeFromSplit(sessionId); + } if (pathname === `/chat/${sessionId}`) { router.push("/chat"); } @@ -309,14 +311,27 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { const isSearching = searchQuery.length > 0; - const filteredSessions = searchQuery - ? sessions.filter( + const splitSessionIds = useMemo( + () => new Set(splitSessions.map((s) => s.sessionId)), + [splitSessions] + ); + + const filteredSessions = useMemo(() => { + let result = sessions; + if (searchQuery) { + result = result.filter( (s) => s.title.toLowerCase().includes(searchQuery.toLowerCase()) || (s.project_name && s.project_name.toLowerCase().includes(searchQuery.toLowerCase())) - ) - : sessions; + ); + } + // Exclude sessions in split group (they are shown in the split section) + if (isSplitActive) { + result = result.filter((s) => !splitSessionIds.has(s.id)); + } + return result; + }, [sessions, searchQuery, isSplitActive, splitSessionIds]); const projectGroups = useMemo( () => groupSessionsByProject(filteredSessions), @@ -410,7 +425,71 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { {/* Session list grouped by project */}
- {filteredSessions.length === 0 ? ( + {/* Split group section */} + {isSplitActive && ( +
+
+ + {t('split.splitGroup')} +
+
+ {splitSessions.map((session) => { + const isActiveInSplit = activeColumnId === session.sessionId; + const isSessionStreaming = + activeStreamingSessions.has(session.sessionId) || streamingSessionId === session.sessionId; + const needsApproval = + pendingApprovalSessionIds.has(session.sessionId) || pendingApprovalSessionId === session.sessionId; + + return ( +
{ + e.preventDefault(); + setActiveColumn(session.sessionId); + }} + > + {isSessionStreaming && ( + + + + + )} + {needsApproval && ( + + + + )} +
+ + {session.title} + +
+ +
+ ); + })} +
+
+ )} + + {filteredSessions.length === 0 && (!isSplitActive || splitSessions.length === 0) ? (

{searchQuery ? "No matching threads" : t('chatList.noSessions')}

@@ -514,8 +593,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { activeStreamingSessions.has(session.id) || streamingSessionId === session.id; const needsApproval = pendingApprovalSessionIds.has(session.id) || pendingApprovalSessionId === session.id; - const mode = session.mode || "code"; - const badgeCfg = MODE_BADGE_CONFIG[mode as keyof typeof MODE_BADGE_CONFIG] || MODE_BADGE_CONFIG.code; + const canSplit = !isActive && !isInSplit(session.id); return (
- {/* Streaming pulse indicator */} - {isSessionStreaming && ( - - - - - )} - {/* Approval indicator */} - {needsApproval && ( - - - - )} + {/* Left icon area — always same size, swap content via opacity */} + + {/* Split icon: visible on hover when splittable */} + {canSplit && ( + + )} + {/* Streaming indicator: hidden when hover shows split icon */} + {isSessionStreaming && ( + + + + + )} + {/* Approval indicator: hidden when hover shows split icon */} + {needsApproval && !isSessionStreaming && ( + + + + )} +
{session.title}
-
- {/* Mode badge */} - - {badgeCfg.label} - - + {/* Right area — fixed width, time and delete stacked with opacity */} +
+ {formatRelativeTime(session.updated_at, t)} +
- {(isHovered || isDeleting) && ( - - - - - - {t('chatList.delete')} - - - )}
); })} diff --git a/src/components/layout/SplitChatContainer.tsx b/src/components/layout/SplitChatContainer.tsx new file mode 100644 index 0000000..f59a736 --- /dev/null +++ b/src/components/layout/SplitChatContainer.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useSplit } from "@/hooks/useSplit"; +import { SplitColumn } from "./SplitColumn"; + +export function SplitChatContainer() { + const { splitSessions, activeColumnId, removeFromSplit, setActiveColumn } = useSplit(); + + return ( +
+ {splitSessions.map((session, index) => ( +
+ {index > 0 && ( +
+ )} + removeFromSplit(session.sessionId)} + onFocus={() => setActiveColumn(session.sessionId)} + /> +
+ ))} +
+ ); +} diff --git a/src/components/layout/SplitColumn.tsx b/src/components/layout/SplitColumn.tsx new file mode 100644 index 0000000..3df686f --- /dev/null +++ b/src/components/layout/SplitColumn.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { X } from "lucide-react"; +import type { Message, MessagesResponse, ChatSession } from "@/types"; +import { ChatView } from "@/components/chat/ChatView"; +import { Button } from "@/components/ui/button"; +import { usePanel } from "@/hooks/usePanel"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "@/hooks/useTranslation"; + +interface SplitColumnProps { + sessionId: string; + isActive: boolean; + onClose: () => void; + onFocus: () => void; +} + +export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColumnProps) { + const [messages, setMessages] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sessionTitle, setSessionTitle] = useState(""); + const [sessionModel, setSessionModel] = useState(""); + const [sessionProviderId, setSessionProviderId] = useState(""); + const [sessionMode, setSessionMode] = useState(""); + const [projectName, setProjectName] = useState(""); + const [sessionWorkingDir, setSessionWorkingDir] = useState(""); + const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setPanelOpen } = usePanel(); + const { t } = useTranslation(); + + // Load session metadata + useEffect(() => { + let cancelled = false; + async function loadSession() { + try { + const res = await fetch(`/api/chat/sessions/${sessionId}`); + if (cancelled) return; + if (res.ok) { + const data: { session: ChatSession } = await res.json(); + setSessionTitle(data.session.title || t("chat.newConversation")); + setSessionModel(data.session.model || ""); + setSessionProviderId(data.session.provider_id || ""); + setSessionMode(data.session.mode || "code"); + setProjectName(data.session.project_name || ""); + setSessionWorkingDir(data.session.working_directory || ""); + } + } catch { + // ignore + } + } + loadSession(); + return () => { cancelled = true; }; + }, [sessionId, t]); + + // Load messages + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + setMessages([]); + setHasMore(false); + + async function loadMessages() { + try { + const res = await fetch(`/api/chat/sessions/${sessionId}/messages?limit=30`); + if (cancelled) return; + if (!res.ok) { + if (res.status === 404) { + setError("Session not found"); + return; + } + throw new Error("Failed to load messages"); + } + const data: MessagesResponse = await res.json(); + if (cancelled) return; + setMessages(data.messages); + setHasMore(data.hasMore ?? false); + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : "Failed to load messages"); + } finally { + if (!cancelled) setLoading(false); + } + } + loadMessages(); + return () => { cancelled = true; }; + }, [sessionId]); + + // When this column becomes active, sync PanelContext + useEffect(() => { + if (!isActive) return; + if (sessionWorkingDir) { + setWorkingDirectory(sessionWorkingDir); + localStorage.setItem("codepilot:last-working-directory", sessionWorkingDir); + window.dispatchEvent(new Event("refresh-file-tree")); + } + setSessionId(sessionId); + setPanelOpen(true); + if (sessionTitle) { + setPanelSessionTitle(sessionTitle); + } + }, [isActive, sessionId, sessionWorkingDir, sessionTitle, setWorkingDirectory, setSessionId, setPanelSessionTitle, setPanelOpen]); + + const handleClose = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onClose(); + }, [onClose]); + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+
+
+ ); + } + + return ( +
+ {/* Compact title bar */} +
+
+ {projectName && ( + <> + {projectName} + / + + )} + {sessionTitle} +
+ +
+ {/* ChatView */} +
+ +
+
+ ); +} diff --git a/src/hooks/useSplit.ts b/src/hooks/useSplit.ts new file mode 100644 index 0000000..87d6974 --- /dev/null +++ b/src/hooks/useSplit.ts @@ -0,0 +1,32 @@ +"use client"; + +import { createContext, useContext } from "react"; + +export interface SplitSession { + sessionId: string; + title: string; + workingDirectory: string; + projectName: string; + mode?: string; +} + +export interface SplitContextValue { + splitSessions: SplitSession[]; + activeColumnId: string; + isSplitActive: boolean; + addToSplit: (session: SplitSession) => void; + removeFromSplit: (sessionId: string) => void; + setActiveColumn: (sessionId: string) => void; + exitSplit: () => void; + isInSplit: (sessionId: string) => boolean; +} + +export const SplitContext = createContext(null); + +export function useSplit(): SplitContextValue { + const ctx = useContext(SplitContext); + if (!ctx) { + throw new Error("useSplit must be used within a SplitProvider"); + } + return ctx; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1e96bc9..02257fb 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -456,6 +456,12 @@ const en = { 'cli.field.verbose': 'Verbose', 'cli.field.theme': 'Theme', 'cli.formatError': 'Cannot format: invalid JSON', + + // ── Split screen ───────────────────────────────────────────── + 'split.splitScreen': 'Split Screen', + 'split.closeSplit': 'Close Split', + 'split.splitGroup': 'Split', + 'chatList.splitScreen': 'Split Screen', } as const; export type TranslationKey = keyof typeof en; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 18c3bbf..3b32b28 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -453,6 +453,12 @@ const zh: Record = { 'cli.field.verbose': '详细日志', 'cli.field.theme': '主题', 'cli.formatError': '无法格式化:无效的 JSON', + + // ── Split screen ───────────────────────────────────────────── + 'split.splitScreen': '分屏', + 'split.closeSplit': '关闭分屏', + 'split.splitGroup': '分屏', + 'chatList.splitScreen': '分屏', }; export default zh;