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:
1 parent
c4d03c46e6
commit
4f4ba5f8eb
7 files changed
+574
-70
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user