Skip to content

Commit 4f4ba5f

Browse files
7418claude
andcommitted
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>
1 parent c4d03c4 commit 4f4ba5f

File tree

7 files changed

+573
-69
lines changed

7 files changed

+573
-69
lines changed

src/components/layout/AppShell.tsx

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useState, useEffect, useCallback, useMemo } from "react";
4-
import { usePathname } from "next/navigation";
4+
import { usePathname, useRouter } from "next/navigation";
55
import { TooltipProvider } from "@/components/ui/tooltip";
66
import { NavRail } from "./NavRail";
77
import { ChatListPanel } from "./ChatListPanel";
@@ -14,9 +14,39 @@ import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/u
1414
import { UpdateContext, type UpdateInfo } from "@/hooks/useUpdate";
1515
import { ImageGenContext, useImageGenState } from "@/hooks/useImageGen";
1616
import { BatchImageGenContext, useBatchImageGenState } from "@/hooks/useBatchImageGen";
17+
import { SplitContext, type SplitSession } from "@/hooks/useSplit";
18+
import { SplitChatContainer } from "./SplitChatContainer";
1719
import { ErrorBoundary } from "./ErrorBoundary";
1820
import { 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+
2050
const EMPTY_SET = new Set<string>();
2151
const CHATLIST_MIN = 180;
2252
const CHATLIST_MAX = 400;
@@ -40,6 +70,7 @@ const DISMISSED_VERSION_KEY = "codepilot_dismissed_update_version";
4070

4171
export 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

Comments
 (0)