diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 328ce5b..040b6ad 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -24,6 +24,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) { const [sessionModel, setSessionModel] = useState(''); const [sessionProviderId, setSessionProviderId] = useState(''); const [sessionMode, setSessionMode] = useState(''); + const [sessionInfoLoaded, setSessionInfoLoaded] = useState(false); const [sessionPermissionProfile, setSessionPermissionProfile] = useState<'default' | 'full_access'>('default'); const [projectName, setProjectName] = useState(''); const [sessionWorkingDir, setSessionWorkingDir] = useState(''); @@ -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 (
diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index eeed6fb..886e083 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -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 = { 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', { diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 2f0545e..753b990 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -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(undefined); // Thinking mode from app settings const [thinkingMode, setThinkingMode] = useState('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 diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index 43cd3ca..f21f1ce 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -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 = { - 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 = { - 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') diff --git a/src/components/layout/SplitColumn.tsx b/src/components/layout/SplitColumn.tsx index 33e9dd3..32e6b08 100644 --- a/src/components/layout/SplitColumn.tsx +++ b/src/components/layout/SplitColumn.tsx @@ -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 (