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:
1 parent
e5a48ca6af
commit
18a77dc915
5 files changed
+58
-75
No files matched your search
@@ -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" />
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user