diff --git a/package.json b/package.json index b86b2bc697..6c045b72d9 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "clsx": "^2.1.1", "lucide-react": "^0.562.0", "react-markdown": "^10.1.0", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "zustand": "^5.0.10" }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7736c156e1..741ea3025e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: tailwind-merge: specifier: ^3.4.0 version: 3.4.0 + zustand: + specifier: ^5.0.10 + version: 5.0.10(@types/react@19.2.8)(react@19.2.3) devDependencies: '@electron-toolkit/eslint-config-prettier': specifier: ^3.0.0 @@ -3735,6 +3738,24 @@ packages: zod@4.3.5: resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zustand@5.0.10: + resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -7793,4 +7814,9 @@ snapshots: zod@4.3.5: {} + zustand@5.0.10(@types/react@19.2.8)(react@19.2.3): + optionalDependencies: + '@types/react': 19.2.8 + react: 19.2.3 + zwitch@2.0.4: {} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 97e33513a7..0b80e5d507 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,21 +1,14 @@ /** * Main App component */ -import { useState, useEffect } from 'react' +import { useEffect } from 'react' import { useApp } from './hooks/useApp' -import { ChatView, MessageInput, StatusBar, Settings } from './components' +import { ChatView, MessageInput, StatusBar } from './components' import { AppSidebar } from './components/AppSidebar' +import { Modals } from './components/Modals' import { ThemeProvider } from './contexts/ThemeContext' import { SidebarProvider } from '@/components/ui/sidebar' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' +import { useModalStore } from './stores/modalStore' function AppContent(): React.JSX.Element { const { @@ -39,37 +32,25 @@ function AppContent(): React.JSX.Element { clearError, } = useApp() - // New session dialog state - const [showNewSession, setShowNewSession] = useState(false) - const [newSessionCwd, setNewSessionCwd] = useState('') - - // Settings dialog state - const [showSettings, setShowSettings] = useState(false) + const openModal = useModalStore((s) => s.openModal) // Auto-show new session dialog when agent is running but no session useEffect(() => { if (agentStatus.state === 'running' && !currentSession && sessions.length === 0) { - setNewSessionCwd('') - setShowNewSession(true) + openModal('newSession') } - }, [agentStatus.state, currentSession, sessions.length]) + }, [agentStatus.state, currentSession, sessions.length, openModal]) const handleNewSession = () => { - setNewSessionCwd('') - setShowNewSession(true) + openModal('newSession') } - const handleCreateSession = async () => { - if (!newSessionCwd.trim()) return - + const handleCreateSession = async (cwd: string) => { // Ensure agent is running if (agentStatus.state !== 'running') { await startAgent('opencode') } - - await createSession(newSessionCwd.trim()) - setShowNewSession(false) - setNewSessionCwd('') + await createSession(cwd) } const handleSelectSession = async (sessionId: string) => { @@ -84,7 +65,7 @@ function AppContent(): React.JSX.Element { } return ( -
Recent
+ {/* Recent label */} +Recent
- {/* Session list */} -+
{hasSession ? 'Start a conversation with your coding agent' : 'Create a session to start chatting'} @@ -54,7 +54,7 @@ export function ChatView({ updates, isProcessing, hasSession, onNewSession }: Ch ))} {isProcessing && ( -
+
{children}
)
}
return (
-
+
{children}
)
},
pre: ({ children }) => (
-
+
{children}
),
a: ({ href, children }) => (
-
+
{children}
),
blockquote: ({ children }) => (
-
+
{children}
),
- hr: () =>
,
+ hr: () =>
,
strong: ({ children }) => {children},
em: ({ children }) => {children},
}}
@@ -377,7 +377,7 @@ function ToolCallLine({ toolCall }: { toolCall: ToolCall }) {
const isFailed = toolCall.status === 'failed'
return (
-
+
{/* Icon */}
{icon}
@@ -386,7 +386,7 @@ function ToolCallLine({ toolCall }: { toolCall: ToolCall }) {
{/* Detail in code pill */}
{detail && (
-
+
{detail}
)}
diff --git a/src/renderer/src/components/MessageInput.tsx b/src/renderer/src/components/MessageInput.tsx
index 558e8732b1..14dbc4ddae 100644
--- a/src/renderer/src/components/MessageInput.tsx
+++ b/src/renderer/src/components/MessageInput.tsx
@@ -52,7 +52,7 @@ export function MessageInput({
}
return (
-
+
{isProcessing ? (
@@ -77,7 +77,7 @@ export function MessageInput({
{/* Hint */}
-
+
Press Enter to send, Shift+Enter for new line
diff --git a/src/renderer/src/components/Modals.tsx b/src/renderer/src/components/Modals.tsx
new file mode 100644
index 0000000000..373fb299ad
--- /dev/null
+++ b/src/renderer/src/components/Modals.tsx
@@ -0,0 +1,189 @@
+/**
+ * Global modals registry
+ * All app modals are rendered here and controlled via modalStore
+ */
+import { useState } from 'react'
+import { useModalStore, useModal } from '../stores/modalStore'
+import { Settings } from './Settings'
+import type { MulticaSession } from '../../../shared/types'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+
+interface ModalsProps {
+ // Settings props
+ currentAgentId: string | null
+ onSwitchAgent: (agentId: string) => Promise
+ // NewSession props
+ onCreateSession: (cwd: string) => Promise
+ // DeleteSession props
+ onDeleteSession: (sessionId: string) => void
+}
+
+export function Modals({
+ currentAgentId,
+ onSwitchAgent,
+ onCreateSession,
+ onDeleteSession,
+}: ModalsProps) {
+ const closeModal = useModalStore((s) => s.closeModal)
+
+ return (
+ <>
+ closeModal('settings')}
+ />
+ closeModal('newSession')}
+ />
+ closeModal('deleteSession')}
+ />
+ >
+ )
+}
+
+// Settings Modal
+interface SettingsModalProps {
+ currentAgentId: string | null
+ onSwitchAgent: (agentId: string) => Promise
+ onClose: () => void
+}
+
+function SettingsModal({ currentAgentId, onSwitchAgent, onClose }: SettingsModalProps) {
+ const { isOpen } = useModal('settings')
+
+ return (
+
+ )
+}
+
+// New Session Modal
+interface NewSessionModalProps {
+ onCreateSession: (cwd: string) => Promise
+ onClose: () => void
+}
+
+function NewSessionModal({ onCreateSession, onClose }: NewSessionModalProps) {
+ const { isOpen } = useModal('newSession')
+ const [cwd, setCwd] = useState('')
+
+ const handleCreate = async () => {
+ if (!cwd.trim()) return
+ await onCreateSession(cwd.trim())
+ setCwd('')
+ onClose()
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) {
+ setCwd('')
+ onClose()
+ }
+ }
+
+ return (
+
+ )
+}
+
+// Delete Session Modal
+interface DeleteSessionModalProps {
+ onDeleteSession: (sessionId: string) => void
+ onClose: () => void
+}
+
+function DeleteSessionModal({ onDeleteSession, onClose }: DeleteSessionModalProps) {
+ const { isOpen, data: session } = useModal('deleteSession')
+
+ const handleConfirm = () => {
+ if (session) {
+ onDeleteSession(session.id)
+ onClose()
+ }
+ }
+
+ const getSessionTitle = (s: MulticaSession): string => {
+ if (s.title) return s.title
+ const parts = s.workingDirectory.split('/')
+ return parts[parts.length - 1] || s.workingDirectory
+ }
+
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx
index 9da52a038c..b558ab95f5 100644
--- a/src/renderer/src/components/Settings.tsx
+++ b/src/renderer/src/components/Settings.tsx
@@ -93,7 +93,7 @@ export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: Set
return (
@@ -62,10 +59,6 @@ export function StatusBar({
Stop
) : null}
-
-
)
@@ -101,7 +94,7 @@ function AgentStatusBadge({ status }: AgentStatusBadgeProps) {
return (
- {text}
+ {text}
)
}
diff --git a/src/renderer/src/stores/modalStore.ts b/src/renderer/src/stores/modalStore.ts
new file mode 100644
index 0000000000..159bd0c0bb
--- /dev/null
+++ b/src/renderer/src/stores/modalStore.ts
@@ -0,0 +1,57 @@
+/**
+ * Global modal state management using Zustand
+ */
+import { create } from 'zustand'
+import type { MulticaSession } from '../../../shared/types'
+
+// Modal types
+export type ModalType = 'settings' | 'newSession' | 'deleteSession'
+
+// Modal data types
+interface ModalDataMap {
+ settings: undefined
+ newSession: undefined
+ deleteSession: MulticaSession
+}
+
+interface ModalState {
+ isOpen: boolean
+ data?: ModalDataMap[T]
+}
+
+interface ModalStore {
+ modals: {
+ [K in ModalType]: ModalState
+ }
+ openModal: (type: T, data?: ModalDataMap[T]) => void
+ closeModal: (type: ModalType) => void
+}
+
+export const useModalStore = create((set) => ({
+ modals: {
+ settings: { isOpen: false },
+ newSession: { isOpen: false },
+ deleteSession: { isOpen: false },
+ },
+ openModal: (type, data) =>
+ set((state) => ({
+ modals: {
+ ...state.modals,
+ [type]: { isOpen: true, data },
+ },
+ })),
+ closeModal: (type) =>
+ set((state) => ({
+ modals: {
+ ...state.modals,
+ [type]: { isOpen: false, data: undefined },
+ },
+ })),
+}))
+
+// Convenience selectors
+export const useModal = (type: T) =>
+ useModalStore((state) => state.modals[type] as ModalState)
+
+export const useOpenModal = () => useModalStore((state) => state.openModal)
+export const useCloseModal = () => useModalStore((state) => state.closeModal)
diff --git a/src/renderer/src/styles/index.css b/src/renderer/src/styles/index.css
index 45f922c799..67f5f4dbc6 100644
--- a/src/renderer/src/styles/index.css
+++ b/src/renderer/src/styles/index.css
@@ -3,45 +3,12 @@
@custom-variant dark (&:is(.dark *));
-/* Dark theme (default) */
-:root,
-.dark {
- --color-primary: #292929;
- --color-primary-dark: #363636;
- --color-primary-text: #ffffff;
- --color-accent: #22c55e;
- --color-accent-muted: rgba(34, 197, 94, 0.15);
- --color-background: #0f0f0f;
- --color-surface: #1a1a1a;
- --color-surface-hover: #252525;
- --color-border: #2e2e2e;
- --color-text: #ffffff;
- --color-text-muted: #a1a1aa;
-}
-
-/* Light theme */
-.light {
- --color-primary: #f5f5f5;
- --color-primary-dark: #e5e5e5;
- --color-primary-text: #171717;
- --color-accent: #16a34a;
- --color-accent-muted: rgba(22, 163, 74, 0.1);
- --color-background: #ffffff;
- --color-surface: #f5f5f5;
- --color-surface-hover: #e5e5e5;
- --color-border: #e5e5e5;
- --color-text: #171717;
- --color-text-muted: #525252;
-}
-
/* Base styles */
* {
box-sizing: border-box;
}
body {
- background-color: var(--color-background);
- color: var(--color-text);
-webkit-font-smoothing: antialiased;
font-family:
-apple-system,
@@ -73,12 +40,12 @@ body {
}
::-webkit-scrollbar-thumb {
- background-color: var(--color-border);
+ background-color: var(--border);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
- background-color: var(--color-text-muted);
+ background-color: var(--muted-foreground);
}
@theme inline {