feat: 聊天分屏功能 — 支持多会话并排查看

新增聊天分屏功能,允许同时查看和操作多个聊天会话:

新增文件:
- src/hooks/useSplit.ts: SplitContext 和 useSplit hook,管理分屏会话列表、
  活跃列、添加/移除/退出分屏等操作,状态通过 localStorage 持久化
- src/components/layout/SplitColumn.tsx: 单个分屏列组件,独立加载会话元数据
  和消息,渲染紧凑标题栏 + ChatView,活跃列蓝色描边并同步 PanelContext
- src/components/layout/SplitChatContainer.tsx: 分屏容器,并排渲染多个
  SplitColumn,列间以 1px 分隔线分隔

修改文件:
- AppShell.tsx: 新增分屏状态管理(splitSessions/activeColumnId),包裹
  SplitContext.Provider,分屏激活时渲染 SplitChatContainer 替代路由页面,
  URL 与活跃列同步,离开聊天路由自动退出分屏
- ChatListPanel.tsx: 聊天卡片 hover 交互重构 — 移除模式徽章(Code/Plan),
  左侧 indent 区域 hover 显示分屏图标(点击将该会话加入分屏),右侧时间区域
  hover 切换为删除图标;分屏激活时列表顶部显示灰色描边分屏组卡片,分屏中的
  会话从常规列表过滤掉;所有元素始终存在于 DOM 中通过 opacity 切换,避免
  hover 时布局跳动
- en.ts / zh.ts: 新增 split.splitScreen、split.closeSplit、
  split.splitGroup、chatList.splitScreen 翻译

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-28 01:13:19 +08:00
1 parent c4d03c46e6
commit 4f4ba5f8eb
7 files changed
+574 -70

No files matched your search

