fix: file tree attachment on Electron + model selection follows session

File tree "+" button (Bug 1):
- Replace fragile fetch/blob attachment bridge with @filepath text insertion
- Works identically on web and Electron (pure text, no /api/files/raw fetch)
- Remove unused mimeFromFilename helper

Model selection follows session (Bug 2):
- page.tsx: include model/provider_id in session creation request
- [id]/page.tsx: add sessionInfoLoaded gate — ChatView only mounts after
  session metadata is fetched, preventing stale localStorage fallback
- SplitColumn.tsx: same sessionInfoLoaded gate for split-pane mode
- ChatView.tsx: useEffect sync only overrides model/provider when props
  are non-empty; empty = genuinely no model saved (old/imported sessions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-11 11:46:34 +08:00
1 parent e5a48ca6af
commit 18a77dc915
5 files changed
+58 -75

No files matched your search

+9 -2
View File
@@ -24,6 +24,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
const [sessionModel, setSessionModel] = useState<string>('');
const [sessionProviderId, setSessionProviderId] = useState<string>('');
const [sessionMode, setSessionMode] = useState<string>('');
const [sessionInfoLoaded, setSessionInfoLoaded] = useState(false);
const [sessionPermissionProfile, setSessionPermissionProfile] = useState<'default' | 'full_access'>('default');
const [projectName, setProjectName] = useState<string>('');
const [sessionWorkingDir, setSessionWorkingDir] = useState<string>('');
@@ -79,8 +80,12 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
// Load session info and set working directory
useEffect(() => {
let cancelled = false;
// Clear stale directory immediately so FileTree doesn't show old project
// Clear stale state immediately so ChatView doesn't inherit previous session's values
setWorkingDirectory('');
setSessionModel('');
setSessionProviderId('');
setSessionMode('');
setSessionInfoLoaded(false);
async function loadSession() {
try {
@@ -108,6 +113,8 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
}
} catch {
// Session info load failed - panel will still work without directory
} finally {
if (!cancelled) setSessionInfoLoaded(true);
}
}
@@ -152,7 +159,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
return () => { cancelled = true; };
}, [id]);
if (loading) {
if (loading || !sessionInfoLoaded) {
return (
<div className="flex h-full items-center justify-center">
<SpinnerGap size={32} className="animate-spin text-muted-foreground" />
+3 -1
View File
@@ -127,12 +127,14 @@ export default function NewChatPage() {
let sessionId = '';
try {
// Create a new session with working directory
// Create a new session with working directory + model/provider
const createBody: Record<string, string> = {
title: content.slice(0, 50),
mode,
working_directory: workingDir.trim(),
permission_profile: permissionProfile,
model: currentModel,
provider_id: currentProviderId,
};
const createRes = await fetch('/api/chat/sessions', {
+16 -7
View File
@@ -48,21 +48,30 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const [loadingMore, setLoadingMore] = useState(false);
const loadingMoreRef = useRef(false);
const [mode, setMode] = useState(initialMode || 'code');
const [currentModel, setCurrentModel] = useState(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
const [currentProviderId, setCurrentProviderId] = useState(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
// Parent components (page.tsx, SplitColumn) guarantee that session metadata is
// loaded before ChatView mounts, so modelName/providerId reflect the session's
// actual values. Fall back to localStorage only when genuinely empty (old/imported
// sessions that never had a model saved).
const [currentModel, setCurrentModel] = useState(() => modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
const [currentProviderId, setCurrentProviderId] = useState(() => providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
// Effort level selected in MessageInput — lifted here so it enters the stream chain
const [selectedEffort, setSelectedEffort] = useState<string | undefined>(undefined);
// Thinking mode from app settings
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
// 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.
// Sync model/provider when session data loads (props update after async fetch).
// Only override when the session actually has a saved model/provider — empty string
// means "not yet loaded" or "old session without model"; in that case keep current
// value (which was properly initialized from localStorage on mount).
useEffect(() => {
setCurrentModel(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
if (modelName) {
setCurrentModel(modelName);
}
}, [modelName]);
useEffect(() => {
setCurrentProviderId(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
if (providerId) {
setCurrentProviderId(providerId);
}
}, [providerId]);
// Fetch thinking mode from app settings
+23 -64
View File
@@ -241,79 +241,21 @@ function AttachFileButton() {
);
}
/**
* Infer a MIME type from a filename extension so that files added from the
* file tree pass the PromptInput accept-type validation. Code / text files
* are mapped to `text/*` subtypes; images and PDFs get their standard types.
* Falls back to `application/octet-stream` for unknown extensions.
*/
function mimeFromFilename(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || '';
const TEXT_EXTS: Record<string, string> = {
md: 'text/markdown', mdx: 'text/markdown',
txt: 'text/plain', csv: 'text/csv',
json: 'application/json',
ts: 'text/typescript', tsx: 'text/typescript',
js: 'text/javascript', jsx: 'text/javascript',
py: 'text/x-python', go: 'text/x-go', rs: 'text/x-rust',
rb: 'text/x-ruby', java: 'text/x-java', c: 'text/x-c',
cpp: 'text/x-c++', h: 'text/x-c', hpp: 'text/x-c++',
cs: 'text/x-csharp', swift: 'text/x-swift', kt: 'text/x-kotlin',
html: 'text/html', css: 'text/css', scss: 'text/css',
xml: 'text/xml', yaml: 'text/yaml', yml: 'text/yaml',
toml: 'text/plain', ini: 'text/plain', cfg: 'text/plain',
sh: 'text/x-shellscript', bash: 'text/x-shellscript', zsh: 'text/x-shellscript',
sql: 'text/x-sql', graphql: 'text/plain', gql: 'text/plain',
vue: 'text/plain', svelte: 'text/plain', astro: 'text/plain',
env: 'text/plain', gitignore: 'text/plain', dockerignore: 'text/plain',
dockerfile: 'text/plain', makefile: 'text/plain',
log: 'text/plain', lock: 'text/plain',
};
const IMAGE_EXTS: Record<string, string> = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
};
if (TEXT_EXTS[ext]) return TEXT_EXTS[ext];
if (IMAGE_EXTS[ext]) return IMAGE_EXTS[ext];
if (ext === 'pdf') return 'application/pdf';
return 'application/octet-stream';
}
/**
* Bridge component that listens for 'attach-file-to-chat' custom events
* from the file tree and adds files as attachments. Must be rendered inside PromptInput.
* from the file tree and inserts `@filepath` into the textarea.
* Works identically on web and Electron (pure text, no fetch/blob).
*/
function FileTreeAttachmentBridge() {
const attachments = usePromptInputAttachments();
const attachmentsRef = useRef(attachments);
useEffect(() => {
attachmentsRef.current = attachments;
}, [attachments]);
useEffect(() => {
const handler = async (e: Event) => {
const handler = (e: Event) => {
const customEvent = e as CustomEvent<{ path: string }>;
const filePath = customEvent.detail?.path;
if (!filePath) return;
try {
const res = await fetch(`/api/files/raw?path=${encodeURIComponent(filePath)}`);
if (!res.ok) {
console.warn(`[FileTreeAttachment] Failed to fetch file: ${res.status} ${res.statusText}`, filePath);
return;
}
const blob = await res.blob();
// Handle both Unix (/) and Windows (\) path separators
const filename = filePath.split(/[/\\]/).pop() || 'file';
// Use a proper MIME type derived from the extension so the file
// passes PromptInput's accept-type validation (text/* etc.)
const mime = mimeFromFilename(filename);
const file = new File([blob], filename, { type: mime });
attachmentsRef.current.add([file]);
} catch (err) {
console.warn('[FileTreeAttachment] Error attaching file:', filePath, err);
}
// Dispatch a second event that the outer MessageInput component listens for
// to insert the @-mention into the textarea
window.dispatchEvent(new CustomEvent('insert-file-mention', { detail: { path: filePath } }));
};
window.addEventListener('attach-file-to-chat', handler);
@@ -417,6 +359,23 @@ export function MessageInput({
}
}, [onAssistantTrigger]);
// Listen for file tree "+" button: insert @filepath into textarea
useEffect(() => {
const handler = (e: Event) => {
const filePath = (e as CustomEvent<{ path: string }>).detail?.path;
if (!filePath) return;
const mention = `@${filePath} `;
setInputValue((prev) => {
// Insert at end, adding a space separator if needed
const needsSpace = prev.length > 0 && !prev.endsWith(' ') && !prev.endsWith('\n');
return prev + (needsSpace ? ' ' : '') + mention;
});
setTimeout(() => textareaRef.current?.focus(), 0);
};
window.addEventListener('insert-file-mention', handler);
return () => window.removeEventListener('insert-file-mention', handler);
}, []);
// Fetch provider groups from API
const fetchProviderModels = useCallback(() => {
fetch('/api/providers/models')
+7 -1
View File
@@ -24,6 +24,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
const [sessionTitle, setSessionTitle] = useState("");
const [sessionModel, setSessionModel] = useState("");
const [sessionProviderId, setSessionProviderId] = useState("");
const [sessionInfoLoaded, setSessionInfoLoaded] = useState(false);
const [sessionMode, setSessionMode] = useState("");
const [projectName, setProjectName] = useState("");
const [sessionWorkingDir, setSessionWorkingDir] = useState("");
@@ -33,6 +34,9 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
// Load session metadata
useEffect(() => {
let cancelled = false;
setSessionInfoLoaded(false);
setSessionModel("");
setSessionProviderId("");
async function loadSession() {
try {
const res = await fetch(`/api/chat/sessions/${sessionId}`);
@@ -48,6 +52,8 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
}
} catch {
// ignore
} finally {
if (!cancelled) setSessionInfoLoaded(true);
}
}
loadSession();
@@ -111,7 +117,7 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
onClose();
}, [onClose]);
if (loading) {
if (loading || !sessionInfoLoaded) {
return (
<div
className={cn(