fix: 模型切换串台 + 发送消息后滚动不到底部

1. page.tsx — loadSession 加 cancelled 竞态守卫,快速切换会话时
   丢弃过期的 fetch 结果,防止旧会话的 model/provider 覆盖新会话
2. ChatView.tsx — 去掉 if(modelName)/if(providerId) 守卫,改为
   无条件同步 props,空值走 localStorage → default fallback 链
3. ChatListPanel.tsx + types + route.ts + db.ts — 三个创建会话入口
   (handleFolderSelect/handleNewChat/handleCreateSessionInProject)
   统一从 localStorage 读取并传递 model + provider_id,DB 层写入
4. MessageList.tsx — ScrollOnStream 增加 messageCount prop,
   消息数增加时立即 scrollToBottom,覆盖乐观插入和 assistant 落地

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-02 12:01:10 +08:00
1 parent b5e5645702
commit c9f1b62809
7 files changed
+40 -11

No files matched your search

+1
View File
@@ -43,6 +43,7 @@ export async function POST(request: NextRequest) {
body.system_prompt,
body.working_directory,
body.mode,
body.provider_id,
);
const response: SessionResponse = { session };
return Response.json(response, { status: 201 });
+5
View File
@@ -78,11 +78,15 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
// Load session info and set working directory
useEffect(() => {
let cancelled = false;
async function loadSession() {
try {
const res = await fetch(`/api/chat/sessions/${id}`);
if (cancelled) return;
if (res.ok) {
const data: { session: ChatSession } = await res.json();
if (cancelled) return;
if (data.session.working_directory) {
setWorkingDirectory(data.session.working_directory);
setSessionWorkingDir(data.session.working_directory);
@@ -105,6 +109,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
}
loadSession();
return () => { cancelled = true; };
}, [id, setWorkingDirectory, setSessionId, setPanelSessionTitle, setPanelOpen]);
useEffect(() => {
+4 -2
View File
@@ -36,11 +36,13 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const [currentProviderId, setCurrentProviderId] = useState(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
// Sync model/provider when session data loads (props update after async fetch)
// Unconditional: when modelName is empty (old session with no saved model),
// fall back to localStorage or default to avoid stale values from previous session.
useEffect(() => {
if (modelName) setCurrentModel(modelName);
setCurrentModel(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
}, [modelName]);
useEffect(() => {
if (providerId) setCurrentProviderId(providerId);
setCurrentProviderId(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
}, [providerId]);
// Stream snapshot from the manager — drives all streaming UI
+12 -3
View File
@@ -15,12 +15,21 @@ import { StreamingMessage } from './StreamingMessage';
import { CodePilotLogo } from './CodePilotLogo';
/**
* Scrolls to bottom when streaming starts.
* Scrolls to bottom when streaming starts or new messages are appended.
* Must be rendered inside <Conversation> (StickToBottom provider).
*/
function ScrollOnStream({ isStreaming }: { isStreaming: boolean }) {
function ScrollOnStream({ isStreaming, messageCount }: { isStreaming: boolean; messageCount: number }) {
const { scrollToBottom } = useStickToBottomContext();
const wasStreaming = useRef(false);
const prevCount = useRef(messageCount);
// Scroll when new messages are appended (covers optimistic user message + assistant completion)
useEffect(() => {
if (messageCount > prevCount.current) {
scrollToBottom();
}
prevCount.current = messageCount;
}, [messageCount, scrollToBottom]);
useEffect(() => {
if (isStreaming && !wasStreaming.current) {
@@ -116,7 +125,7 @@ export function MessageList({
return (
<Conversation>
<ScrollOnStream isStreaming={isStreaming} />
<ScrollOnStream isStreaming={isStreaming} messageCount={messages.length} />
<ConversationContent className="mx-auto max-w-3xl px-4 py-6 gap-6">
{hasMore && (
<div className="flex justify-center">
+14 -4
View File
@@ -131,12 +131,20 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
const [creatingChat, setCreatingChat] = useState(false);
/** Read current model + provider_id from localStorage for new session creation */
const getCurrentModelAndProvider = useCallback(() => {
const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : '';
const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : '';
return { model, provider_id };
}, []);
const handleFolderSelect = useCallback(async (path: string) => {
try {
const { model, provider_id } = getCurrentModelAndProvider();
const res = await fetch("/api/chat/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ working_directory: path }),
body: JSON.stringify({ working_directory: path, model, provider_id }),
});
if (res.ok) {
const data = await res.json();
@@ -146,7 +154,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
} catch {
// Silently fail
}
}, [router]);
}, [router, getCurrentModelAndProvider]);
const openFolderPicker = useCallback(async (defaultPath?: string) => {
if (isElectron) {
@@ -180,10 +188,11 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
return;
}
const { model, provider_id } = getCurrentModelAndProvider();
const res = await fetch("/api/chat/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ working_directory: lastDir, model: localStorage.getItem('codepilot:last-model') || '' }),
body: JSON.stringify({ working_directory: lastDir, model, provider_id }),
});
if (!res.ok) {
// Backend rejected it (e.g. INVALID_DIRECTORY) — prompt user
@@ -302,10 +311,11 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
) => {
e.stopPropagation();
try {
const { model, provider_id } = getCurrentModelAndProvider();
const res = await fetch("/api/chat/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ working_directory: workingDirectory }),
body: JSON.stringify({ working_directory: workingDirectory, model, provider_id }),
});
if (res.ok) {
const data = await res.json();
+3 -2
View File
@@ -686,6 +686,7 @@ export function createSession(
systemPrompt?: string,
workingDirectory?: string,
mode?: string,
providerId?: string,
): ChatSession {
const db = getDb();
const id = crypto.randomBytes(16).toString('hex');
@@ -694,8 +695,8 @@ export function createSession(
const projectName = path.basename(wd);
db.prepare(
'INSERT INTO chat_sessions (id, title, created_at, updated_at, model, system_prompt, working_directory, sdk_session_id, project_name, status, mode, sdk_cwd) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, title || 'New Chat', now, now, model || '', systemPrompt || '', wd, '', projectName, 'active', mode || 'code', wd);
'INSERT INTO chat_sessions (id, title, created_at, updated_at, model, system_prompt, working_directory, sdk_session_id, project_name, status, mode, sdk_cwd, provider_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, title || 'New Chat', now, now, model || '', systemPrompt || '', wd, '', projectName, 'active', mode || 'code', wd, providerId || '');
return getSession(id)!;
}
+1
View File
@@ -174,6 +174,7 @@ export interface CreateSessionRequest {
system_prompt?: string;
working_directory?: string;
mode?: string;
provider_id?: string;
}
export interface SendMessageRequest {