From 17266ba5f34dd079ced03a67a36321ae58c40eec Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 16 Jun 2026 22:08:01 +0800 Subject: [PATCH 1/7] Split the global state into multiple independent stores and refactor the references --- packages/desktop/src/App.tsx | 9 +- packages/desktop/src/TitleBar.tsx | 6 +- packages/desktop/src/agent/AgentSidebar.tsx | 23 +- packages/desktop/src/agent/AgentWorkspace.tsx | 45 +- packages/desktop/src/agent/ApprovalPanel.tsx | 8 +- packages/desktop/src/agent/AutomationForm.tsx | 4 +- .../desktop/src/agent/AutomationPanel.tsx | 12 +- packages/desktop/src/agent/MessageStream.tsx | 24 +- packages/desktop/src/agent/ProjectStrip.tsx | 29 +- packages/desktop/src/agent/TodoPanel.tsx | 6 +- packages/desktop/src/hooks/useAgent.ts | 110 +-- packages/desktop/src/hooks/useFileSystem.ts | 7 +- packages/desktop/src/hooks/useGit.ts | 8 +- packages/desktop/src/layouts/AgentLayout.tsx | 4 +- packages/desktop/src/layouts/IDELayout.tsx | 4 +- .../desktop/src/settings/AgentConfigPanel.tsx | 4 +- .../src/settings/GlobalSettingsPage.tsx | 8 +- packages/desktop/src/settings/HooksPanel.tsx | 4 +- packages/desktop/src/settings/McpPanel.tsx | 4 +- packages/desktop/src/settings/MemoryPanel.tsx | 4 +- .../src/settings/ProjectSettingsPage.tsx | 4 +- packages/desktop/src/settings/SkillPanel.tsx | 4 +- .../desktop/src/settings/SubagentsPanel.tsx | 6 +- packages/desktop/src/stores/agent.store.ts | 421 ++++++++++ packages/desktop/src/stores/files.store.ts | 69 ++ packages/desktop/src/stores/global.store.ts | 762 ------------------ packages/desktop/src/stores/rollback.store.ts | 106 +++ packages/desktop/src/stores/storage.ts | 24 + packages/desktop/src/stores/ui.store.ts | 78 ++ .../desktop/src/stores/workspace.store.ts | 103 +++ .../test/global-store-rollback-state.test.ts | 58 +- packages/desktop/test/global-store.test.ts | 382 ++++----- .../test/message-stream-scroll.test.tsx | 45 +- .../test/performance-optimization.test.ts | 88 +- .../desktop/test/todo-panel-state.test.ts | 84 +- 35 files changed, 1295 insertions(+), 1262 deletions(-) create mode 100644 packages/desktop/src/stores/agent.store.ts create mode 100644 packages/desktop/src/stores/files.store.ts delete mode 100644 packages/desktop/src/stores/global.store.ts create mode 100644 packages/desktop/src/stores/rollback.store.ts create mode 100644 packages/desktop/src/stores/storage.ts create mode 100644 packages/desktop/src/stores/ui.store.ts create mode 100644 packages/desktop/src/stores/workspace.store.ts diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx index 109e5c1..ee44265 100644 --- a/packages/desktop/src/App.tsx +++ b/packages/desktop/src/App.tsx @@ -1,14 +1,15 @@ import { useEffect } from 'react'; -import { useGlobalStore } from './stores/global.store'; +import { useUIStore } from './stores/ui.store'; +import { useWorkspaceStore } from './stores/workspace.store'; import AgentLayout from './layouts/AgentLayout'; import IDELayout from './layouts/IDELayout'; import TitleBar from './TitleBar'; import ErrorBoundary from './shared/ErrorBoundary'; export default function App() { - const mode = useGlobalStore((s) => s.ui.mode); - const theme = useGlobalStore((s) => s.ui.theme); - const rootPath = useGlobalStore((s) => s.workspace.rootPath); + const mode = useUIStore((s) => s.mode); + const theme = useUIStore((s) => s.theme); + const rootPath = useWorkspaceStore((s) => s.rootPath); // Sync workspace cwd to main process for git polling useEffect(() => { diff --git a/packages/desktop/src/TitleBar.tsx b/packages/desktop/src/TitleBar.tsx index fa2e11d..ae686c7 100644 --- a/packages/desktop/src/TitleBar.tsx +++ b/packages/desktop/src/TitleBar.tsx @@ -1,10 +1,10 @@ -import { useGlobalStore } from './stores/global.store'; +import { useUIStore } from './stores/ui.store'; const isWindows = window.electronAPI?.platform === 'win32'; export default function TitleBar() { - const mode = useGlobalStore((s) => s.ui.mode); - const setMode = useGlobalStore((s) => s.setMode); + const mode = useUIStore((s) => s.mode); + const setMode = useUIStore((s) => s.setMode); if (!isWindows) return null; diff --git a/packages/desktop/src/agent/AgentSidebar.tsx b/packages/desktop/src/agent/AgentSidebar.tsx index f793ee9..9c88920 100644 --- a/packages/desktop/src/agent/AgentSidebar.tsx +++ b/packages/desktop/src/agent/AgentSidebar.tsx @@ -1,6 +1,8 @@ import { useState, useMemo } from 'react'; import { Plus, Search, Zap, Settings } from 'lucide-react'; -import { useGlobalStore } from '../stores/global.store'; +import { useUIStore } from '../stores/ui.store'; +import { useWorkspaceStore } from '../stores/workspace.store'; +import { useAgentStore } from '../stores/agent.store'; import { api } from '../lib/api'; function normalizeCwd(p: string): string { @@ -18,15 +20,15 @@ function relativeTime(ts: number): string { } export default function AgentSidebar() { - const sidebarCollapsed = useGlobalStore((s) => s.ui.sidebarCollapsed); - const currentThreadId = useGlobalStore((s) => s.agent.currentThreadId); - const rootPath = useGlobalStore((s) => s.workspace.rootPath); - const workspace = useGlobalStore((s) => s.workspace); - const setCurrentThread = useGlobalStore((s) => s.setCurrentThread); - const setView = useGlobalStore((s) => s.setView); + const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed); + const currentThreadId = useAgentStore((s) => s.currentThreadId); + const rootPath = useWorkspaceStore((s) => s.rootPath); + const workspace = useWorkspaceStore(); + const setCurrentThread = useAgentStore((s) => s.setCurrentThread); + const setView = useUIStore((s) => s.setView); // Subscribe to raw threads, derive list with useMemo for stable reference - const rawThreads = useGlobalStore((s) => s.agent.threads); + const rawThreads = useAgentStore((s) => s.threads); const threadList = useMemo(() => { const normalizedRoot = normalizeCwd(rootPath); return Object.values(rawThreads) @@ -41,8 +43,7 @@ export default function AgentSidebar() { await api(`/api/sessions/${threadId}`, { method: 'DELETE' }).catch((e) => { console.error('Failed to delete session:', e); }); - const store = useGlobalStore.getState(); - const rootPath = store.workspace.rootPath; + const rootPath = useWorkspaceStore.getState().rootPath; if (rootPath) { try { const sessions = await api(`/api/sessions?cwd=${encodeURIComponent(rootPath)}`); @@ -55,7 +56,7 @@ export default function AgentSidebar() { createdAt: new Date(s.createdAt).getTime(), updatedAt: new Date(s.updatedAt).getTime(), })); - store.loadThreads(threads); + useAgentStore.getState().loadThreads(threads); } catch {} } if (threadId === currentThreadId) { diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index 301f96c..89784e4 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useCallback, useLayoutEffect, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { Send, Square, ShieldAlert, ShieldCheck, Shield, Eye } from 'lucide-react'; -import { useGlobalStore } from '../stores/global.store'; +import { useAgentStore } from '../stores/agent.store'; +import { useWorkspaceStore } from '../stores/workspace.store'; import { API_BASE, api } from '../lib/api'; import { setSessionPermissionMode } from '../lib/core-api'; import MessageStream from './MessageStream'; @@ -11,12 +12,12 @@ import ApprovalPanel from './ApprovalPanel'; // ─── ContextIndicator ────────────────────────────────────────────────────── function ContextIndicator({ threadId }: { threadId: string }) { - const contextUsage = useGlobalStore((s) => s.agent.contextUsage); - const usage = useGlobalStore((s) => s.agent.usageByThreadId[threadId]); - const setContextUsage = useGlobalStore((s) => s.setContextUsage); - const isCompressing = useGlobalStore((s) => s.agent.isCompressing); - const startCompressing = useGlobalStore((s) => s.startCompressing); - const stopCompressing = useGlobalStore((s) => s.stopCompressing); + const contextUsage = useAgentStore((s) => s.contextUsage); + const usage = useAgentStore((s) => s.usageByThreadId[threadId]); + const setContextUsage = useAgentStore((s) => s.setContextUsage); + const isCompressing = useAgentStore((s) => s.isCompressing); + const startCompressing = useAgentStore((s) => s.startCompressing); + const stopCompressing = useAgentStore((s) => s.stopCompressing); const r = 7; const circ = 2 * Math.PI * r; @@ -110,9 +111,9 @@ function ContextIndicator({ threadId }: { threadId: string }) { // ─── ModelSelector ───────────────────────────────────────────────────────── function ModelSelector() { - const model = useGlobalStore((s) => s.agent.model); - const models = useGlobalStore((s) => s.agent.models); - const setModel = useGlobalStore((s) => s.setModel); + const model = useAgentStore((s) => s.model); + const models = useAgentStore((s) => s.models); + const setModel = useAgentStore((s) => s.setModel); const [open, setOpen] = useState(false); const buttonRef = useRef(null); const dropdownRef = useRef(null); @@ -210,18 +211,18 @@ function InputBox({ }) { const [text, setText] = useState(''); const textareaRef = useRef(null); - const currentThreadId = useGlobalStore((s) => s.agent.currentThreadId); - const isStreaming = useGlobalStore((s) => { - const tid = s.agent.currentThreadId; + const currentThreadId = useAgentStore((s) => s.currentThreadId); + const isStreaming = useAgentStore((s) => { + const tid = s.currentThreadId; if (!tid) return false; - const thread = s.agent.threads[tid]; + const thread = s.threads[tid]; return thread?.turns.some((t) => t.status === 'running') ?? false; }); - const approvalPolicy = useGlobalStore((s) => s.agent.approvalPolicy); - const workspace = useGlobalStore((s) => s.workspace); - const setApprovalPolicy = useGlobalStore((s) => s.setApprovalPolicy); - const pendingInput = useGlobalStore((s) => s.agent.pendingInput); - const setPendingInput = useGlobalStore((s) => s.setPendingInput); + const approvalPolicy = useAgentStore((s) => s.approvalPolicy); + const workspace = useWorkspaceStore(); + const setApprovalPolicy = useAgentStore((s) => s.setApprovalPolicy); + const pendingInput = useAgentStore((s) => s.pendingInput); + const setPendingInput = useAgentStore((s) => s.setPendingInput); // Consume pendingInput when it's set useEffect(() => { @@ -349,9 +350,9 @@ interface AgentWorkspaceProps { } export default function AgentWorkspace({ sendMessage, abort }: AgentWorkspaceProps) { - const currentThreadId = useGlobalStore((s) => s.agent.currentThreadId); - const isCompressing = useGlobalStore((s) => s.agent.isCompressing); - const workspace = useGlobalStore((s) => s.workspace); + const currentThreadId = useAgentStore((s) => s.currentThreadId); + const isCompressing = useAgentStore((s) => s.isCompressing); + const workspace = useWorkspaceStore(); if (!currentThreadId) { return ( diff --git a/packages/desktop/src/agent/ApprovalPanel.tsx b/packages/desktop/src/agent/ApprovalPanel.tsx index f888b36..dbc0f59 100644 --- a/packages/desktop/src/agent/ApprovalPanel.tsx +++ b/packages/desktop/src/agent/ApprovalPanel.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import type { Item } from '@shared/types'; -import { useGlobalStore } from '../stores/global.store'; +import { useAgentStore } from '../stores/agent.store'; import { useAgentApproval } from '../hooks/useAgent'; import ToolCallCard from '../shared/ToolCallCard'; @@ -13,8 +13,8 @@ export default function ApprovalPanel({ threadId }: ApprovalPanelProps) { const { approveTool, rejectTool } = useAgentApproval(); // Stable string key: only changes when pending item IDs change, not on every content update - const pendingKey = useGlobalStore((s) => { - const thread = s.agent.threads[threadId]; + const pendingKey = useAgentStore((s) => { + const thread = s.threads[threadId]; if (!thread) return ''; return thread.turns .flatMap((t) => t.items) @@ -26,7 +26,7 @@ export default function ApprovalPanel({ threadId }: ApprovalPanelProps) { // Only compute pending items when the key changes const pendingItems = useMemo(() => { if (!pendingKey) return []; - const thread = useGlobalStore.getState().agent.threads[threadId]; + const thread = useAgentStore.getState().threads[threadId]; if (!thread) return []; return thread.turns.flatMap((turn) => turn.items.filter( diff --git a/packages/desktop/src/agent/AutomationForm.tsx b/packages/desktop/src/agent/AutomationForm.tsx index 61d8830..6cefd4a 100644 --- a/packages/desktop/src/agent/AutomationForm.tsx +++ b/packages/desktop/src/agent/AutomationForm.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { X } from 'lucide-react'; -import { useGlobalStore, type Automation } from '../stores/global.store'; +import { useAgentStore, type Automation } from '../stores/agent.store'; import { createAutomation, updateAutomation } from '../lib/core-api'; interface AutomationFormProps { @@ -38,7 +38,7 @@ export function AutomationForm({ onClose, onSaved, }: AutomationFormProps) { - const automations = useGlobalStore((s) => s.agent.automations); + const automations = useAgentStore((s) => s.automations); const existing = automationId ? automations.find((a: Automation) => a.id === automationId) : null; const [name, setName] = useState(existing?.name ?? ''); diff --git a/packages/desktop/src/agent/AutomationPanel.tsx b/packages/desktop/src/agent/AutomationPanel.tsx index 90a18fe..3aa1798 100644 --- a/packages/desktop/src/agent/AutomationPanel.tsx +++ b/packages/desktop/src/agent/AutomationPanel.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react'; import { ArrowLeft, Plus, Play, Trash2, Power, Clock, FolderOpen } from 'lucide-react'; -import { useGlobalStore, type Automation } from '../stores/global.store'; +import { useAgentStore, type Automation } from '../stores/agent.store'; +import { useWorkspaceStore } from '../stores/workspace.store'; +import { useUIStore } from '../stores/ui.store'; import { listAutomations, deleteAutomation, @@ -10,10 +12,10 @@ import { import { AutomationForm } from './AutomationForm'; export function AutomationPanel() { - const automations = useGlobalStore((s) => s.agent.automations); - const setAutomations = useGlobalStore((s) => s.setAutomations); - const workspace = useGlobalStore((s) => s.workspace); - const setView = useGlobalStore((s) => s.setView); + const automations = useAgentStore((s) => s.automations); + const setAutomations = useAgentStore((s) => s.setAutomations); + const workspace = useWorkspaceStore(); + const setView = useUIStore((s) => s.setView); const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [loading, setLoading] = useState(false); diff --git a/packages/desktop/src/agent/MessageStream.tsx b/packages/desktop/src/agent/MessageStream.tsx index fc78b2b..4b475ca 100644 --- a/packages/desktop/src/agent/MessageStream.tsx +++ b/packages/desktop/src/agent/MessageStream.tsx @@ -1,7 +1,8 @@ import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react'; import { Copy, Check } from 'lucide-react'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { useGlobalStore } from '../stores/global.store'; +import { useAgentStore } from '../stores/agent.store'; +import { useRollbackStore } from '../stores/rollback.store'; import MessageItem from '../shared/MessageItem'; import UnifiedDiffView from '../shared/UnifiedDiffView'; import type { Item } from '@shared/types'; @@ -55,9 +56,9 @@ function TurnDiffPanel({ onRevertFile, onRevertTurn, }: TurnDiffPanelProps) { - const rawCheckpointDiffByTurnId = useGlobalStore((s) => s.rollback.checkpointDiffByTurnId); - const rawTurnCheckpointMapping = useGlobalStore((s) => s.rollback.turnCheckpointMapping); - const revertedFilesByTurnId = useGlobalStore((s) => s.rollback.revertedFilesByTurnId); + const rawCheckpointDiffByTurnId = useRollbackStore((s) => s.checkpointDiffByTurnId); + const rawTurnCheckpointMapping = useRollbackStore((s) => s.turnCheckpointMapping); + const revertedFilesByTurnId = useRollbackStore((s) => s.revertedFilesByTurnId); const checkpointDiffs = useMemo(() => { const prefix = `${threadId}:`; @@ -213,8 +214,8 @@ function TurnDiffPanel({ } export default function MessageStream({ threadId }: MessageStreamProps) { - const turns = useGlobalStore((s) => s.agent.threads[threadId]?.turns ?? []); - const setCurrentThread = useGlobalStore((s) => s.setCurrentThread); + const turns = useAgentStore((s) => s.threads[threadId]?.turns ?? []); + const setCurrentThread = useAgentStore((s) => s.setCurrentThread); const { approveTool, rejectTool } = useAgentApproval(); const { loadCheckpointDiff, @@ -231,8 +232,8 @@ export default function MessageStream({ threadId }: MessageStreamProps) { const parentRef = useRef(null); const didScrollToEndRef = useRef(false); const loadedCheckpointRef = useRef(null); - const markFileRestored = useGlobalStore((s) => s.markFileRestored); - const setPendingInput = useGlobalStore((s) => s.setPendingInput); + const markFileRestored = useRollbackStore((s) => s.markFileRestored); + const setPendingInput = useAgentStore((s) => s.setPendingInput); const [showRollbackPanel, setShowRollbackPanel] = useState<{ turnId: string; @@ -406,9 +407,8 @@ export default function MessageStream({ threadId }: MessageStreamProps) { if (loadedCheckpointRef.current === loadKey) return; loadedCheckpointRef.current = loadKey; - const state = useGlobalStore.getState(); - const existingMapping = state.rollback.turnCheckpointMapping[threadId] ?? EMPTY_MAPPING; - const existingDiffs = state.rollback.checkpointDiffByTurnId; + const existingMapping = useRollbackStore.getState().turnCheckpointMapping[threadId] ?? EMPTY_MAPPING; + const existingDiffs = useRollbackStore.getState().checkpointDiffByTurnId; const alreadyLoaded = completedTurnIds.some((id) => getCheckpointKey(threadId, id, existingDiffs, existingMapping) !== null @@ -438,7 +438,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) { const result = await undoCodeRollback(threadId, uiTurnId, false); if (result.restored) { const key = `${threadId}:${uiTurnId}`; - delete useGlobalStore.getState().rollback.revertedFilesByTurnId[key]; + delete useRollbackStore.getState().revertedFilesByTurnId[key]; } } else { await revertFiles(threadId, files); diff --git a/packages/desktop/src/agent/ProjectStrip.tsx b/packages/desktop/src/agent/ProjectStrip.tsx index 49c230b..140b9e5 100644 --- a/packages/desktop/src/agent/ProjectStrip.tsx +++ b/packages/desktop/src/agent/ProjectStrip.tsx @@ -1,6 +1,8 @@ import { useState, useMemo } from 'react'; import { Settings, ChevronLeft, ChevronRight } from 'lucide-react'; -import { useGlobalStore } from '../stores/global.store'; +import { useUIStore } from '../stores/ui.store'; +import { useWorkspaceStore } from '../stores/workspace.store'; +import { useAgentStore } from '../stores/agent.store'; import { API_BASE, api } from '../lib/api'; import type { Project, Thread } from '@shared/types'; @@ -122,9 +124,9 @@ function SessionListPopup({ } export default function ProjectStrip() { - const projects = useGlobalStore((s) => s.workspace.projects); - const currentProjectId = useGlobalStore((s) => s.workspace.currentProjectId); - const rawThreads = useGlobalStore((s) => s.agent.threads); + const projects = useWorkspaceStore((s) => s.projects); + const currentProjectId = useWorkspaceStore((s) => s.currentProjectId); + const rawThreads = useAgentStore((s) => s.threads); const threadMetadata = useMemo(() => { return Object.values(rawThreads).map((t) => ({ id: t.id, @@ -133,13 +135,13 @@ export default function ProjectStrip() { updatedAt: t.updatedAt, })); }, [rawThreads]); - const currentThreadId = useGlobalStore((s) => s.agent.currentThreadId); - const sidebarCollapsed = useGlobalStore((s) => s.ui.sidebarCollapsed); - const switchProject = useGlobalStore((s) => s.switchProject); - const addProject = useGlobalStore((s) => s.addProject); - const setCurrentThread = useGlobalStore((s) => s.setCurrentThread); - const setView = useGlobalStore((s) => s.setView); - const toggleSidebar = useGlobalStore((s) => s.toggleSidebar); + const currentThreadId = useAgentStore((s) => s.currentThreadId); + const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed); + const switchProject = useWorkspaceStore((s) => s.switchProject); + const addProject = useWorkspaceStore((s) => s.addProject); + const setCurrentThread = useAgentStore((s) => s.setCurrentThread); + const setView = useUIStore((s) => s.setView); + const toggleSidebar = useUIStore((s) => s.toggleSidebar); const [hoveredId, setHoveredId] = useState(null); @@ -147,8 +149,7 @@ export default function ProjectStrip() { await api(`/api/sessions/${threadId}`, { method: 'DELETE' }).catch((e) => { console.error('Failed to delete session:', e); }); - const store = useGlobalStore.getState(); - const rootPath = store.workspace.rootPath; + const rootPath = useWorkspaceStore.getState().rootPath; if (rootPath) { try { const sessions = await api(`/api/sessions?cwd=${encodeURIComponent(rootPath)}`); @@ -161,7 +162,7 @@ export default function ProjectStrip() { createdAt: new Date(s.createdAt).getTime(), updatedAt: new Date(s.updatedAt).getTime(), })); - store.loadThreads(threads); + useAgentStore.getState().loadThreads(threads); } catch {} } if (threadId === currentThreadId) { diff --git a/packages/desktop/src/agent/TodoPanel.tsx b/packages/desktop/src/agent/TodoPanel.tsx index 9029b74..760b8ac 100644 --- a/packages/desktop/src/agent/TodoPanel.tsx +++ b/packages/desktop/src/agent/TodoPanel.tsx @@ -1,4 +1,4 @@ -import { useGlobalStore } from '../stores/global.store'; +import { useAgentStore } from '../stores/agent.store'; import type { TodoItem } from '@shared/types'; function TodoItemRow({ item }: { item: TodoItem }) { @@ -31,8 +31,8 @@ function TodoItemRow({ item }: { item: TodoItem }) { } export default function TodoPanel({ threadId }: { threadId: string }) { - const state = useGlobalStore((s) => s.agent.todoByThreadId[threadId]); - const toggleCollapsed = useGlobalStore((s) => s.toggleTodoCollapsed); + const state = useAgentStore((s) => s.todoByThreadId[threadId]); + const toggleCollapsed = useAgentStore((s) => s.toggleTodoCollapsed); if (!state?.hasSeenNonEmptyTodo) return null; diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index a8a7a5f..b215277 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -1,5 +1,7 @@ import { useEffect, useCallback, useRef } from 'react'; -import { useGlobalStore, type ModelEntry } from '../stores/global.store'; +import { useAgentStore, type ModelEntry } from '../stores/agent.store'; +import { useWorkspaceStore } from '../stores/workspace.store'; +import { useRollbackStore } from '../stores/rollback.store'; import { agentClient } from '../lib/core-api'; import type { StreamChunk } from '@codingcode/core/client/types'; import { ApiError } from '../lib/api'; @@ -42,23 +44,23 @@ const abortControllers = new Map(); // ---- useAgentCore: sendMessage + abort + initialization ---- export function useAgentCore() { - const startTurn = useGlobalStore((s) => s.startTurn); - const applyChunk = useGlobalStore((s) => s.applyChunk); - const updateTurnId = useGlobalStore((s) => s.updateTurnId); - const completeTurn = useGlobalStore((s) => s.completeTurn); - const setPendingInput = useGlobalStore((s) => s.setPendingInput); - const clearRunningTurns = useGlobalStore((s) => s.clearRunningTurns); - const applyTodoUpdate = useGlobalStore((s) => s.applyTodoUpdate); - const setCurrentThread = useGlobalStore((s) => s.setCurrentThread); - const loadThreads = useGlobalStore((s) => s.loadThreads); - const setThreadTurns = useGlobalStore((s) => s.setThreadTurns); - const setModel = useGlobalStore((s) => s.setModel); - const setModels = useGlobalStore((s) => s.setModels); - const setContextUsage = useGlobalStore((s) => s.setContextUsage); - const setThreadUsage = useGlobalStore((s) => s.setThreadUsage); - const workspace = useGlobalStore((s) => s.workspace); - const currentThreadId = useGlobalStore((s) => s.agent.currentThreadId); - const approvalPolicy = useGlobalStore((s) => s.agent.approvalPolicy); + const startTurn = useAgentStore((s) => s.startTurn); + const applyChunk = useAgentStore((s) => s.applyChunk); + const updateTurnId = useAgentStore((s) => s.updateTurnId); + const completeTurn = useAgentStore((s) => s.completeTurn); + const setPendingInput = useAgentStore((s) => s.setPendingInput); + const clearRunningTurns = useAgentStore((s) => s.clearRunningTurns); + const applyTodoUpdate = useAgentStore((s) => s.applyTodoUpdate); + const setCurrentThread = useAgentStore((s) => s.setCurrentThread); + const loadThreads = useAgentStore((s) => s.loadThreads); + const setThreadTurns = useAgentStore((s) => s.setThreadTurns); + const setModel = useAgentStore((s) => s.setModel); + const setModels = useAgentStore((s) => s.setModels); + const setContextUsage = useAgentStore((s) => s.setContextUsage); + const setThreadUsage = useAgentStore((s) => s.setThreadUsage); + const workspace = useWorkspaceStore(); + const currentThreadId = useAgentStore((s) => s.currentThreadId); + const approvalPolicy = useAgentStore((s) => s.approvalPolicy); // Load sessions, models, and projects on mount useEffect(() => { @@ -104,7 +106,7 @@ export function useAgentCore() { // Load history from HTTP when switching to a thread with no turns useEffect(() => { if (!currentThreadId) return; - const thread = useGlobalStore.getState().agent.threads[currentThreadId]; + const thread = useAgentStore.getState().threads[currentThreadId]; if (!thread || thread.turns.length > 0) return; getSessionHistory(currentThreadId) .then((turns) => { @@ -188,8 +190,8 @@ export function useAgentCore() { completion: event.completion, total: event.total, }); - const state = useGlobalStore.getState(); - const model = state.agent.models.find((m) => m.id === state.agent.model); + const agentState = useAgentStore.getState(); + const model = agentState.models.find((m) => m.id === agentState.model); if (model) { setContextUsage({ used: event.prompt, contextWindow: model.context_window }); } @@ -197,7 +199,7 @@ export function useAgentCore() { } case 'reactive_compact': { - const contextUsage = useGlobalStore.getState().agent.contextUsage; + const contextUsage = useAgentStore.getState().contextUsage; if (contextUsage) { setContextUsage({ used: event.promptEstimate, @@ -312,7 +314,7 @@ export function useAgentCore() { // ---- useAgentApproval: approveTool + rejectTool ---- export function useAgentApproval() { - const updateToolCallStatus = useGlobalStore((s) => s.updateToolCallStatus); + const updateToolCallStatus = useAgentStore((s) => s.updateToolCallStatus); const approveTool = useCallback( async (threadId: string, callId: string) => { @@ -344,24 +346,24 @@ export function useAgentApproval() { // ---- useAgentRollback: all rollback methods ---- export function useAgentRollback() { - const workspace = useGlobalStore((s) => s.workspace); - const setPendingInput = useGlobalStore((s) => s.setPendingInput); - const clearRunningTurns = useGlobalStore((s) => s.clearRunningTurns); - const setThreadTurns = useGlobalStore((s) => s.setThreadTurns); - const setContextUsage = useGlobalStore((s) => s.setContextUsage); - const loadThreads = useGlobalStore((s) => s.loadThreads); - const setThreadUsage = useGlobalStore((s) => s.setThreadUsage); + const workspace = useWorkspaceStore(); + const setPendingInput = useAgentStore((s) => s.setPendingInput); + const clearRunningTurns = useAgentStore((s) => s.clearRunningTurns); + const setThreadTurns = useAgentStore((s) => s.setThreadTurns); + const setContextUsage = useAgentStore((s) => s.setContextUsage); + const loadThreads = useAgentStore((s) => s.loadThreads); + const setThreadUsage = useAgentStore((s) => s.setThreadUsage); // Rollback store - const revertedFilesByTurnId = useGlobalStore((s) => s.rollback.revertedFilesByTurnId); - const setRollbackState = useGlobalStore((s) => s.setRollbackState); - const setCheckpointDiff = useGlobalStore((s) => s.setCheckpointDiff); - const markFileReverted = useGlobalStore((s) => s.markFileReverted); - const markFileRestored = useGlobalStore((s) => s.markFileRestored); - const setTurnCheckpointMapping = useGlobalStore((s) => s.setTurnCheckpointMapping); - const initRevertedFilesFromState = useGlobalStore((s) => s.initRevertedFilesFromState); + const revertedFilesByTurnId = useRollbackStore((s) => s.revertedFilesByTurnId); + const setRollbackState = useRollbackStore((s) => s.setRollbackState); + const setCheckpointDiff = useRollbackStore((s) => s.setCheckpointDiff); + const markFileReverted = useRollbackStore((s) => s.markFileReverted); + const markFileRestored = useRollbackStore((s) => s.markFileRestored); + const setTurnCheckpointMapping = useRollbackStore((s) => s.setTurnCheckpointMapping); + const initRevertedFilesFromState = useRollbackStore((s) => s.initRevertedFilesFromState); const resolveUITurnId = useCallback((threadId: string, checkpointId: number): string => { - const mapping = useGlobalStore.getState().rollback.turnCheckpointMapping; + const mapping = useRollbackStore.getState().turnCheckpointMapping; const uiId = mapping[threadId]?.[checkpointId]; if (uiId) return uiId; return String(checkpointId); @@ -369,13 +371,13 @@ export function useAgentRollback() { const loadCheckpointDiff = useCallback( async (threadId: string, turnId?: string) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; + const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; const parsed = turnId != null ? parseInt(turnId, 10) : undefined; const numericTurnId = parsed != null && !isNaN(parsed) ? parsed : undefined; const diff = await getCheckpointDiff(threadId, cwd, numericTurnId); setCheckpointDiff(threadId, String(diff.turnId), diff); if (diff.turnId > 0 && numericTurnId == null) { - const thread = useGlobalStore.getState().agent.threads[threadId]; + const thread = useAgentStore.getState().threads[threadId]; if (thread) { const completed = thread.turns.filter((t) => t.status === 'completed'); const last = completed[completed.length - 1]; @@ -391,7 +393,7 @@ export function useAgentRollback() { const revertFile = useCallback( async (threadId: string, file: string) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; + const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; const { result } = await revertCheckpointFiles(threadId, cwd, [file]); if (result.reverted) { markFileReverted(threadId, resolveUITurnId(threadId, result.throughTurnId), file); @@ -403,7 +405,7 @@ export function useAgentRollback() { const revertFiles = useCallback( async (threadId: string, files: string[]) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; + const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; const { result } = await revertCheckpointFiles(threadId, cwd, files); if (result.reverted) { const uiId = resolveUITurnId(threadId, result.throughTurnId); @@ -418,7 +420,7 @@ export function useAgentRollback() { const previewRollback = useCallback( async (threadId: string, throughTurnId: number) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; + const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; const preview = await previewRollbackDiff(threadId, cwd, throughTurnId); return preview; }, @@ -427,7 +429,7 @@ export function useAgentRollback() { const rollbackCode = useCallback( async (threadId: string, throughTurnId: number) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; + const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; const { result } = await rollbackCodeToTurn(threadId, cwd, throughTurnId); return result; }, @@ -436,7 +438,7 @@ export function useAgentRollback() { const rollbackCtx = useCallback( async (threadId: string, throughTurnId: number) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; + const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; const res = await rollbackContext(threadId, cwd, throughTurnId); clearRunningTurns(threadId); setThreadTurns(threadId, res.turns as Turn[]); @@ -444,8 +446,8 @@ export function useAgentRollback() { setPendingInput(res.rolledBackMessage); } if (res.promptEstimate != null) { - const state = useGlobalStore.getState(); - const entry = state.agent.models.find((m) => m.id === state.agent.model); + const agentState = useAgentStore.getState(); + const entry = agentState.models.find((m) => m.id === agentState.model); const contextWindow = entry?.context_window ?? 0; if (contextWindow > 0) { setContextUsage({ used: res.promptEstimate, contextWindow }); @@ -458,15 +460,15 @@ export function useAgentRollback() { const rollbackBoth = useCallback( async (threadId: string, throughTurnId: number) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; + const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; const res = await rollbackBothToTurn(threadId, cwd, throughTurnId); setThreadTurns(threadId, res.turns as Turn[]); if (res.rolledBackMessage) { setPendingInput(res.rolledBackMessage); } if (res.promptEstimate != null) { - const state = useGlobalStore.getState(); - const entry = state.agent.models.find((m) => m.id === state.agent.model); + const agentState = useAgentStore.getState(); + const entry = agentState.models.find((m) => m.id === agentState.model); const contextWindow = entry?.context_window ?? 0; if (contextWindow > 0) { setContextUsage({ used: res.promptEstimate, contextWindow }); @@ -479,7 +481,7 @@ export function useAgentRollback() { const undoCodeRollback = useCallback( async (threadId: string, uiTurnId: string, force?: boolean, files?: string[]) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; + const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; const { result } = await undoLastCodeRollback(threadId, cwd, force, files); if (result.restored) { for (const f of result.restoredFiles) { @@ -493,7 +495,7 @@ export function useAgentRollback() { const forkThread = useCallback( async (threadId: string, atTurnId?: number) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; + const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; const res = await forkSession(threadId, cwd, atTurnId); return res.sessionId; }, @@ -502,7 +504,7 @@ export function useAgentRollback() { const initRollbackState = useCallback( async (threadId: string) => { - const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; + const cwd = useAgentStore.getState().threads[threadId]?.cwd ?? workspace.rootPath; try { const state = await getRollbackState(threadId, cwd); setRollbackState(threadId, state); @@ -521,7 +523,7 @@ export function useAgentRollback() { } catch (e) { console.error('Failed to delete session:', e); } - const currentCwd = useGlobalStore.getState().workspace.rootPath; + const currentCwd = useWorkspaceStore.getState().rootPath; if (currentCwd) { const sessions = await listSessions(currentCwd).catch(() => []); if (sessions) { diff --git a/packages/desktop/src/hooks/useFileSystem.ts b/packages/desktop/src/hooks/useFileSystem.ts index 08d5118..b46217d 100644 --- a/packages/desktop/src/hooks/useFileSystem.ts +++ b/packages/desktop/src/hooks/useFileSystem.ts @@ -1,10 +1,11 @@ import { useEffect } from 'react'; -import { useGlobalStore } from '../stores/global.store'; +import { useFilesStore } from '../stores/files.store'; +import { useWorkspaceStore } from '../stores/workspace.store'; import type { FileNode } from '@shared/types'; export function useFileSystem() { - const setFileTree = useGlobalStore((s) => s.setFileTree); - const rootPath = useGlobalStore((s) => s.workspace.rootPath); + const setFileTree = useFilesStore((s) => s.setFileTree); + const rootPath = useWorkspaceStore((s) => s.rootPath); useEffect(() => { if (!rootPath) return; diff --git a/packages/desktop/src/hooks/useGit.ts b/packages/desktop/src/hooks/useGit.ts index 405dfa8..a36fe2f 100644 --- a/packages/desktop/src/hooks/useGit.ts +++ b/packages/desktop/src/hooks/useGit.ts @@ -1,11 +1,11 @@ import { useEffect } from 'react'; -import { useGlobalStore } from '../stores/global.store'; +import { useWorkspaceStore } from '../stores/workspace.store'; import type { GitStatus } from '@shared/types'; export function useGit() { - const setGit = useGlobalStore((s) => s.setGit); - const git = useGlobalStore((s) => s.git); - const rootPath = useGlobalStore((s) => s.workspace.rootPath); + const setGit = useWorkspaceStore((s) => s.setGit); + const git = useWorkspaceStore((s) => s.git); + const rootPath = useWorkspaceStore((s) => s.rootPath); useEffect(() => { if (!rootPath) return; diff --git a/packages/desktop/src/layouts/AgentLayout.tsx b/packages/desktop/src/layouts/AgentLayout.tsx index c0ce321..06d4910 100644 --- a/packages/desktop/src/layouts/AgentLayout.tsx +++ b/packages/desktop/src/layouts/AgentLayout.tsx @@ -1,5 +1,5 @@ import { useAgentCore } from '../hooks/useAgent'; -import { useGlobalStore } from '../stores/global.store'; +import { useUIStore } from '../stores/ui.store'; import ProjectStrip from '../agent/ProjectStrip'; import AgentSidebar from '../agent/AgentSidebar'; import AgentWorkspace from '../agent/AgentWorkspace'; @@ -9,7 +9,7 @@ import ProjectSettingsPage from '../settings/ProjectSettingsPage'; export default function AgentLayout() { const { sendMessage, abort } = useAgentCore(); - const view = useGlobalStore((s) => s.ui.view); + const view = useUIStore((s) => s.view); if (view === 'global-settings') { return ; diff --git a/packages/desktop/src/layouts/IDELayout.tsx b/packages/desktop/src/layouts/IDELayout.tsx index 18a0780..0c6d4f4 100644 --- a/packages/desktop/src/layouts/IDELayout.tsx +++ b/packages/desktop/src/layouts/IDELayout.tsx @@ -1,7 +1,7 @@ -import { useGlobalStore } from '../stores/global.store'; +import { useUIStore } from '../stores/ui.store'; export default function IDELayout() { - const setMode = useGlobalStore((s) => s.setMode); + const setMode = useUIStore((s) => s.setMode); return (
diff --git a/packages/desktop/src/settings/AgentConfigPanel.tsx b/packages/desktop/src/settings/AgentConfigPanel.tsx index 6eb6de1..6e3161f 100644 --- a/packages/desktop/src/settings/AgentConfigPanel.tsx +++ b/packages/desktop/src/settings/AgentConfigPanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { useGlobalStore } from '../stores/global.store'; +import { useAgentStore } from '../stores/agent.store'; import { getAgentConfig, setAgentConfig, setCompactionModel } from '../lib/core-api'; const inputCls = @@ -9,7 +9,7 @@ const selectCls = 'w-[200px] bg-[var(--bg-hover)] border border-[var(--border-hover)] text-[var(--text-title)] px-3 py-2 rounded text-[13px] focus:outline-none focus:ring-1 focus:ring-[var(--accent-primary)]'; export default function AgentConfigPanel() { - const models = useGlobalStore((s) => s.agent.models); + const models = useAgentStore((s) => s.models); const [maxSteps, setMaxSteps] = useState(200); const [maxStopContinuations, setMaxStopContinuations] = useState(2); diff --git a/packages/desktop/src/settings/GlobalSettingsPage.tsx b/packages/desktop/src/settings/GlobalSettingsPage.tsx index 63cd692..b145f38 100644 --- a/packages/desktop/src/settings/GlobalSettingsPage.tsx +++ b/packages/desktop/src/settings/GlobalSettingsPage.tsx @@ -1,5 +1,5 @@ import { ArrowLeft } from 'lucide-react'; -import { useGlobalStore } from '../stores/global.store'; +import { useUIStore } from '../stores/ui.store'; import { useState } from 'react'; import McpPanel from './McpPanel'; import HooksPanel from './HooksPanel'; @@ -25,10 +25,10 @@ const THEMES = [ ]; export default function GlobalSettingsPage() { - const setView = useGlobalStore((s) => s.setView); + const setView = useUIStore((s) => s.setView); const [section, setSection] = useState
('theme'); - const theme = useGlobalStore((s) => s.ui.theme); - const setTheme = useGlobalStore((s) => s.setTheme); + const theme = useUIStore((s) => s.theme); + const setTheme = useUIStore((s) => s.setTheme); return (
diff --git a/packages/desktop/src/settings/HooksPanel.tsx b/packages/desktop/src/settings/HooksPanel.tsx index e4a1f05..e3cc4dc 100644 --- a/packages/desktop/src/settings/HooksPanel.tsx +++ b/packages/desktop/src/settings/HooksPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import Toggle from './Toggle'; -import { useGlobalStore } from '../stores/global.store'; +import { useWorkspaceStore } from '../stores/workspace.store'; import { listHooks, createHook, @@ -110,7 +110,7 @@ export default function HooksPanel({ global: isGlobal }: { global?: boolean }) { const [editingName, setEditingName] = useState(null); const [deletingName, setDeletingName] = useState(null); const [form, setForm] = useState(EMPTY_FORM); - const rootPath = useGlobalStore((s) => s.workspace.rootPath); + const rootPath = useWorkspaceStore((s) => s.rootPath); const cwd = isGlobal ? undefined : rootPath; const load = async () => { diff --git a/packages/desktop/src/settings/McpPanel.tsx b/packages/desktop/src/settings/McpPanel.tsx index 6a09262..ef32f93 100644 --- a/packages/desktop/src/settings/McpPanel.tsx +++ b/packages/desktop/src/settings/McpPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import Toggle from './Toggle'; -import { useGlobalStore } from '../stores/global.store'; +import { useWorkspaceStore } from '../stores/workspace.store'; import { listMcpServers, setMcpDisabled, @@ -51,7 +51,7 @@ export default function McpPanel({ global: isGlobal }: { global?: boolean }) { const [editingName, setEditingName] = useState(null); const [deletingName, setDeletingName] = useState(null); const [form, setForm] = useState(EMPTY_FORM); - const rootPath = useGlobalStore((s) => s.workspace.rootPath); + const rootPath = useWorkspaceStore((s) => s.rootPath); const cwd = isGlobal ? undefined : rootPath; const load = async () => { diff --git a/packages/desktop/src/settings/MemoryPanel.tsx b/packages/desktop/src/settings/MemoryPanel.tsx index aab671b..3c6c4b0 100644 --- a/packages/desktop/src/settings/MemoryPanel.tsx +++ b/packages/desktop/src/settings/MemoryPanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { useGlobalStore } from '../stores/global.store'; +import { useAgentStore } from '../stores/agent.store'; import Toggle from './Toggle'; import { getMemoryConfig, @@ -32,7 +32,7 @@ interface FormType { const EMPTY_FORM: FormType = { name: '', description: '' }; export default function MemoryPanel() { - const models = useGlobalStore((s) => s.agent.models); + const models = useAgentStore((s) => s.models); const [config, setConfig] = useState({ enabled: false, types: [], diff --git a/packages/desktop/src/settings/ProjectSettingsPage.tsx b/packages/desktop/src/settings/ProjectSettingsPage.tsx index 5cb3bd1..f5b69a7 100644 --- a/packages/desktop/src/settings/ProjectSettingsPage.tsx +++ b/packages/desktop/src/settings/ProjectSettingsPage.tsx @@ -1,5 +1,5 @@ import { ArrowLeft } from 'lucide-react'; -import { useGlobalStore } from '../stores/global.store'; +import { useUIStore } from '../stores/ui.store'; import { useState } from 'react'; import McpPanel from './McpPanel'; import HooksPanel from './HooksPanel'; @@ -18,7 +18,7 @@ const NAV_ITEMS: { id: Section; label: string }[] = [ ]; export default function ProjectSettingsPage() { - const setView = useGlobalStore((s) => s.setView); + const setView = useUIStore((s) => s.setView); const [section, setSection] = useState
('mcp'); return ( diff --git a/packages/desktop/src/settings/SkillPanel.tsx b/packages/desktop/src/settings/SkillPanel.tsx index 17f6a7a..286d5a2 100644 --- a/packages/desktop/src/settings/SkillPanel.tsx +++ b/packages/desktop/src/settings/SkillPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import Toggle from './Toggle'; -import { useGlobalStore } from '../stores/global.store'; +import { useWorkspaceStore } from '../stores/workspace.store'; import { listSkills, toggleSkill } from '../lib/core-api'; interface SkillEntry { @@ -14,7 +14,7 @@ interface SkillEntry { export default function SkillPanel({ global: isGlobal }: { global?: boolean }) { const [skills, setSkills] = useState([]); const [loading, setLoading] = useState(true); - const rootPath = useGlobalStore((s) => s.workspace.rootPath); + const rootPath = useWorkspaceStore((s) => s.rootPath); const cwd = isGlobal ? undefined : rootPath; const load = async () => { diff --git a/packages/desktop/src/settings/SubagentsPanel.tsx b/packages/desktop/src/settings/SubagentsPanel.tsx index 430d86d..f0a0e6c 100644 --- a/packages/desktop/src/settings/SubagentsPanel.tsx +++ b/packages/desktop/src/settings/SubagentsPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import Toggle from './Toggle'; -import { useGlobalStore } from '../stores/global.store'; +import { useWorkspaceStore } from '../stores/workspace.store'; import { listAgents, getSubagentEnabled, @@ -13,7 +13,7 @@ import { deleteAgent, listMcpServers, } from '../lib/core-api'; -import type { ModelEntry } from '../stores/global.store'; +import type { ModelEntry } from '../stores/agent.store'; const AVAILABLE_TOOLS = [ 'read_file', @@ -78,7 +78,7 @@ export default function SubagentsPanel({ global: isGlobal }: { global?: boolean const [editingName, setEditingName] = useState(null); const [deletingName, setDeletingName] = useState(null); const [form, setForm] = useState(EMPTY_FORM); - const rootPath = useGlobalStore((s) => s.workspace.rootPath); + const rootPath = useWorkspaceStore((s) => s.rootPath); const cwd = isGlobal ? undefined : rootPath; const load = async () => { diff --git a/packages/desktop/src/stores/agent.store.ts b/packages/desktop/src/stores/agent.store.ts new file mode 100644 index 0000000..8b17bbe --- /dev/null +++ b/packages/desktop/src/stores/agent.store.ts @@ -0,0 +1,421 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { immer } from 'zustand/middleware/immer'; +import type { Thread, Turn, Item, TodoItem } from '@shared/types'; +import { buildToolDiff } from '../lib/diff-compute'; +import { createDebouncedStorage, normalizeCwd } from './storage'; +import { useRollbackStore } from './rollback.store'; + +export interface ModelEntry { + id: string; + name: string; + provider: string; + context_window: number; +} + +interface TodoPanelState { + items: TodoItem[]; + hasSeenNonEmptyTodo: boolean; + collapsed: boolean; +} + +export interface Automation { + id: string; + name: string; + description: string; + cron: string; + timezone: string; + sandbox: 'readonly' | 'workspace-write'; + enabled: boolean; + projectCwd: string; + runOnce: boolean; + createdAt: number; + updatedAt: number; + lastRunAt: number | null; + lastSessionId: string | null; +} + +interface AgentState { + currentThreadId: string | null; + threads: Record; + approvalPolicy: 'ask-all' | 'smart-allow' | 'full-allow' | 'read-only'; + model: string; + models: ModelEntry[]; + contextUsage: { used: number; contextWindow: number } | null; + todoByThreadId: Record; + pendingInput: string | null; + usageByThreadId: Record; + isCompressing: boolean; + automations: Automation[]; +} + +interface AgentActions { + setCurrentThread: (id: string | null) => void; + upsertThread: (thread: Thread) => void; + setThreadTurns: (threadId: string, turns: Turn[]) => void; + setThreadCwd: (threadId: string, cwd: string) => void; + setApprovalPolicy: (policy: AgentState['approvalPolicy']) => void; + setModel: (model: string) => void; + setModels: (models: ModelEntry[]) => void; + setContextUsage: (usage: { used: number; contextWindow: number } | null) => void; + setThreadUsage: ( + threadId: string, + usage: { prompt: number; completion: number; total: number } + ) => void; + loadThreads: (threads: Thread[]) => void; + updateToolCallStatus: ( + threadId: string, + callId: string, + status: 'pending' | 'approved' | 'rejected' | 'running' + ) => void; + startTurn: (threadId: string, turn: Turn, meta?: { cwd?: string; title?: string }) => void; + applyChunk: (threadId: string, turnId: string, chunk: Item) => void; + updateTurnId: (threadId: string, oldTurnId: string, newTurnId: string) => void; + completeTurn: (threadId: string, turnId: string, status: 'completed' | 'error') => void; + setPendingInput: (input: string | null) => void; + clearRunningTurns: (threadId: string) => void; + applyTodoUpdate: (threadId: string, items: TodoItem[]) => void; + toggleTodoCollapsed: (threadId: string) => void; + setAutomations: (automations: Automation[]) => void; + startCompressing: () => void; + stopCompressing: () => void; +} + +export const useAgentStore = create()( + persist( + immer((set) => ({ + currentThreadId: null, + threads: {}, + approvalPolicy: 'ask-all', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, + automations: [], + + setCurrentThread: (id) => + set((s) => { + s.currentThreadId = id; + if (id) { + const usage = s.usageByThreadId[id]; + const model = s.models.find((m) => m.id === s.model); + if (usage && model) { + s.contextUsage = { used: usage.total, contextWindow: model.context_window }; + } else { + s.contextUsage = null; + } + } else { + s.contextUsage = null; + } + }), + + upsertThread: (thread) => + set((s) => { + s.threads[thread.id] = thread; + }), + + setThreadTurns: (threadId, turns) => + set((s) => { + const thread = s.threads[threadId]; + if (thread) { + s.threads[threadId] = { ...thread, turns }; + } + }), + + setThreadCwd: (threadId, cwd) => + set((s) => { + const thread = s.threads[threadId]; + if (thread) thread.cwd = cwd; + }), + + setApprovalPolicy: (policy) => + set((s) => { + s.approvalPolicy = policy; + }), + + setModel: (model) => + set((s) => { + s.model = model; + }), + + setModels: (models) => + set((s) => { + s.models = models; + }), + + setContextUsage: (usage) => + set((s) => { + s.contextUsage = usage; + }), + + setThreadUsage: (threadId, usage) => + set((s) => { + s.usageByThreadId[threadId] = usage; + }), + + loadThreads: (threads) => { + const incomingIds = new Set(threads.map((t) => t.id)); + set((s) => { + const next: Record = {}; + for (const t of threads) { + const existing = s.threads[t.id]; + next[t.id] = existing ? { ...t, turns: existing.turns } : t; + } + for (const [id, thread] of Object.entries(s.threads)) { + if (!incomingIds.has(id) && thread.turns.some((t) => t.status === 'running')) { + next[id] = thread; + } + } + s.threads = next; + for (const id of Object.keys(s.usageByThreadId)) { + if (!incomingIds.has(id)) { + delete s.usageByThreadId[id]; + } + } + for (const id of Object.keys(s.todoByThreadId)) { + if (!incomingIds.has(id)) { + delete s.todoByThreadId[id]; + } + } + }); + useRollbackStore.getState().cleanupDeletedThreads(incomingIds); + }, + + updateToolCallStatus: (threadId, callId, status) => + set((s) => { + const thread = s.threads[threadId]; + if (!thread) return; + for (const turn of thread.turns) { + const idx = turn.items.findIndex((i) => i.id === callId && i.type === 'tool_call'); + if (idx >= 0) { + const existing = turn.items[idx] as Item & { type: 'tool_call' }; + turn.items[idx] = { ...existing, status }; + break; + } + } + }), + + startTurn: (threadId, turn, meta) => + set((s) => { + const thread = s.threads[threadId]; + if (!thread) { + s.threads[threadId] = { + id: threadId, + projectId: '', + title: meta?.title ?? 'New Conversation', + cwd: meta?.cwd ? normalizeCwd(meta.cwd) : '', + turns: [turn], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + } else { + thread.turns.push(turn); + thread.updatedAt = Date.now(); + } + }), + + applyChunk: (threadId, turnId, chunk) => + set((s) => { + const thread = s.threads[threadId]; + if (!thread) return; + const turn = thread.turns.find((t) => t.id === turnId); + if (!turn) return; + + if (chunk.type === 'message' && chunk.role === 'assistant' && chunk.partial) { + const existing = turn.items.find((i) => i.id === chunk.id); + if (existing && existing.type === 'message' && existing.role === 'assistant') { + existing.content += chunk.content; + existing.partial = true; + } else { + turn.items.push({ ...chunk, partial: true }); + } + return; + } + + if (chunk.type === 'message' && chunk.role === 'assistant' && chunk.partial === false) { + const existing = turn.items.findIndex((i) => i.id === chunk.id); + if (existing >= 0) { + const current = turn.items[existing]; + if (!current) return; + if (current.type === 'message' && current.role === 'assistant') { + turn.items[existing] = { + ...chunk, + content: current.content || chunk.content, + partial: false, + }; + } else { + turn.items[existing] = { ...chunk, partial: false }; + } + } else { + turn.items.push({ ...chunk, partial: false }); + } + return; + } + + if (chunk.type === 'tool_call') { + const existing = turn.items.findIndex((i) => i.id === chunk.id); + if (existing >= 0) { + const existingItem = turn.items[existing] as Item & { status?: string }; + if (existingItem.status === 'pending' && chunk.status === 'running') { + return; + } + turn.items[existing] = chunk; + } else { + turn.items.push(chunk); + } + return; + } + + if (chunk.type === 'tool_result') { + let targetChunk = chunk; + const callIdx = turn.items.findIndex( + (i) => i.type === 'tool_call' && i.id === chunk.callId + ); + if (callIdx >= 0) { + const callItem = turn.items[callIdx] as any; + callItem.status = 'approved'; + targetChunk = buildToolDiff(chunk, callItem) as any; + turn.items.push(targetChunk); + return; + } + for (const t of thread.turns) { + if (t === turn) continue; + const otherCallIdx = t.items.findIndex( + (i) => i.type === 'tool_call' && i.id === chunk.callId + ); + if (otherCallIdx >= 0) { + const callItem = t.items[otherCallIdx] as any; + callItem.status = 'approved'; + targetChunk = buildToolDiff(chunk, callItem) as any; + t.items.push(targetChunk); + return; + } + } + turn.items.push(targetChunk); + return; + } + + const existing = turn.items.findIndex((i) => i.id === chunk.id); + if (existing >= 0) { + turn.items[existing] = chunk; + } else { + turn.items.push(chunk); + } + }), + + updateTurnId: (threadId, oldTurnId, newTurnId) => + set((s) => { + const thread = s.threads[threadId]; + if (!thread) return; + const turn = thread.turns.find((t) => t.id === oldTurnId); + if (!turn) return; + turn.id = newTurnId; + }), + + completeTurn: (threadId, turnId, status) => + set((s) => { + const thread = s.threads[threadId]; + if (!thread) return; + const turn = thread.turns.find((t) => t.id === turnId); + if (!turn) return; + turn.status = status; + thread.updatedAt = Date.now(); + for (const item of turn.items) { + if (item.type === 'message' && item.role === 'assistant') { + item.partial = false; + } + } + }), + + setPendingInput: (input) => + set((s) => { + s.pendingInput = input; + }), + + clearRunningTurns: (threadId) => + set((s) => { + const thread = s.threads[threadId]; + if (!thread) return; + thread.turns = thread.turns.filter((t) => t.status !== 'running'); + }), + + applyTodoUpdate: (threadId, items) => + set((s) => { + const previous = s.todoByThreadId[threadId]; + if (items.length > 0) { + s.todoByThreadId[threadId] = { + items, + hasSeenNonEmptyTodo: true, + collapsed: previous?.collapsed ?? false, + }; + return; + } + if (previous?.hasSeenNonEmptyTodo) { + s.todoByThreadId[threadId] = { + ...previous, + items: previous.items, + hasSeenNonEmptyTodo: true, + }; + return; + } + s.todoByThreadId[threadId] = { + items: [], + hasSeenNonEmptyTodo: false, + collapsed: previous?.collapsed ?? false, + }; + }), + + toggleTodoCollapsed: (threadId) => + set((s) => { + const previous = s.todoByThreadId[threadId]; + if (!previous) return; + previous.collapsed = !previous.collapsed; + }), + + setAutomations: (automations) => + set((s) => { + s.automations = automations; + }), + + startCompressing: () => + set((s) => { + s.isCompressing = true; + }), + + stopCompressing: () => + set((s) => { + s.isCompressing = false; + }), + })), + { + name: 'codingcode-agent-store', + storage: createJSONStorage(() => createDebouncedStorage()), + partialize: (state) => ({ + approvalPolicy: state.approvalPolicy, + model: state.model, + }), + merge: (persisted, current) => { + const p = persisted as any; + const OLD_POLICY_MAP: Record = { + suggest: 'ask-all', + 'auto-edit': 'smart-allow', + 'full-auto': 'full-allow', + }; + const rawPolicy = p?.approvalPolicy; + const migratedPolicy = rawPolicy ? (OLD_POLICY_MAP[rawPolicy] ?? rawPolicy) : undefined; + return { + ...current, + ...p, + approvalPolicy: migratedPolicy ?? current.approvalPolicy, + threads: {}, + todoByThreadId: {}, + contextUsage: null, + usageByThreadId: {}, + }; + }, + } + ) +); diff --git a/packages/desktop/src/stores/files.store.ts b/packages/desktop/src/stores/files.store.ts new file mode 100644 index 0000000..f91c9bc --- /dev/null +++ b/packages/desktop/src/stores/files.store.ts @@ -0,0 +1,69 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { immer } from 'zustand/middleware/immer'; +import type { FileNode, OpenFile } from '@shared/types'; +import { createDebouncedStorage } from './storage'; + +interface FilesState { + tree: FileNode[]; + activeFilePath: string | null; + openFiles: OpenFile[]; +} + +interface FilesActions { + setFileTree: (tree: FileNode[]) => void; + setActiveFile: (path: string | null) => void; + openFile: (path: string) => void; + closeFile: (path: string) => void; + setFileDirty: (path: string, isDirty: boolean) => void; +} + +export const useFilesStore = create()( + persist( + immer((set) => ({ + tree: [], + activeFilePath: null, + openFiles: [], + + setFileTree: (tree) => + set((s) => { + s.tree = tree; + }), + setActiveFile: (path) => + set((s) => { + s.activeFilePath = path; + }), + openFile: (path) => + set((s) => { + if (!s.openFiles.find((f) => f.path === path)) { + s.openFiles.push({ path, isDirty: false }); + } + s.activeFilePath = path; + }), + closeFile: (path) => + set((s) => { + s.openFiles = s.openFiles.filter((f) => f.path !== path); + if (s.activeFilePath === path) { + const last = s.openFiles[s.openFiles.length - 1]; + s.activeFilePath = last ? last.path : null; + } + }), + setFileDirty: (path, isDirty) => + set((s) => { + const f = s.openFiles.find((f) => f.path === path); + if (f) f.isDirty = isDirty; + }), + })), + { + name: 'codingcode-files-store', + storage: createJSONStorage(() => createDebouncedStorage()), + partialize: (state) => ({ openFiles: state.openFiles }), + merge: (persisted, current) => ({ + ...current, + ...(persisted as any), + tree: [], + activeFilePath: null, + }), + } + ) +); diff --git a/packages/desktop/src/stores/global.store.ts b/packages/desktop/src/stores/global.store.ts deleted file mode 100644 index 190372b..0000000 --- a/packages/desktop/src/stores/global.store.ts +++ /dev/null @@ -1,762 +0,0 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import { immer } from 'zustand/middleware/immer'; -import type { - FileNode, - GitStatus, - Item, - OpenFile, - Project, - TerminalSession, - Thread, - Turn, - TodoItem, -} from '@shared/types'; -import type { SessionRollbackState, CheckpointDiff } from '../lib/core-api'; -import { buildToolDiff } from '../lib/diff-compute'; - -function normalizeCwd(p: string): string { - return p.replace(/\\/g, '/').replace(/^([A-Z]):/, (_, l: string) => `${l.toLowerCase()}:`); -} - -export interface ModelEntry { - id: string; - name: string; - provider: string; - context_window: number; -} - -interface UIState { - mode: 'agent' | 'ide'; - view: 'agent' | 'global-settings' | 'project-settings' | 'automation'; - sidebarCollapsed: boolean; - sidebarWidth: number; - rightPanelWidth: number; - bottomPanelHeight: number; - ideSidebarView: 'explorer' | 'search' | 'git' | 'extensions'; - theme: 'dark' | 'light' | 'paper'; -} - -interface WorkspaceState { - rootPath: string; - name: string; - projects: Project[]; - currentProjectId: string; -} - -interface FilesState { - tree: FileNode[]; - activeFilePath: string | null; - openFiles: OpenFile[]; -} - -interface TodoPanelState { - items: TodoItem[]; - hasSeenNonEmptyTodo: boolean; - collapsed: boolean; -} - -export interface Automation { - id: string; - name: string; - description: string; - cron: string; - timezone: string; - sandbox: 'readonly' | 'workspace-write'; - enabled: boolean; - projectCwd: string; - runOnce: boolean; - createdAt: number; - updatedAt: number; - lastRunAt: number | null; - lastSessionId: string | null; -} - -interface AgentState { - currentThreadId: string | null; - threads: Record; - approvalPolicy: 'ask-all' | 'smart-allow' | 'full-allow' | 'read-only'; - model: string; - models: ModelEntry[]; - contextUsage: { used: number; contextWindow: number } | null; - todoByThreadId: Record; - pendingInput: string | null; - usageByThreadId: Record; - isCompressing: boolean; - automations: Automation[]; -} - -interface RollbackState { - rollbackStateByThreadId: Record; - checkpointDiffByTurnId: Record; - revertedFilesByTurnId: Record; - turnCheckpointMapping: Record>; -} - -interface GlobalState { - ui: UIState; - workspace: WorkspaceState; - files: FilesState; - git: GitStatus; - terminals: TerminalSession[]; - agent: AgentState; - rollback: RollbackState; -} - -interface GlobalActions { - setMode: (mode: 'agent' | 'ide') => void; - setView: (view: UIState['view']) => void; - toggleSidebar: () => void; - setSidebarWidth: (w: number) => void; - setRightPanelWidth: (w: number) => void; - setBottomPanelHeight: (h: number) => void; - setIdeSidebarView: (view: UIState['ideSidebarView']) => void; - setTheme: (theme: UIState['theme']) => void; - setWorkspace: (rootPath: string, name: string) => void; - setProjects: (projects: Project[]) => void; - setCurrentProject: (id: string) => void; - addProject: (project: Project) => void; - removeProject: (id: string) => void; - switchProject: (id: string) => void; - setFileTree: (tree: FileNode[]) => void; - setActiveFile: (path: string | null) => void; - openFile: (path: string) => void; - closeFile: (path: string) => void; - setFileDirty: (path: string, isDirty: boolean) => void; - setGit: (status: GitStatus) => void; - addTerminal: (session: TerminalSession) => void; - removeTerminal: (id: string) => void; - setCurrentThread: (id: string | null) => void; - upsertThread: (thread: Thread) => void; - setThreadTurns: (threadId: string, turns: Turn[]) => void; - setThreadCwd: (threadId: string, cwd: string) => void; - setApprovalPolicy: (policy: AgentState['approvalPolicy']) => void; - setModel: (model: string) => void; - setModels: (models: ModelEntry[]) => void; - setContextUsage: (usage: { used: number; contextWindow: number } | null) => void; - setThreadUsage: ( - threadId: string, - usage: { prompt: number; completion: number; total: number } - ) => void; - loadThreads: (threads: Thread[]) => void; - updateToolCallStatus: ( - threadId: string, - callId: string, - status: 'pending' | 'approved' | 'rejected' | 'running' - ) => void; - startTurn: (threadId: string, turn: Turn, meta?: { cwd?: string; title?: string }) => void; - applyChunk: (threadId: string, turnId: string, chunk: Item) => void; - updateTurnId: (threadId: string, oldTurnId: string, newTurnId: string) => void; - completeTurn: (threadId: string, turnId: string, status: 'completed' | 'error') => void; - setPendingInput: (input: string | null) => void; - clearRunningTurns: (threadId: string) => void; - applyTodoUpdate: (threadId: string, items: TodoItem[]) => void; - toggleTodoCollapsed: (threadId: string) => void; - setAutomations: (automations: Automation[]) => void; - // Rollback state - setRollbackState: (threadId: string, state: SessionRollbackState) => void; - setCheckpointDiff: (threadId: string, turnId: string, diff: CheckpointDiff) => void; - markFileReverted: (threadId: string, turnId: string, file: string) => void; - markFileRestored: (threadId: string, turnId: string, file: string) => void; - initRevertedFilesFromState: (threadId: string) => void; - setTurnCheckpointMapping: (threadId: string, checkpointId: number, uiTurnId: string) => void; - startCompressing: () => void; - stopCompressing: () => void; -} - -const initialGit: GitStatus = { - branch: 'main', - isDirty: false, - staged: [], - unstaged: [], -}; - -// Debounced localStorage adapter to avoid blocking main thread on every set() -let persistDebounceTimer: ReturnType | undefined; -const debouncedStateStorage = { - getItem: (name: string): string | null => localStorage.getItem(name), - setItem: (name: string, value: string): void => { - clearTimeout(persistDebounceTimer); - persistDebounceTimer = setTimeout(() => { - try { - localStorage.setItem(name, value); - } catch (e) { - console.error('Failed to persist state:', e); - } - }, 500); - }, - removeItem: (name: string): void => { - clearTimeout(persistDebounceTimer); - localStorage.removeItem(name); - }, -}; - -export const useGlobalStore = create()( - persist( - immer((set) => ({ - ui: { - mode: 'agent', - view: 'agent', - sidebarCollapsed: false, - sidebarWidth: 220, - rightPanelWidth: 320, - bottomPanelHeight: 200, - ideSidebarView: 'explorer', - theme: 'dark', - }, - workspace: { - rootPath: '', - name: '', - projects: [], - currentProjectId: '', - }, - files: { - tree: [], - activeFilePath: null, - openFiles: [], - }, - git: initialGit, - terminals: [], - agent: { - currentThreadId: null, - threads: {}, - approvalPolicy: 'ask-all', - model: '', - models: [], - contextUsage: null, - todoByThreadId: {}, - pendingInput: null, - usageByThreadId: {}, - isCompressing: false, - automations: [], - }, - rollback: { - rollbackStateByThreadId: {}, - checkpointDiffByTurnId: {}, - revertedFilesByTurnId: {}, - turnCheckpointMapping: {}, - }, - - setMode: (mode) => - set((s) => { - s.ui.mode = mode; - }), - setView: (view) => - set((s) => { - s.ui.view = view; - }), - toggleSidebar: () => - set((s) => { - s.ui.sidebarCollapsed = !s.ui.sidebarCollapsed; - }), - setSidebarWidth: (w) => - set((s) => { - s.ui.sidebarWidth = w; - }), - setRightPanelWidth: (w) => - set((s) => { - s.ui.rightPanelWidth = w; - }), - setBottomPanelHeight: (h) => - set((s) => { - s.ui.bottomPanelHeight = h; - }), - setIdeSidebarView: (view) => - set((s) => { - s.ui.ideSidebarView = view; - }), - setTheme: (theme) => - set((s) => { - s.ui.theme = theme; - }), - setWorkspace: (rootPath, name) => - set((s) => { - s.workspace.rootPath = normalizeCwd(rootPath); - s.workspace.name = name; - }), - setProjects: (projects) => - set((s) => { - s.workspace.projects = projects; - }), - setCurrentProject: (id) => - set((s) => { - s.workspace.currentProjectId = id; - }), - addProject: (project) => - set((s) => { - if (!s.workspace.projects.find((p) => p.id === project.id)) { - s.workspace.projects.push(project); - } - }), - removeProject: (id) => - set((s) => { - s.workspace.projects = s.workspace.projects.filter((p) => p.id !== id); - }), - switchProject: (id) => - set((s) => { - const project = s.workspace.projects.find((p) => p.id === id); - if (!project) return; - s.workspace.currentProjectId = id; - s.workspace.rootPath = normalizeCwd(project.rootPath); - s.workspace.name = project.name; - s.agent.currentThreadId = null; - }), - setFileTree: (tree) => - set((s) => { - s.files.tree = tree; - }), - setActiveFile: (path) => - set((s) => { - s.files.activeFilePath = path; - }), - openFile: (path) => - set((s) => { - if (!s.files.openFiles.find((f) => f.path === path)) { - s.files.openFiles.push({ path, isDirty: false }); - } - s.files.activeFilePath = path; - }), - closeFile: (path) => - set((s) => { - s.files.openFiles = s.files.openFiles.filter((f) => f.path !== path); - if (s.files.activeFilePath === path) { - const last = s.files.openFiles[s.files.openFiles.length - 1]; - s.files.activeFilePath = last ? last.path : null; - } - }), - setFileDirty: (path, isDirty) => - set((s) => { - const f = s.files.openFiles.find((f) => f.path === path); - if (f) f.isDirty = isDirty; - }), - setGit: (status) => - set((s) => { - s.git = status; - }), - addTerminal: (session) => - set((s) => { - s.terminals.push(session); - }), - removeTerminal: (id) => - set((s) => { - s.terminals = s.terminals.filter((t) => t.id !== id); - }), - setCurrentThread: (id) => - set((s) => { - s.agent.currentThreadId = id; - if (id) { - const usage = s.agent.usageByThreadId[id]; - const model = s.agent.models.find((m) => m.id === s.agent.model); - if (usage && model) { - s.agent.contextUsage = { used: usage.total, contextWindow: model.context_window }; - } else { - s.agent.contextUsage = null; - } - } else { - s.agent.contextUsage = null; - } - }), - upsertThread: (thread) => - set((s) => { - s.agent.threads[thread.id] = thread; - }), - setThreadTurns: (threadId, turns) => - set((s) => { - const thread = s.agent.threads[threadId]; - if (thread) { - s.agent.threads[threadId] = { ...thread, turns }; - } - }), - setThreadCwd: (threadId, cwd) => - set((s) => { - const thread = s.agent.threads[threadId]; - if (thread) thread.cwd = cwd; - }), - setApprovalPolicy: (policy) => - set((s) => { - s.agent.approvalPolicy = policy; - }), - setModel: (model) => - set((s) => { - s.agent.model = model; - }), - setModels: (models) => - set((s) => { - s.agent.models = models; - }), - setContextUsage: (usage) => - set((s) => { - s.agent.contextUsage = usage; - }), - setThreadUsage: (threadId, usage) => - set((s) => { - s.agent.usageByThreadId[threadId] = usage; - }), - loadThreads: (threads) => - set((s) => { - const incomingIds = new Set(threads.map((t) => t.id)); - const next: Record = {}; - for (const t of threads) { - const existing = s.agent.threads[t.id]; - next[t.id] = existing ? { ...t, turns: existing.turns } : t; - } - for (const [id, thread] of Object.entries(s.agent.threads)) { - if (!incomingIds.has(id) && thread.turns.some((t) => t.status === 'running')) { - next[id] = thread; - } - } - s.agent.threads = next; - // Clean up usage entries for deleted threads - for (const id of Object.keys(s.agent.usageByThreadId)) { - if (!incomingIds.has(id)) { - delete s.agent.usageByThreadId[id]; - } - } - // Clean up todoByThreadId for deleted threads - for (const id of Object.keys(s.agent.todoByThreadId)) { - if (!incomingIds.has(id)) { - delete s.agent.todoByThreadId[id]; - } - } - // Clean up rollback data for deleted threads - for (const id of Object.keys(s.rollback.rollbackStateByThreadId)) { - if (!incomingIds.has(id)) { - delete s.rollback.rollbackStateByThreadId[id]; - } - } - for (const key of Object.keys(s.rollback.checkpointDiffByTurnId)) { - const threadId = key.split(':')[0]; - if (threadId && !incomingIds.has(threadId)) { - delete s.rollback.checkpointDiffByTurnId[key]; - } - } - for (const id of Object.keys(s.rollback.revertedFilesByTurnId)) { - const threadId = id.split(':')[0]; - if (threadId && !incomingIds.has(threadId)) { - delete s.rollback.revertedFilesByTurnId[id]; - } - } - for (const id of Object.keys(s.rollback.turnCheckpointMapping)) { - if (!incomingIds.has(id)) { - delete s.rollback.turnCheckpointMapping[id]; - } - } - }), - - updateToolCallStatus: (threadId, callId, status) => - set((s) => { - const thread = s.agent.threads[threadId]; - if (!thread) return; - for (const turn of thread.turns) { - const idx = turn.items.findIndex((i) => i.id === callId && i.type === 'tool_call'); - if (idx >= 0) { - const existing = turn.items[idx] as Item & { type: 'tool_call' }; - turn.items[idx] = { ...existing, status }; - break; - } - } - }), - - startTurn: (threadId, turn, meta) => - set((s) => { - const thread = s.agent.threads[threadId]; - if (!thread) { - s.agent.threads[threadId] = { - id: threadId, - projectId: '', - title: meta?.title ?? 'New Conversation', - cwd: meta?.cwd ? normalizeCwd(meta.cwd) : '', - turns: [turn], - createdAt: Date.now(), - updatedAt: Date.now(), - }; - } else { - thread.turns.push(turn); - thread.updatedAt = Date.now(); - } - }), - - applyChunk: (threadId, turnId, chunk) => - set((s) => { - const thread = s.agent.threads[threadId]; - if (!thread) return; - const turn = thread.turns.find((t) => t.id === turnId); - if (!turn) return; - - if (chunk.type === 'message' && chunk.role === 'assistant' && chunk.partial) { - const existing = turn.items.find((i) => i.id === chunk.id); - if (existing && existing.type === 'message' && existing.role === 'assistant') { - existing.content += chunk.content; - existing.partial = true; - } else { - turn.items.push({ ...chunk, partial: true }); - } - return; - } - - if (chunk.type === 'message' && chunk.role === 'assistant' && chunk.partial === false) { - const existing = turn.items.findIndex((i) => i.id === chunk.id); - if (existing >= 0) { - const current = turn.items[existing]; - if (!current) return; - if (current.type === 'message' && current.role === 'assistant') { - turn.items[existing] = { - ...chunk, - content: current.content || chunk.content, - partial: false, - }; - } else { - turn.items[existing] = { ...chunk, partial: false }; - } - } else { - turn.items.push({ ...chunk, partial: false }); - } - return; - } - - if (chunk.type === 'tool_call') { - const existing = turn.items.findIndex((i) => i.id === chunk.id); - if (existing >= 0) { - const existingItem = turn.items[existing] as Item & { status?: string }; - if (existingItem.status === 'pending' && chunk.status === 'running') { - return; - } - turn.items[existing] = chunk; - } else { - turn.items.push(chunk); - } - return; - } - - if (chunk.type === 'tool_result') { - let targetChunk = chunk; - // Search current turn first (most common case) - const callIdx = turn.items.findIndex( - (i) => i.type === 'tool_call' && i.id === chunk.callId - ); - if (callIdx >= 0) { - const callItem = turn.items[callIdx] as any; - callItem.status = 'approved'; - targetChunk = buildToolDiff(chunk, callItem) as any; - // Push to end instead of splice to avoid shifting existing item indices - turn.items.push(targetChunk); - return; - } - // Fallback: search other turns (rare) - for (const t of thread.turns) { - if (t === turn) continue; - const otherCallIdx = t.items.findIndex( - (i) => i.type === 'tool_call' && i.id === chunk.callId - ); - if (otherCallIdx >= 0) { - const callItem = t.items[otherCallIdx] as any; - callItem.status = 'approved'; - targetChunk = buildToolDiff(chunk, callItem) as any; - t.items.push(targetChunk); - return; - } - } - turn.items.push(targetChunk); - return; - } - - const existing = turn.items.findIndex((i) => i.id === chunk.id); - if (existing >= 0) { - turn.items[existing] = chunk; - } else { - turn.items.push(chunk); - } - }), - - updateTurnId: (threadId, oldTurnId, newTurnId) => - set((s) => { - const thread = s.agent.threads[threadId]; - if (!thread) return; - const turn = thread.turns.find((t) => t.id === oldTurnId); - if (!turn) return; - turn.id = newTurnId; - }), - - completeTurn: (threadId, turnId, status) => - set((s) => { - const thread = s.agent.threads[threadId]; - if (!thread) return; - const turn = thread.turns.find((t) => t.id === turnId); - if (!turn) return; - turn.status = status; - thread.updatedAt = Date.now(); - for (const item of turn.items) { - if (item.type === 'message' && item.role === 'assistant') { - item.partial = false; - } - } - }), - - setPendingInput: (input) => - set((s) => { - s.agent.pendingInput = input; - }), - - clearRunningTurns: (threadId) => - set((s) => { - const thread = s.agent.threads[threadId]; - if (!thread) return; - thread.turns = thread.turns.filter((t) => t.status !== 'running'); - }), - - applyTodoUpdate: (threadId, items) => - set((s) => { - const previous = s.agent.todoByThreadId[threadId]; - if (items.length > 0) { - s.agent.todoByThreadId[threadId] = { - items, - hasSeenNonEmptyTodo: true, - collapsed: previous?.collapsed ?? false, - }; - return; - } - if (previous?.hasSeenNonEmptyTodo) { - s.agent.todoByThreadId[threadId] = { - ...previous, - items: previous.items, - hasSeenNonEmptyTodo: true, - }; - return; - } - s.agent.todoByThreadId[threadId] = { - items: [], - hasSeenNonEmptyTodo: false, - collapsed: previous?.collapsed ?? false, - }; - }), - - toggleTodoCollapsed: (threadId) => - set((s) => { - const previous = s.agent.todoByThreadId[threadId]; - if (!previous) return; - previous.collapsed = !previous.collapsed; - }), - - setAutomations: (automations) => - set((s) => { - s.agent.automations = automations; - }), - - // Rollback actions - setRollbackState: (threadId, state) => - set((s) => { - s.rollback.rollbackStateByThreadId[threadId] = state as any; - }), - setCheckpointDiff: (threadId, turnId, diff) => - set((s) => { - s.rollback.checkpointDiffByTurnId[`${threadId}:${turnId}`] = diff as any; - }), - markFileReverted: (threadId, turnId, file) => - set((s) => { - const key = `${threadId}:${turnId}`; - if (!s.rollback.revertedFilesByTurnId[key]) { - s.rollback.revertedFilesByTurnId[key] = []; - } - if (!s.rollback.revertedFilesByTurnId[key].includes(file)) { - s.rollback.revertedFilesByTurnId[key].push(file); - } - }), - markFileRestored: (threadId, turnId, file) => - set((s) => { - const key = `${threadId}:${turnId}`; - const arr = s.rollback.revertedFilesByTurnId[key]; - if (arr) { - s.rollback.revertedFilesByTurnId[key] = arr.filter((f) => f !== file); - } - }), - initRevertedFilesFromState: (threadId) => - set((s) => { - const state = s.rollback.rollbackStateByThreadId[threadId]; - if (!state) return; - const revertedFiles = state.code.revertedFiles ?? []; - const checkpointTurnId = state.code.lastEntry?.throughTurnId; - if (revertedFiles.length === 0 || checkpointTurnId === undefined) return; - const uiTurnId = s.rollback.turnCheckpointMapping[threadId]?.[checkpointTurnId]; - if (!uiTurnId) return; - const key = `${threadId}:${uiTurnId}`; - s.rollback.revertedFilesByTurnId[key] = revertedFiles; - }), - setTurnCheckpointMapping: (threadId, checkpointId, uiTurnId) => - set((s) => { - if (!s.rollback.turnCheckpointMapping[threadId]) { - s.rollback.turnCheckpointMapping[threadId] = {}; - } - s.rollback.turnCheckpointMapping[threadId][checkpointId] = uiTurnId; - }), - startCompressing: () => - set((s) => { - s.agent.isCompressing = true; - }), - stopCompressing: () => - set((s) => { - s.agent.isCompressing = false; - }), - })), - { - name: 'codingcode-desktop-store', - storage: createJSONStorage(() => debouncedStateStorage), - partialize: (state) => ({ - ui: { - mode: state.ui.mode, - view: state.ui.view, - sidebarCollapsed: state.ui.sidebarCollapsed, - sidebarWidth: state.ui.sidebarWidth, - rightPanelWidth: state.ui.rightPanelWidth, - bottomPanelHeight: state.ui.bottomPanelHeight, - ideSidebarView: state.ui.ideSidebarView, - theme: state.ui.theme, - }, - workspace: { - rootPath: state.workspace.rootPath, - name: state.workspace.name, - projects: state.workspace.projects, - currentProjectId: state.workspace.currentProjectId, - }, - files: { - openFiles: state.files.openFiles, - }, - agent: { - approvalPolicy: state.agent.approvalPolicy, - model: state.agent.model, - }, - }), - merge: (persisted, current) => { - const persistedAny = persisted as any; - // Migrate old approvalPolicy values to new names - const OLD_POLICY_MAP: Record = { - suggest: 'ask-all', - 'auto-edit': 'smart-allow', - 'full-auto': 'full-allow', - }; - const rawPolicy = persistedAny?.agent?.approvalPolicy; - const migratedPolicy = rawPolicy ? (OLD_POLICY_MAP[rawPolicy] ?? rawPolicy) : undefined; - return { - ...current, - ...persistedAny, - git: initialGit, - terminals: [], - files: { - ...current.files, - ...persistedAny.files, - tree: [], - activeFilePath: null, - }, - agent: { - ...current.agent, - ...persistedAny.agent, - approvalPolicy: migratedPolicy ?? current.agent.approvalPolicy, - threads: {}, - todoByThreadId: {}, - contextUsage: null, - usageByThreadId: {}, - }, - }; - }, - } - ) -); diff --git a/packages/desktop/src/stores/rollback.store.ts b/packages/desktop/src/stores/rollback.store.ts new file mode 100644 index 0000000..1b3cb9c --- /dev/null +++ b/packages/desktop/src/stores/rollback.store.ts @@ -0,0 +1,106 @@ +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; +import type { SessionRollbackState, CheckpointDiff } from '../lib/core-api'; + +interface RollbackState { + rollbackStateByThreadId: Record; + checkpointDiffByTurnId: Record; + revertedFilesByTurnId: Record; + turnCheckpointMapping: Record>; +} + +interface RollbackActions { + setRollbackState: (threadId: string, state: SessionRollbackState) => void; + setCheckpointDiff: (threadId: string, turnId: string, diff: CheckpointDiff) => void; + markFileReverted: (threadId: string, turnId: string, file: string) => void; + markFileRestored: (threadId: string, turnId: string, file: string) => void; + initRevertedFilesFromState: (threadId: string) => void; + setTurnCheckpointMapping: (threadId: string, checkpointId: number, uiTurnId: string) => void; + cleanupDeletedThreads: (incomingIds: Set) => void; +} + +export const useRollbackStore = create()( + immer((set) => ({ + rollbackStateByThreadId: {}, + checkpointDiffByTurnId: {}, + revertedFilesByTurnId: {}, + turnCheckpointMapping: {}, + + setRollbackState: (threadId, state) => + set((s) => { + s.rollbackStateByThreadId[threadId] = state as any; + }), + + setCheckpointDiff: (threadId, turnId, diff) => + set((s) => { + s.checkpointDiffByTurnId[`${threadId}:${turnId}`] = diff as any; + }), + + markFileReverted: (threadId, turnId, file) => + set((s) => { + const key = `${threadId}:${turnId}`; + if (!s.revertedFilesByTurnId[key]) { + s.revertedFilesByTurnId[key] = []; + } + if (!s.revertedFilesByTurnId[key].includes(file)) { + s.revertedFilesByTurnId[key].push(file); + } + }), + + markFileRestored: (threadId, turnId, file) => + set((s) => { + const key = `${threadId}:${turnId}`; + const arr = s.revertedFilesByTurnId[key]; + if (arr) { + s.revertedFilesByTurnId[key] = arr.filter((f) => f !== file); + } + }), + + initRevertedFilesFromState: (threadId) => + set((s) => { + const state = s.rollbackStateByThreadId[threadId]; + if (!state) return; + const revertedFiles = state.code.revertedFiles ?? []; + const checkpointTurnId = state.code.lastEntry?.throughTurnId; + if (revertedFiles.length === 0 || checkpointTurnId === undefined) return; + const uiTurnId = s.turnCheckpointMapping[threadId]?.[checkpointTurnId]; + if (!uiTurnId) return; + const key = `${threadId}:${uiTurnId}`; + s.revertedFilesByTurnId[key] = revertedFiles; + }), + + setTurnCheckpointMapping: (threadId, checkpointId, uiTurnId) => + set((s) => { + if (!s.turnCheckpointMapping[threadId]) { + s.turnCheckpointMapping[threadId] = {}; + } + s.turnCheckpointMapping[threadId][checkpointId] = uiTurnId; + }), + + cleanupDeletedThreads: (incomingIds) => + set((s) => { + for (const id of Object.keys(s.rollbackStateByThreadId)) { + if (!incomingIds.has(id)) { + delete s.rollbackStateByThreadId[id]; + } + } + for (const key of Object.keys(s.checkpointDiffByTurnId)) { + const threadId = key.split(':')[0]; + if (threadId && !incomingIds.has(threadId)) { + delete s.checkpointDiffByTurnId[key]; + } + } + for (const id of Object.keys(s.revertedFilesByTurnId)) { + const threadId = id.split(':')[0]; + if (threadId && !incomingIds.has(threadId)) { + delete s.revertedFilesByTurnId[id]; + } + } + for (const id of Object.keys(s.turnCheckpointMapping)) { + if (!incomingIds.has(id)) { + delete s.turnCheckpointMapping[id]; + } + } + }), + })) +); diff --git a/packages/desktop/src/stores/storage.ts b/packages/desktop/src/stores/storage.ts new file mode 100644 index 0000000..5276656 --- /dev/null +++ b/packages/desktop/src/stores/storage.ts @@ -0,0 +1,24 @@ +export function createDebouncedStorage() { + let timer: ReturnType | undefined; + return { + getItem: (name: string): string | null => localStorage.getItem(name), + setItem: (name: string, value: string): void => { + clearTimeout(timer); + timer = setTimeout(() => { + try { + localStorage.setItem(name, value); + } catch (e) { + console.error('Failed to persist state:', e); + } + }, 500); + }, + removeItem: (name: string): void => { + clearTimeout(timer); + localStorage.removeItem(name); + }, + }; +} + +export function normalizeCwd(p: string): string { + return p.replace(/\\/g, '/').replace(/^([A-Z]):/, (_, l: string) => `${l.toLowerCase()}:`); +} diff --git a/packages/desktop/src/stores/ui.store.ts b/packages/desktop/src/stores/ui.store.ts new file mode 100644 index 0000000..4e4206c --- /dev/null +++ b/packages/desktop/src/stores/ui.store.ts @@ -0,0 +1,78 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { immer } from 'zustand/middleware/immer'; +import { createDebouncedStorage } from './storage'; + +interface UIState { + mode: 'agent' | 'ide'; + view: 'agent' | 'global-settings' | 'project-settings' | 'automation'; + sidebarCollapsed: boolean; + sidebarWidth: number; + rightPanelWidth: number; + bottomPanelHeight: number; + ideSidebarView: 'explorer' | 'search' | 'git' | 'extensions'; + theme: 'dark' | 'light' | 'paper'; +} + +interface UIActions { + setMode: (mode: 'agent' | 'ide') => void; + setView: (view: UIState['view']) => void; + toggleSidebar: () => void; + setSidebarWidth: (w: number) => void; + setRightPanelWidth: (w: number) => void; + setBottomPanelHeight: (h: number) => void; + setIdeSidebarView: (view: UIState['ideSidebarView']) => void; + setTheme: (theme: UIState['theme']) => void; +} + +export const useUIStore = create()( + persist( + immer((set) => ({ + mode: 'agent', + view: 'agent', + sidebarCollapsed: false, + sidebarWidth: 220, + rightPanelWidth: 320, + bottomPanelHeight: 200, + ideSidebarView: 'explorer', + theme: 'dark', + + setMode: (mode) => + set((s) => { + s.mode = mode; + }), + setView: (view) => + set((s) => { + s.view = view; + }), + toggleSidebar: () => + set((s) => { + s.sidebarCollapsed = !s.sidebarCollapsed; + }), + setSidebarWidth: (w) => + set((s) => { + s.sidebarWidth = w; + }), + setRightPanelWidth: (w) => + set((s) => { + s.rightPanelWidth = w; + }), + setBottomPanelHeight: (h) => + set((s) => { + s.bottomPanelHeight = h; + }), + setIdeSidebarView: (view) => + set((s) => { + s.ideSidebarView = view; + }), + setTheme: (theme) => + set((s) => { + s.theme = theme; + }), + })), + { + name: 'codingcode-ui-store', + storage: createJSONStorage(() => createDebouncedStorage()), + } + ) +); diff --git a/packages/desktop/src/stores/workspace.store.ts b/packages/desktop/src/stores/workspace.store.ts new file mode 100644 index 0000000..fd55b16 --- /dev/null +++ b/packages/desktop/src/stores/workspace.store.ts @@ -0,0 +1,103 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { immer } from 'zustand/middleware/immer'; +import type { Project, GitStatus } from '@shared/types'; +import { createDebouncedStorage, normalizeCwd } from './storage'; +import { useAgentStore } from './agent.store'; + +interface WorkspaceState { + rootPath: string; + name: string; + projects: Project[]; + currentProjectId: string; + git: GitStatus; +} + +interface WorkspaceActions { + setWorkspace: (rootPath: string, name: string) => void; + setProjects: (projects: Project[]) => void; + setCurrentProject: (id: string) => void; + addProject: (project: Project) => void; + removeProject: (id: string) => void; + switchProject: (id: string) => void; + setGit: (status: GitStatus) => void; +} + +const initialGit: GitStatus = { + branch: 'main', + isDirty: false, + staged: [], + unstaged: [], +}; + +export const useWorkspaceStore = create()( + persist( + immer((set) => ({ + rootPath: '', + name: '', + projects: [], + currentProjectId: '', + git: initialGit, + + setWorkspace: (rootPath, name) => + set((s) => { + s.rootPath = normalizeCwd(rootPath); + s.name = name; + }), + + setProjects: (projects) => + set((s) => { + s.projects = projects; + }), + + setCurrentProject: (id) => + set((s) => { + s.currentProjectId = id; + }), + + addProject: (project) => + set((s) => { + if (!s.projects.find((p) => p.id === project.id)) { + s.projects.push(project); + } + }), + + removeProject: (id) => + set((s) => { + s.projects = s.projects.filter((p) => p.id !== id); + }), + + switchProject: (id) => { + let found = false; + set((s) => { + const project = s.projects.find((p) => p.id === id); + if (!project) return; + found = true; + s.currentProjectId = id; + s.rootPath = normalizeCwd(project.rootPath); + s.name = project.name; + }); + if (found) { + useAgentStore.getState().setCurrentThread(null); + } + }, + + setGit: (status) => + set((s) => { + s.git = status; + }), + })), + { + name: 'codingcode-workspace-store', + storage: createJSONStorage(() => createDebouncedStorage()), + merge: (persisted, current) => { + const p = persisted as any; + return { + ...current, + ...p, + git: initialGit, + }; + }, + } + ) +); diff --git a/packages/desktop/test/global-store-rollback-state.test.ts b/packages/desktop/test/global-store-rollback-state.test.ts index edba340..2c9cb70 100644 --- a/packages/desktop/test/global-store-rollback-state.test.ts +++ b/packages/desktop/test/global-store-rollback-state.test.ts @@ -1,16 +1,14 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useGlobalStore } from '../src/stores/global.store'; +import { useRollbackStore } from '../src/stores/rollback.store'; describe('Rollback state in global store', () => { beforeEach(() => { // Reset the store state - useGlobalStore.setState({ - rollback: { + useRollbackStore.setState({ rollbackStateByThreadId: {}, checkpointDiffByTurnId: {}, revertedFilesByTurnId: {}, turnCheckpointMapping: {}, - }, }); }); @@ -24,9 +22,9 @@ describe('Rollback state in global store', () => { lastEntryId: 'entry1', }, }; - useGlobalStore.getState().setRollbackState('thread1', state as any); + useRollbackStore.getState().setRollbackState('thread1', state as any); - const stored = useGlobalStore.getState().rollback.rollbackStateByThreadId['thread1']; + const stored = useRollbackStore.getState().rollbackStateByThreadId['thread1']; expect(stored).toBeDefined(); expect(stored!.code.revertedFiles).toEqual(['/test/file.ts']); }); @@ -45,9 +43,9 @@ describe('Rollback state in global store', () => { }, ], }; - useGlobalStore.getState().setCheckpointDiff('thread1', '3', diff); + useRollbackStore.getState().setCheckpointDiff('thread1', '3', diff); - const cached = useGlobalStore.getState().rollback.checkpointDiffByTurnId['thread1:3']; + const cached = useRollbackStore.getState().checkpointDiffByTurnId['thread1:3']; expect(cached).toBeDefined(); expect(cached!.turnId).toBe(3); expect(cached!.files).toHaveLength(1); @@ -56,29 +54,29 @@ describe('Rollback state in global store', () => { }); it('markFileReverted adds file to reverted list', () => { - useGlobalStore.getState().markFileReverted('thread1', '3', '/test/a.ts'); - const reverted = useGlobalStore.getState().rollback.revertedFilesByTurnId['thread1:3']; + useRollbackStore.getState().markFileReverted('thread1', '3', '/test/a.ts'); + const reverted = useRollbackStore.getState().revertedFilesByTurnId['thread1:3']; expect(reverted).toContain('/test/a.ts'); }); it('markFileReverted does not duplicate entries', () => { - useGlobalStore.getState().markFileReverted('thread1', '3', '/test/a.ts'); - useGlobalStore.getState().markFileReverted('thread1', '3', '/test/a.ts'); - const reverted = useGlobalStore.getState().rollback.revertedFilesByTurnId['thread1:3']; + useRollbackStore.getState().markFileReverted('thread1', '3', '/test/a.ts'); + useRollbackStore.getState().markFileReverted('thread1', '3', '/test/a.ts'); + const reverted = useRollbackStore.getState().revertedFilesByTurnId['thread1:3']; expect(reverted).toEqual(['/test/a.ts']); }); it('markFileRestored removes file from reverted list', () => { - useGlobalStore.getState().markFileReverted('thread1', '3', '/test/a.ts'); - useGlobalStore.getState().markFileReverted('thread1', '3', '/test/b.ts'); - useGlobalStore.getState().markFileRestored('thread1', '3', '/test/a.ts'); + useRollbackStore.getState().markFileReverted('thread1', '3', '/test/a.ts'); + useRollbackStore.getState().markFileReverted('thread1', '3', '/test/b.ts'); + useRollbackStore.getState().markFileRestored('thread1', '3', '/test/a.ts'); - const reverted = useGlobalStore.getState().rollback.revertedFilesByTurnId['thread1:3']; + const reverted = useRollbackStore.getState().revertedFilesByTurnId['thread1:3']; expect(reverted).toEqual(['/test/b.ts']); }); it('initRevertedFilesFromState populates from server state', () => { - useGlobalStore.getState().setTurnCheckpointMapping('thread1', 5, 'ui-turn-5'); + useRollbackStore.getState().setTurnCheckpointMapping('thread1', 5, 'ui-turn-5'); const state = { context: { active: false, currentThroughTurnId: null }, code: { @@ -88,17 +86,17 @@ describe('Rollback state in global store', () => { lastEntryId: 'e1', }, }; - useGlobalStore.getState().setRollbackState('thread1', state as any); - useGlobalStore.getState().initRevertedFilesFromState('thread1'); + useRollbackStore.getState().setRollbackState('thread1', state as any); + useRollbackStore.getState().initRevertedFilesFromState('thread1'); const key = 'thread1:ui-turn-5'; - const reverted = useGlobalStore.getState().rollback.revertedFilesByTurnId[key]; + const reverted = useRollbackStore.getState().revertedFilesByTurnId[key]; expect(reverted).toEqual(['/a.ts', '/b.ts']); }); it('setTurnCheckpointMapping links checkpoint turnId to UI turnId', () => { - useGlobalStore.getState().setTurnCheckpointMapping('thread1', 1, 'ui-turn-1'); - const mapping = useGlobalStore.getState().rollback.turnCheckpointMapping['thread1']; + useRollbackStore.getState().setTurnCheckpointMapping('thread1', 1, 'ui-turn-1'); + const mapping = useRollbackStore.getState().turnCheckpointMapping['thread1']; expect(mapping).toBeDefined(); expect(mapping![1]).toBe('ui-turn-1'); }); @@ -117,9 +115,9 @@ describe('Rollback state in global store', () => { }, ], }; - useGlobalStore.getState().setCheckpointDiff('thread1', '1', diff); + useRollbackStore.getState().setCheckpointDiff('thread1', '1', diff); - const direct = useGlobalStore.getState().rollback.checkpointDiffByTurnId['thread1:1']; + const direct = useRollbackStore.getState().checkpointDiffByTurnId['thread1:1']; expect(direct).toBeDefined(); expect(direct!.turnId).toBe(1); }); @@ -138,22 +136,22 @@ describe('Rollback state in global store', () => { }, ], }; - useGlobalStore.getState().setCheckpointDiff('thread1', '2', diff); - useGlobalStore.getState().setTurnCheckpointMapping('thread1', 2, 'ui-turn-2'); + useRollbackStore.getState().setCheckpointDiff('thread1', '2', diff); + useRollbackStore.getState().setTurnCheckpointMapping('thread1', 2, 'ui-turn-2'); - const mapping = useGlobalStore.getState().rollback.turnCheckpointMapping['thread1']; + const mapping = useRollbackStore.getState().turnCheckpointMapping['thread1']; expect(mapping![2]).toBe('ui-turn-2'); // Simulating getCheckpointKey logic: when directKey misses, mapping resolves it const uiTurnId = 'ui-turn-2'; const directKey = 'thread1:ui-turn-2'; - const cached = useGlobalStore.getState().rollback.checkpointDiffByTurnId[directKey]; + const cached = useRollbackStore.getState().checkpointDiffByTurnId[directKey]; expect(cached).toBeUndefined(); for (const [cpId, mappedUiId] of Object.entries(mapping!)) { if (mappedUiId === uiTurnId) { const resolvedKey = `thread1:${cpId}`; - const resolvedDiff = useGlobalStore.getState().rollback.checkpointDiffByTurnId[resolvedKey]; + const resolvedDiff = useRollbackStore.getState().checkpointDiffByTurnId[resolvedKey]; expect(resolvedDiff).toBeDefined(); expect(resolvedDiff!.turnId).toBe(2); return; diff --git a/packages/desktop/test/global-store.test.ts b/packages/desktop/test/global-store.test.ts index f8d5c19..a6bd9f3 100644 --- a/packages/desktop/test/global-store.test.ts +++ b/packages/desktop/test/global-store.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useGlobalStore } from '../src/stores/global.store'; +import { useAgentStore } from '../src/stores/agent.store'; +import { useWorkspaceStore } from '../src/stores/workspace.store'; +import { useRollbackStore } from '../src/stores/rollback.store'; import type { Item, Turn, Project } from '../shared/types'; function freshProject(id: string, rootPath: string): Project { @@ -8,25 +10,23 @@ function freshProject(id: string, rootPath: string): Project { } beforeEach(() => { - useGlobalStore.setState({ - agent: { - currentThreadId: null, - threads: {}, - approvalPolicy: 'ask-all', - model: '', - models: [], - contextUsage: null, - todoByThreadId: {}, - pendingInput: null, - usageByThreadId: {}, - isCompressing: false, - }, - workspace: { - rootPath: '', - name: '', - projects: [], - currentProjectId: '', - }, + useAgentStore.setState({ + currentThreadId: null, + threads: {}, + approvalPolicy: 'ask-all', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, + }); + useWorkspaceStore.setState({ + rootPath: '', + name: '', + projects: [], + currentProjectId: '', }); }); @@ -40,9 +40,9 @@ describe('global store - agent streaming actions', () => { it('startTurn creates a thread if missing', () => { const turn = makeTurn([{ id: 'u1', type: 'message', role: 'user', content: 'hello' }]); - useGlobalStore.getState().startTurn(threadId, turn); + useAgentStore.getState().startTurn(threadId, turn); - const thread = useGlobalStore.getState().agent.threads[threadId]; + const thread = useAgentStore.getState().threads[threadId]; expect(thread).toBeDefined(); expect(thread.turns).toHaveLength(1); expect(thread.turns[0].id).toBe(turnId); @@ -51,7 +51,7 @@ describe('global store - agent streaming actions', () => { it('applyChunk adds streaming assistant item to turn.items and accumulates content', () => { const turn = makeTurn([]); - useGlobalStore.getState().startTurn(threadId, turn); + useAgentStore.getState().startTurn(threadId, turn); const delta1: Item = { id: 'msg-1', @@ -67,10 +67,10 @@ describe('global store - agent streaming actions', () => { content: ' world', partial: true, }; - useGlobalStore.getState().applyChunk(threadId, turnId, delta1); - useGlobalStore.getState().applyChunk(threadId, turnId, delta2); + useAgentStore.getState().applyChunk(threadId, turnId, delta1); + useAgentStore.getState().applyChunk(threadId, turnId, delta2); - const items = useGlobalStore.getState().agent.threads[threadId].turns[0].items; + const items = useAgentStore.getState().threads[threadId].turns[0].items; const message = items.find((i) => i.id === 'msg-1'); expect(message).toBeDefined(); expect((message as any).partial).toBe(true); @@ -80,17 +80,17 @@ describe('global store - agent streaming actions', () => { it('applyChunk commits partial=false message to turn.items', () => { const turn = makeTurn([]); - useGlobalStore.getState().startTurn(threadId, turn); + useAgentStore.getState().startTurn(threadId, turn); // Accumulate some text - useGlobalStore.getState().applyChunk(threadId, turnId, { + useAgentStore.getState().applyChunk(threadId, turnId, { id: 'msg-1', type: 'message', role: 'assistant', content: 'Hello ', partial: true, }); - useGlobalStore.getState().applyChunk(threadId, turnId, { + useAgentStore.getState().applyChunk(threadId, turnId, { id: 'msg-1', type: 'message', role: 'assistant', @@ -99,7 +99,7 @@ describe('global store - agent streaming actions', () => { }); // Commit - useGlobalStore.getState().applyChunk(threadId, turnId, { + useAgentStore.getState().applyChunk(threadId, turnId, { id: 'msg-1', type: 'message', role: 'assistant', @@ -107,7 +107,7 @@ describe('global store - agent streaming actions', () => { partial: false, }); - const items = useGlobalStore.getState().agent.threads[threadId].turns[0].items; + const items = useAgentStore.getState().threads[threadId].turns[0].items; const committed = items.find((i) => i.id === 'msg-1'); expect(committed).toBeDefined(); expect((committed as any).content).toBe('Hello world'); @@ -116,7 +116,7 @@ describe('global store - agent streaming actions', () => { it('applyChunk upserts tool_call by id', () => { const turn = makeTurn([]); - useGlobalStore.getState().startTurn(threadId, turn); + useAgentStore.getState().startTurn(threadId, turn); const pending: Item = { id: 'call-1', @@ -125,7 +125,7 @@ describe('global store - agent streaming actions', () => { args: {}, status: 'pending', }; - useGlobalStore.getState().applyChunk(threadId, turnId, pending); + useAgentStore.getState().applyChunk(threadId, turnId, pending); const running: Item = { id: 'call-1', @@ -134,9 +134,9 @@ describe('global store - agent streaming actions', () => { args: {}, status: 'running', }; - useGlobalStore.getState().applyChunk(threadId, turnId, running); + useAgentStore.getState().applyChunk(threadId, turnId, running); - const items = useGlobalStore.getState().agent.threads[threadId].turns[0].items; + const items = useAgentStore.getState().threads[threadId].turns[0].items; const toolItem = items.find((i) => i.id === 'call-1'); expect(toolItem).toBeDefined(); expect((toolItem as any).status).toBe('pending'); @@ -146,7 +146,7 @@ describe('global store - agent streaming actions', () => { it('applyChunk marks matching tool_call as rejected via id upsert', () => { const turn = makeTurn([]); - useGlobalStore.getState().startTurn(threadId, turn); + useAgentStore.getState().startTurn(threadId, turn); const toolCall: Item = { id: 'tc-rej', @@ -155,7 +155,7 @@ describe('global store - agent streaming actions', () => { args: {}, status: 'pending', }; - useGlobalStore.getState().applyChunk(threadId, turnId, toolCall); + useAgentStore.getState().applyChunk(threadId, turnId, toolCall); const denied: Item = { id: 'tc-rej', @@ -164,16 +164,16 @@ describe('global store - agent streaming actions', () => { args: {}, status: 'rejected', }; - useGlobalStore.getState().applyChunk(threadId, turnId, denied); + useAgentStore.getState().applyChunk(threadId, turnId, denied); - const items = useGlobalStore.getState().agent.threads[threadId].turns[0].items; + const items = useAgentStore.getState().threads[threadId].turns[0].items; expect(items).toHaveLength(1); expect((items[0] as any).status).toBe('rejected'); }); it('applyChunk marks matching tool_call as approved via callId', () => { const turn = makeTurn([]); - useGlobalStore.getState().startTurn(threadId, turn); + useAgentStore.getState().startTurn(threadId, turn); const toolCall: Item = { id: 'tc-same', @@ -182,7 +182,7 @@ describe('global store - agent streaming actions', () => { args: { path: 'foo.ts' }, status: 'running', }; - useGlobalStore.getState().applyChunk(threadId, turnId, toolCall); + useAgentStore.getState().applyChunk(threadId, turnId, toolCall); const toolResult: Item = { id: 'res-1', @@ -192,9 +192,9 @@ describe('global store - agent streaming actions', () => { output: 'ok', exitCode: 0, }; - useGlobalStore.getState().applyChunk(threadId, turnId, toolResult); + useAgentStore.getState().applyChunk(threadId, turnId, toolResult); - const items = useGlobalStore.getState().agent.threads[threadId].turns[0].items; + const items = useAgentStore.getState().threads[threadId].turns[0].items; const call = items.find((i) => i.id === 'tc-same'); expect(call).toBeDefined(); expect((call as any).status).toBe('approved'); @@ -203,9 +203,9 @@ describe('global store - agent streaming actions', () => { it('completeTurn marks turn completed and clears streaming', () => { const turn = makeTurn([]); - useGlobalStore.getState().startTurn(threadId, turn); + useAgentStore.getState().startTurn(threadId, turn); - useGlobalStore.getState().applyChunk(threadId, turnId, { + useAgentStore.getState().applyChunk(threadId, turnId, { id: 'msg-x', type: 'message', role: 'assistant', @@ -213,9 +213,9 @@ describe('global store - agent streaming actions', () => { partial: true, }); - useGlobalStore.getState().completeTurn(threadId, turnId, 'completed'); + useAgentStore.getState().completeTurn(threadId, turnId, 'completed'); - const updatedTurn = useGlobalStore.getState().agent.threads[threadId].turns[0]; + const updatedTurn = useAgentStore.getState().threads[threadId].turns[0]; expect(updatedTurn.status).toBe('completed'); expect((updatedTurn.items.find((i) => i.id === 'msg-x') as any).content).toBe('hi'); expect((updatedTurn.items.find((i) => i.id === 'msg-x') as any).partial).toBe(false); @@ -223,17 +223,17 @@ describe('global store - agent streaming actions', () => { it('completeTurn marks streaming assistant item complete without changing content', () => { const turn = makeTurn([]); - useGlobalStore.getState().startTurn(threadId, turn); + useAgentStore.getState().startTurn(threadId, turn); // Simulate streaming without a final partial=false event (safety net) - useGlobalStore.getState().applyChunk(threadId, turnId, { + useAgentStore.getState().applyChunk(threadId, turnId, { id: 'msg-1', type: 'message', role: 'assistant', content: 'Hello', partial: true, }); - useGlobalStore.getState().applyChunk(threadId, turnId, { + useAgentStore.getState().applyChunk(threadId, turnId, { id: 'msg-1', type: 'message', role: 'assistant', @@ -241,9 +241,9 @@ describe('global store - agent streaming actions', () => { partial: true, }); - useGlobalStore.getState().completeTurn(threadId, turnId, 'completed'); + useAgentStore.getState().completeTurn(threadId, turnId, 'completed'); - const items = useGlobalStore.getState().agent.threads[threadId].turns[0].items; + const items = useAgentStore.getState().threads[threadId].turns[0].items; const assistantItem = items.find((i) => i.id === 'msg-1'); expect(assistantItem).toBeDefined(); expect((assistantItem as any).content).toBe('Hello world'); @@ -252,17 +252,17 @@ describe('global store - agent streaming actions', () => { it('completeTurn with partial=false already received does not double-persist', () => { const turn = makeTurn([]); - useGlobalStore.getState().startTurn(threadId, turn); + useAgentStore.getState().startTurn(threadId, turn); // Final message already committed via applyChunk partial=false - useGlobalStore.getState().applyChunk(threadId, turnId, { + useAgentStore.getState().applyChunk(threadId, turnId, { id: 'msg-1', type: 'message', role: 'assistant', content: 'Hello', partial: true, }); - useGlobalStore.getState().applyChunk(threadId, turnId, { + useAgentStore.getState().applyChunk(threadId, turnId, { id: 'msg-1', type: 'message', role: 'assistant', @@ -270,9 +270,9 @@ describe('global store - agent streaming actions', () => { partial: false, }); - useGlobalStore.getState().completeTurn(threadId, turnId, 'completed'); + useAgentStore.getState().completeTurn(threadId, turnId, 'completed'); - const items = useGlobalStore.getState().agent.threads[threadId].turns[0].items; + const items = useAgentStore.getState().threads[threadId].turns[0].items; const assistantItem = items.find((i) => i.id === 'msg-1'); // Content comes from the committed applyChunk (which uses accumulated streaming) expect((assistantItem as any).content).toBe('Hello'); @@ -282,17 +282,17 @@ describe('global store - agent streaming actions', () => { it('keeps same assistant message id isolated across threads', () => { const threadA = 'thread-a'; const threadB = 'thread-b'; - useGlobalStore.getState().startTurn(threadA, { id: 'turn-a', items: [], status: 'running' }); - useGlobalStore.getState().startTurn(threadB, { id: 'turn-b', items: [], status: 'running' }); + useAgentStore.getState().startTurn(threadA, { id: 'turn-a', items: [], status: 'running' }); + useAgentStore.getState().startTurn(threadB, { id: 'turn-b', items: [], status: 'running' }); - useGlobalStore.getState().applyChunk(threadA, 'turn-a', { + useAgentStore.getState().applyChunk(threadA, 'turn-a', { id: 'assistant-1', type: 'message', role: 'assistant', content: 'A', partial: true, }); - useGlobalStore.getState().applyChunk(threadB, 'turn-b', { + useAgentStore.getState().applyChunk(threadB, 'turn-b', { id: 'assistant-1', type: 'message', role: 'assistant', @@ -300,8 +300,8 @@ describe('global store - agent streaming actions', () => { partial: true, }); - const itemA = useGlobalStore.getState().agent.threads[threadA].turns[0].items[0]; - const itemB = useGlobalStore.getState().agent.threads[threadB].turns[0].items[0]; + const itemA = useAgentStore.getState().threads[threadA].turns[0].items[0]; + const itemB = useAgentStore.getState().threads[threadB].turns[0].items[0]; expect((itemA as any).content).toBe('A'); expect((itemB as any).content).toBe('B'); }); @@ -329,26 +329,26 @@ describe('global store - loadThreads', () => { it('preserves in-flight thread with running turn not yet persisted by backend', () => { const turn = makeTurn([{ id: 'u1', type: 'message', role: 'user', content: 'hello' }]); - useGlobalStore.getState().startTurn(threadId, turn); + useAgentStore.getState().startTurn(threadId, turn); // Backend returns empty list (new thread not persisted yet) - useGlobalStore.getState().loadThreads([]); + useAgentStore.getState().loadThreads([]); - const thread = useGlobalStore.getState().agent.threads[threadId]; + const thread = useAgentStore.getState().threads[threadId]; expect(thread).toBeDefined(); expect(thread!.turns[0]!.status).toBe('running'); }); it('preserves in-memory turns when backend returns thread with empty turns', () => { const turn = makeTurn([{ id: 'u1', type: 'message', role: 'user', content: 'hello' }]); - useGlobalStore.getState().startTurn(threadId, turn); - useGlobalStore.getState().completeTurn(threadId, turnId, 'completed'); + useAgentStore.getState().startTurn(threadId, turn); + useAgentStore.getState().completeTurn(threadId, turnId, 'completed'); // Backend now returns threads with empty turns (history lives in codingcode session files) const backendThread = makeThread([]); - useGlobalStore.getState().loadThreads([backendThread]); + useAgentStore.getState().loadThreads([backendThread]); - const thread = useGlobalStore.getState().agent.threads[threadId]; + const thread = useAgentStore.getState().threads[threadId]; // In-memory turns are preserved expect(thread.turns[0].items).toHaveLength(1); expect((thread.turns[0].items[0] as any).content).toBe('hello'); @@ -356,55 +356,55 @@ describe('global store - loadThreads', () => { it('does not preserve completed thread absent from backend list', () => { const turn = makeTurn([]); - useGlobalStore.getState().startTurn(threadId, turn); - useGlobalStore.getState().completeTurn(threadId, turnId, 'completed'); + useAgentStore.getState().startTurn(threadId, turn); + useAgentStore.getState().completeTurn(threadId, turnId, 'completed'); - useGlobalStore.getState().loadThreads([]); + useAgentStore.getState().loadThreads([]); - expect(useGlobalStore.getState().agent.threads[threadId]).toBeUndefined(); + expect(useAgentStore.getState().threads[threadId]).toBeUndefined(); }); }); describe('global store - path normalization', () => { it('setWorkspace normalizes Windows backslash path', () => { - useGlobalStore.getState().setWorkspace('C:\\Users\\10116\\Desktop', 'Desktop'); - expect(useGlobalStore.getState().workspace.rootPath).toBe('c:/Users/10116/Desktop'); - expect(useGlobalStore.getState().workspace.name).toBe('Desktop'); + useWorkspaceStore.getState().setWorkspace('C:\\Users\\10116\\Desktop', 'Desktop'); + expect(useWorkspaceStore.getState().rootPath).toBe('c:/Users/10116/Desktop'); + expect(useWorkspaceStore.getState().name).toBe('Desktop'); }); it('setWorkspace normalizes uppercase drive letter', () => { - useGlobalStore.getState().setWorkspace('D:/Projects/foo', 'foo'); - expect(useGlobalStore.getState().workspace.rootPath).toBe('d:/Projects/foo'); + useWorkspaceStore.getState().setWorkspace('D:/Projects/foo', 'foo'); + expect(useWorkspaceStore.getState().rootPath).toBe('d:/Projects/foo'); }); it('setWorkspace leaves already-normalized path unchanged', () => { - useGlobalStore.getState().setWorkspace('c:/users/foo', 'foo'); - expect(useGlobalStore.getState().workspace.rootPath).toBe('c:/users/foo'); + useWorkspaceStore.getState().setWorkspace('c:/users/foo', 'foo'); + expect(useWorkspaceStore.getState().rootPath).toBe('c:/users/foo'); }); it('startTurn normalizes cwd so it matches backend format', () => { const threadId = 'thread-norm'; - useGlobalStore + useAgentStore .getState() .startTurn( threadId, { id: 'turn-1', items: [], status: 'running' }, { cwd: 'C:\\Users\\10116\\Desktop', title: 'test' } ); - expect(useGlobalStore.getState().agent.threads[threadId].cwd).toBe('c:/Users/10116/Desktop'); + expect(useAgentStore.getState().threads[threadId].cwd).toBe('c:/Users/10116/Desktop'); }); it('normalized workspace cwd and normalized thread cwd are equal → single group', () => { - useGlobalStore.getState().setWorkspace('C:\\Users\\10116\\Desktop', 'Desktop'); - useGlobalStore + useWorkspaceStore.getState().setWorkspace('C:\\Users\\10116\\Desktop', 'Desktop'); + useAgentStore .getState() .startTurn( 'thread-group', { id: 'turn-1', items: [], status: 'running' }, { cwd: 'C:\\Users\\10116\\Desktop' } ); - const { rootPath } = useGlobalStore.getState().workspace; - const { cwd } = useGlobalStore.getState().agent.threads['thread-group']; + const { rootPath } = useWorkspaceStore.getState(); + const { cwd } = useAgentStore.getState().threads['thread-group']; expect(cwd).toBe(rootPath); }); }); @@ -412,32 +412,32 @@ describe('global store - path normalization', () => { describe('global store - setThreadCwd', () => { it('updates cwd of an existing thread', () => { const threadId = 'thread-cwd'; - useGlobalStore + useAgentStore .getState() .startTurn(threadId, { id: 'turn-1', items: [], status: 'running' }, { cwd: '' }); - expect(useGlobalStore.getState().agent.threads[threadId].cwd).toBe(''); + expect(useAgentStore.getState().threads[threadId].cwd).toBe(''); - useGlobalStore.getState().setThreadCwd(threadId, '/actual/path'); + useAgentStore.getState().setThreadCwd(threadId, '/actual/path'); - expect(useGlobalStore.getState().agent.threads[threadId].cwd).toBe('/actual/path'); + expect(useAgentStore.getState().threads[threadId].cwd).toBe('/actual/path'); }); it('does nothing when thread does not exist', () => { - expect(() => useGlobalStore.getState().setThreadCwd('nonexistent', '/path')).not.toThrow(); + expect(() => useAgentStore.getState().setThreadCwd('nonexistent', '/path')).not.toThrow(); }); it('cwd survives a loadThreads call that preserves running threads', () => { const threadId = 'thread-cwd2'; - useGlobalStore + useAgentStore .getState() .startTurn(threadId, { id: 'turn-1', items: [], status: 'running' }, { cwd: '' }); - useGlobalStore.getState().setThreadCwd(threadId, '/actual/path'); + useAgentStore.getState().setThreadCwd(threadId, '/actual/path'); // Backend hasn't persisted the running thread yet — returns empty list - useGlobalStore.getState().loadThreads([]); + useAgentStore.getState().loadThreads([]); - expect(useGlobalStore.getState().agent.threads[threadId]!.cwd).toBe('/actual/path'); + expect(useAgentStore.getState().threads[threadId]!.cwd).toBe('/actual/path'); }); }); @@ -446,20 +446,20 @@ describe('global store - per-thread isStreaming derivation', () => { const threadA = 'thread-a'; const threadB = 'thread-b'; - useGlobalStore.getState().startTurn(threadA, { id: 'turn-a', items: [], status: 'running' }); - useGlobalStore.getState().startTurn(threadB, { id: 'turn-b', items: [], status: 'running' }); + useAgentStore.getState().startTurn(threadA, { id: 'turn-a', items: [], status: 'running' }); + useAgentStore.getState().startTurn(threadB, { id: 'turn-b', items: [], status: 'running' }); const isStreamingA = () => - useGlobalStore.getState().agent.threads[threadA]?.turns.some((t) => t.status === 'running') ?? + useAgentStore.getState().threads[threadA]?.turns.some((t) => t.status === 'running') ?? false; const isStreamingB = () => - useGlobalStore.getState().agent.threads[threadB]?.turns.some((t) => t.status === 'running') ?? + useAgentStore.getState().threads[threadB]?.turns.some((t) => t.status === 'running') ?? false; expect(isStreamingA()).toBe(true); expect(isStreamingB()).toBe(true); - useGlobalStore.getState().completeTurn(threadA, 'turn-a', 'completed'); + useAgentStore.getState().completeTurn(threadA, 'turn-a', 'completed'); // Thread A done, Thread B still running expect(isStreamingA()).toBe(false); @@ -469,17 +469,17 @@ describe('global store - per-thread isStreaming derivation', () => { it('thread with no running turns is not streaming', () => { const threadId = 'thread-x'; const isStreaming = () => - useGlobalStore + useAgentStore .getState() - .agent.threads[threadId]?.turns.some((t) => t.status === 'running') ?? false; + .threads[threadId]?.turns.some((t) => t.status === 'running') ?? false; // Thread not yet created expect(isStreaming()).toBe(false); - useGlobalStore.getState().startTurn(threadId, { id: 'turn-1', items: [], status: 'running' }); + useAgentStore.getState().startTurn(threadId, { id: 'turn-1', items: [], status: 'running' }); expect(isStreaming()).toBe(true); - useGlobalStore.getState().completeTurn(threadId, 'turn-1', 'completed'); + useAgentStore.getState().completeTurn(threadId, 'turn-1', 'completed'); expect(isStreaming()).toBe(false); }); }); @@ -487,71 +487,71 @@ describe('global store - per-thread isStreaming derivation', () => { describe('global store - project management', () => { it('addProject adds to list', () => { const p = freshProject('p1', '/home/user/project-a'); - useGlobalStore.getState().addProject(p); - expect(useGlobalStore.getState().workspace.projects).toHaveLength(1); - expect(useGlobalStore.getState().workspace.projects[0].id).toBe('p1'); + useWorkspaceStore.getState().addProject(p); + expect(useWorkspaceStore.getState().projects).toHaveLength(1); + expect(useWorkspaceStore.getState().projects[0].id).toBe('p1'); }); it('addProject does not duplicate by id', () => { const p = freshProject('p1', '/home/user/project-a'); - useGlobalStore.getState().addProject(p); - useGlobalStore.getState().addProject(p); - expect(useGlobalStore.getState().workspace.projects).toHaveLength(1); + useWorkspaceStore.getState().addProject(p); + useWorkspaceStore.getState().addProject(p); + expect(useWorkspaceStore.getState().projects).toHaveLength(1); }); it('removeProject removes from list', () => { - useGlobalStore.getState().addProject(freshProject('p1', '/a')); - useGlobalStore.getState().addProject(freshProject('p2', '/b')); - useGlobalStore.getState().removeProject('p1'); - expect(useGlobalStore.getState().workspace.projects).toHaveLength(1); - expect(useGlobalStore.getState().workspace.projects[0].id).toBe('p2'); + useWorkspaceStore.getState().addProject(freshProject('p1', '/a')); + useWorkspaceStore.getState().addProject(freshProject('p2', '/b')); + useWorkspaceStore.getState().removeProject('p1'); + expect(useWorkspaceStore.getState().projects).toHaveLength(1); + expect(useWorkspaceStore.getState().projects[0].id).toBe('p2'); }); it('switchProject updates currentProjectId, rootPath, and name', () => { - useGlobalStore.getState().addProject(freshProject('p1', 'C:\\Users\\test\\alpha')); - useGlobalStore.getState().addProject(freshProject('p2', 'D:\\beta')); + useWorkspaceStore.getState().addProject(freshProject('p1', 'C:\\Users\\test\\alpha')); + useWorkspaceStore.getState().addProject(freshProject('p2', 'D:\\beta')); - useGlobalStore.getState().switchProject('p2'); - expect(useGlobalStore.getState().workspace.currentProjectId).toBe('p2'); - expect(useGlobalStore.getState().workspace.rootPath).toBe('d:/beta'); - expect(useGlobalStore.getState().workspace.name).toBe('beta'); + useWorkspaceStore.getState().switchProject('p2'); + expect(useWorkspaceStore.getState().currentProjectId).toBe('p2'); + expect(useWorkspaceStore.getState().rootPath).toBe('d:/beta'); + expect(useWorkspaceStore.getState().name).toBe('beta'); }); it('switchProject normalizes Windows path', () => { - useGlobalStore.getState().addProject(freshProject('p1', 'C:\\MyProject')); - useGlobalStore.getState().switchProject('p1'); - expect(useGlobalStore.getState().workspace.rootPath).toBe('c:/MyProject'); + useWorkspaceStore.getState().addProject(freshProject('p1', 'C:\\MyProject')); + useWorkspaceStore.getState().switchProject('p1'); + expect(useWorkspaceStore.getState().rootPath).toBe('c:/MyProject'); }); it('switchProject is no-op for unknown id', () => { - useGlobalStore.getState().addProject(freshProject('p1', 'C:\\ProjectA')); - useGlobalStore.getState().switchProject('p1'); - useGlobalStore.getState().switchProject('nonexistent'); - expect(useGlobalStore.getState().workspace.currentProjectId).toBe('p1'); - expect(useGlobalStore.getState().workspace.rootPath).toBe('c:/ProjectA'); - expect(useGlobalStore.getState().workspace.name).toBe('ProjectA'); + useWorkspaceStore.getState().addProject(freshProject('p1', 'C:\\ProjectA')); + useWorkspaceStore.getState().switchProject('p1'); + useWorkspaceStore.getState().switchProject('nonexistent'); + expect(useWorkspaceStore.getState().currentProjectId).toBe('p1'); + expect(useWorkspaceStore.getState().rootPath).toBe('c:/ProjectA'); + expect(useWorkspaceStore.getState().name).toBe('ProjectA'); }); it('setProjects replaces entire list', () => { - useGlobalStore.getState().setProjects([freshProject('p1', '/a'), freshProject('p2', '/b')]); - expect(useGlobalStore.getState().workspace.projects).toHaveLength(2); - useGlobalStore.getState().setProjects([freshProject('p3', '/c')]); - expect(useGlobalStore.getState().workspace.projects).toHaveLength(1); - expect(useGlobalStore.getState().workspace.projects[0].id).toBe('p3'); + useWorkspaceStore.getState().setProjects([freshProject('p1', '/a'), freshProject('p2', '/b')]); + expect(useWorkspaceStore.getState().projects).toHaveLength(2); + useWorkspaceStore.getState().setProjects([freshProject('p3', '/c')]); + expect(useWorkspaceStore.getState().projects).toHaveLength(1); + expect(useWorkspaceStore.getState().projects[0].id).toBe('p3'); }); it('setCurrentProject updates only currentProjectId, not rootPath', () => { - useGlobalStore.getState().setWorkspace('/some/path', 'some'); - useGlobalStore.getState().setCurrentProject('xyz'); - expect(useGlobalStore.getState().workspace.currentProjectId).toBe('xyz'); - expect(useGlobalStore.getState().workspace.rootPath).toBe('/some/path'); + useWorkspaceStore.getState().setWorkspace('/some/path', 'some'); + useWorkspaceStore.getState().setCurrentProject('xyz'); + expect(useWorkspaceStore.getState().currentProjectId).toBe('xyz'); + expect(useWorkspaceStore.getState().rootPath).toBe('/some/path'); }); }); describe('global store - token usage', () => { it('setThreadUsage stores usage by threadId', () => { - useGlobalStore.getState().setThreadUsage('t1', { prompt: 1000, completion: 500, total: 1500 }); - expect(useGlobalStore.getState().agent.usageByThreadId['t1']).toEqual({ + useAgentStore.getState().setThreadUsage('t1', { prompt: 1000, completion: 500, total: 1500 }); + expect(useAgentStore.getState().usageByThreadId['t1']).toEqual({ prompt: 1000, completion: 500, total: 1500, @@ -559,127 +559,127 @@ describe('global store - token usage', () => { }); it('setThreadUsage stores usage but does not update contextUsage', () => { - useGlobalStore + useAgentStore .getState() .setModels([{ id: 'm1', name: 'Model', provider: 'openai', context_window: 128000 }]); - useGlobalStore.getState().setModel('m1'); - useGlobalStore.getState().setCurrentThread('t1'); - useGlobalStore.getState().setThreadUsage('t1', { prompt: 1000, completion: 500, total: 1500 }); - expect(useGlobalStore.getState().agent.usageByThreadId['t1']).toEqual({ + useAgentStore.getState().setModel('m1'); + useAgentStore.getState().setCurrentThread('t1'); + useAgentStore.getState().setThreadUsage('t1', { prompt: 1000, completion: 500, total: 1500 }); + expect(useAgentStore.getState().usageByThreadId['t1']).toEqual({ prompt: 1000, completion: 500, total: 1500, }); // contextUsage is no longer updated by setThreadUsage - expect(useGlobalStore.getState().agent.contextUsage).toBeNull(); + expect(useAgentStore.getState().contextUsage).toBeNull(); }); it('setCurrentThread restores contextUsage from usageByThreadId', () => { - useGlobalStore + useAgentStore .getState() .setModels([{ id: 'm1', name: 'Model', provider: 'openai', context_window: 128000 }]); - useGlobalStore.getState().setModel('m1'); - useGlobalStore.getState().setThreadUsage('t1', { prompt: 1000, completion: 500, total: 1500 }); - useGlobalStore.getState().setCurrentThread('t1'); - expect(useGlobalStore.getState().agent.contextUsage).toEqual({ + useAgentStore.getState().setModel('m1'); + useAgentStore.getState().setThreadUsage('t1', { prompt: 1000, completion: 500, total: 1500 }); + useAgentStore.getState().setCurrentThread('t1'); + expect(useAgentStore.getState().contextUsage).toEqual({ used: 1500, contextWindow: 128000, }); }); it('setCurrentThread clears contextUsage when no usage for thread', () => { - useGlobalStore.getState().setContextUsage({ used: 100, contextWindow: 128000 }); - useGlobalStore.getState().setCurrentThread('t1'); - expect(useGlobalStore.getState().agent.contextUsage).toBeNull(); + useAgentStore.getState().setContextUsage({ used: 100, contextWindow: 128000 }); + useAgentStore.getState().setCurrentThread('t1'); + expect(useAgentStore.getState().contextUsage).toBeNull(); }); }); describe('global store - compressing state', () => { it('initial isCompressing is false', () => { - expect(useGlobalStore.getState().agent.isCompressing).toBe(false); + expect(useAgentStore.getState().isCompressing).toBe(false); }); it('startCompressing sets isCompressing to true', () => { - useGlobalStore.getState().startCompressing(); - expect(useGlobalStore.getState().agent.isCompressing).toBe(true); + useAgentStore.getState().startCompressing(); + expect(useAgentStore.getState().isCompressing).toBe(true); }); it('stopCompressing sets isCompressing to false', () => { - useGlobalStore.getState().startCompressing(); - expect(useGlobalStore.getState().agent.isCompressing).toBe(true); - useGlobalStore.getState().stopCompressing(); - expect(useGlobalStore.getState().agent.isCompressing).toBe(false); + useAgentStore.getState().startCompressing(); + expect(useAgentStore.getState().isCompressing).toBe(true); + useAgentStore.getState().stopCompressing(); + expect(useAgentStore.getState().isCompressing).toBe(false); }); }); describe('global store - loadThreads orphan data cleanup', () => { it('cleans up todoByThreadId for deleted threads', () => { - useGlobalStore.getState().applyTodoUpdate('deleted-thread', [ + useAgentStore.getState().applyTodoUpdate('deleted-thread', [ { id: '1', text: 'todo', status: 'in_progress' }, ]); - expect(useGlobalStore.getState().agent.todoByThreadId['deleted-thread']).toBeDefined(); + expect(useAgentStore.getState().todoByThreadId['deleted-thread']).toBeDefined(); - useGlobalStore.getState().loadThreads([]); - expect(useGlobalStore.getState().agent.todoByThreadId['deleted-thread']).toBeUndefined(); + useAgentStore.getState().loadThreads([]); + expect(useAgentStore.getState().todoByThreadId['deleted-thread']).toBeUndefined(); }); it('preserves todoByThreadId for threads still in the list', () => { - useGlobalStore.getState().applyTodoUpdate('kept-thread', [ + useAgentStore.getState().applyTodoUpdate('kept-thread', [ { id: '1', text: 'todo', status: 'in_progress' }, ]); - useGlobalStore.getState().loadThreads([ + useAgentStore.getState().loadThreads([ { id: 'kept-thread', projectId: '', title: 'test', cwd: '/x', turns: [], createdAt: 1, updatedAt: 2 }, ]); - expect(useGlobalStore.getState().agent.todoByThreadId['kept-thread']).toBeDefined(); + expect(useAgentStore.getState().todoByThreadId['kept-thread']).toBeDefined(); }); it('cleans up rollbackStateByThreadId for deleted threads', () => { - useGlobalStore.getState().setRollbackState('deleted-thread', { + useRollbackStore.getState().setRollbackState('deleted-thread', { context: { active: false, currentThroughTurnId: null }, code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: '' }, } as any); - useGlobalStore.getState().loadThreads([]); - expect(useGlobalStore.getState().rollback.rollbackStateByThreadId['deleted-thread']).toBeUndefined(); + useAgentStore.getState().loadThreads([]); + expect(useRollbackStore.getState().rollbackStateByThreadId['deleted-thread']).toBeUndefined(); }); it('cleans up checkpointDiffByTurnId for deleted threads', () => { - useGlobalStore.getState().setCheckpointDiff('deleted-thread', '1', { + useRollbackStore.getState().setCheckpointDiff('deleted-thread', '1', { turnId: 1, files: [], } as any); - useGlobalStore.getState().loadThreads([]); - expect(useGlobalStore.getState().rollback.checkpointDiffByTurnId['deleted-thread:1']).toBeUndefined(); + useAgentStore.getState().loadThreads([]); + expect(useRollbackStore.getState().checkpointDiffByTurnId['deleted-thread:1']).toBeUndefined(); }); it('cleans up revertedFilesByTurnId for deleted threads', () => { - useGlobalStore.getState().markFileReverted('deleted-thread', '1', '/a.ts'); - useGlobalStore.getState().loadThreads([]); - expect(useGlobalStore.getState().rollback.revertedFilesByTurnId['deleted-thread:1']).toBeUndefined(); + useRollbackStore.getState().markFileReverted('deleted-thread', '1', '/a.ts'); + useAgentStore.getState().loadThreads([]); + expect(useRollbackStore.getState().revertedFilesByTurnId['deleted-thread:1']).toBeUndefined(); }); it('cleans up turnCheckpointMapping for deleted threads', () => { - useGlobalStore.getState().setTurnCheckpointMapping('deleted-thread', 1, 'ui-1'); - useGlobalStore.getState().loadThreads([]); - expect(useGlobalStore.getState().rollback.turnCheckpointMapping['deleted-thread']).toBeUndefined(); + useRollbackStore.getState().setTurnCheckpointMapping('deleted-thread', 1, 'ui-1'); + useAgentStore.getState().loadThreads([]); + expect(useRollbackStore.getState().turnCheckpointMapping['deleted-thread']).toBeUndefined(); }); it('preserves rollback data for threads still in the list', () => { - useGlobalStore.getState().setRollbackState('kept-thread', { + useRollbackStore.getState().setRollbackState('kept-thread', { context: { active: false, currentThroughTurnId: null }, code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: '' }, } as any); - useGlobalStore.getState().setCheckpointDiff('kept-thread', '1', { + useRollbackStore.getState().setCheckpointDiff('kept-thread', '1', { turnId: 1, files: [], } as any); - useGlobalStore.getState().markFileReverted('kept-thread', '1', '/a.ts'); - useGlobalStore.getState().setTurnCheckpointMapping('kept-thread', 1, 'ui-1'); + useRollbackStore.getState().markFileReverted('kept-thread', '1', '/a.ts'); + useRollbackStore.getState().setTurnCheckpointMapping('kept-thread', 1, 'ui-1'); - useGlobalStore.getState().loadThreads([ + useAgentStore.getState().loadThreads([ { id: 'kept-thread', projectId: '', title: 'test', cwd: '/x', turns: [], createdAt: 1, updatedAt: 2 }, ]); - expect(useGlobalStore.getState().rollback.rollbackStateByThreadId['kept-thread']).toBeDefined(); - expect(useGlobalStore.getState().rollback.checkpointDiffByTurnId['kept-thread:1']).toBeDefined(); - expect(useGlobalStore.getState().rollback.revertedFilesByTurnId['kept-thread:1']).toBeDefined(); - expect(useGlobalStore.getState().rollback.turnCheckpointMapping['kept-thread']).toBeDefined(); + expect(useRollbackStore.getState().rollbackStateByThreadId['kept-thread']).toBeDefined(); + expect(useRollbackStore.getState().checkpointDiffByTurnId['kept-thread:1']).toBeDefined(); + expect(useRollbackStore.getState().revertedFilesByTurnId['kept-thread:1']).toBeDefined(); + expect(useRollbackStore.getState().turnCheckpointMapping['kept-thread']).toBeDefined(); }); }); diff --git a/packages/desktop/test/message-stream-scroll.test.tsx b/packages/desktop/test/message-stream-scroll.test.tsx index 11c4672..d107536 100644 --- a/packages/desktop/test/message-stream-scroll.test.tsx +++ b/packages/desktop/test/message-stream-scroll.test.tsx @@ -4,7 +4,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, cleanup, act } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; -import { useGlobalStore } from '../src/stores/global.store'; +import { useAgentStore } from '../src/stores/agent.store'; +import { useRollbackStore } from '../src/stores/rollback.store'; import MessageStream from '../src/agent/MessageStream'; import type { Turn } from '../shared/types'; @@ -51,8 +52,8 @@ function makeTurn(id: string, items: Turn['items']): Turn { function setThread(threadId: string, turns: Turn[]) { act(() => { - useGlobalStore.setState((s) => { - s.agent.threads[threadId] = { + useAgentStore.setState((s) => { + s.threads[threadId] = { id: threadId, projectId: '', title: threadId, @@ -70,26 +71,24 @@ beforeEach(() => { scrollToEndMock.mockClear(); measureElementMock.mockClear(); lastVirtualizerOptions = null; - useGlobalStore.setState({ - agent: { - currentThreadId: null, - threads: {}, - approvalPolicy: 'ask-all', - model: '', - models: [], - contextUsage: null, - todoByThreadId: {}, - pendingInput: null, - usageByThreadId: {}, - isCompressing: false, - automations: [], - }, - rollback: { - rollbackStateByThreadId: {}, - checkpointDiffByTurnId: {}, - revertedFilesByTurnId: {}, - turnCheckpointMapping: {}, - }, + useAgentStore.setState({ + currentThreadId: null, + threads: {}, + approvalPolicy: 'ask-all', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, + automations: [], + }); + useRollbackStore.setState({ + rollbackStateByThreadId: {}, + checkpointDiffByTurnId: {}, + revertedFilesByTurnId: {}, + turnCheckpointMapping: {}, }); }); diff --git a/packages/desktop/test/performance-optimization.test.ts b/packages/desktop/test/performance-optimization.test.ts index 8375203..48019c7 100644 --- a/packages/desktop/test/performance-optimization.test.ts +++ b/packages/desktop/test/performance-optimization.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { computeDiff } from '../src/lib/diff-compute'; import { parseUnifiedDiff } from '../src/lib/diff-parser'; -import { useGlobalStore } from '../src/stores/global.store'; +import { useAgentStore } from '../src/stores/agent.store'; import type { Item, Turn } from '../shared/types'; // ─── diff-compute: large file protection ───────────────────────────────── @@ -54,19 +54,17 @@ describe('computeDiff - large file protection', () => { describe('global store - applyChunk tool_result searches current turn first', () => { beforeEach(() => { - useGlobalStore.setState({ - agent: { - currentThreadId: null, - threads: {}, - approvalPolicy: 'ask-all', - model: '', - models: [], - contextUsage: null, - todoByThreadId: {}, - pendingInput: null, - usageByThreadId: {}, - isCompressing: false, - }, + useAgentStore.setState({ + currentThreadId: null, + threads: {}, + approvalPolicy: 'ask-all', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, }); }); @@ -74,17 +72,17 @@ describe('global store - applyChunk tool_result searches current turn first', () const threadId = 't1'; // Turn 1 with a tool_call - useGlobalStore.getState().startTurn(threadId, { + useAgentStore.getState().startTurn(threadId, { id: 'turn-1', items: [ { id: 'call-1', type: 'tool_call', name: 'read_file', args: {}, status: 'running' } as Item, ], status: 'completed', }); - useGlobalStore.getState().completeTurn(threadId, 'turn-1', 'completed'); + useAgentStore.getState().completeTurn(threadId, 'turn-1', 'completed'); // Turn 2 with a tool_call of same name but different id - useGlobalStore.getState().startTurn(threadId, { + useAgentStore.getState().startTurn(threadId, { id: 'turn-2', items: [ { id: 'call-2', type: 'tool_call', name: 'read_file', args: {}, status: 'running' } as Item, @@ -93,7 +91,7 @@ describe('global store - applyChunk tool_result searches current turn first', () }); // Apply tool_result for call-2 (should find it in turn-2 first) - useGlobalStore.getState().applyChunk(threadId, 'turn-2', { + useAgentStore.getState().applyChunk(threadId, 'turn-2', { id: 'res-2', type: 'tool_result', callId: 'call-2', @@ -102,7 +100,7 @@ describe('global store - applyChunk tool_result searches current turn first', () exitCode: 0, } as Item); - const turn2 = useGlobalStore.getState().agent.threads[threadId].turns[1]; + const turn2 = useAgentStore.getState().threads[threadId].turns[1]; const call = turn2.items.find((i) => i.id === 'call-2') as any; expect(call.status).toBe('approved'); expect(turn2.items).toHaveLength(2); // call + result @@ -112,24 +110,24 @@ describe('global store - applyChunk tool_result searches current turn first', () const threadId = 't1'; // Turn 1 with tool_call - useGlobalStore.getState().startTurn(threadId, { + useAgentStore.getState().startTurn(threadId, { id: 'turn-1', items: [ { id: 'call-1', type: 'tool_call', name: 'read_file', args: {}, status: 'running' } as Item, ], status: 'completed', }); - useGlobalStore.getState().completeTurn(threadId, 'turn-1', 'completed'); + useAgentStore.getState().completeTurn(threadId, 'turn-1', 'completed'); // Turn 2 with no tool_call - useGlobalStore.getState().startTurn(threadId, { + useAgentStore.getState().startTurn(threadId, { id: 'turn-2', items: [], status: 'running', }); // Apply tool_result for call-1 (should find it in turn-1 via fallback) - useGlobalStore.getState().applyChunk(threadId, 'turn-2', { + useAgentStore.getState().applyChunk(threadId, 'turn-2', { id: 'res-1', type: 'tool_result', callId: 'call-1', @@ -138,7 +136,7 @@ describe('global store - applyChunk tool_result searches current turn first', () exitCode: 0, } as Item); - const turn1 = useGlobalStore.getState().agent.threads[threadId].turns[0]; + const turn1 = useAgentStore.getState().threads[threadId].turns[0]; const call = turn1.items.find((i) => i.id === 'call-1') as any; expect(call.status).toBe('approved'); }); @@ -148,9 +146,9 @@ describe('global store - applyChunk tool_result searches current turn first', () describe('global store - persist partialize', () => { it('partialize does not include usageByThreadId', () => { - const state = useGlobalStore.getState(); + const state = useAgentStore.getState(); // Access the persist config's partialize - const store: any = useGlobalStore; + const store: any = useAgentStore; const persistConfig = store.persist?.options; if (persistConfig?.partialize) { const partial = persistConfig.partialize(state); @@ -195,26 +193,24 @@ describe('main.ts - resource cleanup', () => { describe('global store - applyChunk tool_result uses push', () => { beforeEach(() => { - useGlobalStore.setState({ - agent: { - currentThreadId: null, - threads: {}, - approvalPolicy: 'ask-all', - model: '', - models: [], - contextUsage: null, - todoByThreadId: {}, - pendingInput: null, - usageByThreadId: {}, - isCompressing: false, - }, + useAgentStore.setState({ + currentThreadId: null, + threads: {}, + approvalPolicy: 'ask-all', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, + pendingInput: null, + usageByThreadId: {}, + isCompressing: false, }); }); it('tool_result is pushed to end, not spliced after tool_call', () => { const threadId = 't1'; - useGlobalStore.getState().startTurn(threadId, { + useAgentStore.getState().startTurn(threadId, { id: 'turn-1', items: [ { id: 'msg-1', type: 'message', role: 'user', content: 'hi' } as Item, @@ -231,7 +227,7 @@ describe('global store - applyChunk tool_result uses push', () => { }); // Apply tool_result for call-1 - useGlobalStore.getState().applyChunk(threadId, 'turn-1', { + useAgentStore.getState().applyChunk(threadId, 'turn-1', { id: 'res-1', type: 'tool_result', callId: 'call-1', @@ -240,7 +236,7 @@ describe('global store - applyChunk tool_result uses push', () => { exitCode: 0, } as Item); - const turn = useGlobalStore.getState().agent.threads[threadId].turns[0]; + const turn = useAgentStore.getState().threads[threadId].turns[0]; // tool_result should be at the end, not between call-1 and msg-2 const lastItem = turn.items[turn.items.length - 1]; expect(lastItem.type).toBe('tool_result'); @@ -251,7 +247,7 @@ describe('global store - applyChunk tool_result uses push', () => { it('existing item indices are not shifted when tool_result is pushed', () => { const threadId = 't1'; - useGlobalStore.getState().startTurn(threadId, { + useAgentStore.getState().startTurn(threadId, { id: 'turn-1', items: [ { id: 'call-1', type: 'tool_call', name: 'edit', args: {}, status: 'running' } as Item, @@ -267,11 +263,11 @@ describe('global store - applyChunk tool_result uses push', () => { }); // Record index of msg-1 before tool_result - const beforeTurn = useGlobalStore.getState().agent.threads[threadId].turns[0]; + const beforeTurn = useAgentStore.getState().threads[threadId].turns[0]; const msgIndexBefore = beforeTurn.items.findIndex((i) => i.id === 'msg-1'); expect(msgIndexBefore).toBe(1); - useGlobalStore.getState().applyChunk(threadId, 'turn-1', { + useAgentStore.getState().applyChunk(threadId, 'turn-1', { id: 'res-1', type: 'tool_result', callId: 'call-1', @@ -280,7 +276,7 @@ describe('global store - applyChunk tool_result uses push', () => { exitCode: 0, } as Item); - const afterTurn = useGlobalStore.getState().agent.threads[threadId].turns[0]; + const afterTurn = useAgentStore.getState().threads[threadId].turns[0]; const msgIndexAfter = afterTurn.items.findIndex((i) => i.id === 'msg-1'); // msg-1 should still be at the same index expect(msgIndexAfter).toBe(msgIndexBefore); diff --git a/packages/desktop/test/todo-panel-state.test.ts b/packages/desktop/test/todo-panel-state.test.ts index 4ef011c..c55e2ba 100644 --- a/packages/desktop/test/todo-panel-state.test.ts +++ b/packages/desktop/test/todo-panel-state.test.ts @@ -1,24 +1,16 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useGlobalStore } from '../src/stores/global.store'; +import { useAgentStore } from '../src/stores/agent.store'; import type { TodoItem } from '../shared/types'; beforeEach(() => { - useGlobalStore.setState({ - agent: { - currentThreadId: null, - threads: {}, - approvalPolicy: 'ask-all', - model: '', - models: [], - contextUsage: null, - todoByThreadId: {}, - }, - workspace: { - rootPath: '', - name: '', - projects: [], - currentProjectId: '', - }, + useAgentStore.setState({ + currentThreadId: null, + threads: {}, + approvalPolicy: 'ask-all', + model: '', + models: [], + contextUsage: null, + todoByThreadId: {}, }); }); @@ -32,9 +24,9 @@ function makeItems(): TodoItem[] { describe('applyTodoUpdate', () => { it('sets hasSeenNonEmptyTodo when receiving non-empty items', () => { - useGlobalStore.getState().applyTodoUpdate('t1', makeItems()); + useAgentStore.getState().applyTodoUpdate('t1', makeItems()); - const state = useGlobalStore.getState().agent.todoByThreadId['t1']!; + const state = useAgentStore.getState().todoByThreadId['t1']!; expect(state).toBeDefined(); expect(state.hasSeenNonEmptyTodo).toBe(true); expect(state.items).toHaveLength(3); @@ -42,38 +34,38 @@ describe('applyTodoUpdate', () => { }); it('replaces previous items when receiving new non-empty items', () => { - useGlobalStore.getState().applyTodoUpdate('t1', makeItems()); - useGlobalStore.getState().applyTodoUpdate('t1', [{ step: '新任务', status: 'pending' }]); + useAgentStore.getState().applyTodoUpdate('t1', makeItems()); + useAgentStore.getState().applyTodoUpdate('t1', [{ step: '新任务', status: 'pending' }]); - const state = useGlobalStore.getState().agent.todoByThreadId['t1']!; + const state = useAgentStore.getState().todoByThreadId['t1']!; expect(state.items).toHaveLength(1); expect(state.items[0].step).toBe('新任务'); expect(state.hasSeenNonEmptyTodo).toBe(true); }); it('retains old items when receiving empty update after having seen non-empty', () => { - useGlobalStore.getState().applyTodoUpdate('t1', makeItems()); - useGlobalStore.getState().applyTodoUpdate('t1', []); + useAgentStore.getState().applyTodoUpdate('t1', makeItems()); + useAgentStore.getState().applyTodoUpdate('t1', []); - const state = useGlobalStore.getState().agent.todoByThreadId['t1']!; + const state = useAgentStore.getState().todoByThreadId['t1']!; expect(state.hasSeenNonEmptyTodo).toBe(true); expect(state.items).toHaveLength(3); }); it('does not mark hasSeenNonEmptyTodo when receiving empty items for a new thread', () => { - useGlobalStore.getState().applyTodoUpdate('t1', []); + useAgentStore.getState().applyTodoUpdate('t1', []); - const state = useGlobalStore.getState().agent.todoByThreadId['t1']!; + const state = useAgentStore.getState().todoByThreadId['t1']!; expect(state.hasSeenNonEmptyTodo).toBe(false); expect(state.items).toHaveLength(0); }); it('is isolated by threadId', () => { - useGlobalStore.getState().applyTodoUpdate('t1', makeItems()); - useGlobalStore.getState().applyTodoUpdate('t2', [{ step: 't2任务', status: 'completed' }]); + useAgentStore.getState().applyTodoUpdate('t1', makeItems()); + useAgentStore.getState().applyTodoUpdate('t2', [{ step: 't2任务', status: 'completed' }]); - const s1 = useGlobalStore.getState().agent.todoByThreadId['t1']!; - const s2 = useGlobalStore.getState().agent.todoByThreadId['t2']!; + const s1 = useAgentStore.getState().todoByThreadId['t1']!; + const s2 = useAgentStore.getState().todoByThreadId['t2']!; expect(s1.items).toHaveLength(3); expect(s2.items).toHaveLength(1); @@ -83,37 +75,37 @@ describe('applyTodoUpdate', () => { describe('toggleTodoCollapsed', () => { it('toggles collapsed state', () => { - useGlobalStore.getState().applyTodoUpdate('t1', makeItems()); - expect(useGlobalStore.getState().agent.todoByThreadId['t1']!.collapsed).toBe(false); + useAgentStore.getState().applyTodoUpdate('t1', makeItems()); + expect(useAgentStore.getState().todoByThreadId['t1']!.collapsed).toBe(false); - useGlobalStore.getState().toggleTodoCollapsed('t1'); - expect(useGlobalStore.getState().agent.todoByThreadId['t1']!.collapsed).toBe(true); + useAgentStore.getState().toggleTodoCollapsed('t1'); + expect(useAgentStore.getState().todoByThreadId['t1']!.collapsed).toBe(true); - useGlobalStore.getState().toggleTodoCollapsed('t1'); - expect(useGlobalStore.getState().agent.todoByThreadId['t1']!.collapsed).toBe(false); + useAgentStore.getState().toggleTodoCollapsed('t1'); + expect(useAgentStore.getState().todoByThreadId['t1']!.collapsed).toBe(false); }); it('is isolated by threadId', () => { - useGlobalStore.getState().applyTodoUpdate('t1', makeItems()); - useGlobalStore.getState().applyTodoUpdate('t2', makeItems()); + useAgentStore.getState().applyTodoUpdate('t1', makeItems()); + useAgentStore.getState().applyTodoUpdate('t2', makeItems()); - useGlobalStore.getState().toggleTodoCollapsed('t1'); + useAgentStore.getState().toggleTodoCollapsed('t1'); - expect(useGlobalStore.getState().agent.todoByThreadId['t1']!.collapsed).toBe(true); - expect(useGlobalStore.getState().agent.todoByThreadId['t2']!.collapsed).toBe(false); + expect(useAgentStore.getState().todoByThreadId['t1']!.collapsed).toBe(true); + expect(useAgentStore.getState().todoByThreadId['t2']!.collapsed).toBe(false); }); }); describe('Todo summary statistics', () => { it('counts statuses correctly', () => { - useGlobalStore.getState().applyTodoUpdate('t1', [ + useAgentStore.getState().applyTodoUpdate('t1', [ { step: 'a', status: 'pending' }, { step: 'b', status: 'pending' }, { step: 'c', status: 'in_progress' }, { step: 'd', status: 'completed' }, ]); - const items = useGlobalStore.getState().agent.todoByThreadId['t1']!.items; + const items = useAgentStore.getState().todoByThreadId['t1']!.items; const pending = items.filter((i: TodoItem) => i.status === 'pending').length; const inProgress = items.filter((i: TodoItem) => i.status === 'in_progress').length; const completed = items.filter((i: TodoItem) => i.status === 'completed').length; @@ -124,12 +116,12 @@ describe('Todo summary statistics', () => { }); it('identifies all-completed state', () => { - useGlobalStore.getState().applyTodoUpdate('t1', [ + useAgentStore.getState().applyTodoUpdate('t1', [ { step: 'a', status: 'completed' }, { step: 'b', status: 'completed' }, ]); - const items = useGlobalStore.getState().agent.todoByThreadId['t1']!.items; + const items = useAgentStore.getState().todoByThreadId['t1']!.items; const allCompleted = items.length > 0 && items.every((i: TodoItem) => i.status === 'completed'); expect(allCompleted).toBe(true); From 137d918d24319d0e5b30e7f9cdb294a14bbeddb0 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 16 Jun 2026 23:56:00 +0800 Subject: [PATCH 2/7] feat: add electron-builder packaging and GitHub Releases publishing --- .github/workflows/release.yml | 46 +++++ package.json | 7 +- packages/codingcode/package.json | 3 + packages/codingcode/scripts/bundle.mjs | 29 +++ packages/codingcode/src/cli.ts | 2 +- .../test/ci/tooling-scripts.test.ts | 28 +++ packages/desktop/electron-builder.yml | 48 +++++ .../desktop/electron/core/child-process.ts | 44 +++-- packages/desktop/package.json | 9 +- packages/desktop/test/child-process.test.ts | 177 ++++++++++++------ packages/infra/src/logger.ts | 8 +- packages/infra/test/logger.test.ts | 36 +++- pnpm-lock.yaml | 3 + 13 files changed, 355 insertions(+), 85 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 packages/codingcode/scripts/bundle.mjs create mode 100644 packages/desktop/electron-builder.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f907042 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,46 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build & publish + shell: bash + run: | + pnpm --filter @codingcode/desktop run bundle:backend + pnpm --filter @codingcode/desktop run build + if [ "$RUNNER_OS" = "Windows" ]; then PLAT=--win; \ + elif [ "$RUNNER_OS" = "macOS" ]; then PLAT=--mac; \ + else PLAT=--linux; fi + pnpm --filter @codingcode/desktop exec electron-builder $PLAT --publish always + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/package.json b/package.json index 1f5f00b..daa5b99 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,11 @@ "lint:fix": "eslint \"packages/*/src/**/*.ts\" \"packages/*/src/**/*.tsx\" \"packages/*/test/**/*.ts\" --fix", "format": "prettier --write \"packages/**/*.{ts,tsx,json}\" \"*.json\" \"*.ts\"", "format:check": "prettier --check \"packages/**/*.{ts,tsx,json}\" \"*.json\" \"*.ts\"", - "debug": "tsx src/debug.ts" + "debug": "tsx src/debug.ts", + "dist": "pnpm --filter @codingcode/desktop run dist", + "dist:win": "pnpm --filter @codingcode/desktop run dist:win", + "dist:mac": "pnpm --filter @codingcode/desktop run dist:mac", + "dist:linux": "pnpm --filter @codingcode/desktop run dist:linux" }, "dependencies": { "@ai-sdk/deepseek": "^2.0.35", @@ -43,6 +47,7 @@ "@eslint/js": "^10.0.1", "@types/node": "^22.0.0", "eslint": "^10.4.1", + "esbuild": "^0.25.0", "playwright-core": "^1.60.0", "prettier": "^3.8.3", "tsx": "^4.19.0", diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 9908484..3b69a85 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -3,6 +3,9 @@ "version": "0.1.0", "type": "module", "main": "./src/layer.ts", + "scripts": { + "bundle": "node scripts/bundle.mjs" + }, "exports": { ".": "./src/layer.ts", "./agent/agent": "./src/agent/agent.ts", diff --git a/packages/codingcode/scripts/bundle.mjs b/packages/codingcode/scripts/bundle.mjs new file mode 100644 index 0000000..2830194 --- /dev/null +++ b/packages/codingcode/scripts/bundle.mjs @@ -0,0 +1,29 @@ +import { build } from 'esbuild'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); + +await build({ + entryPoints: [resolve(root, 'src/cli.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + outfile: resolve(root, 'dist/cli.bundle.js'), + // pino-pretty 仅在开发模式使用,生产模式不会加载 + external: ['pino-pretty'], + // 动态 import 的 TUI 路径无法静态解析,保持原样 + splitting: false, + sourcemap: true, + minify: false, + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + logOverride: { + 'commonjs-variable-in-esm': 'silent', + }, +}); + +console.log('Backend bundle written to dist/cli.bundle.js'); diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index 0bfff9c..b3ba273 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -3,7 +3,7 @@ import { serve } from '@hono/node-server'; import { LLMFactoryService } from './llm/factory.js'; import { createServer } from './server/index.js'; import { createAppRuntime } from './layer.js'; -import { loadConfig, ensureUserConfig } from '../../infra/src/config.js'; +import { loadConfig, ensureUserConfig } from '@codingcode/infra/config'; import { WorkspaceService, parseWorkspaceArgs } from './core/workspace.js'; import { findAvailablePort } from './server/port-discovery.js'; import { AgentError } from './core/error.js'; diff --git a/packages/codingcode/test/ci/tooling-scripts.test.ts b/packages/codingcode/test/ci/tooling-scripts.test.ts index 4ff5095..6ae3dc8 100644 --- a/packages/codingcode/test/ci/tooling-scripts.test.ts +++ b/packages/codingcode/test/ci/tooling-scripts.test.ts @@ -40,6 +40,34 @@ describe('CI tooling configuration', () => { expect(content).toContain('build-desktop:'); }); + it('GitHub Actions release workflow exists and is triggered by tags', () => { + const workflowPath = join(root, '.github/workflows/release.yml'); + expect(existsSync(workflowPath)).toBe(true); + const content = readFileSync(workflowPath, 'utf8'); + expect(content).toContain('tags:'); + expect(content).toContain("- 'v*'"); + expect(content).toContain('permissions:'); + expect(content).toContain('contents: write'); + expect(content).toContain('GH_TOKEN'); + expect(content).toContain('--publish always'); + }); + + it('electron-builder.yml has publish config for GitHub Releases', () => { + const configPath = join(root, 'packages/desktop/electron-builder.yml'); + expect(existsSync(configPath)).toBe(true); + const content = readFileSync(configPath, 'utf8'); + expect(content).toContain('publish:'); + expect(content).toContain('provider: github'); + expect(content).toContain('releaseType: draft'); + }); + + it('desktop package.json has release script', () => { + const pkgPath = join(root, 'packages/desktop/package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + expect(pkg.scripts.release).toBeDefined(); + expect(pkg.scripts.release).toContain('--publish always'); + }); + it('pnpm run lint exits successfully', () => { expect(() => execSync('pnpm run lint', { cwd: root, stdio: 'pipe' })).not.toThrow(); }, 20000); diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml new file mode 100644 index 0000000..e313580 --- /dev/null +++ b/packages/desktop/electron-builder.yml @@ -0,0 +1,48 @@ +appId: com.codingcode.desktop +productName: Coding Agent +copyright: Copyright © 2025 + +directories: + buildResources: resources + output: dist + +files: + - out/**/* + - "!out/shots" + +# 后端打包为单文件 bundle,通过 extraResources 放入安装包 +extraResources: + - from: ../codingcode/dist/cli.bundle.js + to: backend/cli.bundle.js + - from: ../codingcode/dist/cli.bundle.js.map + to: backend/cli.bundle.js.map + +asar: true + +win: + target: + - target: nsis + arch: [x64] + +mac: + target: + - target: dmg + arch: [x64, arm64] + category: public.app-category.developer-tools + +linux: + target: + - target: AppImage + arch: [x64] + category: Development + +nsis: + oneClick: false + allowToChangeInstallationDirectory: true + createDesktopShortcut: true + +publish: + provider: github + owner: phantom5099 + repo: coding-code + releaseType: draft diff --git a/packages/desktop/electron/core/child-process.ts b/packages/desktop/electron/core/child-process.ts index 2e4fd5a..930082c 100644 --- a/packages/desktop/electron/core/child-process.ts +++ b/packages/desktop/electron/core/child-process.ts @@ -1,41 +1,57 @@ import { spawn, ChildProcess } from 'child_process'; -import { resolve } from 'path'; +import { resolve, join } from 'path'; import { app } from 'electron'; let child: ChildProcess | null = null; -function getCliPath(): string { - const root = resolve(app.getAppPath(), '../../'); - return resolve(root, 'packages/codingcode/src/cli.ts'); +function isDev(): boolean { + // electron-vite 开发模式下会设置此环境变量 + return !!process.env.ELECTRON_RENDERER_URL; +} + +function getBackendEntry(): string { + if (isDev()) { + return resolve(app.getAppPath(), '../../packages/codingcode/src/cli.ts'); + } + // 生产模式:后端 bundle 通过 extraResources 放到 resources/backend/ + const resourcesDir = + process.platform === 'darwin' + ? join(process.execPath, '../../Resources') + : join(process.execPath, '../resources'); + return join(resourcesDir, 'backend', 'cli.bundle.js'); } function getProjectRoot(): string { - return resolve(app.getAppPath(), '../../'); + if (isDev()) { + return resolve(app.getAppPath(), '../../'); + } + return process.cwd(); } export async function startBackend(): Promise { return new Promise((resolvePromise, reject) => { - const cliPath = getCliPath(); + const entry = getBackendEntry(); const root = getProjectRoot(); const isWin = process.platform === 'win32'; - if (isWin) { - // On Windows, .cmd files require shell mode, but spawn with shell:true - // does not auto-quote arguments. Paths with spaces get split. - // Solution: construct the full command string and pass it directly. + if (isDev()) { + // 开发模式:tsx 运行 TypeScript 源码 const tsxPath = resolve(root, 'node_modules/.bin/tsx.cmd'); - const cmd = `"${tsxPath}" "${cliPath}" serve`; + const cmd = `"${tsxPath}" "${entry}" serve`; child = spawn(cmd, [], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], shell: true, }); } else { - // On Unix, no shell needed — spawn handles paths with spaces natively. - const tsxPath = resolve(root, 'node_modules/.bin/tsx'); - child = spawn(tsxPath, [cliPath, 'serve'], { + // 生产模式:node 运行 esbuild 打包后的单文件 + child = spawn('node', [entry, 'serve'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + NODE_ENV: 'production', + }, }); } diff --git a/packages/desktop/package.json b/packages/desktop/package.json index e68d036..6d31feb 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -6,10 +6,17 @@ "scripts": { "dev": "node scripts/dev.mjs", "build": "electron-vite build", + "bundle:backend": "pnpm --filter @codingcode/core run bundle", "preview": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", "verify": "electron-vite build && node scripts/verify.mjs", - "test": "vitest run" + "test": "vitest run", + "pack": "electron-builder --dir", + "dist": "pnpm run bundle:backend && pnpm run build && electron-builder", + "dist:win": "pnpm run bundle:backend && pnpm run build && electron-builder --win", + "dist:mac": "pnpm run bundle:backend && pnpm run build && electron-builder --mac", + "dist:linux": "pnpm run bundle:backend && pnpm run build && electron-builder --linux", + "release": "pnpm run bundle:backend && pnpm run build && electron-builder --publish always" }, "dependencies": { "@codingcode/core": "workspace:*", diff --git a/packages/desktop/test/child-process.test.ts b/packages/desktop/test/child-process.test.ts index f0a5b12..656eb0b 100644 --- a/packages/desktop/test/child-process.test.ts +++ b/packages/desktop/test/child-process.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app/path' }, @@ -21,96 +21,151 @@ function createMockChildProcess() { } describe('child-process', () => { + let originalRendererUrl: string | undefined; + beforeEach(() => { vi.clearAllMocks(); stopBackend(); + originalRendererUrl = process.env.ELECTRON_RENDERER_URL; }); - it('spawns tsx with serve argument and resolves port', async () => { - const mockCp = createMockChildProcess(); - vi.mocked(spawn).mockReturnValue(mockCp); + afterEach(() => { + if (originalRendererUrl !== undefined) { + process.env.ELECTRON_RENDERER_URL = originalRendererUrl; + } else { + delete process.env.ELECTRON_RENDERER_URL; + } + }); - const promise = startBackend(); + describe('dev mode (ELECTRON_RENDERER_URL set)', () => { + beforeEach(() => { + process.env.ELECTRON_RENDERER_URL = 'http://localhost:5173'; + }); - // On the test platform (non-Windows or Windows), verify spawn was called - expect(spawn).toHaveBeenCalledTimes(1); - const [cmd, args, options] = vi.mocked(spawn).mock.calls[0]; - // Command or first arg should contain tsx - const fullCmd = typeof cmd === 'string' ? cmd : ''; - const fullArgs = Array.isArray(args) ? args.join(' ') : ''; - expect(fullCmd + ' ' + fullArgs).toContain('tsx'); - expect(fullCmd + ' ' + fullArgs).toContain('cli.ts'); - expect(fullCmd + ' ' + fullArgs).toContain('serve'); + it('spawns tsx with serve argument and resolves port', async () => { + const mockCp = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockCp); - // Simulate the CLI outputting the ready signal - mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:9090\n')); + const promise = startBackend(); - const port = await promise; - expect(port).toBe(9090); - }); + expect(spawn).toHaveBeenCalledTimes(1); + const [cmd, args, options] = vi.mocked(spawn).mock.calls[0]; + const fullCmd = typeof cmd === 'string' ? cmd : ''; + const fullArgs = Array.isArray(args) ? args.join(' ') : ''; + expect(fullCmd + ' ' + fullArgs).toContain('tsx'); + expect(fullCmd + ' ' + fullArgs).toContain('cli.ts'); + expect(fullCmd + ' ' + fullArgs).toContain('serve'); - it('rejects if child process exits before ready', async () => { - const mockCp = createMockChildProcess(); - vi.mocked(spawn).mockReturnValue(mockCp); + mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:9090\n')); - const promise = startBackend(); + const port = await promise; + expect(port).toBe(9090); + }); - mockCp.emit('exit', 1); + it('rejects if child process exits before ready', async () => { + const mockCp = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockCp); - await expect(promise).rejects.toThrow('Backend process exited with code 1'); - }); + const promise = startBackend(); - it('rejects if child process errors before ready', async () => { - const mockCp = createMockChildProcess(); - vi.mocked(spawn).mockReturnValue(mockCp); + mockCp.emit('exit', 1); - const promise = startBackend(); + await expect(promise).rejects.toThrow('Backend process exited with code 1'); + }); - mockCp.emit('error', new Error('spawn failed')); + it('rejects if child process errors before ready', async () => { + const mockCp = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockCp); - await expect(promise).rejects.toThrow('spawn failed'); - }); + const promise = startBackend(); - it('ignores non-ready stdout lines', async () => { - const mockCp = createMockChildProcess(); - vi.mocked(spawn).mockReturnValue(mockCp); + mockCp.emit('error', new Error('spawn failed')); - const promise = startBackend(); + await expect(promise).rejects.toThrow('spawn failed'); + }); - mockCp.stdout.emit('data', Buffer.from('Workspace: /some/path\n')); - mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:8080\n')); + it('ignores non-ready stdout lines', async () => { + const mockCp = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockCp); - const port = await promise; - expect(port).toBe(8080); - }); + const promise = startBackend(); - it('stopBackend kills the child process', async () => { - const mockCp = createMockChildProcess(); - vi.mocked(spawn).mockReturnValue(mockCp); + mockCp.stdout.emit('data', Buffer.from('Workspace: /some/path\n')); + mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:8080\n')); - const promise = startBackend(); - mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:8080\n')); - await promise; + const port = await promise; + expect(port).toBe(8080); + }); - stopBackend(); + it('stopBackend kills the child process', async () => { + const mockCp = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockCp); - expect(mockCp.kill).toHaveBeenCalled(); - }); + const promise = startBackend(); + mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:8080\n')); + await promise; + + stopBackend(); + + expect(mockCp.kill).toHaveBeenCalled(); + }); + + it('stopBackend is safe when no child is running', () => { + expect(() => stopBackend()).not.toThrow(); + }); - it('stopBackend is safe when no child is running', () => { - expect(() => stopBackend()).not.toThrow(); + it('resolves only the first ready signal', async () => { + const mockCp = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockCp); + + const promise = startBackend(); + + mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:8080\n')); + mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:9090\n')); + + const port = await promise; + expect(port).toBe(8080); + }); }); - it('resolves only the first ready signal', async () => { - const mockCp = createMockChildProcess(); - vi.mocked(spawn).mockReturnValue(mockCp); + describe('production mode (no ELECTRON_RENDERER_URL)', () => { + beforeEach(() => { + delete process.env.ELECTRON_RENDERER_URL; + }); + + it('spawns node with bundled cli.bundle.js and serve argument', async () => { + const mockCp = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockCp); + + const promise = startBackend(); + + expect(spawn).toHaveBeenCalledTimes(1); + const [cmd, args, options] = vi.mocked(spawn).mock.calls[0]; + expect(cmd).toBe('node'); + expect(Array.isArray(args)).toBe(true); + const argStr = (args as string[]).join(' '); + expect(argStr).toContain('cli.bundle.js'); + expect(argStr).toContain('serve'); + + // 生产模式应设置 NODE_ENV=production + const env = (options as any)?.env; + expect(env?.NODE_ENV).toBe('production'); + + mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:3000\n')); + + const port = await promise; + expect(port).toBe(3000); + }); + + it('rejects if child process exits before ready in production', async () => { + const mockCp = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockCp); - const promise = startBackend(); + const promise = startBackend(); - mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:8080\n')); - mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:9090\n')); + mockCp.emit('exit', 1); - const port = await promise; - expect(port).toBe(8080); + await expect(promise).rejects.toThrow('Backend process exited with code 1'); + }); }); }); diff --git a/packages/infra/src/logger.ts b/packages/infra/src/logger.ts index e55ffa8..26b5941 100644 --- a/packages/infra/src/logger.ts +++ b/packages/infra/src/logger.ts @@ -13,14 +13,14 @@ export function createLogger(level = process.env.LOG_LEVEL ?? 'info'): Logger { const isDev = process.env.NODE_ENV !== 'production'; if (!isDev) { + // 生产模式:使用 pino.destination 同步写入文件,不依赖 worker 线程 + // 这样可以被 esbuild 打包为单文件,无需额外 node_modules const logDir = join(homedir(), '.codingcode', 'logs'); try { mkdirSync(logDir, { recursive: true }); } catch {} - return (pino as any)({ - level, - transport: { target: 'pino/file', options: { destination: join(logDir, 'app.log') } }, - }); + const dest = join(logDir, 'app.log'); + return (pino as any)({ level }, (pino as any).destination(dest)); } return (pino as any)({ level, diff --git a/packages/infra/test/logger.test.ts b/packages/infra/test/logger.test.ts index 8701fb9..1713c32 100644 --- a/packages/infra/test/logger.test.ts +++ b/packages/infra/test/logger.test.ts @@ -1,5 +1,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +// 捕获 pino 调用参数的 mock +const pinoCalls: any[] = []; +vi.mock('pino', () => { + const mockLogger = { + level: 'info', + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }; + const mockPino: any = (opts: any, dest?: any) => { + pinoCalls.push({ opts, dest }); + if (dest) { + return { ...mockLogger, level: opts?.level ?? 'info' }; + } + return { ...mockLogger, level: opts?.level ?? 'info' }; + }; + mockPino.destination = (path: string) => ({ type: 'destination', path }); + return { default: mockPino }; +}); + describe('createLogger', () => { let originalEnv: string | undefined; @@ -13,6 +34,7 @@ describe('createLogger', () => { beforeEach(() => { vi.resetModules(); + pinoCalls.length = 0; originalEnv = process.env.NODE_ENV; }); @@ -29,7 +51,8 @@ describe('createLogger', () => { expect(logger).toBeDefined(); expect(logger.level).toBe('debug'); - expect(() => logger.info('test')).not.toThrow(); + // Electron 模式不使用 transport + expect(pinoCalls[0]?.opts?.transport).toBeUndefined(); }); it('returns pino-pretty logger in dev Node.js', async () => { @@ -43,10 +66,11 @@ describe('createLogger', () => { expect(logger).toBeDefined(); expect(logger.level).toBe('info'); - expect(() => logger.info('test')).not.toThrow(); + // 开发模式使用 pino-pretty transport + expect(pinoCalls[0]?.opts?.transport?.target).toBe('pino-pretty'); }); - it('returns file logger in production Node.js', async () => { + it('returns file logger via pino.destination in production Node.js', async () => { const versions = { ...process.versions }; delete (versions as any).electron; setVersions(versions); @@ -57,5 +81,11 @@ describe('createLogger', () => { expect(logger).toBeDefined(); expect(logger.level).toBe('warn'); + // 生产模式不使用 transport(避免 worker 线程依赖) + expect(pinoCalls[0]?.opts?.transport).toBeUndefined(); + // 生产模式使用 pino.destination 写入文件 + expect(pinoCalls[0]?.dest).toBeDefined(); + expect(pinoCalls[0]?.dest?.type).toBe('destination'); + expect(pinoCalls[0]?.dest?.path).toContain('app.log'); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da35346..a789814 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.19 + esbuild: + specifier: ^0.25.0 + version: 0.25.0 eslint: specifier: ^10.4.1 version: 10.4.1(jiti@2.7.0) From e006d58714887feef1a399244277ef92f92d2ab6 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 17 Jun 2026 00:01:33 +0800 Subject: [PATCH 3/7] fix: remove duplicate pnpm version in release workflow --- .github/workflows/release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f907042..4060813 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,8 +21,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 11 - name: Setup Node.js uses: actions/setup-node@v4 From 2c75f161888203fcc1eb3b75a53897f9af8a6e7e Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 17 Jun 2026 00:05:10 +0800 Subject: [PATCH 4/7] fix: use Node.js 22 for pnpm@11 compatibility --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4060813..3fff048 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - name: Install dependencies From 0fafe5bd6341b3489eaa91df10666ceeec0ea051 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 17 Jun 2026 00:24:24 +0800 Subject: [PATCH 5/7] fix: split release workflow into explicit steps with matrix platform --- .github/workflows/release.yml | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fff048..ea030e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,13 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest, macos-latest, ubuntu-latest] + include: + - os: windows-latest + platform: win + - os: macos-latest + platform: mac + - os: ubuntu-latest + platform: linux runs-on: ${{ matrix.os }} steps: - name: Checkout @@ -31,14 +37,13 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build & publish - shell: bash - run: | - pnpm --filter @codingcode/desktop run bundle:backend - pnpm --filter @codingcode/desktop run build - if [ "$RUNNER_OS" = "Windows" ]; then PLAT=--win; \ - elif [ "$RUNNER_OS" = "macOS" ]; then PLAT=--mac; \ - else PLAT=--linux; fi - pnpm --filter @codingcode/desktop exec electron-builder $PLAT --publish always + - name: Bundle backend + run: pnpm --filter @codingcode/desktop run bundle:backend + + - name: Build Electron app + run: pnpm --filter @codingcode/desktop run build + + - name: Package & publish + run: pnpm --filter @codingcode/desktop exec electron-builder --${{ matrix.platform }} --publish always env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From a3f03eb8c7eec7d67e2d05b05c928ec445d021d8 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 17 Jun 2026 00:54:29 +0800 Subject: [PATCH 6/7] fix: split release workflow into build and release stages --- .github/workflows/release.yml | 39 +++++++++++++++++-- .../test/ci/tooling-scripts.test.ts | 4 +- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea030e2..a967c9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ permissions: contents: write jobs: - release: + build: strategy: fail-fast: false matrix: @@ -43,7 +43,40 @@ jobs: - name: Build Electron app run: pnpm --filter @codingcode/desktop run build - - name: Package & publish - run: pnpm --filter @codingcode/desktop exec electron-builder --${{ matrix.platform }} --publish always + - name: Package (no publish) + run: pnpm --filter @codingcode/desktop exec electron-builder --${{ matrix.platform }} --publish never + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist-${{ matrix.platform }} + path: | + packages/desktop/dist/*.exe + packages/desktop/dist/*.exe.blockmap + packages/desktop/dist/*.yml + packages/desktop/dist/*.dmg + packages/desktop/dist/*.zip + packages/desktop/dist/*.AppImage + if-no-files-found: warn + + release: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: assets + merge-multiple: true + + - name: Create GitHub Release and upload assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" \ + --title "${{ github.ref_name }}" \ + --generate-notes \ + assets/* diff --git a/packages/codingcode/test/ci/tooling-scripts.test.ts b/packages/codingcode/test/ci/tooling-scripts.test.ts index 6ae3dc8..1002b5d 100644 --- a/packages/codingcode/test/ci/tooling-scripts.test.ts +++ b/packages/codingcode/test/ci/tooling-scripts.test.ts @@ -49,7 +49,9 @@ describe('CI tooling configuration', () => { expect(content).toContain('permissions:'); expect(content).toContain('contents: write'); expect(content).toContain('GH_TOKEN'); - expect(content).toContain('--publish always'); + expect(content).toContain('--publish never'); + expect(content).toContain('gh release create'); + expect(content).toContain('needs: build'); }); it('electron-builder.yml has publish config for GitHub Releases', () => { From cb2baeb52fe0aa418768145989df4872d86cbfa5 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 17 Jun 2026 01:09:43 +0800 Subject: [PATCH 7/7] fix unfind model --- packages/desktop/electron-builder.yml | 2 ++ packages/desktop/electron/core/child-process.ts | 15 +++++++++------ packages/desktop/test/child-process.test.ts | 6 ++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index e313580..a331e4c 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -16,6 +16,8 @@ extraResources: to: backend/cli.bundle.js - from: ../codingcode/dist/cli.bundle.js.map to: backend/cli.bundle.js.map + - from: ../../config/models.json + to: config/models.json asar: true diff --git a/packages/desktop/electron/core/child-process.ts b/packages/desktop/electron/core/child-process.ts index 930082c..22fe371 100644 --- a/packages/desktop/electron/core/child-process.ts +++ b/packages/desktop/electron/core/child-process.ts @@ -9,23 +9,26 @@ function isDev(): boolean { return !!process.env.ELECTRON_RENDERER_URL; } +function getResourcesDir(): string { + return process.platform === 'darwin' + ? join(process.execPath, '../../Resources') + : join(process.execPath, '../resources'); +} + function getBackendEntry(): string { if (isDev()) { return resolve(app.getAppPath(), '../../packages/codingcode/src/cli.ts'); } // 生产模式:后端 bundle 通过 extraResources 放到 resources/backend/ - const resourcesDir = - process.platform === 'darwin' - ? join(process.execPath, '../../Resources') - : join(process.execPath, '../resources'); - return join(resourcesDir, 'backend', 'cli.bundle.js'); + return join(getResourcesDir(), 'backend', 'cli.bundle.js'); } function getProjectRoot(): string { if (isDev()) { return resolve(app.getAppPath(), '../../'); } - return process.cwd(); + // 生产模式:config/models.json 通过 extraResources 放到 resources/config/ + return getResourcesDir(); } export async function startBackend(): Promise { diff --git a/packages/desktop/test/child-process.test.ts b/packages/desktop/test/child-process.test.ts index 656eb0b..09a595c 100644 --- a/packages/desktop/test/child-process.test.ts +++ b/packages/desktop/test/child-process.test.ts @@ -151,6 +151,12 @@ describe('child-process', () => { const env = (options as any)?.env; expect(env?.NODE_ENV).toBe('production'); + // 生产模式 cwd 应指向 resources 目录(而非 process.cwd()) + const cwd = (options as any)?.cwd; + expect(cwd).toBeDefined(); + expect(cwd).not.toBe(process.cwd()); + expect(cwd.toLowerCase()).toContain('resources'); + mockCp.stdout.emit('data', Buffer.from('CODINGCODE_SERVER_READY:3000\n')); const port = await promise;