+166 -3
View File
@@ -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<string>();
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<SplitSession[]>(() => loadSplitSessions());
const [activeColumnId, setActiveColumnIdRaw] = useState<string>(() => 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 (
<UpdateContext.Provider value={updateContextValue}>
<PanelContext.Provider value={panelContextValue}>
<SplitContext.Provider value={splitContextValue}>
<ImageGenContext.Provider value={imageGenValue}>
<BatchImageGenContext.Provider value={batchImageGenValue}>
<TooltipProvider delayDuration={300}>
@@ -439,7 +597,11 @@ export function AppShell({ children }: { children: React.ReactNode }) {
/>
<UpdateBanner />
<main className="relative flex-1 overflow-hidden">
<ErrorBoundary>{children}</ErrorBoundary>
{isSplitActive ? (
<SplitChatContainer />
) : (
<ErrorBoundary>{children}</ErrorBoundary>
)}
</main>
</div>
{isChatDetailRoute && previewFile && (
@@ -469,6 +631,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</TooltipProvider>
</BatchImageGenContext.Provider>
</ImageGenContext.Provider>
</SplitContext.Provider>
</PanelContext.Provider>
</UpdateContext.Provider>
);
+152 -67
View File
@@ -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<ChatSession[]>([]);
@@ -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 */}
<ScrollArea className="flex-1 min-h-0 px-3">
<div className="flex flex-col pb-3">
{filteredSessions.length === 0 ? (
{/* Split group section */}
{isSplitActive && (
<div className="mb-2 rounded-lg border border-border/60 bg-muted/30 p-1.5">
<div className="flex items-center gap-1.5 px-2 py-1">
<Columns2 className="h-3 w-3 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground">{t('split.splitGroup')}</span>
</div>
<div className="mt-0.5 flex flex-col gap-0.5">
{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 (
<div
key={session.sessionId}
className={cn(
"group relative flex items-center gap-1.5 rounded-md pl-7 pr-2 py-1.5 transition-all duration-150 min-w-0 cursor-pointer",
isActiveInSplit
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-accent/50"
)}
onClick={(e) => {
e.preventDefault();
setActiveColumn(session.sessionId);
}}
>
{isSessionStreaming && (
<span className="relative flex h-2 w-2 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
</span>
)}
{needsApproval && (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<HugeiconsIcon icon={Notification02Icon} className="h-2.5 w-2.5 text-amber-500" />
</span>
)}
<div className="flex-1 min-w-0">
<span className="line-clamp-1 text-[12px] font-medium leading-tight break-all">
{session.title}
</span>
</div>
<Button
variant="ghost"
size="icon-xs"
className="h-4 w-4 shrink-0 text-muted-foreground/60 hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
removeFromSplit(session.sessionId);
}}
>
<X className="h-2.5 w-2.5" />
<span className="sr-only">{t('split.closeSplit')}</span>
</Button>
</div>
);
})}
</div>
</div>
)}
{filteredSessions.length === 0 && (!isSplitActive || splitSessions.length === 0) ? (
<p className="px-2.5 py-3 text-[11px] text-muted-foreground/60">
{searchQuery ? "No matching threads" : t('chatList.noSessions')}
</p>
@@ -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 (
<div
@@ -529,74 +607,81 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
<Link
href={`/chat/${session.id}`}
className={cn(
"flex items-center gap-1.5 rounded-md pl-7 pr-2 py-1.5 transition-all duration-150 min-w-0",
"flex items-center gap-1.5 rounded-md pl-2 pr-2 py-1.5 transition-all duration-150 min-w-0",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-accent/50"
)}
>
{/* Streaming pulse indicator */}
{isSessionStreaming && (
<span className="relative flex h-2 w-2 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
</span>
)}
{/* Approval indicator */}
{needsApproval && (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
<HugeiconsIcon
icon={Notification02Icon}
className="h-2.5 w-2.5 text-amber-500"
/>
</span>
)}
{/* Left icon area — always same size, swap content via opacity */}
<span className="relative flex h-3.5 w-3.5 shrink-0 items-center justify-center">
{/* Split icon: visible on hover when splittable */}
{canSplit && (
<button
className={cn(
"absolute inset-0 flex items-center justify-center text-muted-foreground hover:text-foreground transition-opacity",
isHovered ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
addToSplit({
sessionId: session.id,
title: session.title,
workingDirectory: session.working_directory || "",
projectName: session.project_name || "",
mode: session.mode,
});
}}
>
<Columns2 className="h-3 w-3" />
</button>
)}
{/* Streaming indicator: hidden when hover shows split icon */}
{isSessionStreaming && (
<span className={cn(
"relative flex h-2 w-2 transition-opacity",
isHovered && canSplit ? "opacity-0" : "opacity-100"
)}>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
</span>
)}
{/* Approval indicator: hidden when hover shows split icon */}
{needsApproval && !isSessionStreaming && (
<span className={cn(
"flex h-3.5 w-3.5 items-center justify-center rounded-full bg-amber-500/10 transition-opacity",
isHovered && canSplit ? "opacity-0" : "opacity-100"
)}>
<HugeiconsIcon icon={Notification02Icon} className="h-2.5 w-2.5 text-amber-500" />
</span>
)}
</span>
<div className="flex-1 min-w-0">
<span className="line-clamp-1 text-[12px] font-medium leading-tight break-all">
{session.title}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Mode badge */}
<span
className={cn(
"text-[9px] px-1 py-0.5 rounded font-medium leading-none",
badgeCfg.className
)}
>
{badgeCfg.label}
</span>
<span className="text-[10px] text-muted-foreground/40">
{/* Right area — fixed width, time and delete stacked with opacity */}
<div className="relative w-[38px] h-4 shrink-0">
<span className={cn(
"absolute inset-0 flex items-center justify-end text-[10px] text-muted-foreground/40 truncate transition-opacity",
(isHovered || isDeleting) ? "opacity-0" : "opacity-100"
)}>
{formatRelativeTime(session.updated_at, t)}
</span>
<button
className={cn(
"absolute inset-0 flex items-center justify-end text-muted-foreground/60 hover:text-destructive transition-opacity",
(isHovered || isDeleting) ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={(e) => handleDeleteSession(e, session.id)}
disabled={isDeleting}
>
<HugeiconsIcon icon={Delete02Icon} className="h-3 w-3" />
</button>
</div>
</Link>
{(isHovered || isDeleting) && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="absolute right-1 top-1 bg-sidebar text-muted-foreground/60 hover:text-destructive"
onClick={(e) =>
handleDeleteSession(e, session.id)
}
disabled={isDeleting}
>
<HugeiconsIcon
icon={Delete02Icon}
className="h-3 w-3"
/>
<span className="sr-only">
{t('chatList.delete')}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('chatList.delete')}
</TooltipContent>
</Tooltip>
)}
</div>
);
})}
@@ -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 (
<div className="flex h-full gap-0">
{splitSessions.map((session, index) => (
<div key={session.sessionId} className="contents">
{index > 0 && (
<div className="w-px bg-border shrink-0" />
)}
<SplitColumn
sessionId={session.sessionId}
isActive={activeColumnId === session.sessionId}
onClose={() => removeFromSplit(session.sessionId)}
onFocus={() => setActiveColumn(session.sessionId)}
/>
</div>
))}
</div>
);
}
+186
View File
@@ -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<Message[]>([]);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div
className={cn(
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
isActive ? "border-blue-500" : "border-transparent"
)}
onClick={onFocus}
>
<div className="flex h-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
</div>
);
}
if (error) {
return (
<div
className={cn(
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
isActive ? "border-blue-500" : "border-transparent"
)}
onClick={onFocus}
>
<div className="flex h-full items-center justify-center">
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
);
}
return (
<div
className={cn(
"flex flex-1 min-w-0 flex-col overflow-hidden rounded-md border-2 transition-colors",
isActive ? "border-blue-500" : "border-transparent"
)}
onClick={onFocus}
>
{/* Compact title bar */}
<div className="flex h-9 shrink-0 items-center justify-between px-3 border-b bg-muted/30">
<div className="flex items-center gap-1.5 min-w-0">
{projectName && (
<>
<span className="text-[11px] text-muted-foreground shrink-0">{projectName}</span>
<span className="text-[11px] text-muted-foreground shrink-0">/</span>
</>
)}
<span className="text-[11px] font-medium truncate">{sessionTitle}</span>
</div>
<Button
variant="ghost"
size="icon-xs"
className="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground"
onClick={handleClose}
>
<X className="h-3 w-3" />
<span className="sr-only">{t("split.closeSplit")}</span>
</Button>
</div>
{/* ChatView */}
<div className="flex-1 min-h-0 overflow-hidden">
<ChatView
key={sessionId}
sessionId={sessionId}
initialMessages={messages}
initialHasMore={hasMore}
modelName={sessionModel}
initialMode={sessionMode}
providerId={sessionProviderId}
/>
</div>
</div>
);
}
+32
View File
@@ -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<SplitContextValue | null>(null);
export function useSplit(): SplitContextValue {
const ctx = useContext(SplitContext);
if (!ctx) {
throw new Error("useSplit must be used within a SplitProvider");
}
return ctx;
}
+6
View File
@@ -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;
+6
View File
@@ -453,6 +453,12 @@ const zh: Record<TranslationKey, string> = {
'cli.field.verbose': '详细日志',
'cli.field.theme': '主题',
'cli.formatError': '无法格式化:无效的 JSON',
// ── Split screen ─────────────────────────────────────────────
'split.splitScreen': '分屏',
'split.closeSplit': '关闭分屏',
'split.splitGroup': '分屏',
'chatList.splitScreen': '分屏',
};
export default zh;