diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 50d89864..1f6c0ff7 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useParams } from 'react-router'; import type { LayoutNode, SelectedSession } from 'officerdev'; import { WorkspaceView } from 'officerdev'; @@ -9,6 +9,25 @@ import { defaultLayout } from './defaultLayout'; export { ChatHistory as ChatHistoryApp } from './Widget'; +// Allowed panel app types for the /chat screen +const ALLOWED_APP_TYPES = new Set(['chat-session-list', 'chat-detail', null]); + +/** Recursively fix any panel that uses a wrong app type (e.g. officerdev/chat) */ +function normalizeLayout(node: LayoutNode): LayoutNode { + if (node.type === 'panel') { + if (!ALLOWED_APP_TYPES.has(node.appType)) { + return { ...node, appType: 'chat-detail' }; + } + return node; + } + const children = node.children.map((c) => { + const fixed = normalizeLayout(c.node); + return fixed === c.node ? c : { ...c, node: fixed }; + }); + const changed = children.some((c, i) => c !== node.children[i]); + return changed ? { ...node, children } : node; +} + type SessionListPageProps = { isNew?: boolean; }; @@ -17,7 +36,21 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { const { sessionId } = useParams<{ sessionId: string }>(); const { sessions } = useChatSessions(); const [, setSelected] = usePanelChannel('chat:selected-session', null); - const workspace = useWorkspacesState('screens/chat', defaultLayout); + const rawWorkspace = useWorkspacesState('screens/chat', defaultLayout); + + // Normalize synchronously so the wrong panel never renders + const workspace = useMemo(() => { + const fixed = normalizeLayout(rawWorkspace.value); + if (fixed === rawWorkspace.value) return rawWorkspace; + return { ...rawWorkspace, value: fixed }; + }, [rawWorkspace]); + + // Persist the fix to the backend + useEffect(() => { + if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { + rawWorkspace.setValue(workspace.value); + } + }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); useEffect(() => { if (isNew) { diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx new file mode 100644 index 00000000..cded3749 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx @@ -0,0 +1,116 @@ +import { useState, useEffect, useMemo } from 'react'; +import { toast } from 'sonner'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useSettings } from 'state/useSettings'; +import { useVisiblePiModels, getProviderDisplayName, type ModelOption } from 'state/useModels'; + +function buildGroups(models: ModelOption[]) { + const groups: Record = {}; + for (const m of models) { + const provider = m.provider ?? 'Other'; + if (!groups[provider]) groups[provider] = []; + groups[provider]!.push({ id: m.id, name: m.name }); + } + return Object.entries(groups) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) })); +} + +const NONE = '__none__'; + +export const AIModels = () => { + const { settings, saveSettings } = useSettings(); + const piModels = useVisiblePiModels(); + const [isSaving, setIsSaving] = useState(false); + + const [chatModel, setChatModel] = useState(settings.chat.defaultModel); + const [projectModel, setProjectModel] = useState(settings.chat.defaultProjectModel); + const [taskModel, setTaskModel] = useState(settings.tasks.defaultModel); + + useEffect(() => { + setChatModel(settings.chat.defaultModel); + setProjectModel(settings.chat.defaultProjectModel); + setTaskModel(settings.tasks.defaultModel); + }, [settings]); + + const groups = useMemo(() => buildGroups(piModels), [piModels]); + + const handleSave = async () => { + if (isSaving) return; + setIsSaving(true); + try { + await saveSettings({ + ...settings, + chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel }, + tasks: { ...settings.tasks, defaultModel: taskModel }, + }); + toast.success('AI model defaults saved'); + } catch { + toast.error('Failed to save settings'); + } finally { + setIsSaving(false); + } + }; + + const renderModelSelect = (value: string | null, onChange: (v: string | null) => void, placeholder: string) => ( + + ); + + return ( +
+ + + + + + + +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx index dfa3f868..ede5605b 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { User, Lock, Globe, ListChecks } from 'lucide-react'; +import { User, Lock, Globe, Bot } from 'lucide-react'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { WorkspaceLayout } from 'officerdev'; @@ -7,14 +7,14 @@ import { createSettingsPanelComponents, type SettingsSection } from '../Settings import { UserData } from './UserData'; import { ChangePassword } from './ChangePassword'; import { Languages } from './Languages'; -import { TaskDefaults } from './TaskDefaults'; +import { AIModels } from './AIModels'; const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED'; const sections: SettingsSection[] = [ { key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: }, { key: 'change-password', icon: Lock, title: 'Change Password', description: 'Update your password', content: }, - { key: 'tasks', icon: ListChecks, title: 'Task Defaults', description: 'Default model for tasks', content: }, + { key: 'ai-models', icon: Bot, title: 'AI Models', description: 'Default models for chat, projects, and tasks', content: }, { key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: }, ]; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index 7345b198..94a5228f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -201,7 +201,7 @@ function ChatDefaultsSection() { try { const updated: UserSettings = { ...settings, - chat: { defaultProvider: 'pi', defaultModel: model, systemPrompt, temperature, defaultPwd }, + chat: { ...settings.chat, defaultModel: model, systemPrompt, temperature, defaultPwd }, }; await saveSettings(updated); toast.success('Chat defaults saved'); diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index 9766e08e..011f884f 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -23,7 +23,7 @@ export const ChatPanelWrapper = () => { const sessionId = selection?.sessionId ?? undefined; const model = selection?.model ?? undefined; - const chat = usePiChat(sessionId, model, { replaceUrl: false }); + const chat = usePiChat(sessionId, model, { replaceUrl: false, projectScoped: scoped }); useEffect(() => { setActiveSession(chat.sessionId); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 0669206d..b3b95ad6 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useLocation } from 'react-router'; +import { useLocation, useNavigate } from 'react-router'; import { Trash2, Home, Monitor } from 'lucide-react'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { useAuth } from 'hooks/useAuth'; @@ -66,6 +66,7 @@ type SessionChatProps = { }; function SessionChat({ sessionId, model }: SessionChatProps) { + const navigate = useNavigate(); const { sessions, deleteSession } = useChatSessions(); const [, setSelected] = usePanelChannel(CHANNEL, null); const sessionTitle = sessions.find((s) => s.id === sessionId)?.title; @@ -81,7 +82,7 @@ function SessionChat({ sessionId, model }: SessionChatProps) { onDelete={async () => { await deleteSession(sessionId); setSelected(null); - window.history.replaceState(null, '', '/chat'); + navigate('/chat', { replace: true }); }} /> { + const navigate = useNavigate(); const { sessions, deleteSession } = useChatSessions(); const { groups } = useChatGroups(); const [selected, setSelected] = usePanelChannel('chat:selected-session', null); @@ -47,13 +49,13 @@ export const SessionList = () => { const handleSelect = (session: (typeof sessions)[number]) => { setSelected({ id: session.id, model: session.model ?? null }); - window.history.replaceState(null, '', `/chat/${session.id}`); + navigate(`/chat/${session.id}`, { replace: true }); }; const handleDelete = async (id: string) => { if (selected?.id === id) { setSelected(null); - window.history.replaceState(null, '', '/chat'); + navigate('/chat', { replace: true }); } await deleteSession(id); }; @@ -133,7 +135,7 @@ export const SessionList = () => {