diff --git a/AGENTS.md b/AGENTS.md index f804eb9420..f91a5fd680 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,6 @@ # Kandev Engineering Guide > **Purpose**: Architecture notes, key patterns, and conventions for LLM agents working on Kandev. -> -> **Related**: [ARCHITECTURE.md](ARCHITECTURE.md), [docs/asyncapi.yaml](docs/asyncapi.yaml) ## Repo Layout @@ -62,12 +60,13 @@ apps/backend/ ### Key Concepts **Orchestrator** coordinates task execution: -- Receives task start/stop/resume requests +- Receives task start/stop/resume requests via WebSocket - Delegates to lifecycle manager for agent operations - Handles event-driven state transitions +- Located in `internal/orchestrator/` **Lifecycle Manager** (`internal/agent/lifecycle/`) manages agent instances: -- `Manager` - central coordinator (~900 lines after refactor) +- `Manager` - central coordinator for agent lifecycle - `Runtime` interface - abstracts execution environment (Docker, Standalone) - `ExecutionStore` - thread-safe in-memory execution tracking - `SessionManager` - ACP session initialization and resume @@ -81,22 +80,12 @@ apps/backend/ - Exposes workspace operations (shell, git, files) - Supports multiple concurrent instances on different ports -### Executor and Runtime - -**Executor** (database model) defines where agents run: - -| Type | Description | Status | -|------|-------------|--------| -| `local_pc` | Standalone process on host | Implemented | -| `local_docker` | Docker container on host | Implemented | -| `remote_docker` | Remote Docker daemon | Planned | -| `remote_vps` | Remote server via SSH | Planned | -| `k8s` | Kubernetes pods | Planned | - -**Runtime** (interface) implements execution: -- `DockerRuntime` - creates containers, mounts workspace -- `StandaloneRuntime` - uses shared agentctl process +**Executor Types** (database model): +- `local_pc` - Standalone process on host ✅ +- `local_docker` - Docker container on host ✅ +- `remote_docker`, `remote_vps`, `k8s` - Planned +**Runtime Interface** implements execution: ```go type Runtime interface { Name() runtime.Name @@ -110,48 +99,31 @@ type Runtime interface { ### Execution Flow ``` -Client (WS) Orchestrator Lifecycle Manager Runtime agentctl - | | | | | - | task.start | | | | - |----------------->| LaunchAgent() | | | - | |------------------->| CreateInstance() | | - | | |----------------->| (container/proc) | - | | | |----------------->| - | | StartAgentProcess()| | | - | |------------------->| ConfigureAgent() | | - | | |---------------------------------->| - | | | Start() | | - | | |---------------------------------->| - | | | | agent proc | - | | |<---- stream updates (WS) ---------| - |<---- WS events --| | | | +Client (WS) Orchestrator Lifecycle Manager Runtime agentctl + | | | | | + | task.start | | | | + |--------------->| LaunchAgent() | | | + | |--------------------->| CreateInstance() | | + | | |----------------->| (container) | + | | | |--------------->| + | | StartAgentProcess() | | | + | |--------------------->| ConfigureAgent() | | + | | |-----------------------------> | + | | | Start() | | + | | |-----------------------------> | + | | | | agent proc | + | | |<---- stream updates (WS) ---------| + |<--- WS events -| | | | ``` -### Session Resume - -Sessions can be resumed after interruption: +**Session Resume:** - `TaskSession.ACPSessionID` - stored ACP session ID for resume - `ExecutorRunning` - tracks active executor state (container ID, port, worktree) - On backend restart: `RecoverInstances()` finds running containers, reconnects streams -### Provider Pattern +**Provider Pattern:** Packages expose `Provide(cfg, log) (*impl, cleanup, error)` for DI. Returns implementation, cleanup function, and error. Cleanup called during graceful shutdown. -Packages expose `Provide(...)` functions for dependency injection: - -```go -func Provide(cfg *config.Config, log *logger.Logger) (*impl, func() error, error) -``` - -- Returns concrete implementation, cleanup function, and error -- Cleanup called during graceful shutdown -- Keeps `cmd/kandev/main.go` thin - -### Worktrees - -Git worktrees provide workspace isolation: -- Each session can have its own worktree (branch) -- Prevents conflicts between concurrent agents -- Managed by `internal/worktree/Manager` +**Worktrees:** `internal/worktree/Manager` provides workspace isolation. Each session can have its own worktree (branch) to prevent conflicts between concurrent agents. --- @@ -175,7 +147,6 @@ Git worktrees provide workspace isolation: ### Adapter Model Protocol adapters normalize different agent CLIs: - - `AgentAdapter` interface defines `Start()`, `Stop()`, `Prompt()`, `Cancel()` - `ACPAdapter` - ACP JSON-RPC over stdio - `CodexAdapter` - Codex-style JSON-RPC @@ -207,77 +178,53 @@ JSON-RPC 2.0 over stdin/stdout between agentctl and agent process. SSR Fetch -> Hydrate Store -> Components Read Store -> Hooks Subscribe ``` -**This pattern must be followed. Do not fetch data directly in components.** +**Never fetch data directly in components.** -1. **SSR fetches** in layout/page server components -2. **Hydrate store** via `StateHydrator` component -3. **Components read from store only** (Zustand selectors) -4. **Hooks subscribe to WS channels** for real-time updates -5. **WS events update store** via event handlers +### Store Structure (Domain Slices) -### Store Structure +``` +lib/state/ +├── store.ts (~370 lines) # Root composition +├── slices/ # Domain slices +│ ├── kanban/ # boards, tasks, columns +│ ├── session/ # sessions, messages, turns, worktrees +│ ├── session-runtime/ # shell, processes, git, context +│ ├── workspace/ # workspaces, repos, branches +│ ├── settings/ # executors, agents, editors, prompts +│ └── ui/ # preview, connection, active state +├── hydration/ # SSR merge strategies +└── selectors/ # Memoized selectors (future) -```typescript -// Task/session data keyed by ID -messages.bySession[sessionId] -gitStatus[sessionId] -shell.outputs[sessionId] - -// Active selection -tasks.activeTaskId -tasks.activeSessionId +hooks/domains/{kanban,session,workspace,settings}/ # Domain-organized hooks +lib/api/domains/{kanban,session,workspace,settings,process}-api.ts # API clients ``` -### Custom Hooks Pattern +**Key State Paths:** +- `messages.bySession[sessionId]`, `shell.outputs[sessionId]`, `gitStatus.bySessionId[sessionId]` +- `tasks.activeTaskId`, `tasks.activeSessionId`, `workspaces.activeId` +- `repositories.byWorkspace`, `repositoryBranches.byRepository` -Hooks encapsulate subscription + store access: +**Hydration:** `lib/state/hydration/merge-strategies.ts` has `deepMerge()`, `mergeSessionMap()`, `mergeLoadingState()` to avoid overwriting live client state. Pass `activeSessionId` to protect active sessions. -```typescript -// Hook subscribes to WS channel, returns data from store -function useSessionMessages(sessionId: string) { - useSessionSubscription(sessionId) // Subscribe on mount, cleanup on unmount - return useStore(state => state.messages.bySession[sessionId]) -} -``` +**Hooks Pattern:** Hooks in `hooks/domains/` encapsulate WS subscription + store selection. WS client deduplicates subscriptions automatically. -### WebSocket Subscription +### WS -- Components call subscription hooks (e.g., `useTaskSubscription(taskId)`) -- WS client **deduplicates** subscriptions with reference counting -- Reconnects automatically re-subscribe all active channels -- Event handlers route payloads to store slices - -**Why This Matters**: Without this pattern, you get duplicate subscriptions, stale data, and race conditions. - -### WS Message Format - -```json -{ - "id": "uuid", - "type": "request|response|notification|error", - "action": "action.name", - "payload": { ... }, - "timestamp": "2026-01-10T12:00:00Z" -} -``` - -See `docs/asyncapi.yaml` for all WS actions. +**Format:** `{id, type, action, payload, timestamp}`. See `docs/asyncapi.yaml` for all actions. --- ## Best Practices ### Backend -- Use provider pattern for dependency injection -- Keep agents logging to stderr; stdout only for ACP -- Pass context through call chains (but detach for background work) -- Use event bus for cross-component communication +- Provider pattern for DI; stderr for logs, stdout for ACP only +- Pass context through chains; event bus for cross-component comm ### Frontend -- Never fetch in components; use SSR + store hydration -- Always use subscription hooks; never raw WS calls in components -- Keep components reading from store only -- Split large pages into smaller components +- **Data:** SSR fetch → hydrate → read store. Never fetch in components +- **Components:** <200 lines, extract to domain components, composition over props +- **Hooks:** Domain-organized in `hooks/domains/`, encapsulate subscription + selection +- **WS:** Use subscription hooks only; client auto-deduplicates --- diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 11741d33d6..75dc8b1efc 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,6 +1,6 @@ import { PageClient } from '@/app/page-client'; import { StateHydrator } from '@/components/state-hydrator'; -import { fetchBoardSnapshot, fetchUserSettings, listBoards, listRepositories, listWorkspaces, listTaskSessionMessages } from '@/lib/http'; +import { fetchBoardSnapshot, fetchUserSettings, listBoards, listRepositories, listWorkspaces, listTaskSessionMessages } from '@/lib/api'; import { snapshotToState } from '@/lib/ssr/mapper'; import type { AppState } from '@/lib/state/store'; diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index 934faca808..ddf2e950bf 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -9,7 +9,7 @@ import { listRepositories, listTaskSessionMessages, listTaskSessions, -} from '@/lib/http'; +} from '@/lib/api'; import type { ListMessagesResponse, Task } from '@/lib/types/http'; import { snapshotToState, taskToState } from '@/lib/ssr/mapper'; import TaskPageClient from '@/app/task/[id]/page-client'; diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 138d960add..da1b15ca2e 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -28,10 +28,10 @@ import { updateAgentProfileAction, updateAgentProfileMcpConfigAction, } from '@/app/actions/agents'; -import type { Agent, AgentDiscovery, AgentProfile, McpServerDef } from '@/lib/types/http'; +import type { Agent, AgentDiscovery, AgentProfile, McpServerDef, AvailableAgent } from '@/lib/types/http'; import { generateUUID } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; -import { useAvailableAgents } from '@/hooks/use-available-agents'; +import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents'; import { ProfileMcpConfigCard } from './profile-mcp-config-card'; type DraftProfile = AgentProfile & { @@ -106,14 +106,14 @@ function AgentSetupForm({ const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [newProfileId, setNewProfileId] = useState(null); const resolveDisplayName = (name: string) => - availableAgents.find((item) => item.name === name)?.display_name ?? ''; + availableAgents.find((item: AvailableAgent) => item.name === name)?.display_name ?? ''; const hasInvalidMcpConfig = useMemo(() => { return draftAgent.profiles.some((profile) => Boolean(profile.mcp_config?.error)); }, [draftAgent.profiles]); // Get model config for the current agent const currentAgentModelConfig = useMemo(() => { - const agent = availableAgents.find((item) => item.name === draftAgent.name); + const agent = availableAgents.find((item: AvailableAgent) => item.name === draftAgent.name); return agent?.model_config ?? { default_model: '', available_models: [] }; }, [availableAgents, draftAgent.name]); @@ -156,9 +156,9 @@ function AgentSetupForm({ }; const upsertAgent = (agent: Agent) => { - const exists = settingsAgents.some((item) => item.id === agent.id); + const exists = settingsAgents.some((item: Agent) => item.id === agent.id); const nextAgents = exists - ? settingsAgents.map((item) => (item.id === agent.id ? agent : item)) + ? settingsAgents.map((item: Agent) => (item.id === agent.id ? agent : item)) : [...settingsAgents, agent]; syncAgentsToStore(nextAgents); }; @@ -460,7 +460,7 @@ function AgentSetupForm({ - {currentAgentModelConfig.available_models.map((model) => ( + {currentAgentModelConfig.available_models.map((model: { id: string; name: string; is_default?: boolean }) => ( {model.name} {model.is_default && ( @@ -561,19 +561,19 @@ export default function AgentSetupPage() { const availableAgents = useAvailableAgents().items; const discoveryAgent = useMemo(() => { - return discoveryAgents.find((agent) => agent.name === decodedKey); + return discoveryAgents.find((agent: AgentDiscovery) => agent.name === decodedKey); }, [decodedKey, discoveryAgents]); const savedAgent = useMemo(() => { - return savedAgents.find((agent) => agent.id === decodedKey || agent.name === decodedKey) ?? null; + return savedAgents.find((agent: Agent) => agent.id === decodedKey || agent.name === decodedKey) ?? null; }, [decodedKey, savedAgents]); const initialAgent = useMemo(() => { if (!decodedKey) return null; const resolveDisplayName = (name: string) => - availableAgents.find((item) => item.name === name)?.display_name ?? ''; + availableAgents.find((item: AvailableAgent) => item.name === name)?.display_name ?? ''; const resolveDefaultModel = (name: string) => - availableAgents.find((item) => item.name === name)?.model_config?.default_model ?? ''; + availableAgents.find((item: AvailableAgent) => item.name === name)?.model_config?.default_model ?? ''; if (savedAgent) { return ensureProfiles(cloneAgent(savedAgent), resolveDisplayName(savedAgent.name), resolveDefaultModel(savedAgent.name)); } diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx index c9fc5e2f30..280e5a29e7 100644 --- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx @@ -1,5 +1,5 @@ import { StateHydrator } from '@/components/state-hydrator'; -import { listAvailableAgents, getAgentProfileMcpConfig } from '@/lib/http'; +import { listAvailableAgents, getAgentProfileMcpConfig } from '@/lib/api'; import type { AgentProfileMcpConfig } from '@/lib/types/http'; import { AgentProfilePage } from '@/components/settings/agent-profile-page'; diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts index 256c0c8158..de73dff334 100644 --- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts +++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts @@ -1,9 +1,9 @@ 'use client'; import { useMemo } from 'react'; -import { useAvailableAgents } from '@/hooks/use-available-agents'; +import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents'; import { useAppStore } from '@/components/state-provider'; -import type { Agent, AgentProfile, ModelConfig } from '@/lib/types/http'; +import type { Agent, AgentProfile, ModelConfig, AvailableAgent } from '@/lib/types/http'; type AgentProfileSettingsResult = { agent: Agent | null; @@ -19,15 +19,15 @@ export function useAgentProfileSettings( const availableAgents = useAvailableAgents().items; const agent = useMemo(() => { - return settingsAgents.find((item) => item.name === agentKey) ?? null; + return settingsAgents.find((item: Agent) => item.name === agentKey) ?? null; }, [agentKey, settingsAgents]); const profile = useMemo(() => { - return agent?.profiles.find((item) => item.id === profileId) ?? null; + return agent?.profiles.find((item: AgentProfile) => item.id === profileId) ?? null; }, [agent?.profiles, profileId]); const modelConfig = useMemo(() => { - const availableAgent = availableAgents.find((item) => item.name === agent?.name); + const availableAgent = availableAgents.find((item: AvailableAgent) => item.name === agent?.name); return availableAgent?.model_config ?? { default_model: '', available_models: [] }; }, [availableAgents, agent?.name]); diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 8056d6f501..22945e8e26 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -8,8 +8,9 @@ import { Button } from '@kandev/ui/button'; import { Card, CardContent } from '@kandev/ui/card'; import { Separator } from '@kandev/ui/separator'; import { useAppStore } from '@/components/state-provider'; -import { listAgentDiscovery } from '@/lib/http'; -import { useAvailableAgents } from '@/hooks/use-available-agents'; +import { listAgentDiscovery } from '@/lib/api'; +import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents'; +import type { AgentDiscovery, Agent, AvailableAgent, AgentProfile } from '@/lib/types/http'; export default function AgentsSettingsPage() { const discoveryAgents = useAppStore((state) => state.agentDiscovery.items); @@ -19,12 +20,12 @@ export default function AgentsSettingsPage() { const [rescanning, setRescanning] = useState(false); const installedAgents = useMemo( - () => discoveryAgents.filter((agent) => agent.available), + () => discoveryAgents.filter((agent: AgentDiscovery) => agent.available), [discoveryAgents] ); const savedAgentsByName = useMemo( - () => new Map(savedAgents.map((agent) => [agent.name, agent])), + () => new Map(savedAgents.map((agent: Agent) => [agent.name, agent])), [savedAgents] ); @@ -75,8 +76,8 @@ export default function AgentsSettingsPage() { )}
- {installedAgents.map((agent) => { - const savedAgent = savedAgentsByName.get(agent.name); + {installedAgents.map((agent: AgentDiscovery) => { + const savedAgent = savedAgentsByName.get(agent.name) as Agent | undefined; const configured = Boolean(savedAgent && savedAgent.profiles.length > 0); const hasAgentRecord = Boolean(savedAgent); return ( @@ -85,7 +86,7 @@ export default function AgentsSettingsPage() {

- {availableAgents.find((item) => item.name === agent.name)?.display_name ?? agent.name} + {availableAgents.find((item: AvailableAgent) => item.name === agent.name)?.display_name ?? agent.name}

{agent.supports_mcp && MCP} {configured && Configured} @@ -107,7 +108,7 @@ export default function AgentsSettingsPage() {
- {savedAgents.some((agent) => agent.profiles.length > 0) && ( + {savedAgents.some((agent: Agent) => agent.profiles.length > 0) && (
@@ -116,8 +117,8 @@ export default function AgentsSettingsPage() {
- {savedAgents.flatMap((agent) => - agent.profiles.map((profile) => { + {savedAgents.flatMap((agent: Agent) => + agent.profiles.map((profile: AgentProfile) => { const profilePath = `/settings/agents/${encodeURIComponent(agent.name)}/profiles/${profile.id}`; return ( diff --git a/apps/web/app/settings/environment/[id]/page.tsx b/apps/web/app/settings/environment/[id]/page.tsx index e5a5761274..4adee6e1d4 100644 --- a/apps/web/app/settings/environment/[id]/page.tsx +++ b/apps/web/app/settings/environment/[id]/page.tsx @@ -35,7 +35,7 @@ export default function EnvironmentEditPage({ params }: { params: Promise<{ id: const { id } = use(params); const router = useRouter(); const environment = useAppStore((state) => - state.environments.items.find((item) => item.id === id) ?? null + state.environments.items.find((item: Environment) => item.id === id) ?? null ); if (!environment) { diff --git a/apps/web/app/settings/environments/page.tsx b/apps/web/app/settings/environments/page.tsx index 2bc7d0aff3..ffb305bae0 100644 --- a/apps/web/app/settings/environments/page.tsx +++ b/apps/web/app/settings/environments/page.tsx @@ -6,6 +6,7 @@ import { Button } from '@kandev/ui/button'; import { Separator } from '@kandev/ui/separator'; import { EnvironmentCard } from '@/components/settings/environment-card'; import { useAppStore } from '@/components/state-provider'; +import type { Environment } from '@/lib/types/http'; export default function EnvironmentsSettingsPage() { const environments = useAppStore((state) => state.environments.items); @@ -30,7 +31,7 @@ export default function EnvironmentsSettingsPage() {
- {environments.map((env) => ( + {environments.map((env: Environment) => ( ))}
diff --git a/apps/web/app/settings/executor/[id]/page.tsx b/apps/web/app/settings/executor/[id]/page.tsx index 75fa2601f0..a4c6f509d3 100644 --- a/apps/web/app/settings/executor/[id]/page.tsx +++ b/apps/web/app/settings/executor/[id]/page.tsx @@ -25,7 +25,7 @@ import type { Executor } from '@/lib/types/http'; export default function ExecutorEditPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const router = useRouter(); - const executor = useAppStore((state) => state.executors.items.find((item) => item.id === id) ?? null); + const executor = useAppStore((state) => state.executors.items.find((item: Executor) => item.id === id) ?? null); if (!executor) { return ( @@ -108,7 +108,7 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) { setDraft(updated); setSavedExecutor(updated); setExecutors( - executors.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)) + executors.map((item: Executor) => (item.id === updated.id ? { ...item, ...updated } : item)) ); // Stay on the executor page after saving so the user can continue editing. } finally { @@ -126,7 +126,7 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) { } else { await deleteExecutorAction(draft.id); } - setExecutors(executors.filter((item) => item.id !== draft.id)); + setExecutors(executors.filter((item: Executor) => item.id !== draft.id)); router.push('/settings/executors'); } finally { setIsDeleting(false); diff --git a/apps/web/app/settings/executor/new/page.tsx b/apps/web/app/settings/executor/new/page.tsx index 89f895c034..879e601d84 100644 --- a/apps/web/app/settings/executor/new/page.tsx +++ b/apps/web/app/settings/executor/new/page.tsx @@ -57,7 +57,7 @@ function ExecutorCreatePageContent() { const created = client ? await client.request('executor.create', payload) : await createExecutorAction(payload); - setExecutors([...executors.filter((item) => item.id !== created.id), created]); + setExecutors([...executors.filter((item: Executor) => item.id !== created.id), created]); router.push('/settings/executors'); } finally { setIsCreating(false); diff --git a/apps/web/app/settings/executors/page.tsx b/apps/web/app/settings/executors/page.tsx index aa0d65b605..cb8c5c282d 100644 --- a/apps/web/app/settings/executors/page.tsx +++ b/apps/web/app/settings/executors/page.tsx @@ -6,6 +6,7 @@ import { Badge } from '@kandev/ui/badge'; import { Card, CardContent } from '@kandev/ui/card'; import { Separator } from '@kandev/ui/separator'; import { useAppStore } from '@/components/state-provider'; +import type { Executor } from '@/lib/types/http'; export default function ExecutorsSettingsPage() { const executors = useAppStore((state) => state.executors.items); @@ -78,8 +79,8 @@ export default function ExecutorsSettingsPage() {
{executors - .filter((executor) => executor.type !== 'remote_docker') - .map((executor) => { + .filter((executor: Executor) => executor.type !== 'remote_docker') + .map((executor: Executor) => { const Icon = executor.type === 'local_pc' ? IconCpu : IconServer; const typeLabel = executor.type === 'local_pc' diff --git a/apps/web/app/settings/general/editors/page.tsx b/apps/web/app/settings/general/editors/page.tsx index 2ff417ca11..2a0932508b 100644 --- a/apps/web/app/settings/general/editors/page.tsx +++ b/apps/web/app/settings/general/editors/page.tsx @@ -1,6 +1,6 @@ import { EditorsSettings } from '@/components/settings/editors-settings'; import { StateProvider } from '@/components/state-provider'; -import { fetchUserSettings, listEditors } from '@/lib/http'; +import { fetchUserSettings, listEditors } from '@/lib/api'; export default async function GeneralEditorsPage() { let initialState = {}; diff --git a/apps/web/app/settings/general/notifications/page.tsx b/apps/web/app/settings/general/notifications/page.tsx index a8a1363283..0a83aa5d3f 100644 --- a/apps/web/app/settings/general/notifications/page.tsx +++ b/apps/web/app/settings/general/notifications/page.tsx @@ -1,6 +1,6 @@ import { NotificationsSettings } from '@/components/settings/notifications-settings'; import { StateProvider } from '@/components/state-provider'; -import { listNotificationProviders } from '@/lib/http'; +import { listNotificationProviders } from '@/lib/api'; export default async function GeneralNotificationsPage() { let initialState = {}; diff --git a/apps/web/app/settings/general/page.tsx b/apps/web/app/settings/general/page.tsx index c29eb7a1d3..949371d188 100644 --- a/apps/web/app/settings/general/page.tsx +++ b/apps/web/app/settings/general/page.tsx @@ -1,6 +1,6 @@ import { GeneralSettings } from '@/components/settings/general-settings'; import { StateProvider } from '@/components/state-provider'; -import { fetchUserSettings } from '@/lib/http'; +import { fetchUserSettings } from '@/lib/api'; export default async function GeneralSettingsPage() { let initialState = {}; diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 6f4fb5be31..f40f8d86a8 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -7,7 +7,7 @@ import { listEnvironments, listExecutors, listWorkspaces, -} from '@/lib/http'; +} from '@/lib/api'; export default function SettingsLayout({ children, diff --git a/apps/web/app/settings/prompts/page.tsx b/apps/web/app/settings/prompts/page.tsx index 1e3010b5f3..65ac08bb7f 100644 --- a/apps/web/app/settings/prompts/page.tsx +++ b/apps/web/app/settings/prompts/page.tsx @@ -1,6 +1,6 @@ import { PromptsSettings } from '@/components/settings/prompts-settings'; import { StateProvider } from '@/components/state-provider'; -import { listPrompts } from '@/lib/http'; +import { listPrompts } from '@/lib/api'; export default async function PromptsSettingsPage() { let initialState = {}; diff --git a/apps/web/app/settings/workspace/workspace-edit-client.tsx b/apps/web/app/settings/workspace/workspace-edit-client.tsx index cfd0959445..53c765b63f 100644 --- a/apps/web/app/settings/workspace/workspace-edit-client.tsx +++ b/apps/web/app/settings/workspace/workspace-edit-client.tsx @@ -19,7 +19,8 @@ import { DialogTitle, } from '@kandev/ui/dialog'; import { updateWorkspaceAction, deleteWorkspaceAction } from '@/app/actions/workspaces'; -import type { Workspace } from '@/lib/types/http'; +import type { Workspace, Executor, Environment } from '@/lib/types/http'; +import type { AgentProfileOption } from '@/lib/state/slices'; import { useRequest } from '@/lib/http/use-request'; import { useToast } from '@/components/toast-provider'; import { useAppStore } from '@/components/state-provider'; @@ -31,7 +32,7 @@ type WorkspaceEditClientProps = { export function WorkspaceEditClient({ workspaceId }: WorkspaceEditClientProps) { const workspace = useAppStore((state) => - state.workspaces.items.find((item) => item.id === workspaceId) ?? null + state.workspaces.items.find((item: Workspace) => item.id === workspaceId) ?? null ); if (!workspace) { @@ -89,7 +90,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction); const workspaces = useAppStore((state) => state.workspaces.items); const setWorkspaces = useAppStore((state) => state.setWorkspaces); - const activeExecutors = executors.filter((executor) => executor.status === 'active'); + const activeExecutors = executors.filter((executor: Executor) => executor.status === 'active'); const isDefaultExecutorDirty = defaultExecutorId !== savedDefaultExecutorId; const isDefaultEnvironmentDirty = defaultEnvironmentId !== savedDefaultEnvironmentId; const isDefaultAgentProfileDirty = defaultAgentProfileId !== savedDefaultAgentProfileId; @@ -102,7 +103,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { setCurrentWorkspace((prev) => ({ ...prev, ...updated })); setSavedWorkspaceName(updated.name); setWorkspaces( - workspaces.map((workspaceItem) => + workspaces.map((workspaceItem: Workspace) => workspaceItem.id === updated.id ? { ...workspaceItem, @@ -127,7 +128,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { if (deleteConfirmText !== 'delete') return; try { await deleteWorkspaceRequest.run(currentWorkspace.id); - setWorkspaces(workspaces.filter((workspaceItem) => workspaceItem.id !== currentWorkspace.id)); + setWorkspaces(workspaces.filter((workspaceItem: Workspace) => workspaceItem.id !== currentWorkspace.id)); router.push('/settings/workspace'); } catch (error) { toast({ @@ -147,7 +148,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { setCurrentWorkspace((prev) => ({ ...prev, ...updated })); setSavedDefaultExecutorId(updated.default_executor_id ?? ''); setWorkspaces( - workspaces.map((workspaceItem) => + workspaces.map((workspaceItem: Workspace) => workspaceItem.id === updated.id ? { ...workspaceItem, @@ -176,7 +177,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { setCurrentWorkspace((prev) => ({ ...prev, ...updated })); setSavedDefaultEnvironmentId(updated.default_environment_id ?? ''); setWorkspaces( - workspaces.map((workspaceItem) => + workspaces.map((workspaceItem: Workspace) => workspaceItem.id === updated.id ? { ...workspaceItem, @@ -205,7 +206,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { setCurrentWorkspace((prev) => ({ ...prev, ...updated })); setSavedDefaultAgentProfileId(updated.default_agent_profile_id ?? ''); setWorkspaces( - workspaces.map((workspaceItem) => + workspaces.map((workspaceItem: Workspace) => workspaceItem.id === updated.id ? { ...workspaceItem, @@ -277,7 +278,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { No default - {activeExecutors.map((executor) => ( + {activeExecutors.map((executor: Executor) => ( {executor.name} @@ -317,7 +318,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { No default - {environments.map((environment) => ( + {environments.map((environment: Environment) => ( {environment.name} @@ -357,7 +358,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { No default - {agentProfiles.map((profile) => ( + {agentProfiles.map((profile: AgentProfileOption) => ( {profile.label} diff --git a/apps/web/app/settings/workspace/workspaces-page-client.tsx b/apps/web/app/settings/workspace/workspaces-page-client.tsx index 1be21393be..a610a5d8e4 100644 --- a/apps/web/app/settings/workspace/workspaces-page-client.tsx +++ b/apps/web/app/settings/workspace/workspaces-page-client.tsx @@ -13,6 +13,7 @@ import { useRequest } from '@/lib/http/use-request'; import { useToast } from '@/components/toast-provider'; import { RequestIndicator } from '@/components/request-indicator'; import { useAppStore } from '@/components/state-provider'; +import type { Workspace } from '@/lib/types/http'; export function WorkspacesPageClient() { const items = useAppStore((state) => state.workspaces.items); @@ -39,7 +40,7 @@ export function WorkspacesPageClient() { created_at: created.created_at, updated_at: created.updated_at, }, - ...items.map((workspace) => ({ + ...items.map((workspace: Workspace) => ({ id: workspace.id, name: workspace.name, description: workspace.description ?? null, @@ -112,7 +113,7 @@ export function WorkspacesPageClient() { )} - {items.map((workspace) => ( + {items.map((workspace: Workspace) => ( diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index 7055625dba..e7e7583501 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -7,13 +7,14 @@ import { TaskTopBar } from '@/components/task/task-top-bar'; import { TaskLayout } from '@/components/task/task-layout'; import { DebugOverlay } from '@/components/debug-overlay'; import type { Repository, Task } from '@/lib/types/http'; +import type { KanbanState } from '@/lib/state/slices'; import { DEBUG_UI } from '@/lib/config'; -import { useRepositories } from '@/hooks/use-repositories'; -import { useSessionAgent } from '@/hooks/use-session-agent'; -import { useSessionResumption } from '@/hooks/use-session-resumption'; -import { useSessionAgentctl } from '@/hooks/use-session-agentctl'; +import { useRepositories } from '@/hooks/domains/workspace/use-repositories'; +import { useSessionAgent } from '@/hooks/domains/session/use-session-agent'; +import { useSessionResumption } from '@/hooks/domains/session/use-session-resumption'; +import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl'; import { useAppStore } from '@/components/state-provider'; -import { fetchTask } from '@/lib/http'; +import { fetchTask } from '@/lib/api'; import { useTasks } from '@/hooks/use-tasks'; import type { Layout } from 'react-resizable-panels'; @@ -38,7 +39,7 @@ export default function TaskPage({ const setActiveTask = useAppStore((state) => state.setActiveTask); const effectiveTaskId = activeTaskId ?? initialTask?.id ?? null; const kanbanTask = useAppStore((state) => - effectiveTaskId ? state.kanban.tasks.find((item) => item.id === effectiveTaskId) ?? null : null + effectiveTaskId ? state.kanban.tasks.find((item: KanbanState['tasks'][number]) => item.id === effectiveTaskId) ?? null : null ); const task = useMemo(() => { const baseTask = taskDetails ?? initialTask; @@ -140,7 +141,7 @@ export default function TaskPage({ const { repositories } = useRepositories(task?.workspace_id ?? null, Boolean(task?.workspace_id)); const effectiveRepositories = repositories.length ? repositories : initialRepositories; const repository = useMemo( - () => effectiveRepositories.find((item) => item.id === task?.repositories?.[0]?.repository_id) ?? null, + () => effectiveRepositories.find((item: Repository) => item.id === task?.repositories?.[0]?.repository_id) ?? null, [effectiveRepositories, task?.repositories] ); diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 001c0ecc4e..5ac3579dfa 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -1,26 +1,15 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; -import { Column } from './kanban-column'; +import { useEffect } from 'react'; import { Task } from './kanban-card'; import { TaskCreateDialog } from './task-create-dialog'; -import { useRouter } from 'next/navigation'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import type { Task as BackendTask } from '@/lib/types/http'; -import { filterTasksByRepositories } from '@/lib/kanban/filters'; -import { useUserDisplaySettings } from '@/hooks/use-user-display-settings'; -import { useBoards } from '@/hooks/use-boards'; -import { useBoardSnapshot } from '@/hooks/use-board-snapshot'; +import type { BoardState } from '@/lib/state/slices'; import { useDragAndDrop } from '@/hooks/use-drag-and-drop'; -import { useTaskCRUD } from '@/hooks/use-task-crud'; import { KanbanBoardGrid } from './kanban-board-grid'; -import { getWebSocketClient } from '@/lib/ws/connection'; -import { linkToSession } from '@/lib/links'; -import Link from 'next/link'; -import { Button } from '@kandev/ui/button'; -import { IconPlus, IconSettings } from '@tabler/icons-react'; -import { ConnectionStatus } from './connection-status'; -import { KanbanDisplayDropdown } from './kanban-display-dropdown'; +import { KanbanHeader } from './kanban/kanban-header'; +import { useKanbanData, useKanbanActions, useKanbanNavigation } from '@/hooks/domains/kanban'; interface KanbanBoardProps { onPreviewTask?: (task: Task) => void; @@ -28,216 +17,59 @@ interface KanbanBoardProps { } export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}) { - const [taskSessionAvailability, setTaskSessionAvailability] = useState>({}); - const router = useRouter(); + // Store access + const store = useAppStoreApi(); + + // Get initial state for actions hook const kanban = useAppStore((state) => state.kanban); const workspaceState = useAppStore((state) => state.workspaces); - const setActiveWorkspace = useAppStore((state) => state.setActiveWorkspace); const boardsState = useAppStore((state) => state.boards); const setActiveBoard = useAppStore((state) => state.setActiveBoard); const setBoards = useAppStore((state) => state.setBoards); - const store = useAppStoreApi(); - - // Get preview setting from store - const enablePreviewOnClick = useAppStore((state) => state.userSettings.enablePreviewOnClick); - - useBoards(workspaceState.activeId, true); - useBoardSnapshot(boardsState.activeId); - - const handleWorkspaceChange = useCallback( - (nextWorkspaceId: string | null) => { - if (nextWorkspaceId === workspaceState.activeId) { - return; - } - setActiveWorkspace(nextWorkspaceId); - if (nextWorkspaceId) { - router.push(`/?workspaceId=${nextWorkspaceId}`); - } else { - router.push('/'); - } - }, - [router, setActiveWorkspace, workspaceState.activeId] - ); - - const handleBoardChange = useCallback( - (nextBoardId: string | null) => { - if (nextBoardId === boardsState.activeId) { - return; - } - setActiveBoard(nextBoardId); - if (nextBoardId) { - const workspaceId = boardsState.items.find((board) => board.id === nextBoardId)?.workspaceId; - const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : ''; - router.push(`/?boardId=${nextBoardId}${workspaceParam}`); - } - }, - [boardsState.activeId, boardsState.items, router, setActiveBoard] - ); - - const stableWorkspaceId = workspaceState.activeId; - const stableBoardId = boardsState.activeId; + // Consolidated actions hook const { - settings: userSettings, + isDialogOpen, + editingTask, + setIsDialogOpen, + setEditingTask, + handleCreate, + handleEdit, + handleDelete, + handleDialogOpenChange, + handleDialogSuccess, + handleWorkspaceChange, + handleBoardChange, + } = useKanbanActions({ kanban, workspaceState, boardsState }); + + // Data fetching and derived state + const { + enablePreviewOnClick, + userSettings, commitSettings, - selectedRepositoryIds, - } = useUserDisplaySettings({ - workspaceId: stableWorkspaceId, - boardId: stableBoardId, + activeColumns, + visibleTasksWithSessions, + isMounted, + setTaskSessionAvailability, + } = useKanbanData({ onWorkspaceChange: handleWorkspaceChange, onBoardChange: handleBoardChange, }); - const isMounted = useSyncExternalStore( - () => () => { }, - () => true, - () => false - ); + // Navigation hook + const { handleOpenTask, handleCardClick } = useKanbanNavigation({ + enablePreviewOnClick, + onPreviewTask, + onOpenTask, + setEditingTask, + setIsDialogOpen, + setTaskSessionAvailability, + }); - const backendColumns = useMemo( - () => - [...kanban.columns] - .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) - .map((column) => ({ - id: column.id, - title: column.title, - color: column.color || 'bg-neutral-400', - })), - [kanban.columns] - ); - const backendTasks = useMemo( - () => - kanban.tasks.map((task) => ({ - id: task.id, - title: task.title, - columnId: task.columnId, - state: task.state, - description: task.description, - position: task.position, - repositoryId: task.repositoryId, - })), - [kanban.tasks] - ); - const activeColumns = kanban.boardId ? backendColumns : []; - const visibleTasks = useMemo( - () => filterTasksByRepositories(backendTasks, selectedRepositoryIds), - [backendTasks, selectedRepositoryIds] - ); - const visibleTasksWithSessions = useMemo( - () => visibleTasks.map((task) => ({ - ...task, - hasSession: taskSessionAvailability[task.id], - })), - [visibleTasks, taskSessionAvailability] - ); - - // Use custom hooks for drag-and-drop and CRUD operations + // Drag and drop const { activeTask, handleDragStart, handleDragEnd, handleDragCancel } = useDragAndDrop(visibleTasksWithSessions); - const { isDialogOpen, editingTask, handleCreate, handleEdit, handleDelete, handleDialogOpenChange, setIsDialogOpen, setEditingTask } = useTaskCRUD(); - - const handleDialogSuccess = (task: BackendTask, mode: 'create' | 'edit') => { - if (mode === 'create') { - store.getState().hydrate({ - kanban: { - ...kanban, - tasks: [ - ...kanban.tasks, - { - id: task.id, - columnId: task.column_id, - title: task.title, - description: task.description ?? undefined, - position: task.position ?? 0, - state: task.state, - repositoryId: task.repositories?.[0]?.repository_id ?? undefined, - }, - ], - }, - }); - return; - } - store.getState().hydrate({ - kanban: { - ...kanban, - tasks: kanban.tasks.map((item) => - item.id === task.id - ? { - ...item, - title: task.title, - description: task.description ?? undefined, - columnId: task.column_id ?? item.columnId, - position: task.position ?? item.position, - state: task.state ?? item.state, - repositoryId: task.repositories?.[0]?.repository_id ?? item.repositoryId, - } - : item - ), - }, - }); - }; - - const fetchLatestSessionId = async (taskId: string) => { - const client = getWebSocketClient(); - if (!client) return null; - try { - const response = await client.request<{ sessions: Array<{ id: string }> }>( - 'task.session.list', - { task_id: taskId }, - 10000 - ); - setTaskSessionAvailability((prev) => ({ - ...prev, - [taskId]: response.sessions.length > 0, - })); - return response.sessions[0]?.id ?? null; - } catch (error) { - console.error('Failed to load task sessions:', error); - return null; - } - }; - - const handleOpenTask = async (task: Task) => { - const latestSessionId = await fetchLatestSessionId(task.id); - if (!latestSessionId) { - setEditingTask(task); - setIsDialogOpen(true); - return; - } - if (onOpenTask) { - onOpenTask(task, latestSessionId); - } else { - router.push(linkToSession(latestSessionId)); - } - }; - - const handleCardClick = async (task: Task) => { - // Capture the current mode at the start to avoid race conditions - const shouldOpenPreview = enablePreviewOnClick; - - if (shouldOpenPreview) { - // Preview mode - just call the preview handler without fetching session - if (onPreviewTask) { - onPreviewTask(task); - } - } else { - // Navigate mode - fetch session and navigate - const latestSessionId = await fetchLatestSessionId(task.id); - if (!latestSessionId) { - setEditingTask(task); - setIsDialogOpen(true); - return; - } - - if (onOpenTask) { - onOpenTask(task, latestSessionId); - } else { - router.push(linkToSession(latestSessionId)); - } - } - }; - - + // Board selection effect useEffect(() => { const workspaceId = workspaceState.activeId; if (!workspaceId) { @@ -247,9 +79,9 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} } return; } - const workspaceBoards = boardsState.items.filter((board) => board.workspaceId === workspaceId); + const workspaceBoards = boardsState.items.filter((board: BoardState['items'][number]) => board.workspaceId === workspaceId); const desiredBoardId = - (userSettings.boardId && workspaceBoards.some((board) => board.id === userSettings.boardId) + (userSettings.boardId && workspaceBoards.some((board: BoardState['items'][number]) => board.id === userSettings.boardId) ? userSettings.boardId : workspaceBoards[0]?.id) ?? null; setActiveBoard(desiredBoardId); @@ -286,27 +118,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} return (
-
-
- - KanDev.ai - - -
-
- - - - - -
-
+ state.repositories.itemsByWorkspaceId); const repository = useMemo( - () => Object.values(repositoriesByWorkspace).flat().find((repo) => repo.id === task.repositoryId) ?? null, + () => (Object.values(repositoriesByWorkspace).flat() as Repository[]).find((repo) => repo.id === task.repositoryId) ?? null, [repositoriesByWorkspace, task.repositoryId] ); const repositoryName = repository ? getRepositoryDisplayName(repository.local_path) : null; diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index 3a373406d2..d1f142e011 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -6,6 +6,7 @@ import { KanbanCard, Task } from './kanban-card'; import { Badge } from '@kandev/ui/badge'; import { cn, getRepositoryDisplayName } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; +import type { Repository } from '@/lib/types/http'; export interface Column { id: string; @@ -31,7 +32,7 @@ export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditT // Access repositories from store to pass repository names to cards const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); const repositories = useMemo( - () => Object.values(repositoriesByWorkspace).flat(), + () => Object.values(repositoriesByWorkspace).flat() as Repository[], [repositoriesByWorkspace] ); diff --git a/apps/web/components/kanban-display-dropdown.tsx b/apps/web/components/kanban-display-dropdown.tsx index 0b721ed65a..8aec783ff2 100644 --- a/apps/web/components/kanban-display-dropdown.tsx +++ b/apps/web/components/kanban-display-dropdown.tsx @@ -18,6 +18,8 @@ import { } from '@kandev/ui/select'; import { IconAdjustmentsHorizontal } from '@tabler/icons-react'; import { useKanbanDisplaySettings } from '@/hooks/use-kanban-display-settings'; +import type { Workspace, Repository } from '@/lib/types/http'; +import type { BoardState } from '@/lib/state/slices'; export function KanbanDisplayDropdown() { const { @@ -58,7 +60,7 @@ export function KanbanDisplayDropdown() { - {workspaces.map((workspace) => ( + {workspaces.map((workspace: Workspace) => ( {workspace.name} @@ -77,7 +79,7 @@ export function KanbanDisplayDropdown() { - {boards.map((board) => ( + {boards.map((board: BoardState['items'][number]) => ( {board.name} @@ -106,7 +108,7 @@ export function KanbanDisplayDropdown() { All repositories - {repositories.map((repo) => ( + {repositories.map((repo: Repository) => ( {repo.name} diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index 748828c9d3..6651de1142 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -9,6 +9,7 @@ import { useKanbanLayout } from "@/hooks/use-kanban-layout"; import { useTaskSession } from "@/hooks/use-task-session"; import { useAppStore } from "@/components/state-provider"; import { Task } from "./kanban-card"; +import type { KanbanState } from "@/lib/state/slices"; import { PREVIEW_PANEL } from "@/lib/settings/constants"; import { linkToSession } from "@/lib/links"; @@ -48,7 +49,7 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) { const selectedTask = useMemo(() => { if (!selectedTaskId || kanbanTasks.length === 0) return null; - const task = kanbanTasks.find((t) => t.id === selectedTaskId); + const task = kanbanTasks.find((t: KanbanState['tasks'][number]) => t.id === selectedTaskId); if (!task) return null; return { diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx new file mode 100644 index 0000000000..ab31b453d2 --- /dev/null +++ b/apps/web/components/kanban/kanban-header.tsx @@ -0,0 +1,37 @@ +'use client'; + +import Link from 'next/link'; +import { Button } from '@kandev/ui/button'; +import { IconPlus, IconSettings } from '@tabler/icons-react'; +import { ConnectionStatus } from '../connection-status'; +import { KanbanDisplayDropdown } from '../kanban-display-dropdown'; + +type KanbanHeaderProps = { + onCreateTask: () => void; +}; + +export function KanbanHeader({ onCreateTask }: KanbanHeaderProps) { + return ( +
+
+ + KanDev.ai + + +
+
+ + + + + +
+
+ ); +} diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index 6993294de8..fcc42cd533 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -88,11 +88,11 @@ function ProfileEditor({ agent, profile, modelConfig, initialMcpConfig }: Profil }); setSavedProfile(updated); setDraft(updated); - const nextAgents = settingsAgents.map((agentItem) => + const nextAgents = settingsAgents.map((agentItem: Agent) => agentItem.id === agent.id ? { ...agentItem, - profiles: agentItem.profiles.map((profileItem) => + profiles: agentItem.profiles.map((profileItem: AgentProfile) => profileItem.id === updated.id ? updated : profileItem ), } @@ -113,11 +113,11 @@ function ProfileEditor({ agent, profile, modelConfig, initialMcpConfig }: Profil const handleDeleteProfile = async () => { try { await deleteAgentProfileAction(draft.id); - const nextAgents = settingsAgents.map((agentItem) => + const nextAgents = settingsAgents.map((agentItem: Agent) => agentItem.id === agent.id ? { ...agentItem, - profiles: agentItem.profiles.filter((profileItem) => profileItem.id !== draft.id), + profiles: agentItem.profiles.filter((profileItem: AgentProfile) => profileItem.id !== draft.id), } : agentItem ); diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx index eb6ab80e4f..ebef23320c 100644 --- a/apps/web/components/settings/editors-settings.tsx +++ b/apps/web/components/settings/editors-settings.tsx @@ -10,8 +10,8 @@ import { SettingsPageTemplate } from '@/components/settings/settings-page-templa import { Combobox, type ComboboxOption } from '@/components/combobox'; import { useAppStore } from '@/components/state-provider'; import { EditableCard } from '@/components/settings/editable-card'; -import { useEditors } from '@/hooks/use-editors'; -import { createEditor, deleteEditor, updateEditor, updateUserSettings } from '@/lib/http'; +import { useEditors } from '@/hooks/domains/settings/use-editors'; +import { createEditor, deleteEditor, updateEditor, updateUserSettings } from '@/lib/api'; import { useRequest } from '@/lib/http/use-request'; import type { EditorOption } from '@/lib/types/http'; diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx index c92bce919b..186b337487 100644 --- a/apps/web/components/settings/notifications-settings.tsx +++ b/apps/web/components/settings/notifications-settings.tsx @@ -14,10 +14,10 @@ import { createNotificationProvider, deleteNotificationProvider, updateNotificationProvider, -} from '@/lib/http'; +} from '@/lib/api'; import { useRequest } from '@/lib/http/use-request'; import { DEFAULT_NOTIFICATION_EVENTS, EVENT_LABELS } from '@/lib/notifications/events'; -import { useNotificationProviders } from '@/hooks/use-notification-providers'; +import { useNotificationProviders } from '@/hooks/domains/settings/use-notification-providers'; import type { NotificationProvider } from '@/lib/types/http'; type ProviderUpdatePayload = { diff --git a/apps/web/components/settings/prompts-settings.tsx b/apps/web/components/settings/prompts-settings.tsx index 15ab5d34d8..b1511fb0db 100644 --- a/apps/web/components/settings/prompts-settings.tsx +++ b/apps/web/components/settings/prompts-settings.tsx @@ -14,9 +14,9 @@ import { import { Input } from '@kandev/ui/input'; import { Textarea } from '@kandev/ui/textarea'; import { SettingsPageTemplate } from '@/components/settings/settings-page-template'; -import { useCustomPrompts } from '@/hooks/use-custom-prompts'; +import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts'; import { useAppStore } from '@/components/state-provider'; -import { createPrompt, deletePrompt, updatePrompt } from '@/lib/http'; +import { createPrompt, deletePrompt, updatePrompt } from '@/lib/api'; import { useRequest } from '@/lib/http/use-request'; import type { CustomPrompt } from '@/lib/types/http'; @@ -68,13 +68,13 @@ export function PromptsSettings() { content: state.content.trim(), }; const updated = await updatePrompt(id, payload, { cache: 'no-store' }); - applyPrompts(prompts.map((prompt) => (prompt.id === id ? updated : prompt))); + applyPrompts(prompts.map((prompt: CustomPrompt) => (prompt.id === id ? updated : prompt))); resetForm(); }); const deleteRequest = useRequest(async (id: string) => { await deletePrompt(id, { cache: 'no-store' }); - applyPrompts(prompts.filter((prompt) => prompt.id !== id)); + applyPrompts(prompts.filter((prompt: CustomPrompt) => prompt.id !== id)); if (editingId === id) { resetForm(); } @@ -188,7 +188,7 @@ export function PromptsSettings() { No prompts yet. Add your first prompt to get started.
) : ( - prompts.map((prompt) => ( + prompts.map((prompt: CustomPrompt) => (
{workspaces.length > 0 && ( - {workspaces.map((workspace) => { + {workspaces.map((workspace: Workspace) => { const workspacePath = `/settings/workspace/${workspace.id}`; const boardsPath = `${workspacePath}/boards`; const repositoriesPath = `${workspacePath}/repositories`; @@ -165,8 +166,8 @@ export function SettingsAppSidebar() { {agents.length > 0 && ( - {agents.flatMap((agent) => - agent.profiles.map((profile) => { + {agents.flatMap((agent: Agent) => + agent.profiles.map((profile: AgentProfile) => { const encodedAgent = encodeURIComponent(agent.name); const profilePath = `/settings/agents/${encodedAgent}/profiles/${profile.id}`; const agentLabel = profile.agent_display_name || agent.name; @@ -207,7 +208,7 @@ export function SettingsAppSidebar() { {environments.length > 0 && ( - {environments.map((env) => ( + {environments.map((env: Environment) => ( Executors - {executors.filter((executor) => executor.type !== 'remote_docker').length > 0 && ( + {executors.filter((executor: Executor) => executor.type !== 'remote_docker').length > 0 && ( {executors - .filter((executor) => executor.type !== 'remote_docker') - .map((executor) => ( + .filter((executor: Executor) => executor.type !== 'remote_docker') + .map((executor: Executor) => ( workspace.id === workspaceId) + ? workspaces.find((workspace: Workspace) => workspace.id === workspaceId) : null; useEffect(() => { @@ -329,15 +330,15 @@ export function TaskCreateDialog({ const normalizeRepoPath = (path: string) => path.replace(/\\/g, '/').replace(/\/+$/g, ''); const workspaceRepoPaths = new Set( repositories - .map((repo) => repo.local_path) + .map((repo: Repository) => repo.local_path) .filter(Boolean) - .map((path) => normalizeRepoPath(path)) + .map((path: string) => normalizeRepoPath(path)) ); const localRepoOptions = discoveredRepositories.filter( - (repo) => !workspaceRepoPaths.has(normalizeRepoPath(repo.path)) + (repo: LocalRepository) => !workspaceRepoPaths.has(normalizeRepoPath(repo.path)) ); const repositoryOptions: RepositoryOption[] = [ - ...repositories.map((repo) => ({ + ...repositories.map((repo: Repository) => ({ value: repo.id, label: repo.name, renderLabel: () => ( @@ -353,7 +354,7 @@ export function TaskCreateDialog({ ), })), - ...localRepoOptions.map((repo) => ({ + ...localRepoOptions.map((repo: LocalRepository) => ({ value: repo.path, label: formatUserHomePath(repo.path), renderLabel: () => ( @@ -401,7 +402,7 @@ export function TaskCreateDialog({ if (!isSessionMode && !startAgent) return; if (isEditMode && agentProfiles.length > 1) return; const defaultProfileId = workspaceDefaults?.default_agent_profile_id ?? null; - if (defaultProfileId && agentProfiles.some((profile) => profile.id === defaultProfileId)) { + if (defaultProfileId && agentProfiles.some((profile: AgentProfileOption) => profile.id === defaultProfileId)) { setAgentProfileId(defaultProfileId); return; } @@ -414,13 +415,13 @@ export function TaskCreateDialog({ const defaultEnvironmentId = workspaceDefaults?.default_environment_id ?? null; if ( defaultEnvironmentId && - environments.some((environment) => environment.id === defaultEnvironmentId) + environments.some((environment: Environment) => environment.id === defaultEnvironmentId) ) { setEnvironmentId(defaultEnvironmentId); return; } const localEnvironment = environments.find( - (environment) => environment.kind === DEFAULT_LOCAL_ENVIRONMENT_KIND + (environment: Environment) => environment.kind === DEFAULT_LOCAL_ENVIRONMENT_KIND ); setEnvironmentId(localEnvironment?.id ?? environments[0].id); }, [open, isEditMode, environmentId, environments, workspaceDefaults, isSessionMode, startAgent]); @@ -429,12 +430,12 @@ export function TaskCreateDialog({ if (!open || isEditMode || executorId || executors.length === 0) return; if (!isSessionMode && !startAgent) return; const defaultExecutorId = workspaceDefaults?.default_executor_id ?? null; - if (defaultExecutorId && executors.some((executor) => executor.id === defaultExecutorId)) { + if (defaultExecutorId && executors.some((executor: Executor) => executor.id === defaultExecutorId)) { setExecutorId(defaultExecutorId); return; } const localExecutor = executors.find( - (executor) => executor.type === DEFAULT_LOCAL_EXECUTOR_TYPE + (executor: Executor) => executor.type === DEFAULT_LOCAL_EXECUTOR_TYPE ); setExecutorId(localExecutor?.id ?? executors[0].id); }, [open, isEditMode, executorId, executors, workspaceDefaults, isSessionMode, startAgent]); @@ -664,7 +665,7 @@ export function TaskCreateDialog({ options={repositoryOptions} value={repositoryId || discoveredRepoPath} onValueChange={(value) => { - const workspaceRepo = repositories.find((repo) => repo.id === value); + const workspaceRepo = repositories.find((repo: Repository) => repo.id === value); if (workspaceRepo) { setRepositoryId(value); setDiscoveredRepoPath(''); @@ -693,7 +694,7 @@ export function TaskCreateDialog({
{ + options={branchOptions.map((branchObj: Branch) => { const displayName = branchObj.type === 'remote' && branchObj.remote ? `${branchObj.remote}/${branchObj.name}` @@ -753,7 +754,7 @@ export function TaskCreateDialog({
) : ( ({ + options={agentProfiles.map((profile: AgentProfileOption) => ({ value: profile.id, label: profile.label, renderLabel: () => { @@ -794,7 +795,7 @@ export function TaskCreateDialog({
({ + options={agentProfiles.map((profile: AgentProfileOption) => ({ value: profile.id, label: profile.label, renderLabel: () => { @@ -859,7 +860,7 @@ export function TaskCreateDialog({ - {environments.map((env) => ( + {environments.map((env: Environment) => ( {env.name} @@ -876,7 +877,7 @@ export function TaskCreateDialog({ - {executors.map((executor) => ( + {executors.map((executor: Executor) => ( {executor.name} @@ -904,7 +905,7 @@ export function TaskCreateDialog({ /> - {environments.map((environment) => ( + {environments.map((environment: Environment) => ( {environment.name} @@ -927,7 +928,7 @@ export function TaskCreateDialog({ /> - {executors.map((executor) => ( + {executors.map((executor: Executor) => ( {executor.name} diff --git a/apps/web/components/task/chat/messages/turn-summary.tsx b/apps/web/components/task/chat/messages/turn-summary.tsx index b86711592a..f385c7cf43 100644 --- a/apps/web/components/task/chat/messages/turn-summary.tsx +++ b/apps/web/components/task/chat/messages/turn-summary.tsx @@ -1,7 +1,7 @@ 'use client'; import { IconChevronRight, IconClock, IconCpu } from '@tabler/icons-react'; -import { useSessionTurn } from '@/hooks/use-session-turn'; +import { useSessionTurn } from '@/hooks/domains/session/use-session-turn'; type TurnSummaryProps = { sessionId: string | null; diff --git a/apps/web/components/task/chat/token-usage-display.tsx b/apps/web/components/task/chat/token-usage-display.tsx index 7654c14e70..dbb1261aa6 100644 --- a/apps/web/components/task/chat/token-usage-display.tsx +++ b/apps/web/components/task/chat/token-usage-display.tsx @@ -2,7 +2,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { cn } from '@/lib/utils'; -import { useSessionContextWindow } from '@/hooks/use-session-context-window'; +import { useSessionContextWindow } from '@/hooks/domains/session/use-session-context-window'; type TokenUsageDisplayProps = { sessionId: string | null; diff --git a/apps/web/components/task/editors-menu.tsx b/apps/web/components/task/editors-menu.tsx index 3e6d3ad98a..6d59331ca3 100644 --- a/apps/web/components/task/editors-menu.tsx +++ b/apps/web/components/task/editors-menu.tsx @@ -5,9 +5,10 @@ import { IconChevronDown, IconCode, IconLoader2 } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@kandev/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; -import { useEditors } from '@/hooks/use-editors'; +import { useEditors } from '@/hooks/domains/settings/use-editors'; import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor'; import { useAppStore } from '@/components/state-provider'; +import type { EditorOption } from '@/lib/types/http'; type EditorsMenuProps = { activeSessionId: string | null; @@ -20,7 +21,7 @@ export function EditorsMenu({ activeSessionId }: EditorsMenuProps) { const enabledEditors = useMemo( () => - editors.filter((editor) => { + editors.filter((editor: EditorOption) => { if (!editor.enabled) return false; if (editor.kind === 'built_in') return editor.installed; return true; @@ -29,7 +30,7 @@ export function EditorsMenu({ activeSessionId }: EditorsMenuProps) { ); const resolvedEditorId = useMemo(() => { - if (defaultEditorId && enabledEditors.some((editor) => editor.id === defaultEditorId)) { + if (defaultEditorId && enabledEditors.some((editor: EditorOption) => editor.id === defaultEditorId)) { return defaultEditorId; } return enabledEditors[0]?.id ?? ''; @@ -75,7 +76,7 @@ export function EditorsMenu({ activeSessionId }: EditorsMenuProps) { {enabledEditors.length === 0 ? ( No editors available ) : ( - enabledEditors.map((editor) => ( + enabledEditors.map((editor: EditorOption) => ( p.id === session.agent_profile_id); + for (const agent of settingsAgents as Agent[]) { + const profile = agent.profiles.find((p: AgentProfile) => p.id === session.agent_profile_id); if (profile) { sessionProfile = { profile, agent }; break; @@ -51,7 +52,7 @@ export function ModelSelector({ sessionId }: ModelSelectorProps) { let availableModels: { id: string; name: string; provider: string; context_window: number; is_default: boolean }[] = []; if (sessionProfile?.agent) { const agentName = sessionProfile.agent.name; - const availableAgent = availableAgents.find((a) => a.name === agentName); + const availableAgent = availableAgents.find((a: AvailableAgent) => a.name === agentName); if (availableAgent?.model_config?.available_models) { availableModels = availableAgent.model_config.available_models; } diff --git a/apps/web/components/task/preview/preview-controls.tsx b/apps/web/components/task/preview/preview-controls.tsx index 9f76cfb3ca..eabaaad5d9 100644 --- a/apps/web/components/task/preview/preview-controls.tsx +++ b/apps/web/components/task/preview/preview-controls.tsx @@ -8,7 +8,7 @@ import { } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; -import { startProcess } from '@/lib/http'; +import { startProcess } from '@/lib/api'; import { useAppStore } from '@/components/state-provider'; import { useLayoutStore } from '@/lib/state/layout-store'; diff --git a/apps/web/components/task/sessions-dropdown.tsx b/apps/web/components/task/sessions-dropdown.tsx index 18fec250a8..9730da064c 100644 --- a/apps/web/components/task/sessions-dropdown.tsx +++ b/apps/web/components/task/sessions-dropdown.tsx @@ -17,6 +17,7 @@ import { getWebSocketClient } from '@/lib/ws/connection'; import { linkToSession } from '@/lib/links'; import { useTaskSessions } from '@/hooks/use-task-sessions'; import type { TaskSession, TaskSessionState } from '@/lib/types/http'; +import type { AgentProfileOption } from '@/lib/state/slices'; import { getSessionStateIcon } from '@/lib/ui/state-icons'; type SessionStatus = 'running' | 'waiting_input' | 'complete' | 'failed' | 'cancelled'; @@ -103,7 +104,7 @@ export function SessionsDropdown({ const { sessions, loadSessions } = useTaskSessions(taskId); const agentLabelsById = useMemo(() => { - return Object.fromEntries(agentProfiles.map((profile) => [profile.id, profile.label])); + return Object.fromEntries(agentProfiles.map((profile: AgentProfileOption) => [profile.id, profile.label])); }, [agentProfiles]); const handleOpenChange = useCallback( @@ -118,7 +119,7 @@ export function SessionsDropdown({ // Update timer every second for running sessions useEffect(() => { const hasRunningSessions = sessions.some( - (session) => session.state === 'RUNNING' || session.state === 'STARTING' + (session: TaskSession) => session.state === 'RUNNING' || session.state === 'STARTING' ); if (!hasRunningSessions) return; @@ -181,7 +182,7 @@ export function SessionsDropdown({ const sortedSessions = useMemo(() => { const visibleSessions = taskId ? sessions : []; - return [...visibleSessions].sort((a, b) => { + return [...visibleSessions].sort((a: TaskSession, b: TaskSession) => { const orderDelta = (STATUS_ORDER[a.state] ?? 99) - (STATUS_ORDER[b.state] ?? 99); if (orderDelta !== 0) return orderDelta; return new Date(b.started_at).getTime() - new Date(a.started_at).getTime(); diff --git a/apps/web/components/task/shell-terminal.tsx b/apps/web/components/task/shell-terminal.tsx index 7b7a41d681..1db478b7e1 100644 --- a/apps/web/components/task/shell-terminal.tsx +++ b/apps/web/components/task/shell-terminal.tsx @@ -6,8 +6,8 @@ import { FitAddon } from '@xterm/addon-fit'; import '@xterm/xterm/css/xterm.css'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; -import { useSession } from '@/hooks/use-session'; -import { useSessionAgentctl } from '@/hooks/use-session-agentctl'; +import { useSession } from '@/hooks/domains/session/use-session'; +import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl'; export function ShellTerminal() { const terminalRef = useRef(null); diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index a987b3f38b..7c3c495a13 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -10,7 +10,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { cn } from '@/lib/utils'; import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; import { useAppStore } from '@/components/state-provider'; -import { useSessionGitStatus } from '@/hooks/use-session-git-status'; +import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; +import type { FileInfo } from '@/lib/state/slices'; type TaskChangesPanelProps = { selectedDiffPath: string | null; @@ -31,14 +32,14 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ const gitStatus = useSessionGitStatus(activeSessionId); // Convert git status files to array for display - const changedFiles = useMemo(() => { + const changedFiles = useMemo(() => { if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) { return []; } - return Object.values(gitStatus.files); + return Object.values(gitStatus.files) as FileInfo[]; }, [gitStatus]); const diffTargets = useMemo( - () => (selectedDiffPath ? [selectedDiffPath] : changedFiles.map((file) => file.path)), + () => (selectedDiffPath ? [selectedDiffPath] : changedFiles.map((file: FileInfo) => file.path)), [selectedDiffPath, changedFiles] ); const diffModeEnum = diffViewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified; diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 4e8b2a36dd..c6119bf880 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -3,11 +3,11 @@ import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { getWebSocketClient } from '@/lib/ws/connection'; -import { useSessionMessages } from '@/hooks/use-session-messages'; -import { useSettingsData } from '@/hooks/use-settings-data'; -import { useSessionState } from '@/hooks/use-session-state'; +import { useSessionMessages } from '@/hooks/domains/session/use-session-messages'; +import { useSettingsData } from '@/hooks/domains/settings/use-settings-data'; +import { useSessionState } from '@/hooks/domains/session/use-session-state'; import { useProcessedMessages } from '@/hooks/use-processed-messages'; -import { useSessionModel } from '@/hooks/use-session-model'; +import { useSessionModel } from '@/hooks/domains/session/use-session-model'; import { useMessageHandler } from '@/hooks/use-message-handler'; import { TaskChatInput } from '@/components/task/task-chat-input'; import { RunningIndicator } from '@/components/task/chat/messages/running-indicator'; diff --git a/apps/web/components/task/task-files-panel.tsx b/apps/web/components/task/task-files-panel.tsx index e17326db5b..1ebea017c1 100644 --- a/apps/web/components/task/task-files-panel.tsx +++ b/apps/web/components/task/task-files-panel.tsx @@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { LineStat } from '@/components/diff-stat'; import { useAppStore } from '@/components/state-provider'; import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor'; -import { useSessionGitStatus } from '@/hooks/use-session-git-status'; +import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useGitOperations } from '@/hooks/use-git-operations'; import type { FileInfo } from '@/lib/state/store'; import { FileBrowser } from '@/components/task/file-browser'; @@ -90,7 +90,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFi if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) { return []; } - return Object.values(gitStatus.files).map((file) => ({ + return (Object.values(gitStatus.files) as FileInfo[]).map((file: FileInfo) => ({ path: file.path, status: file.status, staged: file.staged, diff --git a/apps/web/components/task/task-right-panel.tsx b/apps/web/components/task/task-right-panel.tsx index f285b06378..9fc2f0285e 100644 --- a/apps/web/components/task/task-right-panel.tsx +++ b/apps/web/components/task/task-right-panel.tsx @@ -12,7 +12,7 @@ import { ShellTerminal } from '@/components/task/shell-terminal'; import { ProcessOutputTerminal } from '@/components/task/process-output-terminal'; import { useAppStore } from '@/components/state-provider'; import { useLayoutStore } from '@/lib/state/layout-store'; -import { stopProcess } from '@/lib/http'; +import { stopProcess } from '@/lib/api'; import type { Layout } from 'react-resizable-panels'; type TaskRightPanelProps = { diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index e566d86c5c..c1a318d420 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -1,14 +1,15 @@ 'use client'; import { useCallback, useMemo, useState } from 'react'; -import type { TaskState } from '@/lib/types/http'; +import type { TaskState, Workspace, Repository } from '@/lib/types/http'; +import type { KanbanState } from '@/lib/state/slices'; import { TaskSwitcher } from './task-switcher'; import { Button } from '@kandev/ui/button'; import { IconPlus } from '@tabler/icons-react'; import { TaskCreateDialog } from '@/components/task-create-dialog'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import { linkToSession } from '@/lib/links'; -import { listTaskSessions } from '@/lib/http'; +import { listTaskSessions } from '@/lib/api'; import { useTasks } from '@/hooks/use-tasks'; type TaskSessionSidebarProps = { @@ -39,13 +40,13 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP const workspaceName = useMemo(() => { if (!workspaceId) return 'Workspace'; - return workspaces.find((workspace) => workspace.id === workspaceId)?.name ?? 'Workspace'; + return workspaces.find((workspace: Workspace) => workspace.id === workspaceId)?.name ?? 'Workspace'; }, [workspaceId, workspaces]); const tasksWithRepositories = useMemo(() => { const repositories = workspaceId ? repositoriesByWorkspace[workspaceId] ?? [] : []; - const repositoryPathsById = new Map(repositories.map((repo) => [repo.id, repo.local_path])); - return tasks.map((task) => ({ + const repositoryPathsById = new Map(repositories.map((repo: Repository) => [repo.id, repo.local_path])); + return tasks.map((task: KanbanState['tasks'][number]) => ({ id: task.id, title: task.title, state: task.state as TaskState | undefined, @@ -119,7 +120,7 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP
({ id: column.id, title: column.title }))} + columns={columns.map((column: KanbanState['columns'][number]) => ({ id: column.id, title: column.title }))} activeTaskId={activeTaskId} selectedTaskId={selectedTaskId} onSelectTask={(taskId) => { @@ -135,7 +136,7 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP workspaceId={workspaceId} boardId={boardId} defaultColumnId={columns[0]?.id ?? null} - columns={columns.map((column) => ({ id: column.id, title: column.title }))} + columns={columns.map((column: KanbanState['columns'][number]) => ({ id: column.id, title: column.title }))} onSuccess={(task, _mode, meta) => { store.setState((state) => { if (state.kanban.boardId !== task.board_id) return state; @@ -152,8 +153,8 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP ...state, kanban: { ...state.kanban, - tasks: state.kanban.tasks.some((item) => item.id === task.id) - ? state.kanban.tasks.map((item) => (item.id === task.id ? nextTask : item)) + tasks: state.kanban.tasks.some((item: KanbanState['tasks'][number]) => item.id === task.id) + ? state.kanban.tasks.map((item: KanbanState['tasks'][number]) => (item.id === task.id ? nextTask : item)) : [...state.kanban.tasks, nextTask], }, }; diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 1614dd6bc5..c3ab252e53 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -40,8 +40,9 @@ import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover'; import { Checkbox } from '@kandev/ui/checkbox'; import { Label } from '@kandev/ui/label'; import { CommitStatBadge, LineStat } from '@/components/diff-stat'; -import { useSessionGitStatus } from '@/hooks/use-session-git-status'; +import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useGitOperations } from '@/hooks/use-git-operations'; +import type { FileInfo } from '@/lib/state/slices'; import { formatUserHomePath } from '@/lib/utils'; import { EditorsMenu } from '@/components/task/editors-menu'; import { useToast } from '@/components/toast-provider'; @@ -96,7 +97,7 @@ const TaskTopBar = memo(function TaskTopBar({ let totalAdditions = 0; let totalDeletions = 0; if (gitStatus?.files && Object.keys(gitStatus.files).length > 0) { - for (const file of Object.values(gitStatus.files)) { + for (const file of Object.values(gitStatus.files) as FileInfo[]) { totalAdditions += file.additions || 0; totalDeletions += file.deletions || 0; } diff --git a/apps/web/hooks/domains/kanban/index.ts b/apps/web/hooks/domains/kanban/index.ts new file mode 100644 index 0000000000..1079d2937f --- /dev/null +++ b/apps/web/hooks/domains/kanban/index.ts @@ -0,0 +1,3 @@ +export { useKanbanData } from './use-kanban-data'; +export { useKanbanActions } from './use-kanban-actions'; +export { useKanbanNavigation } from './use-kanban-navigation'; diff --git a/apps/web/hooks/domains/kanban/use-kanban-actions.ts b/apps/web/hooks/domains/kanban/use-kanban-actions.ts new file mode 100644 index 0000000000..4de1b18009 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-kanban-actions.ts @@ -0,0 +1,127 @@ +'use client'; + +import { useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAppStoreApi } from '@/components/state-provider'; +import { useTaskCRUD } from '@/hooks/use-task-crud'; +import type { Task as BackendTask } from '@/lib/types/http'; +import type { KanbanState, WorkspaceState, BoardState } from '@/lib/state/slices'; + +type UseKanbanActionsOptions = { + kanban: KanbanState; + workspaceState: WorkspaceState; + boardsState: BoardState; +}; + +export function useKanbanActions({ kanban, workspaceState, boardsState }: UseKanbanActionsOptions) { + const router = useRouter(); + const store = useAppStoreApi(); + + // CRUD operations from existing hook + const { + isDialogOpen, + editingTask, + handleCreate, + handleEdit, + handleDelete, + handleDialogOpenChange, + setIsDialogOpen, + setEditingTask, + } = useTaskCRUD(); + + // Handle task dialog success (create/update) + const handleDialogSuccess = useCallback( + (task: BackendTask, mode: 'create' | 'edit') => { + if (mode === 'create') { + store.getState().hydrate({ + kanban: { + ...kanban, + tasks: [ + ...kanban.tasks, + { + id: task.id, + columnId: task.column_id, + title: task.title, + description: task.description ?? undefined, + position: task.position ?? 0, + state: task.state, + repositoryId: task.repositories?.[0]?.repository_id ?? undefined, + }, + ], + }, + }); + return; + } + store.getState().hydrate({ + kanban: { + ...kanban, + tasks: kanban.tasks.map((item: KanbanState['tasks'][number]) => + item.id === task.id + ? { + ...item, + title: task.title, + description: task.description ?? undefined, + columnId: task.column_id ?? item.columnId, + position: task.position ?? item.position, + state: task.state ?? item.state, + repositoryId: task.repositories?.[0]?.repository_id ?? item.repositoryId, + } + : item + ), + }, + }); + }, + [kanban, store] + ); + + // Handle workspace change with navigation + const handleWorkspaceChange = useCallback( + (nextWorkspaceId: string | null) => { + if (nextWorkspaceId === workspaceState.activeId) { + return; + } + store.getState().setActiveWorkspace(nextWorkspaceId); + if (nextWorkspaceId) { + router.push(`/?workspaceId=${nextWorkspaceId}`); + } else { + router.push('/'); + } + }, + [router, store, workspaceState.activeId] + ); + + // Handle board change with navigation + const handleBoardChange = useCallback( + (nextBoardId: string | null) => { + if (nextBoardId === boardsState.activeId) { + return; + } + store.getState().setActiveBoard(nextBoardId); + if (nextBoardId) { + const workspaceId = boardsState.items.find((board: BoardState['items'][number]) => board.id === nextBoardId)?.workspaceId; + const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : ''; + router.push(`/?boardId=${nextBoardId}${workspaceParam}`); + } + }, + [router, store, boardsState.activeId, boardsState.items] + ); + + return { + // CRUD state + isDialogOpen, + editingTask, + setIsDialogOpen, + setEditingTask, + + // CRUD actions + handleCreate, + handleEdit, + handleDelete, + handleDialogOpenChange, + handleDialogSuccess, + + // Navigation actions + handleWorkspaceChange, + handleBoardChange, + }; +} diff --git a/apps/web/hooks/domains/kanban/use-kanban-data.ts b/apps/web/hooks/domains/kanban/use-kanban-data.ts new file mode 100644 index 0000000000..9b228211cc --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-kanban-data.ts @@ -0,0 +1,104 @@ +'use client'; + +import { useMemo, useState, useSyncExternalStore } from 'react'; +import { useAppStore } from '@/components/state-provider'; +import { useBoards } from '@/hooks/use-boards'; +import { useBoardSnapshot } from '@/hooks/use-board-snapshot'; +import { useUserDisplaySettings } from '@/hooks/use-user-display-settings'; +import { filterTasksByRepositories } from '@/lib/kanban/filters'; +import type { Column } from '@/components/kanban-column'; + +type KanbanDataOptions = { + onWorkspaceChange: (workspaceId: string | null) => void; + onBoardChange: (boardId: string | null) => void; +}; + +export function useKanbanData({ onWorkspaceChange, onBoardChange }: KanbanDataOptions) { + const [taskSessionAvailability, setTaskSessionAvailability] = useState>({}); + + // Store selectors + const kanban = useAppStore((state) => state.kanban); + const workspaceState = useAppStore((state) => state.workspaces); + const boardsState = useAppStore((state) => state.boards); + const enablePreviewOnClick = useAppStore((state) => state.userSettings.enablePreviewOnClick); + + // Data fetching hooks + useBoards(workspaceState.activeId, true); + useBoardSnapshot(boardsState.activeId); + + // User settings hook + const { + settings: userSettings, + commitSettings, + selectedRepositoryIds, + } = useUserDisplaySettings({ + workspaceId: workspaceState.activeId, + boardId: boardsState.activeId, + onWorkspaceChange, + onBoardChange, + }); + + // SSR safety check + const isMounted = useSyncExternalStore( + () => () => {}, + () => true, + () => false + ); + + // Derived data + const columns = useMemo( + () => + [...kanban.columns] + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) + .map((column) => ({ + id: column.id, + title: column.title, + color: column.color || 'bg-neutral-400', + })), + [kanban.columns] + ); + + const tasks = kanban.tasks.map((task: typeof kanban.tasks[number]) => ({ + id: task.id, + title: task.title, + columnId: task.columnId, + state: task.state, + description: task.description, + position: task.position, + repositoryId: task.repositoryId, + })); + + const activeColumns = kanban.boardId ? columns : []; + + const visibleTasks = useMemo( + () => filterTasksByRepositories(tasks, selectedRepositoryIds), + [tasks, selectedRepositoryIds] + ); + + const visibleTasksWithSessions = useMemo( + () => + visibleTasks.map((task) => ({ + ...task, + hasSession: taskSessionAvailability[task.id], + })), + [visibleTasks, taskSessionAvailability] + ); + + return { + // State + kanban, + workspaceState, + boardsState, + enablePreviewOnClick, + userSettings, + commitSettings, + selectedRepositoryIds, + taskSessionAvailability, + setTaskSessionAvailability, + isMounted, + + // Derived data + activeColumns, + visibleTasksWithSessions, + }; +} diff --git a/apps/web/hooks/domains/kanban/use-kanban-navigation.ts b/apps/web/hooks/domains/kanban/use-kanban-navigation.ts new file mode 100644 index 0000000000..3533e45d94 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-kanban-navigation.ts @@ -0,0 +1,105 @@ +'use client'; + +import { useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { linkToSession } from '@/lib/links'; +import type { Task } from '@/components/kanban-card'; + +type NavigationOptions = { + enablePreviewOnClick?: boolean; + onPreviewTask?: (task: Task) => void; + onOpenTask?: (task: Task, sessionId: string) => void; + setEditingTask: (task: Task) => void; + setIsDialogOpen: (open: boolean) => void; + setTaskSessionAvailability: React.Dispatch>>; +}; + +export function useKanbanNavigation({ + enablePreviewOnClick, + onPreviewTask, + onOpenTask, + setEditingTask, + setIsDialogOpen, + setTaskSessionAvailability, +}: NavigationOptions) { + const router = useRouter(); + + // Fetch latest session ID for a task + const fetchLatestSessionId = useCallback( + async (taskId: string) => { + const client = getWebSocketClient(); + if (!client) return null; + try { + const response = await client.request<{ sessions: Array<{ id: string }> }>( + 'task.session.list', + { task_id: taskId }, + 10000 + ); + setTaskSessionAvailability((prev) => ({ + ...prev, + [taskId]: response.sessions.length > 0, + })); + return response.sessions[0]?.id ?? null; + } catch (error) { + console.error('Failed to load task sessions:', error); + return null; + } + }, + [setTaskSessionAvailability] + ); + + // Open task (always navigates, opens dialog if no session) + const handleOpenTask = useCallback( + async (task: Task) => { + const latestSessionId = await fetchLatestSessionId(task.id); + if (!latestSessionId) { + setEditingTask(task); + setIsDialogOpen(true); + return; + } + if (onOpenTask) { + onOpenTask(task, latestSessionId); + } else { + router.push(linkToSession(latestSessionId)); + } + }, + [fetchLatestSessionId, onOpenTask, router, setEditingTask, setIsDialogOpen] + ); + + // Handle card click (preview or navigate based on settings) + const handleCardClick = useCallback( + async (task: Task) => { + // Capture the current mode at the start to avoid race conditions + const shouldOpenPreview = enablePreviewOnClick; + + if (shouldOpenPreview) { + // Preview mode - just call the preview handler without fetching session + if (onPreviewTask) { + onPreviewTask(task); + } + } else { + // Navigate mode - fetch session and navigate + const latestSessionId = await fetchLatestSessionId(task.id); + if (!latestSessionId) { + setEditingTask(task); + setIsDialogOpen(true); + return; + } + + if (onOpenTask) { + onOpenTask(task, latestSessionId); + } else { + router.push(linkToSession(latestSessionId)); + } + } + }, + [enablePreviewOnClick, onPreviewTask, fetchLatestSessionId, onOpenTask, router, setEditingTask, setIsDialogOpen] + ); + + return { + handleOpenTask, + handleCardClick, + fetchLatestSessionId, + }; +} diff --git a/apps/web/hooks/use-session-agent.ts b/apps/web/hooks/domains/session/use-session-agent.ts similarity index 100% rename from apps/web/hooks/use-session-agent.ts rename to apps/web/hooks/domains/session/use-session-agent.ts diff --git a/apps/web/hooks/use-session-agentctl.ts b/apps/web/hooks/domains/session/use-session-agentctl.ts similarity index 100% rename from apps/web/hooks/use-session-agentctl.ts rename to apps/web/hooks/domains/session/use-session-agentctl.ts diff --git a/apps/web/hooks/use-session-context-window.ts b/apps/web/hooks/domains/session/use-session-context-window.ts similarity index 100% rename from apps/web/hooks/use-session-context-window.ts rename to apps/web/hooks/domains/session/use-session-context-window.ts diff --git a/apps/web/hooks/use-session-git-status.ts b/apps/web/hooks/domains/session/use-session-git-status.ts similarity index 100% rename from apps/web/hooks/use-session-git-status.ts rename to apps/web/hooks/domains/session/use-session-git-status.ts diff --git a/apps/web/hooks/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts similarity index 98% rename from apps/web/hooks/use-session-messages.ts rename to apps/web/hooks/domains/session/use-session-messages.ts index 879babd460..e51f707fd8 100644 --- a/apps/web/hooks/use-session-messages.ts +++ b/apps/web/hooks/domains/session/use-session-messages.ts @@ -34,7 +34,7 @@ export function useSessionMessages( const initialFetchStartRef = useRef(null); const lastFetchedSessionIdRef = useRef(null); const lastFetchStateKeyRef = useRef(null); - const hasAgentMessage = messages.some((message) => message.author_type === 'agent'); + const hasAgentMessage = messages.some((message: Message) => message.author_type === 'agent'); useEffect(() => { if (!taskSessionId) { diff --git a/apps/web/hooks/use-session-model.ts b/apps/web/hooks/domains/session/use-session-model.ts similarity index 88% rename from apps/web/hooks/use-session-model.ts rename to apps/web/hooks/domains/session/use-session-model.ts index 63c0077929..1428a211af 100644 --- a/apps/web/hooks/use-session-model.ts +++ b/apps/web/hooks/domains/session/use-session-model.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { useAppStore } from '@/components/state-provider'; +import type { AgentProfile } from '@/lib/types/http'; export function useSessionModel( resolvedSessionId: string | null, @@ -10,7 +11,7 @@ export function useSessionModel( const sessionProfile = useMemo(() => { if (!agentProfileId) return null; for (const agent of settingsAgents) { - const profile = agent.profiles.find((p) => p.id === agentProfileId); + const profile = agent.profiles.find((p: AgentProfile) => p.id === agentProfileId); if (profile) return profile; } return null; diff --git a/apps/web/hooks/use-session-resumption.ts b/apps/web/hooks/domains/session/use-session-resumption.ts similarity index 100% rename from apps/web/hooks/use-session-resumption.ts rename to apps/web/hooks/domains/session/use-session-resumption.ts diff --git a/apps/web/hooks/use-session-state.ts b/apps/web/hooks/domains/session/use-session-state.ts similarity index 92% rename from apps/web/hooks/use-session-state.ts rename to apps/web/hooks/domains/session/use-session-state.ts index e9f9631964..6a22106ad7 100644 --- a/apps/web/hooks/use-session-state.ts +++ b/apps/web/hooks/domains/session/use-session-state.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/components/state-provider'; -import { useSession } from '@/hooks/use-session'; +import { useSession } from '@/hooks/domains/session/use-session'; import { useTask } from '@/hooks/use-task'; export function useSessionState(sessionId: string | null) { diff --git a/apps/web/hooks/use-session-turn.ts b/apps/web/hooks/domains/session/use-session-turn.ts similarity index 95% rename from apps/web/hooks/use-session-turn.ts rename to apps/web/hooks/domains/session/use-session-turn.ts index 41aa6a9a22..2b0812075a 100644 --- a/apps/web/hooks/use-session-turn.ts +++ b/apps/web/hooks/domains/session/use-session-turn.ts @@ -46,11 +46,11 @@ export function useSessionTurn(sessionId: string | null) { const lastCompletedTurn = useMemo(() => { if (!turns || turns.length === 0) return null; - const completedTurns = turns.filter((t) => t.completed_at); + const completedTurns = turns.filter((t: Turn) => t.completed_at); if (completedTurns.length === 0) return null; // Sort by completed_at descending and return the most recent - return completedTurns.sort((a, b) => { + return completedTurns.sort((a: Turn, b: Turn) => { const aTime = new Date(a.completed_at!).getTime(); const bTime = new Date(b.completed_at!).getTime(); return bTime - aTime; diff --git a/apps/web/hooks/use-session-worktrees.ts b/apps/web/hooks/domains/session/use-session-worktrees.ts similarity index 84% rename from apps/web/hooks/use-session-worktrees.ts rename to apps/web/hooks/domains/session/use-session-worktrees.ts index 087c409ea9..e2703a7ed8 100644 --- a/apps/web/hooks/use-session-worktrees.ts +++ b/apps/web/hooks/domains/session/use-session-worktrees.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useAppStore } from '@/components/state-provider'; -import { useSession } from '@/hooks/use-session'; +import { useSession } from '@/hooks/domains/session/use-session'; export function useSessionWorktrees(sessionId: string | null) { const { session } = useSession(sessionId); @@ -13,7 +13,7 @@ export function useSessionWorktrees(sessionId: string | null) { if (!sessionId) return []; const worktreeIds = sessionWorktreesBySessionId[sessionId]; if (worktreeIds?.length) { - return worktreeIds.map((id) => worktrees[id]).filter(Boolean); + return worktreeIds.map((id: string) => worktrees[id]).filter(Boolean); } if (session?.worktree_id) { const worktree = worktrees[session.worktree_id]; diff --git a/apps/web/hooks/use-session.ts b/apps/web/hooks/domains/session/use-session.ts similarity index 100% rename from apps/web/hooks/use-session.ts rename to apps/web/hooks/domains/session/use-session.ts diff --git a/apps/web/hooks/use-available-agents.ts b/apps/web/hooks/domains/settings/use-available-agents.ts similarity index 94% rename from apps/web/hooks/use-available-agents.ts rename to apps/web/hooks/domains/settings/use-available-agents.ts index d4df887a95..eea942d07c 100644 --- a/apps/web/hooks/use-available-agents.ts +++ b/apps/web/hooks/domains/settings/use-available-agents.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useAppStore } from '@/components/state-provider'; -import { listAvailableAgents } from '@/lib/http'; +import { listAvailableAgents } from '@/lib/api'; export function useAvailableAgents(enabled = true) { const availableAgents = useAppStore((state) => state.availableAgents); diff --git a/apps/web/hooks/use-custom-prompts.ts b/apps/web/hooks/domains/settings/use-custom-prompts.ts similarity index 95% rename from apps/web/hooks/use-custom-prompts.ts rename to apps/web/hooks/domains/settings/use-custom-prompts.ts index edf33fd42f..98011461e3 100644 --- a/apps/web/hooks/use-custom-prompts.ts +++ b/apps/web/hooks/domains/settings/use-custom-prompts.ts @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { listPrompts } from '@/lib/http'; +import { listPrompts } from '@/lib/api'; import { useAppStore } from '@/components/state-provider'; export function useCustomPrompts() { const prompts = useAppStore((state) => state.prompts.items); diff --git a/apps/web/hooks/use-editors.ts b/apps/web/hooks/domains/settings/use-editors.ts similarity index 97% rename from apps/web/hooks/use-editors.ts rename to apps/web/hooks/domains/settings/use-editors.ts index 30f7beb0a3..dd3881385a 100644 --- a/apps/web/hooks/use-editors.ts +++ b/apps/web/hooks/domains/settings/use-editors.ts @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { fetchUserSettings, listEditors } from '@/lib/http'; +import { fetchUserSettings, listEditors } from '@/lib/api'; import { getWebSocketClient } from '@/lib/ws/connection'; import { useAppStore } from '@/components/state-provider'; diff --git a/apps/web/hooks/use-notification-providers.ts b/apps/web/hooks/domains/settings/use-notification-providers.ts similarity index 96% rename from apps/web/hooks/use-notification-providers.ts rename to apps/web/hooks/domains/settings/use-notification-providers.ts index 57bcfb4c01..1a3ebf94cb 100644 --- a/apps/web/hooks/use-notification-providers.ts +++ b/apps/web/hooks/domains/settings/use-notification-providers.ts @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { listNotificationProviders } from '@/lib/http'; +import { listNotificationProviders } from '@/lib/api'; import { useAppStore } from '@/components/state-provider'; export function useNotificationProviders() { diff --git a/apps/web/hooks/use-settings-data.ts b/apps/web/hooks/domains/settings/use-settings-data.ts similarity index 99% rename from apps/web/hooks/use-settings-data.ts rename to apps/web/hooks/domains/settings/use-settings-data.ts index cad1b53282..b30e2d10bb 100644 --- a/apps/web/hooks/use-settings-data.ts +++ b/apps/web/hooks/domains/settings/use-settings-data.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useAppStore } from '@/components/state-provider'; -import { listAgents, listEnvironments, listExecutors } from '@/lib/http'; +import { listAgents, listEnvironments, listExecutors } from '@/lib/api'; export function useSettingsData(enabled = true) { const executors = useAppStore((state) => state.executors.items); diff --git a/apps/web/hooks/use-shell-settings.ts b/apps/web/hooks/domains/settings/use-shell-settings.ts similarity index 100% rename from apps/web/hooks/use-shell-settings.ts rename to apps/web/hooks/domains/settings/use-shell-settings.ts diff --git a/apps/web/hooks/use-repositories.ts b/apps/web/hooks/domains/workspace/use-repositories.ts similarity index 97% rename from apps/web/hooks/use-repositories.ts rename to apps/web/hooks/domains/workspace/use-repositories.ts index c1efb4fb71..2be7258c6e 100644 --- a/apps/web/hooks/use-repositories.ts +++ b/apps/web/hooks/domains/workspace/use-repositories.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import { useAppStore } from '@/components/state-provider'; import type { Repository } from '@/lib/types/http'; -import { listRepositories } from '@/lib/http'; +import { listRepositories } from '@/lib/api'; const EMPTY_REPOSITORIES: Repository[] = []; diff --git a/apps/web/hooks/use-repository-branches.ts b/apps/web/hooks/domains/workspace/use-repository-branches.ts similarity index 97% rename from apps/web/hooks/use-repository-branches.ts rename to apps/web/hooks/domains/workspace/use-repository-branches.ts index c7d52a547b..15276c9e31 100644 --- a/apps/web/hooks/use-repository-branches.ts +++ b/apps/web/hooks/domains/workspace/use-repository-branches.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; import { useAppStore } from '@/components/state-provider'; -import { listRepositoryBranches } from '@/lib/http'; +import { listRepositoryBranches } from '@/lib/api'; import type { Branch } from '@/lib/types/http'; const EMPTY_BRANCHES: Branch[] = []; diff --git a/apps/web/hooks/use-repository.ts b/apps/web/hooks/domains/workspace/use-repository.ts similarity index 72% rename from apps/web/hooks/use-repository.ts rename to apps/web/hooks/domains/workspace/use-repository.ts index df1c5b548c..49c4ca5a50 100644 --- a/apps/web/hooks/use-repository.ts +++ b/apps/web/hooks/domains/workspace/use-repository.ts @@ -1,12 +1,13 @@ import { useMemo } from 'react'; import { useAppStore } from '@/components/state-provider'; +import type { Repository } from '@/lib/types/http'; export function useRepository(repositoryId: string | null) { const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); return useMemo(() => { if (!repositoryId) return null; - const repositories = Object.values(repositoriesByWorkspace).flat(); - return repositories.find((repo) => repo.id === repositoryId) ?? null; + const repositories = Object.values(repositoriesByWorkspace).flat() as Repository[]; + return repositories.find((repo: Repository) => repo.id === repositoryId) ?? null; }, [repositoriesByWorkspace, repositoryId]); } diff --git a/apps/web/hooks/use-active-turn-timer.ts b/apps/web/hooks/use-active-turn-timer.ts index 13a93d3a4c..dd891a9347 100644 --- a/apps/web/hooks/use-active-turn-timer.ts +++ b/apps/web/hooks/use-active-turn-timer.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useAppStore } from '@/components/state-provider'; +import type { Turn } from '@/lib/types/http'; /** * Hook to get a live timer for the active turn of a session. @@ -15,7 +16,7 @@ export function useActiveTurnTimer(sessionId: string | null) { // Find the active turn const activeTurn = useMemo(() => { if (!turns || !activeTurnId) return null; - return turns.find((t) => t.id === activeTurnId) ?? null; + return turns.find((t: Turn) => t.id === activeTurnId) ?? null; }, [turns, activeTurnId]); const [elapsedSeconds, setElapsedSeconds] = useState(() => { diff --git a/apps/web/hooks/use-board-snapshot.ts b/apps/web/hooks/use-board-snapshot.ts index df0c097756..701985725b 100644 --- a/apps/web/hooks/use-board-snapshot.ts +++ b/apps/web/hooks/use-board-snapshot.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { fetchBoardSnapshot } from '@/lib/http'; +import { fetchBoardSnapshot } from '@/lib/api'; import { snapshotToState } from '@/lib/ssr/mapper'; import { useAppStoreApi } from '@/components/state-provider'; diff --git a/apps/web/hooks/use-boards.ts b/apps/web/hooks/use-boards.ts index f50fb729f0..fce250e7e1 100644 --- a/apps/web/hooks/use-boards.ts +++ b/apps/web/hooks/use-boards.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useAppStore } from '@/components/state-provider'; -import { listBoards } from '@/lib/http'; +import { listBoards } from '@/lib/api'; export function useBoards(workspaceId: string | null, enabled = true) { const boards = useAppStore((state) => state.boards.items); diff --git a/apps/web/hooks/use-chat-completions.ts b/apps/web/hooks/use-chat-completions.ts index 2650942ff1..28a0da6896 100644 --- a/apps/web/hooks/use-chat-completions.ts +++ b/apps/web/hooks/use-chat-completions.ts @@ -1,7 +1,8 @@ import { useMemo } from 'react'; import { EditorView } from '@codemirror/view'; import type { CompletionContext } from '@codemirror/autocomplete'; -import { useCustomPrompts } from '@/hooks/use-custom-prompts'; +import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts'; +import type { CustomPrompt } from '@/lib/types/http'; const truncatePreview = (text: string, max = 140) => { if (text.length <= max) return text; @@ -12,7 +13,7 @@ export function useChatCompletions() { const { prompts } = useCustomPrompts(); const promptOptions = useMemo(() => { - return prompts.map((prompt) => ({ + return prompts.map((prompt: CustomPrompt) => ({ id: prompt.id, name: prompt.name, content: prompt.content, @@ -43,8 +44,8 @@ export function useChatCompletions() { const trimmed = mentionMatch.text.trim(); const query = trimmed.startsWith('@') ? trimmed.slice(1).toLowerCase() : trimmed.toLowerCase(); const promptMatches = promptOptions - .filter((prompt) => prompt.name.toLowerCase().startsWith(query)) - .map((prompt) => ({ + .filter((prompt: { id: string; name: string; content: string }) => prompt.name.toLowerCase().startsWith(query)) + .map((prompt: { id: string; name: string; content: string }) => ({ label: `@${prompt.name}`, type: 'prompt', detail: 'Prompt', diff --git a/apps/web/hooks/use-drag-and-drop.ts b/apps/web/hooks/use-drag-and-drop.ts index a0a772ad04..71c366926e 100644 --- a/apps/web/hooks/use-drag-and-drop.ts +++ b/apps/web/hooks/use-drag-and-drop.ts @@ -5,6 +5,7 @@ import { DragStartEvent, DragEndEvent } from '@dnd-kit/core'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import { useTaskActions } from '@/hooks/use-task-actions'; import type { Task } from '@/components/kanban-card'; +import type { KanbanState } from '@/lib/state/slices'; /** * Custom hook that extracts drag-and-drop logic from the KanbanBoard component. @@ -39,15 +40,15 @@ export function useDragAndDrop(visibleTasks: Task[]) { } const targetTasks = kanban.tasks - .filter((task) => task.columnId === newStatus && task.id !== taskId) - .sort((a, b) => a.position - b.position); + .filter((task: KanbanState['tasks'][number]) => task.columnId === newStatus && task.id !== taskId) + .sort((a: KanbanState['tasks'][number], b: KanbanState['tasks'][number]) => a.position - b.position); const nextPosition = targetTasks.length; // Optimistic update store.getState().hydrate({ kanban: { ...kanban, - tasks: kanban.tasks.map((task) => + tasks: kanban.tasks.map((task: KanbanState['tasks'][number]) => task.id === taskId ? { ...task, columnId: newStatus, position: nextPosition } : task diff --git a/apps/web/hooks/use-kanban-display-settings.ts b/apps/web/hooks/use-kanban-display-settings.ts index fcd65168a2..6d391463c6 100644 --- a/apps/web/hooks/use-kanban-display-settings.ts +++ b/apps/web/hooks/use-kanban-display-settings.ts @@ -4,6 +4,7 @@ import { useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useAppStore } from '@/components/state-provider'; import { useUserDisplaySettings } from '@/hooks/use-user-display-settings'; +import type { BoardState } from '@/lib/state/slices'; /** * Custom hook that consolidates all kanban display settings and eliminates prop drilling. @@ -58,7 +59,7 @@ export function useKanbanDisplaySettings() { (nextBoardId: string | null) => { setActiveBoard(nextBoardId); if (nextBoardId) { - const workspaceId = boards.find((board) => board.id === nextBoardId)?.workspaceId; + const workspaceId = boards.find((board: BoardState['items'][number]) => board.id === nextBoardId)?.workspaceId; const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : ''; router.push(`/?boardId=${nextBoardId}${workspaceParam}`); } diff --git a/apps/web/hooks/use-lazy-load-messages.ts b/apps/web/hooks/use-lazy-load-messages.ts index e5c0c403f2..068c19ef31 100644 --- a/apps/web/hooks/use-lazy-load-messages.ts +++ b/apps/web/hooks/use-lazy-load-messages.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { listTaskSessionMessages } from '@/lib/http'; +import { listTaskSessionMessages } from '@/lib/api'; import { useAppStore } from '@/components/state-provider'; export function useLazyLoadMessages(sessionId: string | null) { diff --git a/apps/web/hooks/use-open-session-in-editor.ts b/apps/web/hooks/use-open-session-in-editor.ts index dd3d4ea6fd..d19549509e 100644 --- a/apps/web/hooks/use-open-session-in-editor.ts +++ b/apps/web/hooks/use-open-session-in-editor.ts @@ -1,6 +1,6 @@ 'use client'; -import { openSessionInEditor } from '@/lib/http'; +import { openSessionInEditor } from '@/lib/api'; import { useRequest } from '@/lib/http/use-request'; type OpenEditorOptions = { diff --git a/apps/web/hooks/use-preview-panel.ts b/apps/web/hooks/use-preview-panel.ts index e913631087..7c50866a78 100644 --- a/apps/web/hooks/use-preview-panel.ts +++ b/apps/web/hooks/use-preview-panel.ts @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { getSessionProcess, listSessionProcesses, startProcess, stopProcess } from '@/lib/http'; +import { getSessionProcess, listSessionProcesses, startProcess, stopProcess } from '@/lib/api'; import { useAppStore } from '@/components/state-provider'; import { useLayoutStore } from '@/lib/state/layout-store'; import type { ProcessInfo } from '@/lib/types/http'; diff --git a/apps/web/hooks/use-task-actions.ts b/apps/web/hooks/use-task-actions.ts index 58182191fd..8f17561ffc 100644 --- a/apps/web/hooks/use-task-actions.ts +++ b/apps/web/hooks/use-task-actions.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { deleteTask, moveTask } from '@/lib/http'; +import { deleteTask, moveTask } from '@/lib/api'; type MovePayload = { board_id: string; column_id: string; position: number }; diff --git a/apps/web/hooks/use-task-crud.ts b/apps/web/hooks/use-task-crud.ts index 94143b3022..69a957693c 100644 --- a/apps/web/hooks/use-task-crud.ts +++ b/apps/web/hooks/use-task-crud.ts @@ -4,6 +4,7 @@ import { useCallback, useState } from 'react'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import { useTaskActions } from '@/hooks/use-task-actions'; import type { Task } from '@/components/kanban-card'; +import type { KanbanState } from '@/lib/state/slices'; /** * Custom hook that extracts task CRUD operations from the KanbanBoard component. @@ -36,7 +37,7 @@ export function useTaskCRUD() { store.getState().hydrate({ kanban: { ...kanban, - tasks: kanban.tasks.filter((item) => item.id !== task.id), + tasks: kanban.tasks.filter((item: KanbanState['tasks'][number]) => item.id !== task.id), }, }); diff --git a/apps/web/hooks/use-task-repositories.ts b/apps/web/hooks/use-task-repositories.ts index f84e2ef3cb..0ef199a341 100644 --- a/apps/web/hooks/use-task-repositories.ts +++ b/apps/web/hooks/use-task-repositories.ts @@ -1,11 +1,12 @@ import { useAppStore } from '@/components/state-provider'; import { useTask } from '@/hooks/use-task'; +import type { Repository } from '@/lib/types/http'; export function useTaskRepositories(taskId: string | null) { const task = useTask(taskId); const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); if (!task?.repositoryId) return []; - const repositories = Object.values(repositoriesByWorkspace).flat(); - return repositories.filter((repo) => repo.id === task.repositoryId); + const repositories = Object.values(repositoriesByWorkspace).flat() as Repository[]; + return repositories.filter((repo: Repository) => repo.id === task.repositoryId); } diff --git a/apps/web/hooks/use-task-sessions.ts b/apps/web/hooks/use-task-sessions.ts index 993aba2840..26b481056e 100644 --- a/apps/web/hooks/use-task-sessions.ts +++ b/apps/web/hooks/use-task-sessions.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect } from 'react'; import { useAppStore } from '@/components/state-provider'; -import { listTaskSessions } from '@/lib/http'; +import { listTaskSessions } from '@/lib/api'; import type { TaskSession } from '@/lib/types/http'; const EMPTY_SESSIONS: TaskSession[] = []; diff --git a/apps/web/hooks/use-task.ts b/apps/web/hooks/use-task.ts index c611138a35..21bba877d9 100644 --- a/apps/web/hooks/use-task.ts +++ b/apps/web/hooks/use-task.ts @@ -1,10 +1,11 @@ import { useEffect } from 'react'; import { useAppStore } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; +import type { KanbanState } from '@/lib/state/slices'; export function useTask(taskId: string | null) { const task = useAppStore((state) => - taskId ? state.kanban.tasks.find((item) => item.id === taskId) ?? null : null + taskId ? state.kanban.tasks.find((item: KanbanState['tasks'][number]) => item.id === taskId) ?? null : null ); useEffect(() => { diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index fbe2fcd43b..c9c836d76c 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { getWebSocketClient } from '@/lib/ws/connection'; -import { fetchUserSettings, updateUserSettings } from '@/lib/http'; +import { fetchUserSettings, updateUserSettings } from '@/lib/api'; import { mapSelectedRepositoryIds } from '@/lib/kanban/filters'; import { useAppStore } from '@/components/state-provider'; -import { useRepositories } from '@/hooks/use-repositories'; +import { useRepositories } from '@/hooks/domains/workspace/use-repositories'; +import type { Repository } from '@/lib/types/http'; type DisplaySettings = { workspaceId: string | null; @@ -158,12 +159,12 @@ export function useUserDisplaySettings({ useEffect(() => { if (!userSettings.loaded) return; if (repositories.length === 0) return; - const repoIds = repositories.map((repo) => repo.id); - const validIds = userSettings.repositoryIds.filter((id) => repoIds.includes(id)); + const repoIds = repositories.map((repo: Repository) => repo.id); + const validIds = userSettings.repositoryIds.filter((id: string) => repoIds.includes(id)); const nextIds = validIds; const isSame = nextIds.length === userSettings.repositoryIds.length && - nextIds.every((id, index) => id === userSettings.repositoryIds[index]); + nextIds.every((id: string, index: number) => id === userSettings.repositoryIds[index]); if (!isSame) { queueMicrotask(() => { commitSettings({ diff --git a/apps/web/lib/api/client.ts b/apps/web/lib/api/client.ts new file mode 100644 index 0000000000..508b28e530 --- /dev/null +++ b/apps/web/lib/api/client.ts @@ -0,0 +1,38 @@ +import { getBackendConfig } from '@/lib/config'; + +export type ApiRequestOptions = { + baseUrl?: string; + cache?: RequestCache; + init?: RequestInit; +}; + +function resolveUrl(pathOrUrl: string, baseUrl: string) { + if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) { + return pathOrUrl; + } + return `${baseUrl}${pathOrUrl}`; +} + +export async function fetchJson(pathOrUrl: string, options?: ApiRequestOptions): Promise { + const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl; + const url = resolveUrl(pathOrUrl, baseUrl); + const response = await fetch(url, { + ...options?.init, + cache: options?.cache, + headers: { + 'Content-Type': 'application/json', + ...(options?.init?.headers ?? {}), + }, + }); + if (!response.ok) { + throw new Error(`Request failed: ${response.status} ${response.statusText} (${url})`); + } + if (response.status === 204) { + return undefined as T; + } + const text = await response.text(); + if (!text) { + return undefined as T; + } + return JSON.parse(text) as T; +} diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts new file mode 100644 index 0000000000..a7c131b726 --- /dev/null +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -0,0 +1,90 @@ +import { fetchJson, type ApiRequestOptions } from '../client'; +import { getBackendConfig } from '@/lib/config'; +import type { + BoardSnapshot, + ListBoardsResponse, + CreateTaskResponse, + Task, +} from '@/lib/types/http'; + +// Board operations +export async function listBoards(workspaceId: string, options?: ApiRequestOptions) { + const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl; + const url = new URL(`${baseUrl}/api/v1/boards`); + url.searchParams.set('workspace_id', workspaceId); + return fetchJson(url.toString(), options); +} + +export async function fetchBoardSnapshot(boardId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/boards/${boardId}/snapshot`, options); +} + +// Task operations +export async function createTask( + payload: { + workspace_id: string; + board_id: string; + column_id: string; + title: string; + description?: string; + position?: number; + repositories?: Array<{ + repository_id: string; + base_branch?: string; + local_path?: string; + name?: string; + default_branch?: string; + }>; + state?: Task['state']; + start_agent?: boolean; + agent_profile_id?: string; + }, + options?: ApiRequestOptions +) { + return fetchJson('/api/v1/tasks', { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function updateTask( + taskId: string, + payload: { + title?: string; + description?: string; + position?: number; + state?: Task['state']; + repositories?: Array<{ + repository_id: string; + base_branch?: string; + }>; + }, + options?: ApiRequestOptions +) { + return fetchJson(`/api/v1/tasks/${taskId}`, { + ...options, + init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function deleteTask(taskId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/tasks/${taskId}`, { + ...options, + init: { method: 'DELETE', ...(options?.init ?? {}) }, + }); +} + +export async function moveTask( + taskId: string, + payload: { board_id: string; column_id: string; position: number }, + options?: ApiRequestOptions +) { + return fetchJson(`/api/v1/tasks/${taskId}/move`, { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function fetchTask(taskId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/tasks/${taskId}`, options); +} diff --git a/apps/web/lib/api/domains/process-api.ts b/apps/web/lib/api/domains/process-api.ts new file mode 100644 index 0000000000..b5ae3fac63 --- /dev/null +++ b/apps/web/lib/api/domains/process-api.ts @@ -0,0 +1,44 @@ +import { fetchJson, type ApiRequestOptions } from '../client'; +import { getBackendConfig } from '@/lib/config'; +import type { ProcessInfo } from '@/lib/types/http'; + +// Process operations +export async function startProcess( + sessionId: string, + payload: { kind: string; script_name?: string; repo_id?: string }, + options?: ApiRequestOptions +) { + return fetchJson<{ process: ProcessInfo }>(`/api/v1/task-sessions/${sessionId}/processes/start`, { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function stopProcess( + sessionId: string, + payload: { process_id: string }, + options?: ApiRequestOptions +) { + return fetchJson(`/api/v1/task-sessions/${sessionId}/processes/${payload.process_id}/stop`, { + ...options, + init: { method: 'POST', ...(options?.init ?? {}) }, + }); +} + +export async function listSessionProcesses(sessionId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/task-sessions/${sessionId}/processes`, options); +} + +export async function getSessionProcess( + sessionId: string, + processId: string, + includeOutput = false, + options?: ApiRequestOptions +) { + const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl; + const url = new URL(`${baseUrl}/api/v1/task-sessions/${sessionId}/processes/${processId}`); + if (includeOutput) { + url.searchParams.set('include_output', 'true'); + } + return fetchJson(url.toString(), options); +} diff --git a/apps/web/lib/api/domains/session-api.ts b/apps/web/lib/api/domains/session-api.ts new file mode 100644 index 0000000000..675390886e --- /dev/null +++ b/apps/web/lib/api/domains/session-api.ts @@ -0,0 +1,41 @@ +import { fetchJson, type ApiRequestOptions } from '../client'; +import type { + TaskSessionsResponse, + TaskSessionResponse, + ListMessagesResponse, +} from '@/lib/types/http'; + +// Session operations +export async function listTaskSessions(taskId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/tasks/${taskId}/sessions`, options); +} + +export async function fetchTaskSession(taskSessionId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/task-sessions/${taskSessionId}`, options); +} + +export async function listTaskSessionMessages( + taskSessionId: string, + params?: { limit?: number; before?: string; after?: string; sort?: 'asc' | 'desc' }, + options?: ApiRequestOptions +) { + const query = new URLSearchParams(); + if (params?.limit) query.set('limit', params.limit.toString()); + if (params?.before) query.set('before', params.before); + if (params?.after) query.set('after', params.after); + if (params?.sort) query.set('sort', params.sort); + const suffix = query.toString(); + const url = `/api/v1/task-sessions/${taskSessionId}/messages${suffix ? `?${suffix}` : ''}`; + return fetchJson(url, options); +} + +export async function openSessionInEditor( + sessionId: string, + payload: Partial<{ editor_id: string; editor_type: string; file_path: string; line: number; column: number }>, + options?: ApiRequestOptions +) { + return fetchJson<{ url?: string }>(`/api/v1/task-sessions/${sessionId}/open-editor`, { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts new file mode 100644 index 0000000000..11fecf1628 --- /dev/null +++ b/apps/web/lib/api/domains/settings-api.ts @@ -0,0 +1,193 @@ +import { fetchJson, type ApiRequestOptions } from '../client'; +import type { + ListAgentsResponse, + ListAgentDiscoveryResponse, + ListAvailableAgentsResponse, + AgentProfileMcpConfig, + ListEnvironmentsResponse, + ListExecutorsResponse, + NotificationProvidersResponse, + NotificationProvider, + EditorsResponse, + EditorOption, + CustomPrompt, + PromptsResponse, + UserSettingsResponse, +} from '@/lib/types/http'; + +// User settings +export async function fetchUserSettings(options?: ApiRequestOptions) { + return fetchJson('/api/v1/user/settings', options); +} + +export async function updateUserSettings( + payload: { + workspace_id: string; + board_id: string; + repository_ids: string[]; + preferred_shell?: string; + default_editor_id?: string; + enable_preview_on_click?: boolean; + }, + options?: ApiRequestOptions +) { + return fetchJson('/api/v1/user/settings', { + ...options, + init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +// Executors +export async function listExecutors(options?: ApiRequestOptions): Promise { + return fetchJson('/api/v1/executors', options); +} + +// Environments +export async function listEnvironments(options?: ApiRequestOptions): Promise { + return fetchJson('/api/v1/environments', options); +} + +// Agents +export async function listAgents(options?: ApiRequestOptions): Promise { + return fetchJson('/api/v1/agents', options); +} + +export async function listAgentDiscovery( + options?: ApiRequestOptions +): Promise { + return fetchJson('/api/v1/agents/discovery', options); +} + +export async function listAvailableAgents( + options?: ApiRequestOptions +): Promise { + return fetchJson('/api/v1/agents/available', options); +} + +export async function getAgentProfileMcpConfig( + profileId: string, + options?: ApiRequestOptions +): Promise { + return fetchJson(`/api/v1/agent-profiles/${profileId}/mcp-config`, options); +} + +// Editors +export async function listEditors(options?: ApiRequestOptions) { + return fetchJson('/api/v1/editors', options); +} + +export async function createEditor( + payload: { + name: string; + kind: string; + config?: Record; + enabled?: boolean; + }, + options?: ApiRequestOptions +) { + return fetchJson('/api/v1/editors', { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function updateEditor( + editorId: string, + payload: { + name?: string; + kind?: string; + config?: Record; + enabled?: boolean; + }, + options?: ApiRequestOptions +) { + return fetchJson(`/api/v1/editors/${editorId}`, { + ...options, + init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function deleteEditor(editorId: string, options?: ApiRequestOptions) { + return fetchJson<{ success: boolean }>(`/api/v1/editors/${editorId}`, { + ...options, + init: { method: 'DELETE', ...(options?.init ?? {}) }, + }); +} + +// Prompts +export async function listPrompts(options?: ApiRequestOptions) { + return fetchJson('/api/v1/prompts', options); +} + +export async function createPrompt( + payload: { name: string; content: string }, + options?: ApiRequestOptions +) { + return fetchJson('/api/v1/prompts', { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function updatePrompt( + promptId: string, + payload: { name?: string; content?: string }, + options?: ApiRequestOptions +) { + return fetchJson(`/api/v1/prompts/${promptId}`, { + ...options, + init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function deletePrompt(promptId: string, options?: ApiRequestOptions) { + return fetchJson<{ success: boolean }>(`/api/v1/prompts/${promptId}`, { + ...options, + init: { method: 'DELETE', ...(options?.init ?? {}) }, + }); +} + +// Notification providers +export async function listNotificationProviders(options?: ApiRequestOptions) { + return fetchJson('/api/v1/notification-providers', options); +} + +export async function createNotificationProvider( + payload: { + name: string; + type: NotificationProvider['type']; + config?: NotificationProvider['config']; + enabled?: boolean; + events?: string[]; + }, + options?: ApiRequestOptions +) { + return fetchJson('/api/v1/notification-providers', { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function updateNotificationProvider( + providerId: string, + payload: Partial<{ + name: string; + type: NotificationProvider['type']; + config: NotificationProvider['config']; + enabled: boolean; + events: string[]; + }>, + options?: ApiRequestOptions +) { + return fetchJson(`/api/v1/notification-providers/${providerId}`, { + ...options, + init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function deleteNotificationProvider(providerId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/notification-providers/${providerId}`, { + ...options, + init: { method: 'DELETE', ...(options?.init ?? {}) }, + }); +} diff --git a/apps/web/lib/api/domains/workspace-api.ts b/apps/web/lib/api/domains/workspace-api.ts new file mode 100644 index 0000000000..bdc61c2ccd --- /dev/null +++ b/apps/web/lib/api/domains/workspace-api.ts @@ -0,0 +1,36 @@ +import { fetchJson, type ApiRequestOptions } from '../client'; +import type { + ListWorkspacesResponse, + ListRepositoriesResponse, + RepositoryBranchesResponse, + ListRepositoryScriptsResponse, + Workspace, +} from '@/lib/types/http'; + +// Workspace operations +export async function createWorkspace( + payload: { name: string; description?: string }, + options?: ApiRequestOptions +) { + return fetchJson('/api/v1/workspaces', { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function listWorkspaces(options?: ApiRequestOptions) { + return fetchJson('/api/v1/workspaces', options); +} + +// Repository operations +export async function listRepositories(workspaceId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/workspaces/${workspaceId}/repositories`, options); +} + +export async function listRepositoryBranches(repositoryId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/repositories/${repositoryId}/branches`, options); +} + +export async function listRepositoryScripts(repositoryId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/repositories/${repositoryId}/scripts`, options); +} diff --git a/apps/web/lib/api/index.ts b/apps/web/lib/api/index.ts new file mode 100644 index 0000000000..7660aa1e4e --- /dev/null +++ b/apps/web/lib/api/index.ts @@ -0,0 +1,9 @@ +// Re-export client utilities +export { fetchJson, type ApiRequestOptions } from './client'; + +// Re-export domain APIs +export * from './domains/kanban-api'; +export * from './domains/session-api'; +export * from './domains/workspace-api'; +export * from './domains/settings-api'; +export * from './domains/process-api'; diff --git a/apps/web/lib/http/index.ts b/apps/web/lib/http/index.ts deleted file mode 100644 index 8595d4dd97..0000000000 --- a/apps/web/lib/http/index.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { getBackendConfig } from '@/lib/config'; -import type { - BoardSnapshot, - ListAgentsResponse, - ListAgentDiscoveryResponse, - ListAvailableAgentsResponse, - AgentProfileMcpConfig, - ListBoardsResponse, - ListEnvironmentsResponse, - ListExecutorsResponse, - ListRepositoriesResponse, - ListWorkspacesResponse, - ListMessagesResponse, - RepositoryBranchesResponse, - ListRepositoryScriptsResponse, - NotificationProvidersResponse, - NotificationProvider, - EditorsResponse, - EditorOption, - CustomPrompt, - PromptsResponse, - CreateTaskResponse, - Task, - TaskSessionsResponse, - TaskSessionResponse, - UserSettingsResponse, - ProcessInfo, - Workspace, -} from '@/lib/types/http'; - -export type ApiRequestOptions = { - baseUrl?: string; - cache?: RequestCache; - init?: RequestInit; -}; - -function resolveUrl(pathOrUrl: string, baseUrl: string) { - if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) { - return pathOrUrl; - } - return `${baseUrl}${pathOrUrl}`; -} - -async function fetchJson(pathOrUrl: string, options?: ApiRequestOptions): Promise { - const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl; - const url = resolveUrl(pathOrUrl, baseUrl); - const response = await fetch(url, { - ...options?.init, - cache: options?.cache, - headers: { - 'Content-Type': 'application/json', - ...(options?.init?.headers ?? {}), - }, - }); - if (!response.ok) { - throw new Error(`Request failed: ${response.status} ${response.statusText} (${url})`); - } - if (response.status === 204) { - return undefined as T; - } - const text = await response.text(); - if (!text) { - return undefined as T; - } - return JSON.parse(text) as T; -} - -export async function createWorkspace( - payload: { name: string; description?: string }, - options?: ApiRequestOptions -) { - return fetchJson('/api/v1/workspaces', { - ...options, - init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function listWorkspaces(options?: ApiRequestOptions) { - return fetchJson('/api/v1/workspaces', options); -} - -export async function listBoards(workspaceId: string, options?: ApiRequestOptions) { - const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl; - const url = new URL(`${baseUrl}/api/v1/boards`); - url.searchParams.set('workspace_id', workspaceId); - return fetchJson(url.toString(), options); -} - -export async function fetchBoardSnapshot(boardId: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/boards/${boardId}/snapshot`, options); -} - -export async function listRepositories(workspaceId: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/workspaces/${workspaceId}/repositories`, options); -} - -export async function listRepositoryBranches(repositoryId: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/repositories/${repositoryId}/branches`, options); -} - -export async function listRepositoryScripts(repositoryId: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/repositories/${repositoryId}/scripts`, options); -} - -export async function startProcess( - sessionId: string, - payload: { kind: string; script_name?: string; repo_id?: string }, - options?: ApiRequestOptions -) { - return fetchJson<{ process: ProcessInfo }>(`/api/v1/task-sessions/${sessionId}/processes/start`, { - ...options, - init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function stopProcess( - sessionId: string, - payload: { process_id: string }, - options?: ApiRequestOptions -) { - return fetchJson(`/api/v1/task-sessions/${sessionId}/processes/${payload.process_id}/stop`, { - ...options, - init: { method: 'POST', ...(options?.init ?? {}) }, - }); -} - -export async function listSessionProcesses(sessionId: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/task-sessions/${sessionId}/processes`, options); -} - -export async function getSessionProcess( - sessionId: string, - processId: string, - includeOutput = false, - options?: ApiRequestOptions -) { - const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl; - const url = new URL(`${baseUrl}/api/v1/task-sessions/${sessionId}/processes/${processId}`); - if (includeOutput) { - url.searchParams.set('include_output', 'true'); - } - return fetchJson(url.toString(), options); -} - -export async function createTask( - payload: { - workspace_id: string; - board_id: string; - column_id: string; - title: string; - description?: string; - position?: number; - repositories?: Array<{ - repository_id: string; - base_branch?: string; - local_path?: string; - name?: string; - default_branch?: string; - }>; - state?: Task['state']; - start_agent?: boolean; - agent_profile_id?: string; - }, - options?: ApiRequestOptions -) { - return fetchJson('/api/v1/tasks', { - ...options, - init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function updateTask( - taskId: string, - payload: { - title?: string; - description?: string; - position?: number; - state?: Task['state']; - repositories?: Array<{ - repository_id: string; - base_branch?: string; - }>; - }, - options?: ApiRequestOptions -) { - return fetchJson(`/api/v1/tasks/${taskId}`, { - ...options, - init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function deleteTask(taskId: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/tasks/${taskId}`, { - ...options, - init: { method: 'DELETE', ...(options?.init ?? {}) }, - }); -} - -export async function moveTask( - taskId: string, - payload: { board_id: string; column_id: string; position: number }, - options?: ApiRequestOptions -) { - return fetchJson(`/api/v1/tasks/${taskId}/move`, { - ...options, - init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function fetchUserSettings(options?: ApiRequestOptions) { - return fetchJson('/api/v1/user/settings', options); -} - -export async function updateUserSettings( - payload: { - workspace_id: string; - board_id: string; - repository_ids: string[]; - preferred_shell?: string; - default_editor_id?: string; - enable_preview_on_click?: boolean; - }, - options?: ApiRequestOptions -) { - return fetchJson('/api/v1/user/settings', { - ...options, - init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function listEditors(options?: ApiRequestOptions) { - return fetchJson('/api/v1/editors', options); -} - -export async function createEditor( - payload: { - name: string; - kind: string; - config?: Record; - enabled?: boolean; - }, - options?: ApiRequestOptions -) { - return fetchJson('/api/v1/editors', { - ...options, - init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function updateEditor( - editorId: string, - payload: { - name?: string; - kind?: string; - config?: Record; - enabled?: boolean; - }, - options?: ApiRequestOptions -) { - return fetchJson(`/api/v1/editors/${editorId}`, { - ...options, - init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function deleteEditor(editorId: string, options?: ApiRequestOptions) { - return fetchJson<{ success: boolean }>(`/api/v1/editors/${editorId}`, { - ...options, - init: { method: 'DELETE', ...(options?.init ?? {}) }, - }); -} - -export async function listPrompts(options?: ApiRequestOptions) { - return fetchJson('/api/v1/prompts', options); -} - -export async function createPrompt( - payload: { name: string; content: string }, - options?: ApiRequestOptions -) { - return fetchJson('/api/v1/prompts', { - ...options, - init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function updatePrompt( - promptId: string, - payload: { name?: string; content?: string }, - options?: ApiRequestOptions -) { - return fetchJson(`/api/v1/prompts/${promptId}`, { - ...options, - init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function deletePrompt(promptId: string, options?: ApiRequestOptions) { - return fetchJson<{ success: boolean }>(`/api/v1/prompts/${promptId}`, { - ...options, - init: { method: 'DELETE', ...(options?.init ?? {}) }, - }); -} - -export async function openSessionInEditor( - sessionId: string, - payload: Partial<{ editor_id: string; editor_type: string; file_path: string; line: number; column: number }>, - options?: ApiRequestOptions -) { - return fetchJson<{ url?: string }>(`/api/v1/task-sessions/${sessionId}/open-editor`, { - ...options, - init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function listNotificationProviders(options?: ApiRequestOptions) { - return fetchJson('/api/v1/notification-providers', options); -} - -export async function createNotificationProvider( - payload: { - name: string; - type: NotificationProvider['type']; - config?: NotificationProvider['config']; - enabled?: boolean; - events?: string[]; - }, - options?: ApiRequestOptions -) { - return fetchJson('/api/v1/notification-providers', { - ...options, - init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function updateNotificationProvider( - providerId: string, - payload: Partial<{ - name: string; - type: NotificationProvider['type']; - config: NotificationProvider['config']; - enabled: boolean; - events: string[]; - }>, - options?: ApiRequestOptions -) { - return fetchJson(`/api/v1/notification-providers/${providerId}`, { - ...options, - init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) }, - }); -} - -export async function deleteNotificationProvider(providerId: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/notification-providers/${providerId}`, { - ...options, - init: { method: 'DELETE', ...(options?.init ?? {}) }, - }); -} - -export async function fetchTask(taskId: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/tasks/${taskId}`, options); -} - -export async function listTaskSessions(taskId: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/tasks/${taskId}/sessions`, options); -} - -export async function fetchTaskSession(taskSessionId: string, options?: ApiRequestOptions) { - return fetchJson(`/api/v1/task-sessions/${taskSessionId}`, options); -} - -export async function listTaskSessionMessages( - taskSessionId: string, - params?: { limit?: number; before?: string; after?: string; sort?: 'asc' | 'desc' }, - options?: ApiRequestOptions -) { - const query = new URLSearchParams(); - if (params?.limit) query.set('limit', params.limit.toString()); - if (params?.before) query.set('before', params.before); - if (params?.after) query.set('after', params.after); - if (params?.sort) query.set('sort', params.sort); - const suffix = query.toString(); - const url = `/api/v1/task-sessions/${taskSessionId}/messages${suffix ? `?${suffix}` : ''}`; - return fetchJson(url, options); -} - -export async function listExecutors(options?: ApiRequestOptions): Promise { - return fetchJson('/api/v1/executors', options); -} - -export async function listEnvironments(options?: ApiRequestOptions): Promise { - return fetchJson('/api/v1/environments', options); -} - -export async function listAgents(options?: ApiRequestOptions): Promise { - return fetchJson('/api/v1/agents', options); -} - -export async function listAgentDiscovery( - options?: ApiRequestOptions -): Promise { - return fetchJson('/api/v1/agents/discovery', options); -} - -export async function listAvailableAgents( - options?: ApiRequestOptions -): Promise { - return fetchJson('/api/v1/agents/available', options); -} - -export async function getAgentProfileMcpConfig( - profileId: string, - options?: ApiRequestOptions -): Promise { - return fetchJson(`/api/v1/agent-profiles/${profileId}/mcp-config`, options); -} diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts new file mode 100644 index 0000000000..573d49b486 --- /dev/null +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -0,0 +1,124 @@ +import type { Draft } from 'immer'; +import type { AppState } from '../store'; +import { deepMerge, mergeSessionMap, mergeLoadingState } from './merge-strategies'; + +/** + * Hydration options for controlling merge behavior + */ +export type HydrationOptions = { + /** Active session ID to avoid overwriting live data */ + activeSessionId?: string | null; + /** Whether to skip hydrating session runtime state (shell, processes, git) */ + skipSessionRuntime?: boolean; +}; + +/** + * Hydrates the app state with SSR data using smart merge strategies. + * + * Features: + * - Deep merge for nested objects + * - Avoids overwriting active sessions + * - Preserves loading states to prevent flickering + * - Partial hydration support + */ +export function hydrateState( + draft: Draft, + state: Partial, + options: HydrationOptions = {} +): void { + const { activeSessionId = null, skipSessionRuntime = false } = options; + + // Kanban slice - always safe to hydrate + if (state.kanban) deepMerge(draft.kanban, state.kanban); + if (state.boards) deepMerge(draft.boards, state.boards); + if (state.tasks) deepMerge(draft.tasks, state.tasks); + + // Workspace slice - always safe to hydrate + if (state.workspaces) deepMerge(draft.workspaces, state.workspaces); + if (state.repositories) deepMerge(draft.repositories, state.repositories); + if (state.repositoryBranches) deepMerge(draft.repositoryBranches, state.repositoryBranches); + + // Settings slice - always safe to hydrate, but preserve loading states + if (state.executors) deepMerge(draft.executors, state.executors); + if (state.environments) deepMerge(draft.environments, state.environments); + if (state.settingsAgents) deepMerge(draft.settingsAgents, state.settingsAgents); + if (state.agentDiscovery) deepMerge(draft.agentDiscovery, state.agentDiscovery); + if (state.availableAgents) { + deepMerge(draft.availableAgents, state.availableAgents); + mergeLoadingState(draft.availableAgents, state.availableAgents); + } + if (state.agentProfiles) deepMerge(draft.agentProfiles, state.agentProfiles); + if (state.editors) { + deepMerge(draft.editors, state.editors); + mergeLoadingState(draft.editors, state.editors); + } + if (state.prompts) { + deepMerge(draft.prompts, state.prompts); + mergeLoadingState(draft.prompts, state.prompts); + } + if (state.notificationProviders) { + deepMerge(draft.notificationProviders, state.notificationProviders); + mergeLoadingState(draft.notificationProviders, state.notificationProviders); + } + if (state.settingsData) deepMerge(draft.settingsData, state.settingsData); + if (state.userSettings) deepMerge(draft.userSettings, state.userSettings); + + // Session slice - careful with active sessions + if (state.messages) { + if (state.messages.bySession) { + mergeSessionMap(draft.messages.bySession, state.messages.bySession, activeSessionId); + } + if (state.messages.metaBySession) { + mergeSessionMap(draft.messages.metaBySession, state.messages.metaBySession, activeSessionId); + } + } + if (state.turns) { + if (state.turns.bySession) { + mergeSessionMap(draft.turns.bySession, state.turns.bySession, activeSessionId); + } + if (state.turns.activeBySession) { + mergeSessionMap(draft.turns.activeBySession, state.turns.activeBySession, activeSessionId); + } + } + if (state.taskSessions) deepMerge(draft.taskSessions, state.taskSessions); + if (state.taskSessionsByTask) deepMerge(draft.taskSessionsByTask, state.taskSessionsByTask); + if (state.sessionAgentctl) { + mergeSessionMap(draft.sessionAgentctl.itemsBySessionId, state.sessionAgentctl?.itemsBySessionId, activeSessionId); + } + if (state.worktrees) deepMerge(draft.worktrees, state.worktrees); + if (state.sessionWorktreesBySessionId) { + deepMerge(draft.sessionWorktreesBySessionId, state.sessionWorktreesBySessionId); + } + if (state.pendingModel) deepMerge(draft.pendingModel, state.pendingModel); + if (state.activeModel) deepMerge(draft.activeModel, state.activeModel); + + // Session Runtime slice - skip if requested (volatile state) + if (!skipSessionRuntime) { + if (state.terminal) deepMerge(draft.terminal, state.terminal); + if (state.shell) { + mergeSessionMap(draft.shell.outputs, state.shell?.outputs, activeSessionId); + mergeSessionMap(draft.shell.statuses, state.shell?.statuses, activeSessionId); + } + if (state.processes) deepMerge(draft.processes, state.processes); + if (state.gitStatus) { + mergeSessionMap(draft.gitStatus.bySessionId, state.gitStatus?.bySessionId, activeSessionId); + } + if (state.contextWindow) { + mergeSessionMap(draft.contextWindow.bySessionId, state.contextWindow?.bySessionId, activeSessionId); + } + if (state.agents) deepMerge(draft.agents, state.agents); + } + + // UI slice - merge but don't overwrite active state + if (state.previewPanel) deepMerge(draft.previewPanel, state.previewPanel); + if (state.rightPanel) deepMerge(draft.rightPanel, state.rightPanel); + if (state.diffs) deepMerge(draft.diffs, state.diffs); + if (state.connection) { + // Don't overwrite connection status from SSR - it's always stale + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { status: _status, ...rest } = state.connection || {}; + if (Object.keys(rest).length > 0) { + Object.assign(draft.connection, rest); + } + } +} diff --git a/apps/web/lib/state/hydration/merge-strategies.ts b/apps/web/lib/state/hydration/merge-strategies.ts new file mode 100644 index 0000000000..d662c416a6 --- /dev/null +++ b/apps/web/lib/state/hydration/merge-strategies.ts @@ -0,0 +1,105 @@ +import type { Draft } from 'immer'; + +/** + * Merge strategies for different state shapes + */ + +/** + * Deep merge for nested objects - overwrites at leaf level only + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function deepMerge>(target: Draft, source: Partial): void { + for (const key in source) { + const sourceValue = source[key]; + if (sourceValue === undefined) continue; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const targetValue = (target as any)[key]; + + // If both are plain objects, recurse + if ( + targetValue && + typeof targetValue === 'object' && + !Array.isArray(targetValue) && + sourceValue && + typeof sourceValue === 'object' && + !Array.isArray(sourceValue) + ) { + deepMerge(targetValue, sourceValue); + } else { + // Otherwise, overwrite + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (target as any)[key] = sourceValue; + } + } +} + +/** + * Merge strategy for sessionId-keyed maps + * Only merges sessions that don't exist or are not currently active + */ +export function mergeSessionMap( + target: Draft>, + source: Record | undefined, + activeSessionId: string | null +): void { + if (!source) return; + + for (const sessionId in source) { + // Skip if this is the active session to avoid overwriting live data + if (sessionId === activeSessionId) continue; + + // Only set if not present + if (!(sessionId in target)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (target as any)[sessionId] = source[sessionId]; + } + } +} + +/** + * Merge strategy for arrays - replaces entire array if source is non-empty + */ +export function mergeArray(target: Draft, source: T[] | undefined): void { + if (!source || source.length === 0) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (target as any).splice(0, target.length, ...source); +} + +/** + * Merge strategy for items with timestamps + * Only overwrites if source is newer + */ +export function mergeWithTimestamp( + target: Draft, + source: T | undefined +): void { + if (!source) return; + + const targetTime = target.updatedAt ? new Date(target.updatedAt).getTime() : 0; + const sourceTime = source.updatedAt ? new Date(source.updatedAt).getTime() : 0; + + // Only merge if source is newer or target has no timestamp + if (sourceTime >= targetTime) { + Object.assign(target, source); + } +} + +/** + * Merge strategy for loading states + * Preserves loading: true state to avoid flickering + */ +export function mergeLoadingState( + target: Draft<{ loading: boolean; loaded: boolean }>, + source: { loading?: boolean; loaded?: boolean } | undefined +): void { + if (!source) return; + + // Don't overwrite loading: true with loading: false from SSR + if (target.loading && source.loading === false) { + return; + } + + if (source.loading !== undefined) target.loading = source.loading; + if (source.loaded !== undefined) target.loaded = source.loaded; +} diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts new file mode 100644 index 0000000000..aafb70c80a --- /dev/null +++ b/apps/web/lib/state/slices/index.ts @@ -0,0 +1,79 @@ +// Export slice creators +export { createKanbanSlice, defaultKanbanState } from './kanban/kanban-slice'; +export { createWorkspaceSlice, defaultWorkspaceState } from './workspace/workspace-slice'; +export { createSettingsSlice, defaultSettingsState } from './settings/settings-slice'; +export { createSessionSlice, defaultSessionState } from './session/session-slice'; +export { + createSessionRuntimeSlice, + defaultSessionRuntimeState, +} from './session-runtime/session-runtime-slice'; +export { createUISlice, defaultUIState } from './ui/ui-slice'; + +// Export types +export type { KanbanSlice, KanbanSliceState, KanbanSliceActions } from './kanban/types'; +export type { + WorkspaceSlice, + WorkspaceSliceState, + WorkspaceSliceActions, +} from './workspace/types'; +export type { SettingsSlice, SettingsSliceState, SettingsSliceActions } from './settings/types'; +export type { SessionSlice, SessionSliceState, SessionSliceActions } from './session/types'; +export type { + SessionRuntimeSlice, + SessionRuntimeSliceState, + SessionRuntimeSliceActions, +} from './session-runtime/types'; +export type { UISlice, UISliceState, UISliceActions } from './ui/types'; + +// Re-export commonly used types from each domain +export type { KanbanState, BoardState, TaskState } from './kanban/types'; +export type { WorkspaceState, RepositoriesState, RepositoryBranchesState } from './workspace/types'; +export type { + ExecutorsState, + EnvironmentsState, + SettingsAgentsState, + AgentDiscoveryState, + AvailableAgentsState, + AgentProfileOption, + AgentProfilesState, + EditorsState, + PromptsState, + NotificationProvidersState, + SettingsDataState, + UserSettingsState, +} from './settings/types'; +export type { + MessagesState, + TurnsState, + TaskSessionsState, + TaskSessionsByTaskState, + SessionAgentctlStatus, + SessionAgentctlState, + Worktree, + WorktreesState, + SessionWorktreesState, + PendingModelState, + ActiveModelState, +} from './session/types'; +export type { + TerminalState, + ShellState, + ProcessStatusEntry, + ProcessState, + FileInfo, + GitStatusEntry, + GitStatusState, + ContextWindowEntry, + ContextWindowState, + AgentState, +} from './session-runtime/types'; +export type { + PreviewStage, + PreviewViewMode, + PreviewDevicePreset, + PreviewPanelState, + RightPanelState, + DiffState, + ConnectionState, +} from './ui/types'; +export type { Repository, Branch } from '@/lib/types/http'; diff --git a/apps/web/lib/state/slices/kanban/kanban-slice.ts b/apps/web/lib/state/slices/kanban/kanban-slice.ts new file mode 100644 index 0000000000..103b51ef37 --- /dev/null +++ b/apps/web/lib/state/slices/kanban/kanban-slice.ts @@ -0,0 +1,45 @@ +import type { StateCreator } from 'zustand'; +import type { KanbanSlice, KanbanSliceState } from './types'; + +export const defaultKanbanState: KanbanSliceState = { + kanban: { boardId: null, columns: [], tasks: [] }, + boards: { items: [], activeId: null }, + tasks: { activeTaskId: null, activeSessionId: null }, +}; + +export const createKanbanSlice: StateCreator< + KanbanSlice, + [['zustand/immer', never]], + [], + KanbanSlice +> = (set, get) => ({ + ...defaultKanbanState, + setActiveBoard: (boardId) => { + if (get().boards.activeId === boardId) { + return; + } + set((draft) => { + draft.boards.activeId = boardId; + }); + }, + setBoards: (boards) => + set((draft) => { + draft.boards.items = boards; + if (!draft.boards.activeId && boards.length) { + draft.boards.activeId = boards[0].id; + } + }), + setActiveTask: (taskId) => + set((draft) => { + draft.tasks.activeTaskId = taskId; + }), + setActiveSession: (taskId, sessionId) => + set((draft) => { + draft.tasks.activeTaskId = taskId; + draft.tasks.activeSessionId = sessionId; + }), + clearActiveSession: () => + set((draft) => { + draft.tasks.activeSessionId = null; + }), +}); diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts new file mode 100644 index 0000000000..51aab570d2 --- /dev/null +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -0,0 +1,41 @@ +import type { TaskState as TaskStatus } from '@/lib/types/http'; + +export type KanbanState = { + boardId: string | null; + columns: Array<{ id: string; title: string; color: string; position: number }>; + tasks: Array<{ + id: string; + columnId: string; + title: string; + description?: string; + position: number; + state?: TaskStatus; + repositoryId?: string; + }>; +}; + +export type BoardState = { + items: Array<{ id: string; workspaceId: string; name: string }>; + activeId: string | null; +}; + +export type TaskState = { + activeTaskId: string | null; + activeSessionId: string | null; +}; + +export type KanbanSliceState = { + kanban: KanbanState; + boards: BoardState; + tasks: TaskState; +}; + +export type KanbanSliceActions = { + setActiveBoard: (boardId: string | null) => void; + setBoards: (boards: BoardState['items']) => void; + setActiveTask: (taskId: string) => void; + setActiveSession: (taskId: string, sessionId: string) => void; + clearActiveSession: () => void; +}; + +export type KanbanSlice = KanbanSliceState & KanbanSliceActions; diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts new file mode 100644 index 0000000000..e418279ab7 --- /dev/null +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -0,0 +1,93 @@ +import type { StateCreator } from 'zustand'; +import type { SessionRuntimeSlice, SessionRuntimeSliceState } from './types'; + +const maxProcessOutputBytes = 2 * 1024 * 1024; + +function trimProcessOutput(value: string) { + if (value.length <= maxProcessOutputBytes) { + return value; + } + return value.slice(value.length - maxProcessOutputBytes); +} + +export const defaultSessionRuntimeState: SessionRuntimeSliceState = { + terminal: { terminals: [] }, + shell: { outputs: {}, statuses: {} }, + processes: { + outputsByProcessId: {}, + processesById: {}, + processIdsBySessionId: {}, + activeProcessBySessionId: {}, + devProcessBySessionId: {}, + }, + gitStatus: { bySessionId: {} }, + contextWindow: { bySessionId: {} }, + agents: { agents: [] }, +}; + +export const createSessionRuntimeSlice: StateCreator< + SessionRuntimeSlice, + [['zustand/immer', never]], + [], + SessionRuntimeSlice +> = (set) => ({ + ...defaultSessionRuntimeState, + setTerminalOutput: (terminalId, data) => + set((draft) => { + const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId); + if (existing) { + existing.output.push(data); + } else { + draft.terminal.terminals.push({ id: terminalId, output: [data] }); + } + }), + appendShellOutput: (sessionId, data) => + set((draft) => { + draft.shell.outputs[sessionId] = (draft.shell.outputs[sessionId] || '') + data; + }), + setShellStatus: (sessionId, status) => + set((draft) => { + draft.shell.statuses[sessionId] = status; + }), + clearShellOutput: (sessionId) => + set((draft) => { + draft.shell.outputs[sessionId] = ''; + }), + appendProcessOutput: (processId, data) => + set((draft) => { + const next = (draft.processes.outputsByProcessId[processId] || '') + data; + draft.processes.outputsByProcessId[processId] = trimProcessOutput(next); + }), + upsertProcessStatus: (status) => + set((draft) => { + draft.processes.processesById[status.processId] = status; + const list = draft.processes.processIdsBySessionId[status.sessionId] || []; + if (!list.includes(status.processId)) { + draft.processes.processIdsBySessionId[status.sessionId] = [...list, status.processId]; + } + // Track dev processes (kind === 'dev') + if (status.kind === 'dev') { + draft.processes.devProcessBySessionId[status.sessionId] = status.processId; + } + }), + clearProcessOutput: (processId) => + set((draft) => { + draft.processes.outputsByProcessId[processId] = ''; + }), + setActiveProcess: (sessionId, processId) => + set((draft) => { + draft.processes.activeProcessBySessionId[sessionId] = processId; + }), + setGitStatus: (sessionId, gitStatus) => + set((draft) => { + draft.gitStatus.bySessionId[sessionId] = gitStatus; + }), + clearGitStatus: (sessionId) => + set((draft) => { + delete draft.gitStatus.bySessionId[sessionId]; + }), + setContextWindow: (sessionId, contextWindow) => + set((draft) => { + draft.contextWindow.bySessionId[sessionId] = contextWindow; + }), +}); diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts new file mode 100644 index 0000000000..4a24669693 --- /dev/null +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -0,0 +1,111 @@ +export type TerminalState = { + terminals: Array<{ id: string; output: string[] }>; +}; + +export type ShellState = { + // Map of sessionId to shell output buffer (raw bytes as string) + outputs: Record; + // Map of sessionId to shell status + statuses: Record< + string, + { + available: boolean; + running?: boolean; + shell?: string; + cwd?: string; + } + >; +}; + +export type ProcessStatusEntry = { + processId: string; + sessionId: string; + kind: string; + scriptName?: string; + status: string; + command?: string; + workingDir?: string; + exitCode?: number | null; + startedAt?: string; + updatedAt?: string; +}; + +export type ProcessState = { + outputsByProcessId: Record; + processesById: Record; + processIdsBySessionId: Record; + activeProcessBySessionId: Record; + devProcessBySessionId: Record; +}; + +export type FileInfo = { + path: string; + status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'; + staged: boolean; + additions?: number; + deletions?: number; + old_path?: string; + diff?: string; +}; + +export type GitStatusEntry = { + branch: string | null; + remote_branch: string | null; + modified: string[]; + added: string[]; + deleted: string[]; + untracked: string[]; + renamed: string[]; + ahead: number; + behind: number; + files: Record; + timestamp: string | null; +}; + +export type GitStatusState = { + bySessionId: Record; +}; + +export type ContextWindowEntry = { + size: number; + used: number; + remaining: number; + efficiency: number; + timestamp?: string; +}; + +export type ContextWindowState = { + bySessionId: Record; +}; + +export type AgentState = { + agents: Array<{ id: string; status: 'idle' | 'running' | 'error' }>; +}; + +export type SessionRuntimeSliceState = { + terminal: TerminalState; + shell: ShellState; + processes: ProcessState; + gitStatus: GitStatusState; + contextWindow: ContextWindowState; + agents: AgentState; +}; + +export type SessionRuntimeSliceActions = { + setTerminalOutput: (terminalId: string, data: string) => void; + appendShellOutput: (sessionId: string, data: string) => void; + setShellStatus: ( + sessionId: string, + status: { available: boolean; running?: boolean; shell?: string; cwd?: string } + ) => void; + clearShellOutput: (sessionId: string) => void; + appendProcessOutput: (processId: string, data: string) => void; + upsertProcessStatus: (status: ProcessStatusEntry) => void; + clearProcessOutput: (processId: string) => void; + setActiveProcess: (sessionId: string, processId: string) => void; + setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void; + clearGitStatus: (sessionId: string) => void; + setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void; +}; + +export type SessionRuntimeSlice = SessionRuntimeSliceState & SessionRuntimeSliceActions; diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts new file mode 100644 index 0000000000..f8f6513d09 --- /dev/null +++ b/apps/web/lib/state/slices/session/session-slice.ts @@ -0,0 +1,177 @@ +import type { StateCreator } from 'zustand'; +import type { SessionSlice, SessionSliceState } from './types'; + +export const defaultSessionState: SessionSliceState = { + messages: { bySession: {}, metaBySession: {} }, + turns: { + bySession: {}, + activeBySession: {}, + }, + taskSessions: { items: {} }, + taskSessionsByTask: { itemsByTaskId: {}, loadingByTaskId: {}, loadedByTaskId: {} }, + sessionAgentctl: { itemsBySessionId: {} }, + worktrees: { items: {} }, + sessionWorktreesBySessionId: { itemsBySessionId: {} }, + pendingModel: { bySessionId: {} }, + activeModel: { bySessionId: {} }, +}; + +export const createSessionSlice: StateCreator< + SessionSlice, + [['zustand/immer', never]], + [], + SessionSlice +> = (set) => ({ + ...defaultSessionState, + setMessages: (sessionId, messages, meta) => + set((draft) => { + draft.messages.bySession[sessionId] = messages; + if (!draft.messages.metaBySession[sessionId]) { + draft.messages.metaBySession[sessionId] = { + isLoading: false, + hasMore: false, + oldestCursor: null, + }; + } + if (meta?.hasMore !== undefined) { + draft.messages.metaBySession[sessionId].hasMore = meta.hasMore; + } + if (meta?.oldestCursor !== undefined) { + draft.messages.metaBySession[sessionId].oldestCursor = meta.oldestCursor; + } + }), + addMessage: (message) => + set((draft) => { + const sessionId = message.session_id; + if (!draft.messages.bySession[sessionId]) { + draft.messages.bySession[sessionId] = []; + } + const existing = draft.messages.bySession[sessionId].find((m) => m.id === message.id); + if (!existing) { + draft.messages.bySession[sessionId].push(message); + } + }), + updateMessage: (message) => + set((draft) => { + const sessionId = message.session_id; + const messages = draft.messages.bySession[sessionId]; + if (messages) { + const index = messages.findIndex((m) => m.id === message.id); + if (index !== -1) { + messages[index] = message; + } + } + }), + prependMessages: (sessionId, messages, meta) => + set((draft) => { + const existing = draft.messages.bySession[sessionId] || []; + draft.messages.bySession[sessionId] = [...messages, ...existing]; + if (!draft.messages.metaBySession[sessionId]) { + draft.messages.metaBySession[sessionId] = { + isLoading: false, + hasMore: false, + oldestCursor: null, + }; + } + if (meta?.hasMore !== undefined) { + draft.messages.metaBySession[sessionId].hasMore = meta.hasMore; + } + if (meta?.oldestCursor !== undefined) { + draft.messages.metaBySession[sessionId].oldestCursor = meta.oldestCursor; + } + }), + setMessagesMetadata: (sessionId, meta) => + set((draft) => { + if (!draft.messages.metaBySession[sessionId]) { + draft.messages.metaBySession[sessionId] = { + isLoading: false, + hasMore: false, + oldestCursor: null, + }; + } + if (meta.hasMore !== undefined) { + draft.messages.metaBySession[sessionId].hasMore = meta.hasMore; + } + if (meta.isLoading !== undefined) { + draft.messages.metaBySession[sessionId].isLoading = meta.isLoading; + } + if (meta.oldestCursor !== undefined) { + draft.messages.metaBySession[sessionId].oldestCursor = meta.oldestCursor; + } + }), + setMessagesLoading: (sessionId, loading) => + set((draft) => { + if (!draft.messages.metaBySession[sessionId]) { + draft.messages.metaBySession[sessionId] = { + isLoading: loading, + hasMore: false, + oldestCursor: null, + }; + } else { + draft.messages.metaBySession[sessionId].isLoading = loading; + } + }), + addTurn: (turn) => + set((draft) => { + const sessionId = turn.session_id; + if (!draft.turns.bySession[sessionId]) { + draft.turns.bySession[sessionId] = []; + } + const existing = draft.turns.bySession[sessionId].find((t) => t.id === turn.id); + if (!existing) { + draft.turns.bySession[sessionId].push(turn); + } + }), + completeTurn: (sessionId, turnId, completedAt) => + set((draft) => { + const turns = draft.turns.bySession[sessionId]; + if (turns) { + const turn = turns.find((t) => t.id === turnId); + if (turn) { + turn.completed_at = completedAt; + } + } + }), + setActiveTurn: (sessionId, turnId) => + set((draft) => { + draft.turns.activeBySession[sessionId] = turnId; + }), + setTaskSession: (session) => + set((draft) => { + draft.taskSessions.items[session.id] = session; + }), + setTaskSessionsForTask: (taskId, sessions) => + set((draft) => { + draft.taskSessionsByTask.itemsByTaskId[taskId] = sessions; + draft.taskSessionsByTask.loadingByTaskId[taskId] = false; + draft.taskSessionsByTask.loadedByTaskId[taskId] = true; + }), + setTaskSessionsLoading: (taskId, loading) => + set((draft) => { + draft.taskSessionsByTask.loadingByTaskId[taskId] = loading; + }), + setSessionAgentctlStatus: (sessionId, status) => + set((draft) => { + draft.sessionAgentctl.itemsBySessionId[sessionId] = status; + }), + setWorktree: (worktree) => + set((draft) => { + draft.worktrees.items[worktree.id] = worktree; + }), + setSessionWorktrees: (sessionId, worktreeIds) => + set((draft) => { + draft.sessionWorktreesBySessionId.itemsBySessionId[sessionId] = worktreeIds; + }), + setPendingModel: (sessionId, modelId) => + set((draft) => { + draft.pendingModel.bySessionId[sessionId] = modelId; + }), + clearPendingModel: (sessionId) => + set((draft) => { + delete draft.pendingModel.bySessionId[sessionId]; + }), + setActiveModel: (sessionId, modelId) => + set((draft) => { + draft.activeModel.bySessionId[sessionId] = modelId; + }), +}); diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts new file mode 100644 index 0000000000..f2f0c52723 --- /dev/null +++ b/apps/web/lib/state/slices/session/types.ts @@ -0,0 +1,109 @@ +import type { Message, TaskSession, Turn } from '@/lib/types/http'; + +export type MessagesState = { + bySession: Record; + metaBySession: Record< + string, + { + isLoading: boolean; + hasMore: boolean; + oldestCursor: string | null; + } + >; +}; + +export type TurnsState = { + bySession: Record; + activeBySession: Record; // sessionId -> active turnId +}; + +export type TaskSessionsState = { + items: Record; +}; + +export type TaskSessionsByTaskState = { + itemsByTaskId: Record; + loadingByTaskId: Record; + loadedByTaskId: Record; +}; + +export type SessionAgentctlStatus = { + status: 'starting' | 'ready' | 'error'; + errorMessage?: string; + agentExecutionId?: string; + updatedAt?: string; +}; + +export type SessionAgentctlState = { + itemsBySessionId: Record; +}; + +export type Worktree = { + id: string; + sessionId: string; + repositoryId?: string; + path?: string; + branch?: string; +}; + +export type WorktreesState = { + items: Record; +}; + +export type SessionWorktreesState = { + itemsBySessionId: Record; +}; + +export type PendingModelState = { + bySessionId: Record; +}; + +export type ActiveModelState = { + bySessionId: Record; +}; + +export type SessionSliceState = { + messages: MessagesState; + turns: TurnsState; + taskSessions: TaskSessionsState; + taskSessionsByTask: TaskSessionsByTaskState; + sessionAgentctl: SessionAgentctlState; + worktrees: WorktreesState; + sessionWorktreesBySessionId: SessionWorktreesState; + pendingModel: PendingModelState; + activeModel: ActiveModelState; +}; + +export type SessionSliceActions = { + setMessages: ( + sessionId: string, + messages: Message[], + meta?: { hasMore?: boolean; oldestCursor?: string | null } + ) => void; + addMessage: (message: Message) => void; + updateMessage: (message: Message) => void; + prependMessages: ( + sessionId: string, + messages: Message[], + meta?: { hasMore?: boolean; oldestCursor?: string | null } + ) => void; + setMessagesMetadata: ( + sessionId: string, + meta: { hasMore?: boolean; isLoading?: boolean; oldestCursor?: string | null } + ) => void; + setMessagesLoading: (sessionId: string, loading: boolean) => void; + addTurn: (turn: Turn) => void; + completeTurn: (sessionId: string, turnId: string, completedAt: string) => void; + setActiveTurn: (sessionId: string, turnId: string | null) => void; + setTaskSession: (session: TaskSession) => void; + setTaskSessionsForTask: (taskId: string, sessions: TaskSession[]) => void; + setTaskSessionsLoading: (taskId: string, loading: boolean) => void; + setSessionAgentctlStatus: (sessionId: string, status: SessionAgentctlStatus) => void; + setWorktree: (worktree: Worktree) => void; + setSessionWorktrees: (sessionId: string, worktreeIds: string[]) => void; + setPendingModel: (sessionId: string, modelId: string) => void; + clearPendingModel: (sessionId: string) => void; + setActiveModel: (sessionId: string, modelId: string) => void; +}; + +export type SessionSlice = SessionSliceState & SessionSliceActions; diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts new file mode 100644 index 0000000000..147d36c609 --- /dev/null +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -0,0 +1,112 @@ +import type { StateCreator } from 'zustand'; +import type { SettingsSlice, SettingsSliceState } from './types'; + +export const defaultSettingsState: SettingsSliceState = { + executors: { items: [] }, + environments: { items: [] }, + settingsAgents: { items: [] }, + agentDiscovery: { items: [] }, + availableAgents: { items: [], loading: false, loaded: false }, + agentProfiles: { items: [], version: 0 }, + editors: { items: [], loaded: false, loading: false }, + prompts: { items: [], loaded: false, loading: false }, + notificationProviders: { + items: [], + events: [], + appriseAvailable: false, + loaded: false, + loading: false, + }, + settingsData: { executorsLoaded: false, environmentsLoaded: false, agentsLoaded: false }, + userSettings: { + workspaceId: null, + boardId: null, + repositoryIds: [], + preferredShell: null, + shellOptions: [], + defaultEditorId: null, + enablePreviewOnClick: false, + loaded: false, + }, +}; + +export const createSettingsSlice: StateCreator< + SettingsSlice, + [['zustand/immer', never]], + [], + SettingsSlice +> = (set) => ({ + ...defaultSettingsState, + setExecutors: (executors) => + set((draft) => { + draft.executors.items = executors; + }), + setEnvironments: (environments) => + set((draft) => { + draft.environments.items = environments; + }), + setSettingsAgents: (agents) => + set((draft) => { + draft.settingsAgents.items = agents; + }), + setAgentDiscovery: (agents) => + set((draft) => { + draft.agentDiscovery.items = agents; + }), + setAvailableAgents: (agents) => + set((draft) => { + draft.availableAgents.items = agents; + draft.availableAgents.loading = false; + draft.availableAgents.loaded = true; + }), + setAvailableAgentsLoading: (loading) => + set((draft) => { + draft.availableAgents.loading = loading; + }), + setAgentProfiles: (profiles) => + set((draft) => { + draft.agentProfiles.items = profiles; + }), + setEditors: (editors) => + set((draft) => { + draft.editors.items = editors; + draft.editors.loaded = true; + }), + setEditorsLoading: (loading) => + set((draft) => { + draft.editors.loading = loading; + }), + setPrompts: (prompts) => + set((draft) => { + draft.prompts.items = prompts; + draft.prompts.loaded = true; + }), + setPromptsLoading: (loading) => + set((draft) => { + draft.prompts.loading = loading; + }), + setNotificationProviders: (state) => + set((draft) => { + draft.notificationProviders.items = state.items; + draft.notificationProviders.events = state.events; + draft.notificationProviders.appriseAvailable = state.appriseAvailable; + draft.notificationProviders.loaded = state.loaded; + draft.notificationProviders.loading = state.loading; + }), + setNotificationProvidersLoading: (loading) => + set((draft) => { + draft.notificationProviders.loading = loading; + }), + setSettingsData: (next) => + set((draft) => { + draft.settingsData = { ...draft.settingsData, ...next }; + }), + setUserSettings: (settings) => + set((draft) => { + draft.userSettings = settings; + }), + bumpAgentProfilesVersion: () => + set((draft) => { + draft.agentProfiles.version += 1; + }), +}); diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts new file mode 100644 index 0000000000..bab7f1410c --- /dev/null +++ b/apps/web/lib/state/slices/settings/types.ts @@ -0,0 +1,115 @@ +import type { + Agent, + AvailableAgent, + AgentDiscovery, + CustomPrompt, + Environment, + EditorOption, + Executor, + NotificationProvider, +} from '@/lib/types/http'; + +export type ExecutorsState = { + items: Executor[]; +}; + +export type EnvironmentsState = { + items: Environment[]; +}; + +export type SettingsAgentsState = { + items: Agent[]; +}; + +export type AgentDiscoveryState = { + items: AgentDiscovery[]; +}; + +export type AvailableAgentsState = { + items: AvailableAgent[]; + loading: boolean; + loaded: boolean; +}; + +export type AgentProfileOption = { + id: string; + label: string; + agent_id: string; +}; + +export type AgentProfilesState = { + items: AgentProfileOption[]; + version: number; +}; + +export type EditorsState = { + items: EditorOption[]; + loaded: boolean; + loading: boolean; +}; + +export type PromptsState = { + items: CustomPrompt[]; + loaded: boolean; + loading: boolean; +}; + +export type NotificationProvidersState = { + items: NotificationProvider[]; + events: string[]; + appriseAvailable: boolean; + loaded: boolean; + loading: boolean; +}; + +export type SettingsDataState = { + executorsLoaded: boolean; + environmentsLoaded: boolean; + agentsLoaded: boolean; +}; + +export type UserSettingsState = { + workspaceId: string | null; + boardId: string | null; + repositoryIds: string[]; + preferredShell: string | null; + shellOptions: Array<{ value: string; label: string }>; + defaultEditorId: string | null; + enablePreviewOnClick: boolean; + loaded: boolean; +}; + +export type SettingsSliceState = { + executors: ExecutorsState; + environments: EnvironmentsState; + settingsAgents: SettingsAgentsState; + agentDiscovery: AgentDiscoveryState; + availableAgents: AvailableAgentsState; + agentProfiles: AgentProfilesState; + editors: EditorsState; + prompts: PromptsState; + notificationProviders: NotificationProvidersState; + settingsData: SettingsDataState; + userSettings: UserSettingsState; +}; + +export type SettingsSliceActions = { + setExecutors: (executors: ExecutorsState['items']) => void; + setEnvironments: (environments: EnvironmentsState['items']) => void; + setSettingsAgents: (agents: SettingsAgentsState['items']) => void; + setAgentDiscovery: (agents: AgentDiscoveryState['items']) => void; + setAvailableAgents: (agents: AvailableAgentsState['items']) => void; + setAvailableAgentsLoading: (loading: boolean) => void; + setAgentProfiles: (profiles: AgentProfilesState['items']) => void; + setEditors: (editors: EditorsState['items']) => void; + setEditorsLoading: (loading: boolean) => void; + setPrompts: (prompts: PromptsState['items']) => void; + setPromptsLoading: (loading: boolean) => void; + setNotificationProviders: (state: NotificationProvidersState) => void; + setNotificationProvidersLoading: (loading: boolean) => void; + setSettingsData: (next: Partial) => void; + setUserSettings: (settings: UserSettingsState) => void; + bumpAgentProfilesVersion: () => void; +}; + +export type SettingsSlice = SettingsSliceState & SettingsSliceActions; diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts new file mode 100644 index 0000000000..6b4b94e462 --- /dev/null +++ b/apps/web/lib/state/slices/ui/types.ts @@ -0,0 +1,46 @@ +export type PreviewStage = 'closed' | 'logs' | 'preview'; +export type PreviewViewMode = 'preview' | 'output'; +export type PreviewDevicePreset = 'desktop' | 'tablet' | 'mobile'; + +export type PreviewPanelState = { + openBySessionId: Record; + viewBySessionId: Record; + deviceBySessionId: Record; + stageBySessionId: Record; + urlBySessionId: Record; + urlDraftBySessionId: Record; +}; + +export type RightPanelState = { + activeTabBySessionId: Record; +}; + +export type DiffState = { + files: Array<{ path: string; status: 'A' | 'M' | 'D'; plus: number; minus: number }>; +}; + +export type ConnectionState = { + status: 'disconnected' | 'connecting' | 'connected' | 'error' | 'reconnecting'; + error: string | null; +}; + +export type UISliceState = { + previewPanel: PreviewPanelState; + rightPanel: RightPanelState; + diffs: DiffState; + connection: ConnectionState; +}; + +export type UISliceActions = { + setPreviewOpen: (sessionId: string, open: boolean) => void; + togglePreviewOpen: (sessionId: string) => void; + setPreviewView: (sessionId: string, view: PreviewViewMode) => void; + setPreviewDevice: (sessionId: string, device: PreviewDevicePreset) => void; + setPreviewStage: (sessionId: string, stage: PreviewStage) => void; + setPreviewUrl: (sessionId: string, url: string) => void; + setPreviewUrlDraft: (sessionId: string, url: string) => void; + setRightPanelActiveTab: (sessionId: string, tab: string) => void; + setConnectionStatus: (status: ConnectionState['status'], error?: string | null) => void; +}; + +export type UISlice = UISliceState & UISliceActions; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts new file mode 100644 index 0000000000..1aa0f2f003 --- /dev/null +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -0,0 +1,67 @@ +import type { StateCreator } from 'zustand'; +import { setLocalStorage } from '@/lib/local-storage'; +import type { UISlice, UISliceState } from './types'; + +export const defaultUIState: UISliceState = { + previewPanel: { + openBySessionId: {}, + viewBySessionId: {}, + deviceBySessionId: {}, + stageBySessionId: {}, + urlBySessionId: {}, + urlDraftBySessionId: {}, + }, + rightPanel: { activeTabBySessionId: {} }, + diffs: { files: [] }, + connection: { status: 'disconnected', error: null }, +}; + +export const createUISlice: StateCreator = ( + set, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _get +) => ({ + ...defaultUIState, + setPreviewOpen: (sessionId, open) => + set((draft) => { + draft.previewPanel.openBySessionId[sessionId] = open; + setLocalStorage(`preview-open-${sessionId}`, open); + }), + togglePreviewOpen: (sessionId) => + set((draft) => { + const current = draft.previewPanel.openBySessionId[sessionId] ?? false; + draft.previewPanel.openBySessionId[sessionId] = !current; + setLocalStorage(`preview-open-${sessionId}`, !current); + }), + setPreviewView: (sessionId, view) => + set((draft) => { + draft.previewPanel.viewBySessionId[sessionId] = view; + setLocalStorage(`preview-view-${sessionId}`, view); + }), + setPreviewDevice: (sessionId, device) => + set((draft) => { + draft.previewPanel.deviceBySessionId[sessionId] = device; + setLocalStorage(`preview-device-${sessionId}`, device); + }), + setPreviewStage: (sessionId, stage) => + set((draft) => { + draft.previewPanel.stageBySessionId[sessionId] = stage; + }), + setPreviewUrl: (sessionId, url) => + set((draft) => { + draft.previewPanel.urlBySessionId[sessionId] = url; + }), + setPreviewUrlDraft: (sessionId, url) => + set((draft) => { + draft.previewPanel.urlDraftBySessionId[sessionId] = url; + }), + setRightPanelActiveTab: (sessionId, tab) => + set((draft) => { + draft.rightPanel.activeTabBySessionId[sessionId] = tab; + }), + setConnectionStatus: (status, error) => + set((draft) => { + draft.connection.status = status; + draft.connection.error = error ?? null; + }), +}); diff --git a/apps/web/lib/state/slices/workspace/types.ts b/apps/web/lib/state/slices/workspace/types.ts new file mode 100644 index 0000000000..cbdd3747c0 --- /dev/null +++ b/apps/web/lib/state/slices/workspace/types.ts @@ -0,0 +1,45 @@ +import type { Repository, Branch } from '@/lib/types/http'; + +export type WorkspaceState = { + items: Array<{ + id: string; + name: string; + description?: string | null; + owner_id: string; + default_executor_id?: string | null; + default_environment_id?: string | null; + default_agent_profile_id?: string | null; + created_at: string; + updated_at: string; + }>; + activeId: string | null; +}; + +export type RepositoriesState = { + itemsByWorkspaceId: Record; + loadingByWorkspaceId: Record; + loadedByWorkspaceId: Record; +}; + +export type RepositoryBranchesState = { + itemsByRepositoryId: Record; + loadingByRepositoryId: Record; + loadedByRepositoryId: Record; +}; + +export type WorkspaceSliceState = { + workspaces: WorkspaceState; + repositories: RepositoriesState; + repositoryBranches: RepositoryBranchesState; +}; + +export type WorkspaceSliceActions = { + setActiveWorkspace: (workspaceId: string | null) => void; + setWorkspaces: (workspaces: WorkspaceState['items']) => void; + setRepositories: (workspaceId: string, repositories: Repository[]) => void; + setRepositoriesLoading: (workspaceId: string, loading: boolean) => void; + setRepositoryBranches: (repositoryId: string, branches: Branch[]) => void; + setRepositoryBranchesLoading: (repositoryId: string, loading: boolean) => void; +}; + +export type WorkspaceSlice = WorkspaceSliceState & WorkspaceSliceActions; diff --git a/apps/web/lib/state/slices/workspace/workspace-slice.ts b/apps/web/lib/state/slices/workspace/workspace-slice.ts new file mode 100644 index 0000000000..cf6b81fb9a --- /dev/null +++ b/apps/web/lib/state/slices/workspace/workspace-slice.ts @@ -0,0 +1,52 @@ +import type { StateCreator } from 'zustand'; +import type { WorkspaceSlice, WorkspaceSliceState } from './types'; + +export const defaultWorkspaceState: WorkspaceSliceState = { + workspaces: { items: [], activeId: null }, + repositories: { itemsByWorkspaceId: {}, loadingByWorkspaceId: {}, loadedByWorkspaceId: {} }, + repositoryBranches: { itemsByRepositoryId: {}, loadingByRepositoryId: {}, loadedByRepositoryId: {} }, +}; + +export const createWorkspaceSlice: StateCreator< + WorkspaceSlice, + [['zustand/immer', never]], + [], + WorkspaceSlice +> = (set, get) => ({ + ...defaultWorkspaceState, + setActiveWorkspace: (workspaceId) => { + if (get().workspaces.activeId === workspaceId) { + return; + } + set((draft) => { + draft.workspaces.activeId = workspaceId; + }); + }, + setWorkspaces: (workspaces) => + set((draft) => { + draft.workspaces.items = workspaces; + if (!draft.workspaces.activeId && workspaces.length) { + draft.workspaces.activeId = workspaces[0].id; + } + }), + setRepositories: (workspaceId, repositories) => + set((draft) => { + draft.repositories.itemsByWorkspaceId[workspaceId] = repositories; + draft.repositories.loadingByWorkspaceId[workspaceId] = false; + draft.repositories.loadedByWorkspaceId[workspaceId] = true; + }), + setRepositoriesLoading: (workspaceId, loading) => + set((draft) => { + draft.repositories.loadingByWorkspaceId[workspaceId] = loading; + }), + setRepositoryBranches: (repositoryId, branches) => + set((draft) => { + draft.repositoryBranches.itemsByRepositoryId[repositoryId] = branches; + draft.repositoryBranches.loadingByRepositoryId[repositoryId] = false; + draft.repositoryBranches.loadedByRepositoryId[repositoryId] = true; + }), + setRepositoryBranchesLoading: (repositoryId, loading) => + set((draft) => { + draft.repositoryBranches.loadingByRepositoryId[repositoryId] = loading; + }), +}); diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index cf13c1ec20..4f2df258c0 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -1,364 +1,145 @@ -import type { ReactNode } from 'react'; -import type { - Agent, - AvailableAgent, - AgentDiscovery, - Branch, - CustomPrompt, - Environment, - EditorOption, - Executor, - Message, - NotificationProvider, - Repository, - TaskState as TaskStatus, - TaskSession, - Turn, -} from '@/lib/types/http'; import { createStore } from 'zustand/vanilla'; import { immer } from 'zustand/middleware/immer'; -import { setLocalStorage } from '@/lib/local-storage'; +import { hydrateState } from './hydration/hydrator'; +import type { Repository, Branch, Message, Turn, TaskSession } from '@/lib/types/http'; +import { + createKanbanSlice, + createWorkspaceSlice, + createSettingsSlice, + createSessionSlice, + createSessionRuntimeSlice, + createUISlice, + defaultKanbanState, + defaultWorkspaceState, + defaultSettingsState, + defaultSessionState, + defaultSessionRuntimeState, + defaultUIState, + type WorkspaceState, + type BoardState, + type ExecutorsState, + type EnvironmentsState, + type SettingsAgentsState, + type AgentDiscoveryState, + type AvailableAgentsState, + type AgentProfilesState, + type EditorsState, + type PromptsState, + type NotificationProvidersState, + type SettingsDataState, + type UserSettingsState, + type ProcessStatusEntry, + type Worktree, + type GitStatusEntry, + type ContextWindowEntry, + type SessionAgentctlStatus, + type PreviewStage, + type PreviewViewMode, + type PreviewDevicePreset, + type ConnectionState, +} from './slices'; -const maxProcessOutputBytes = 2 * 1024 * 1024; - -function trimProcessOutput(value: string) { - if (value.length <= maxProcessOutputBytes) { - return value; - } - return value.slice(value.length - maxProcessOutputBytes); -} - -export type KanbanState = { - boardId: string | null; - columns: Array<{ id: string; title: string; color: string; position: number }>; - tasks: Array<{ - id: string; - columnId: string; - title: string; - description?: string; - position: number; - state?: TaskStatus; - repositoryId?: string; - }>; -}; - -export type WorkspaceState = { - items: Array<{ - id: string; - name: string; - description?: string | null; - owner_id: string; - default_executor_id?: string | null; - default_environment_id?: string | null; - default_agent_profile_id?: string | null; - created_at: string; - updated_at: string; - }>; - activeId: string | null; -}; - -export type AgentProfileOption = { - id: string; - label: string; - agent_id: string; -}; - -export type ExecutorsState = { - items: Executor[]; -}; - -export type EnvironmentsState = { - items: Environment[]; -}; - -export type SettingsAgentsState = { - items: Agent[]; -}; - -export type AgentDiscoveryState = { - items: AgentDiscovery[]; -}; - -export type EditorsState = { - items: EditorOption[]; - loaded: boolean; - loading: boolean; -}; - -export type PromptsState = { - items: CustomPrompt[]; - loaded: boolean; - loading: boolean; -}; - -export type NotificationProvidersState = { - items: NotificationProvider[]; - events: string[]; - appriseAvailable: boolean; - loaded: boolean; - loading: boolean; -}; - -export type AvailableAgentsState = { - items: AvailableAgent[]; - loading: boolean; - loaded: boolean; -}; - -export type BoardState = { - items: Array<{ id: string; workspaceId: string; name: string }>; - activeId: string | null; -}; - -export type TaskState = { - activeTaskId: string | null; - activeSessionId: string | null; -}; - -export type AgentState = { - agents: Array<{ id: string; status: 'idle' | 'running' | 'error' }>; -}; - -export type AgentProfilesState = { - items: AgentProfileOption[]; - version: number; -}; - -export type RepositoriesState = { - itemsByWorkspaceId: Record; - loadingByWorkspaceId: Record; - loadedByWorkspaceId: Record; -}; - -export type RepositoryBranchesState = { - itemsByRepositoryId: Record; - loadingByRepositoryId: Record; - loadedByRepositoryId: Record; -}; - -export type SettingsDataState = { - executorsLoaded: boolean; - environmentsLoaded: boolean; - agentsLoaded: boolean; -}; - -export type UserSettingsState = { - workspaceId: string | null; - boardId: string | null; - repositoryIds: string[]; - preferredShell: string | null; - shellOptions: Array<{ value: string; label: string }>; - defaultEditorId: string | null; - enablePreviewOnClick: boolean; - loaded: boolean; -}; - -export type TerminalState = { - terminals: Array<{ id: string; output: string[] }>; -}; - -export type ShellState = { - // Map of sessionId to shell output buffer (raw bytes as string) - outputs: Record; - // Map of sessionId to shell status - statuses: Record< - string, - { - available: boolean; - running?: boolean; - shell?: string; - cwd?: string; - } - >; -}; - -export type ProcessStatusEntry = { - processId: string; - sessionId: string; - kind: string; - scriptName?: string; - status: string; - command?: string; - workingDir?: string; - exitCode?: number | null; - startedAt?: string; - updatedAt?: string; -}; - -export type ProcessState = { - outputsByProcessId: Record; - processesById: Record; - processIdsBySessionId: Record; - activeProcessBySessionId: Record; - devProcessBySessionId: Record; -}; - -export type PreviewStage = 'closed' | 'logs' | 'preview'; -export type PreviewViewMode = 'preview' | 'output'; -export type PreviewDevicePreset = 'desktop' | 'tablet' | 'mobile'; - -export type PreviewPanelState = { - openBySessionId: Record; - viewBySessionId: Record; - deviceBySessionId: Record; - stageBySessionId: Record; - urlBySessionId: Record; - urlDraftBySessionId: Record; -}; - -export type RightPanelState = { - activeTabBySessionId: Record; -}; - - -export type FileInfo = { - path: string; - status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'; - staged: boolean; - additions?: number; - deletions?: number; - old_path?: string; - diff?: string; -}; - -export type GitStatusEntry = { - branch: string | null; - remote_branch: string | null; - modified: string[]; - added: string[]; - deleted: string[]; - untracked: string[]; - renamed: string[]; - ahead: number; - behind: number; - files: Record; - timestamp: string | null; -}; - -export type GitStatusState = { - bySessionId: Record; -}; - -export type ContextWindowEntry = { - size: number; - used: number; - remaining: number; - efficiency: number; - timestamp?: string; -}; - -export type ContextWindowState = { - bySessionId: Record; -}; - -export type Worktree = { - id: string; - sessionId: string; - repositoryId?: string; - path?: string; - branch?: string; -}; - -export type WorktreesState = { - items: Record; -}; - -export type SessionWorktreesState = { - itemsBySessionId: Record; -}; - -export type DiffState = { - files: Array<{ path: string; status: 'A' | 'M' | 'D'; plus: number; minus: number }>; -}; - -export type ConnectionState = { - status: 'disconnected' | 'connecting' | 'connected' | 'error' | 'reconnecting'; - error: string | null; -}; - -export type MessagesState = { - bySession: Record; - metaBySession: Record< - string, - { - isLoading: boolean; - hasMore: boolean; - oldestCursor: string | null; - } - >; -}; - -export type TurnsState = { - bySession: Record; - activeBySession: Record; // sessionId -> active turnId -}; - -export type TaskSessionsState = { - items: Record; -}; - -export type TaskSessionsByTaskState = { - itemsByTaskId: Record; - loadingByTaskId: Record; - loadedByTaskId: Record; -}; - -export type SessionAgentctlStatus = { - status: 'starting' | 'ready' | 'error'; - errorMessage?: string; - agentExecutionId?: string; - updatedAt?: string; -}; - -export type SessionAgentctlState = { - itemsBySessionId: Record; -}; - -export type PendingModelState = { - bySessionId: Record; -}; - -export type ActiveModelState = { - bySessionId: Record; -}; +// Re-export all types from slices for backwards compatibility +export type { + KanbanState, + BoardState, + TaskState, + WorkspaceState, + RepositoriesState, + RepositoryBranchesState, + ExecutorsState, + EnvironmentsState, + SettingsAgentsState, + AgentDiscoveryState, + AvailableAgentsState, + AgentProfileOption, + AgentProfilesState, + EditorsState, + PromptsState, + NotificationProvidersState, + SettingsDataState, + UserSettingsState, + MessagesState, + TurnsState, + TaskSessionsState, + TaskSessionsByTaskState, + SessionAgentctlStatus, + SessionAgentctlState, + Worktree, + WorktreesState, + SessionWorktreesState, + PendingModelState, + ActiveModelState, + TerminalState, + ShellState, + ProcessStatusEntry, + ProcessState, + FileInfo, + GitStatusEntry, + GitStatusState, + ContextWindowEntry, + ContextWindowState, + AgentState, + PreviewStage, + PreviewViewMode, + PreviewDevicePreset, + PreviewPanelState, + RightPanelState, + DiffState, + ConnectionState, +} from './slices'; +// Combined AppState type export type AppState = { - kanban: KanbanState; - workspaces: WorkspaceState; - boards: BoardState; - executors: ExecutorsState; - environments: EnvironmentsState; - settingsAgents: SettingsAgentsState; - agentDiscovery: AgentDiscoveryState; - availableAgents: AvailableAgentsState; - repositories: RepositoriesState; - repositoryBranches: RepositoryBranchesState; - settingsData: SettingsDataState; - editors: EditorsState; - prompts: PromptsState; - notificationProviders: NotificationProvidersState; - tasks: TaskState; - agents: AgentState; - agentProfiles: AgentProfilesState; - userSettings: UserSettingsState; - terminal: TerminalState; - shell: ShellState; - processes: ProcessState; - previewPanel: PreviewPanelState; - rightPanel: RightPanelState; - diffs: DiffState; - gitStatus: GitStatusState; - contextWindow: ContextWindowState; - connection: ConnectionState; - messages: MessagesState; - turns: TurnsState; - taskSessions: TaskSessionsState; - taskSessionsByTask: TaskSessionsByTaskState; - sessionAgentctl: SessionAgentctlState; - worktrees: WorktreesState; - sessionWorktreesBySessionId: SessionWorktreesState; - pendingModel: PendingModelState; - activeModel: ActiveModelState; + // Kanban slice + kanban: typeof defaultKanbanState['kanban']; + boards: typeof defaultKanbanState['boards']; + tasks: typeof defaultKanbanState['tasks']; + + // Workspace slice + workspaces: typeof defaultWorkspaceState['workspaces']; + repositories: typeof defaultWorkspaceState['repositories']; + repositoryBranches: typeof defaultWorkspaceState['repositoryBranches']; + + // Settings slice + executors: typeof defaultSettingsState['executors']; + environments: typeof defaultSettingsState['environments']; + settingsAgents: typeof defaultSettingsState['settingsAgents']; + agentDiscovery: typeof defaultSettingsState['agentDiscovery']; + availableAgents: typeof defaultSettingsState['availableAgents']; + agentProfiles: typeof defaultSettingsState['agentProfiles']; + editors: typeof defaultSettingsState['editors']; + prompts: typeof defaultSettingsState['prompts']; + notificationProviders: typeof defaultSettingsState['notificationProviders']; + settingsData: typeof defaultSettingsState['settingsData']; + userSettings: typeof defaultSettingsState['userSettings']; + + // Session slice + messages: typeof defaultSessionState['messages']; + turns: typeof defaultSessionState['turns']; + taskSessions: typeof defaultSessionState['taskSessions']; + taskSessionsByTask: typeof defaultSessionState['taskSessionsByTask']; + sessionAgentctl: typeof defaultSessionState['sessionAgentctl']; + worktrees: typeof defaultSessionState['worktrees']; + sessionWorktreesBySessionId: typeof defaultSessionState['sessionWorktreesBySessionId']; + pendingModel: typeof defaultSessionState['pendingModel']; + activeModel: typeof defaultSessionState['activeModel']; + + // Session Runtime slice + terminal: typeof defaultSessionRuntimeState['terminal']; + shell: typeof defaultSessionRuntimeState['shell']; + processes: typeof defaultSessionRuntimeState['processes']; + gitStatus: typeof defaultSessionRuntimeState['gitStatus']; + contextWindow: typeof defaultSessionRuntimeState['contextWindow']; + agents: typeof defaultSessionRuntimeState['agents']; + + // UI slice + previewPanel: typeof defaultUIState['previewPanel']; + rightPanel: typeof defaultUIState['rightPanel']; + diffs: typeof defaultUIState['diffs']; + connection: typeof defaultUIState['connection']; + + // Actions from all slices hydrate: (state: Partial) => void; setActiveWorkspace: (workspaceId: string | null) => void; setWorkspaces: (workspaces: WorkspaceState['items']) => void; @@ -443,760 +224,155 @@ export type AppState = { export type AppStore = ReturnType; -const defaultState: AppState = { - kanban: { boardId: null, columns: [], tasks: [] }, - workspaces: { items: [], activeId: null }, - boards: { items: [], activeId: null }, - executors: { items: [] }, - environments: { items: [] }, - settingsAgents: { items: [] }, - agentDiscovery: { items: [] }, - availableAgents: { items: [], loading: false, loaded: false }, - repositories: { itemsByWorkspaceId: {}, loadingByWorkspaceId: {}, loadedByWorkspaceId: {} }, - repositoryBranches: { itemsByRepositoryId: {}, loadingByRepositoryId: {}, loadedByRepositoryId: {} }, - settingsData: { executorsLoaded: false, environmentsLoaded: false, agentsLoaded: false }, - editors: { items: [], loaded: false, loading: false }, - prompts: { items: [], loaded: false, loading: false }, - notificationProviders: { - items: [], - events: [], - appriseAvailable: false, - loaded: false, - loading: false, - }, - tasks: { activeTaskId: null, activeSessionId: null }, - agents: { agents: [] }, - agentProfiles: { items: [], version: 0 }, - userSettings: { - workspaceId: null, - boardId: null, - repositoryIds: [], - preferredShell: null, - shellOptions: [], - defaultEditorId: null, - enablePreviewOnClick: false, - loaded: false, - }, - terminal: { terminals: [] }, - shell: { outputs: {}, statuses: {} }, - processes: { - outputsByProcessId: {}, - processesById: {}, - processIdsBySessionId: {}, - activeProcessBySessionId: {}, - devProcessBySessionId: {}, - }, - previewPanel: { - openBySessionId: {}, - viewBySessionId: {}, - deviceBySessionId: {}, - stageBySessionId: {}, - urlBySessionId: {}, - urlDraftBySessionId: {}, - }, - rightPanel: { activeTabBySessionId: {} }, - diffs: { files: [] }, - gitStatus: { bySessionId: {} }, - contextWindow: { bySessionId: {} }, - connection: { status: 'disconnected', error: null }, - messages: { bySession: {}, metaBySession: {} }, - turns: { - bySession: {}, - activeBySession: {}, - }, - taskSessions: { items: {} }, - taskSessionsByTask: { itemsByTaskId: {}, loadingByTaskId: {}, loadedByTaskId: {} }, - sessionAgentctl: { itemsBySessionId: {} }, - worktrees: { items: {} }, - sessionWorktreesBySessionId: { itemsBySessionId: {} }, - pendingModel: { bySessionId: {} }, - activeModel: { bySessionId: {} }, - hydrate: () => undefined, - setActiveWorkspace: () => undefined, - setWorkspaces: () => undefined, - setActiveBoard: () => undefined, - setBoards: () => undefined, - setExecutors: () => undefined, - setEnvironments: () => undefined, - setSettingsAgents: () => undefined, - setAgentDiscovery: () => undefined, - setAvailableAgents: () => undefined, - setAvailableAgentsLoading: () => undefined, - setAgentProfiles: () => undefined, - setRepositories: () => undefined, - setRepositoriesLoading: () => undefined, - setRepositoryBranches: () => undefined, - setRepositoryBranchesLoading: () => undefined, - setSettingsData: () => undefined, - setEditors: () => undefined, - setEditorsLoading: () => undefined, - setPrompts: () => undefined, - setPromptsLoading: () => undefined, - setNotificationProviders: () => undefined, - setNotificationProvidersLoading: () => undefined, - setUserSettings: () => undefined, - setTerminalOutput: () => undefined, - appendShellOutput: () => undefined, - setShellStatus: () => undefined, - clearShellOutput: () => undefined, - appendProcessOutput: () => undefined, - upsertProcessStatus: () => undefined, - clearProcessOutput: () => undefined, - setActiveProcess: () => undefined, - setPreviewOpen: () => undefined, - togglePreviewOpen: () => undefined, - setPreviewView: () => undefined, - setPreviewDevice: () => undefined, - setPreviewStage: () => undefined, - setPreviewUrl: () => undefined, - setPreviewUrlDraft: () => undefined, - setRightPanelActiveTab: () => undefined, - setConnectionStatus: () => undefined, - setMessages: () => undefined, - addMessage: () => undefined, - addTurn: () => undefined, - completeTurn: () => undefined, - setActiveTurn: () => undefined, - updateMessage: () => undefined, - prependMessages: () => undefined, - setMessagesMetadata: () => undefined, - setMessagesLoading: () => undefined, - setActiveSession: () => undefined, - setActiveTask: () => undefined, - clearActiveSession: () => undefined, - setTaskSession: () => undefined, - setTaskSessionsForTask: () => undefined, - setTaskSessionsLoading: () => undefined, - setSessionAgentctlStatus: () => undefined, - setWorktree: () => undefined, - setSessionWorktrees: () => undefined, - setGitStatus: () => undefined, - clearGitStatus: () => undefined, - setContextWindow: () => undefined, - bumpAgentProfilesVersion: () => undefined, - setPendingModel: () => undefined, - clearPendingModel: () => undefined, - setActiveModel: () => undefined, +const defaultState = { + kanban: defaultKanbanState.kanban, + boards: defaultKanbanState.boards, + tasks: defaultKanbanState.tasks, + workspaces: defaultWorkspaceState.workspaces, + repositories: defaultWorkspaceState.repositories, + repositoryBranches: defaultWorkspaceState.repositoryBranches, + executors: defaultSettingsState.executors, + environments: defaultSettingsState.environments, + settingsAgents: defaultSettingsState.settingsAgents, + agentDiscovery: defaultSettingsState.agentDiscovery, + availableAgents: defaultSettingsState.availableAgents, + agentProfiles: defaultSettingsState.agentProfiles, + editors: defaultSettingsState.editors, + prompts: defaultSettingsState.prompts, + notificationProviders: defaultSettingsState.notificationProviders, + settingsData: defaultSettingsState.settingsData, + userSettings: defaultSettingsState.userSettings, + messages: defaultSessionState.messages, + turns: defaultSessionState.turns, + taskSessions: defaultSessionState.taskSessions, + taskSessionsByTask: defaultSessionState.taskSessionsByTask, + sessionAgentctl: defaultSessionState.sessionAgentctl, + worktrees: defaultSessionState.worktrees, + sessionWorktreesBySessionId: defaultSessionState.sessionWorktreesBySessionId, + pendingModel: defaultSessionState.pendingModel, + activeModel: defaultSessionState.activeModel, + terminal: defaultSessionRuntimeState.terminal, + shell: defaultSessionRuntimeState.shell, + processes: defaultSessionRuntimeState.processes, + gitStatus: defaultSessionRuntimeState.gitStatus, + contextWindow: defaultSessionRuntimeState.contextWindow, + agents: defaultSessionRuntimeState.agents, + previewPanel: defaultUIState.previewPanel, + rightPanel: defaultUIState.rightPanel, + diffs: defaultUIState.diffs, + connection: defaultUIState.connection, }; -function mergeInitialState( - initialState?: Partial -): Omit< - AppState, - | 'hydrate' - | 'setActiveWorkspace' - | 'setWorkspaces' - | 'setActiveBoard' - | 'setBoards' - | 'setExecutors' - | 'setEnvironments' - | 'setSettingsAgents' - | 'setAgentDiscovery' - | 'setAvailableAgents' - | 'setAvailableAgentsLoading' - | 'setAgentProfiles' - | 'setRepositories' - | 'setRepositoriesLoading' - | 'setRepositoryBranches' - | 'setRepositoryBranchesLoading' - | 'setSettingsData' - | 'setEditors' - | 'setEditorsLoading' - | 'setPrompts' - | 'setPromptsLoading' - | 'setNotificationProviders' - | 'setNotificationProvidersLoading' - | 'setUserSettings' - | 'setTerminalOutput' - | 'appendShellOutput' - | 'setShellStatus' - | 'clearShellOutput' - | 'appendProcessOutput' - | 'upsertProcessStatus' - | 'clearProcessOutput' - | 'setActiveProcess' - | 'setPreviewOpen' - | 'togglePreviewOpen' - | 'setPreviewView' - | 'setPreviewDevice' - | 'setPreviewStage' - | 'setPreviewUrl' - | 'setPreviewUrlDraft' - | 'setRightPanelActiveTab' - | 'setConnectionStatus' - | 'setMessages' - | 'addMessage' - | 'addTurn' - | 'completeTurn' - | 'setActiveTurn' - | 'updateMessage' - | 'prependMessages' - | 'setMessagesMetadata' - | 'setMessagesLoading' - | 'setActiveSession' - | 'setActiveTask' - | 'clearActiveSession' - | 'setTaskSession' - | 'setTaskSessionsForTask' - | 'setTaskSessionsLoading' - | 'setSessionAgentctlStatus' - | 'setWorktree' - | 'setSessionWorktrees' - | 'setGitStatus' - | 'clearGitStatus' - | 'setContextWindow' - | 'bumpAgentProfilesVersion' - | 'setPendingModel' - | 'clearPendingModel' - | 'setActiveModel' -> { +function mergeInitialState(initialState?: Partial): typeof defaultState { if (!initialState) return defaultState; + return { - workspaces: { ...defaultState.workspaces, ...initialState.workspaces }, + ...defaultState, + ...initialState, + // Ensure nested objects are properly merged + kanban: { ...defaultState.kanban, ...initialState.kanban }, boards: { ...defaultState.boards, ...initialState.boards }, + tasks: { ...defaultState.tasks, ...initialState.tasks }, + workspaces: { ...defaultState.workspaces, ...initialState.workspaces }, + repositories: { ...defaultState.repositories, ...initialState.repositories }, + repositoryBranches: { ...defaultState.repositoryBranches, ...initialState.repositoryBranches }, executors: { ...defaultState.executors, ...initialState.executors }, environments: { ...defaultState.environments, ...initialState.environments }, settingsAgents: { ...defaultState.settingsAgents, ...initialState.settingsAgents }, agentDiscovery: { ...defaultState.agentDiscovery, ...initialState.agentDiscovery }, availableAgents: { ...defaultState.availableAgents, ...initialState.availableAgents }, - repositories: { ...defaultState.repositories, ...initialState.repositories }, - repositoryBranches: { ...defaultState.repositoryBranches, ...initialState.repositoryBranches }, - settingsData: { ...defaultState.settingsData, ...initialState.settingsData }, + agentProfiles: { ...defaultState.agentProfiles, ...initialState.agentProfiles }, editors: { ...defaultState.editors, ...initialState.editors }, prompts: { ...defaultState.prompts, ...initialState.prompts }, - notificationProviders: { - ...defaultState.notificationProviders, - ...initialState.notificationProviders, - }, - kanban: { ...defaultState.kanban, ...initialState.kanban }, - tasks: { ...defaultState.tasks, ...initialState.tasks }, - agents: { ...defaultState.agents, ...initialState.agents }, - agentProfiles: { ...defaultState.agentProfiles, ...initialState.agentProfiles }, + notificationProviders: { ...defaultState.notificationProviders, ...initialState.notificationProviders }, + settingsData: { ...defaultState.settingsData, ...initialState.settingsData }, userSettings: { ...defaultState.userSettings, ...initialState.userSettings }, - terminal: { ...defaultState.terminal, ...initialState.terminal }, - shell: { ...defaultState.shell, ...initialState.shell }, - processes: { ...defaultState.processes, ...initialState.processes }, - previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel }, - rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel }, - diffs: { ...defaultState.diffs, ...initialState.diffs }, - gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus }, - contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow }, - connection: { ...defaultState.connection, ...initialState.connection }, messages: { ...defaultState.messages, ...initialState.messages }, turns: { ...defaultState.turns, ...initialState.turns }, taskSessions: { ...defaultState.taskSessions, ...initialState.taskSessions }, taskSessionsByTask: { ...defaultState.taskSessionsByTask, ...initialState.taskSessionsByTask }, sessionAgentctl: { ...defaultState.sessionAgentctl, ...initialState.sessionAgentctl }, worktrees: { ...defaultState.worktrees, ...initialState.worktrees }, - sessionWorktreesBySessionId: { - ...defaultState.sessionWorktreesBySessionId, - ...initialState.sessionWorktreesBySessionId, - }, + sessionWorktreesBySessionId: { ...defaultState.sessionWorktreesBySessionId, ...initialState.sessionWorktreesBySessionId }, pendingModel: { ...defaultState.pendingModel, ...initialState.pendingModel }, activeModel: { ...defaultState.activeModel, ...initialState.activeModel }, + terminal: { ...defaultState.terminal, ...initialState.terminal }, + shell: { ...defaultState.shell, ...initialState.shell }, + processes: { ...defaultState.processes, ...initialState.processes }, + gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus }, + contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow }, + agents: { ...defaultState.agents, ...initialState.agents }, + previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel }, + rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel }, + diffs: { ...defaultState.diffs, ...initialState.diffs }, + connection: { ...defaultState.connection, ...initialState.connection }, }; } export function createAppStore(initialState?: Partial) { const merged = mergeInitialState(initialState); + return createStore()( - immer((set, get) => ({ + immer((set, get, api) => ({ ...merged, - hydrate: (state) => - set((draft) => { - // Deep merge to avoid overwriting nested objects with undefined - if (state.workspaces) Object.assign(draft.workspaces, state.workspaces); - if (state.boards) Object.assign(draft.boards, state.boards); - if (state.executors) Object.assign(draft.executors, state.executors); - if (state.environments) Object.assign(draft.environments, state.environments); - if (state.settingsAgents) Object.assign(draft.settingsAgents, state.settingsAgents); - if (state.agentDiscovery) Object.assign(draft.agentDiscovery, state.agentDiscovery); - if (state.availableAgents) Object.assign(draft.availableAgents, state.availableAgents); - if (state.repositories) Object.assign(draft.repositories, state.repositories); - if (state.repositoryBranches) Object.assign(draft.repositoryBranches, state.repositoryBranches); - if (state.settingsData) Object.assign(draft.settingsData, state.settingsData); - if (state.editors) Object.assign(draft.editors, state.editors); - if (state.prompts) Object.assign(draft.prompts, state.prompts); - if (state.notificationProviders) { - Object.assign(draft.notificationProviders, state.notificationProviders); - } - if (state.kanban) Object.assign(draft.kanban, state.kanban); - if (state.tasks) Object.assign(draft.tasks, state.tasks); - if (state.agents) Object.assign(draft.agents, state.agents); - if (state.agentProfiles) Object.assign(draft.agentProfiles, state.agentProfiles); - if (state.userSettings) Object.assign(draft.userSettings, state.userSettings); - if (state.terminal) Object.assign(draft.terminal, state.terminal); - if (state.shell) Object.assign(draft.shell, state.shell); - if (state.processes) Object.assign(draft.processes, state.processes); - if (state.previewPanel) Object.assign(draft.previewPanel, state.previewPanel); - if (state.rightPanel) Object.assign(draft.rightPanel, state.rightPanel); - if (state.diffs) Object.assign(draft.diffs, state.diffs); - if (state.gitStatus) Object.assign(draft.gitStatus, state.gitStatus); - if (state.connection) Object.assign(draft.connection, state.connection); - if (state.messages) Object.assign(draft.messages, state.messages); - if (state.turns) Object.assign(draft.turns, state.turns); - if (state.taskSessions) Object.assign(draft.taskSessions, state.taskSessions); - if (state.taskSessionsByTask) { - Object.assign(draft.taskSessionsByTask, state.taskSessionsByTask); - } - if (state.sessionAgentctl) { - Object.assign(draft.sessionAgentctl, state.sessionAgentctl); - } - if (state.worktrees) Object.assign(draft.worktrees, state.worktrees); - if (state.sessionWorktreesBySessionId) { - Object.assign(draft.sessionWorktreesBySessionId, state.sessionWorktreesBySessionId); - } - }), - setActiveWorkspace: (workspaceId) => { - if (get().workspaces.activeId === workspaceId) { - return; - } - set((draft) => { - draft.workspaces.activeId = workspaceId; - }); - }, - setWorkspaces: (workspaces) => - set((draft) => { - draft.workspaces.items = workspaces; - if (!draft.workspaces.activeId && workspaces.length) { - draft.workspaces.activeId = workspaces[0].id; - } - }), - setBoards: (boards) => - set((draft) => { - draft.boards.items = boards; - if (!draft.boards.activeId && boards.length) { - draft.boards.activeId = boards[0].id; - } - }), - setActiveBoard: (boardId) => { - if (get().boards.activeId === boardId) { - return; - } - set((draft) => { - draft.boards.activeId = boardId; - }); - }, - setExecutors: (executors) => - set((draft) => { - draft.executors.items = executors; - }), - setEnvironments: (environments) => - set((draft) => { - draft.environments.items = environments; - }), - setSettingsAgents: (agents) => - set((draft) => { - draft.settingsAgents.items = agents; - }), - setAgentDiscovery: (agents) => - set((draft) => { - draft.agentDiscovery.items = agents; - }), - setAvailableAgents: (agents) => - set((draft) => { - draft.availableAgents.items = agents; - draft.availableAgents.loading = false; - draft.availableAgents.loaded = true; - }), - setAvailableAgentsLoading: (loading) => - set((draft) => { - draft.availableAgents.loading = loading; - }), - setAgentProfiles: (profiles) => - set((draft) => { - draft.agentProfiles.items = profiles; - }), - setRepositories: (workspaceId, repositories) => - set((draft) => { - draft.repositories.itemsByWorkspaceId[workspaceId] = repositories; - draft.repositories.loadingByWorkspaceId[workspaceId] = false; - draft.repositories.loadedByWorkspaceId[workspaceId] = true; - }), - setRepositoriesLoading: (workspaceId, loading) => - set((draft) => { - draft.repositories.loadingByWorkspaceId[workspaceId] = loading; - }), - setRepositoryBranches: (repositoryId, branches) => - set((draft) => { - draft.repositoryBranches.itemsByRepositoryId[repositoryId] = branches; - draft.repositoryBranches.loadingByRepositoryId[repositoryId] = false; - draft.repositoryBranches.loadedByRepositoryId[repositoryId] = true; - }), - setRepositoryBranchesLoading: (repositoryId, loading) => - set((draft) => { - draft.repositoryBranches.loadingByRepositoryId[repositoryId] = loading; - }), - setSettingsData: (next) => - set((draft) => { - draft.settingsData = { ...draft.settingsData, ...next }; - }), - setEditors: (editors) => - set((draft) => { - draft.editors.items = editors; - draft.editors.loaded = true; - }), - setEditorsLoading: (loading) => - set((draft) => { - draft.editors.loading = loading; - }), - setPrompts: (prompts) => - set((draft) => { - draft.prompts.items = prompts; - draft.prompts.loaded = true; - }), - setPromptsLoading: (loading) => - set((draft) => { - draft.prompts.loading = loading; - }), - setNotificationProviders: (state) => - set((draft) => { - draft.notificationProviders.items = state.items; - draft.notificationProviders.events = state.events; - draft.notificationProviders.appriseAvailable = state.appriseAvailable; - draft.notificationProviders.loaded = state.loaded; - draft.notificationProviders.loading = state.loading; - }), - setNotificationProvidersLoading: (loading) => - set((draft) => { - draft.notificationProviders.loading = loading; - }), - setUserSettings: (settings) => - set((draft) => { - draft.userSettings = settings; - }), - setTerminalOutput: (terminalId, data) => - set((draft) => { - const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId); - if (existing) { - existing.output.push(data); - } else { - draft.terminal.terminals.push({ id: terminalId, output: [data] }); - } - }), - appendShellOutput: (sessionId, data) => - set((draft) => { - draft.shell.outputs[sessionId] = (draft.shell.outputs[sessionId] || '') + data; - }), - setShellStatus: (sessionId, status) => - set((draft) => { - draft.shell.statuses[sessionId] = status; - }), - clearShellOutput: (sessionId) => - set((draft) => { - draft.shell.outputs[sessionId] = ''; - }), - appendProcessOutput: (processId, data) => - set((draft) => { - const next = - (draft.processes.outputsByProcessId[processId] || '') + data; - draft.processes.outputsByProcessId[processId] = trimProcessOutput(next); - }), - upsertProcessStatus: (status) => - set((draft) => { - draft.processes.processesById[status.processId] = status; - const list = draft.processes.processIdsBySessionId[status.sessionId] || []; - if (!list.includes(status.processId)) { - list.push(status.processId); - draft.processes.processIdsBySessionId[status.sessionId] = list; - } - if (!draft.processes.activeProcessBySessionId[status.sessionId]) { - draft.processes.activeProcessBySessionId[status.sessionId] = status.processId; - } - if (status.kind === 'dev') { - draft.processes.devProcessBySessionId[status.sessionId] = status.processId; - } - }), - clearProcessOutput: (processId) => - set((draft) => { - draft.processes.outputsByProcessId[processId] = ''; - }), - setActiveProcess: (sessionId, processId) => - set((draft) => { - draft.processes.activeProcessBySessionId[sessionId] = processId; - }), - setPreviewOpen: (sessionId, open) => - set((draft) => { - draft.previewPanel.openBySessionId[sessionId] = open; - if (!draft.previewPanel.viewBySessionId[sessionId]) { - draft.previewPanel.viewBySessionId[sessionId] = 'preview'; - } - if (!draft.previewPanel.deviceBySessionId[sessionId]) { - draft.previewPanel.deviceBySessionId[sessionId] = 'desktop'; - } - }), - togglePreviewOpen: (sessionId) => - set((draft) => { - const current = draft.previewPanel.openBySessionId[sessionId] ?? false; - draft.previewPanel.openBySessionId[sessionId] = !current; - if (!draft.previewPanel.viewBySessionId[sessionId]) { - draft.previewPanel.viewBySessionId[sessionId] = 'preview'; - } - if (!draft.previewPanel.deviceBySessionId[sessionId]) { - draft.previewPanel.deviceBySessionId[sessionId] = 'desktop'; - } - }), - setPreviewView: (sessionId, view) => - set((draft) => { - draft.previewPanel.viewBySessionId[sessionId] = view; - // Persist to localStorage - const key = `preview-view:${sessionId}`; - setLocalStorage(key, view); - }), - setPreviewDevice: (sessionId, device) => - set((draft) => { - draft.previewPanel.deviceBySessionId[sessionId] = device; - }), - setPreviewStage: (sessionId, stage) => - set((draft) => { - draft.previewPanel.stageBySessionId[sessionId] = stage; - // layout changes handled by layout-store - }), - setPreviewUrl: (sessionId, url) => - set((draft) => { - draft.previewPanel.urlBySessionId[sessionId] = url; - }), - setPreviewUrlDraft: (sessionId, url) => - set((draft) => { - draft.previewPanel.urlDraftBySessionId[sessionId] = url; - }), - setRightPanelActiveTab: (sessionId, tab) => - set((draft) => { - draft.rightPanel.activeTabBySessionId[sessionId] = tab; - }), - setConnectionStatus: (status, error = null) => - set((draft) => { - draft.connection.status = status; - draft.connection.error = error; - }), - setMessages: (sessionId, messages, meta) => - set((draft) => { - draft.messages.bySession[sessionId] = messages; - const existingMeta = draft.messages.metaBySession[sessionId]; - draft.messages.metaBySession[sessionId] = { - isLoading: false, - hasMore: meta?.hasMore ?? existingMeta?.hasMore ?? false, - oldestCursor: - meta?.oldestCursor ?? (messages.length ? messages[0].id : existingMeta?.oldestCursor ?? null), - }; - }), - addMessage: (message) => - set((draft) => { - if (!message.session_id) { - return; - } - const sessionId = message.session_id; - const list = draft.messages.bySession[sessionId] ?? []; - const exists = list.some((item) => item.id === message.id); - if (exists) { - return; - } - draft.messages.bySession[sessionId] = [...list, message]; - const meta = draft.messages.metaBySession[sessionId] ?? { - isLoading: false, - hasMore: false, - oldestCursor: null, - }; - if (!meta.oldestCursor) { - meta.oldestCursor = message.id; - } - draft.messages.metaBySession[sessionId] = meta; - }), - addTurn: (turn) => - set((state) => { - const sessionId = turn.session_id; - if (!state.turns.bySession[sessionId]) { - state.turns.bySession[sessionId] = []; - } - // Add turn if not already present - const existing = state.turns.bySession[sessionId].find((t) => t.id === turn.id); - if (!existing) { - state.turns.bySession[sessionId].push(turn); - } - // Set as active turn if not completed - if (!turn.completed_at) { - state.turns.activeBySession[sessionId] = turn.id; - } - }), - completeTurn: (sessionId, turnId, completedAt) => - set((state) => { - const turns = state.turns.bySession[sessionId]; - if (turns) { - const turn = turns.find((t) => t.id === turnId); - if (turn) { - turn.completed_at = completedAt; - turn.updated_at = completedAt; - } - } - // Clear active turn if it matches - if (state.turns.activeBySession[sessionId] === turnId) { - state.turns.activeBySession[sessionId] = null; - } - }), - setActiveTurn: (sessionId, turnId) => - set((state) => { - state.turns.activeBySession[sessionId] = turnId; - }), - updateMessage: (message) => - set((draft) => { - if (!message.session_id) { - return; - } - const sessionId = message.session_id; - const list = draft.messages.bySession[sessionId]; - if (!list) return; - const index = list.findIndex((item) => item.id === message.id); - if (index !== -1) { - list[index] = message; - } - }), - prependMessages: (sessionId, messages, meta) => - set((draft) => { - const existing = draft.messages.bySession[sessionId] ?? []; - const existingIds = new Set(existing.map((item) => item.id)); - const incoming = messages.filter((item) => !existingIds.has(item.id)); - const next = incoming.length ? [...incoming, ...existing] : existing; - draft.messages.bySession[sessionId] = next; - const currentMeta = draft.messages.metaBySession[sessionId] ?? { - isLoading: false, - hasMore: false, - oldestCursor: null, - }; - draft.messages.metaBySession[sessionId] = { - isLoading: false, - hasMore: meta?.hasMore ?? currentMeta.hasMore, - oldestCursor: - meta?.oldestCursor ?? (next.length ? next[0].id : currentMeta.oldestCursor ?? null), - }; - }), - setMessagesMetadata: (sessionId, meta) => - set((draft) => { - const currentMeta = draft.messages.metaBySession[sessionId] ?? { - isLoading: false, - hasMore: false, - oldestCursor: null, - }; - draft.messages.metaBySession[sessionId] = { - isLoading: meta.isLoading ?? currentMeta.isLoading, - hasMore: meta.hasMore ?? currentMeta.hasMore, - oldestCursor: meta.oldestCursor ?? currentMeta.oldestCursor, - }; - }), - setMessagesLoading: (sessionId, loading) => - set((draft) => { - const currentMeta = draft.messages.metaBySession[sessionId] ?? { - isLoading: false, - hasMore: false, - oldestCursor: null, - }; - currentMeta.isLoading = loading; - draft.messages.metaBySession[sessionId] = currentMeta; - }), - setActiveSession: (taskId, sessionId) => - set((draft) => { - draft.tasks.activeTaskId = taskId; - draft.tasks.activeSessionId = sessionId; - }), - setActiveTask: (taskId) => - set((draft) => { - draft.tasks.activeTaskId = taskId; - draft.tasks.activeSessionId = null; - }), - clearActiveSession: () => - set((draft) => { - draft.tasks.activeTaskId = null; - draft.tasks.activeSessionId = null; - }), - setTaskSession: (session) => - set((draft) => { - // Merge with existing session data to preserve fields like agent_profile_id - const existingSession = draft.taskSessions.items[session.id]; - draft.taskSessions.items[session.id] = existingSession - ? { ...existingSession, ...session } - : session; - if (session.worktree_id) { - draft.worktrees.items[session.worktree_id] = { - id: session.worktree_id, - sessionId: session.id, - repositoryId: session.repository_id ?? undefined, - path: session.worktree_path ?? undefined, - branch: session.worktree_branch ?? undefined, - }; - draft.sessionWorktreesBySessionId.itemsBySessionId[session.id] = [session.worktree_id]; - } - }), - setTaskSessionsForTask: (taskId, sessions) => - set((draft) => { - sessions.forEach((session) => { - draft.taskSessions.items[session.id] = session; - if (session.worktree_id) { - draft.worktrees.items[session.worktree_id] = { - id: session.worktree_id, - sessionId: session.id, - repositoryId: session.repository_id ?? undefined, - path: session.worktree_path ?? undefined, - branch: session.worktree_branch ?? undefined, - }; - draft.sessionWorktreesBySessionId.itemsBySessionId[session.id] = [session.worktree_id]; - } - }); - draft.taskSessionsByTask.itemsByTaskId[taskId] = sessions; - draft.taskSessionsByTask.loadedByTaskId[taskId] = true; - draft.taskSessionsByTask.loadingByTaskId[taskId] = false; - }), - setTaskSessionsLoading: (taskId, loading) => - set((draft) => { - draft.taskSessionsByTask.loadingByTaskId[taskId] = loading; - }), - setSessionAgentctlStatus: (sessionId, status) => - set((draft) => { - draft.sessionAgentctl.itemsBySessionId[sessionId] = status; - }), - setWorktree: (worktree) => - set((draft) => { - draft.worktrees.items[worktree.id] = worktree; - const existing = draft.sessionWorktreesBySessionId.itemsBySessionId[worktree.sessionId] ?? []; - if (!existing.includes(worktree.id)) { - draft.sessionWorktreesBySessionId.itemsBySessionId[worktree.sessionId] = [...existing, worktree.id]; - } - }), - setSessionWorktrees: (sessionId, worktreeIds) => - set((draft) => { - draft.sessionWorktreesBySessionId.itemsBySessionId[sessionId] = worktreeIds; - }), - setGitStatus: (sessionId, gitStatus) => - set((draft) => { - draft.gitStatus.bySessionId[sessionId] = gitStatus; - }), - clearGitStatus: (sessionId) => - set((draft) => { - delete draft.gitStatus.bySessionId[sessionId]; - }), - setContextWindow: (sessionId, contextWindow) => - set((draft) => { - draft.contextWindow.bySessionId[sessionId] = contextWindow; - }), - bumpAgentProfilesVersion: () => - set((draft) => { - draft.agentProfiles.version += 1; - }), - setPendingModel: (sessionId, modelId) => - set((draft) => { - draft.pendingModel.bySessionId[sessionId] = modelId; - }), - clearPendingModel: (sessionId) => - set((draft) => { - delete draft.pendingModel.bySessionId[sessionId]; - }), - setActiveModel: (sessionId, modelId) => - set((draft) => { - draft.activeModel.bySessionId[sessionId] = modelId; - }), + // Compose all slices + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...createKanbanSlice(set as any, get as any, api as any), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...createWorkspaceSlice(set as any, get as any, api as any), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...createSettingsSlice(set as any, get as any, api as any), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...createSessionSlice(set as any, get as any, api as any), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...createSessionRuntimeSlice(set as any, get as any, api as any), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...createUISlice(set as any, get as any, api as any), + // Override state with merged initial state + kanban: merged.kanban, + boards: merged.boards, + tasks: merged.tasks, + workspaces: merged.workspaces, + repositories: merged.repositories, + repositoryBranches: merged.repositoryBranches, + executors: merged.executors, + environments: merged.environments, + settingsAgents: merged.settingsAgents, + agentDiscovery: merged.agentDiscovery, + availableAgents: merged.availableAgents, + agentProfiles: merged.agentProfiles, + editors: merged.editors, + prompts: merged.prompts, + notificationProviders: merged.notificationProviders, + settingsData: merged.settingsData, + userSettings: merged.userSettings, + messages: merged.messages, + turns: merged.turns, + taskSessions: merged.taskSessions, + taskSessionsByTask: merged.taskSessionsByTask, + sessionAgentctl: merged.sessionAgentctl, + worktrees: merged.worktrees, + sessionWorktreesBySessionId: merged.sessionWorktreesBySessionId, + pendingModel: merged.pendingModel, + activeModel: merged.activeModel, + terminal: merged.terminal, + shell: merged.shell, + processes: merged.processes, + gitStatus: merged.gitStatus, + contextWindow: merged.contextWindow, + agents: merged.agents, + previewPanel: merged.previewPanel, + rightPanel: merged.rightPanel, + diffs: merged.diffs, + connection: merged.connection, + // Add hydrate method + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hydrate: (state) => set((draft) => hydrateState(draft as any, state)), })) ); } export type StoreProviderProps = { - children: ReactNode; + children: React.ReactNode; initialState?: Partial; }; diff --git a/apps/web/lib/state/store.ts.backup b/apps/web/lib/state/store.ts.backup new file mode 100644 index 0000000000..cf13c1ec20 --- /dev/null +++ b/apps/web/lib/state/store.ts.backup @@ -0,0 +1,1202 @@ +import type { ReactNode } from 'react'; +import type { + Agent, + AvailableAgent, + AgentDiscovery, + Branch, + CustomPrompt, + Environment, + EditorOption, + Executor, + Message, + NotificationProvider, + Repository, + TaskState as TaskStatus, + TaskSession, + Turn, +} from '@/lib/types/http'; +import { createStore } from 'zustand/vanilla'; +import { immer } from 'zustand/middleware/immer'; +import { setLocalStorage } from '@/lib/local-storage'; + +const maxProcessOutputBytes = 2 * 1024 * 1024; + +function trimProcessOutput(value: string) { + if (value.length <= maxProcessOutputBytes) { + return value; + } + return value.slice(value.length - maxProcessOutputBytes); +} + +export type KanbanState = { + boardId: string | null; + columns: Array<{ id: string; title: string; color: string; position: number }>; + tasks: Array<{ + id: string; + columnId: string; + title: string; + description?: string; + position: number; + state?: TaskStatus; + repositoryId?: string; + }>; +}; + +export type WorkspaceState = { + items: Array<{ + id: string; + name: string; + description?: string | null; + owner_id: string; + default_executor_id?: string | null; + default_environment_id?: string | null; + default_agent_profile_id?: string | null; + created_at: string; + updated_at: string; + }>; + activeId: string | null; +}; + +export type AgentProfileOption = { + id: string; + label: string; + agent_id: string; +}; + +export type ExecutorsState = { + items: Executor[]; +}; + +export type EnvironmentsState = { + items: Environment[]; +}; + +export type SettingsAgentsState = { + items: Agent[]; +}; + +export type AgentDiscoveryState = { + items: AgentDiscovery[]; +}; + +export type EditorsState = { + items: EditorOption[]; + loaded: boolean; + loading: boolean; +}; + +export type PromptsState = { + items: CustomPrompt[]; + loaded: boolean; + loading: boolean; +}; + +export type NotificationProvidersState = { + items: NotificationProvider[]; + events: string[]; + appriseAvailable: boolean; + loaded: boolean; + loading: boolean; +}; + +export type AvailableAgentsState = { + items: AvailableAgent[]; + loading: boolean; + loaded: boolean; +}; + +export type BoardState = { + items: Array<{ id: string; workspaceId: string; name: string }>; + activeId: string | null; +}; + +export type TaskState = { + activeTaskId: string | null; + activeSessionId: string | null; +}; + +export type AgentState = { + agents: Array<{ id: string; status: 'idle' | 'running' | 'error' }>; +}; + +export type AgentProfilesState = { + items: AgentProfileOption[]; + version: number; +}; + +export type RepositoriesState = { + itemsByWorkspaceId: Record; + loadingByWorkspaceId: Record; + loadedByWorkspaceId: Record; +}; + +export type RepositoryBranchesState = { + itemsByRepositoryId: Record; + loadingByRepositoryId: Record; + loadedByRepositoryId: Record; +}; + +export type SettingsDataState = { + executorsLoaded: boolean; + environmentsLoaded: boolean; + agentsLoaded: boolean; +}; + +export type UserSettingsState = { + workspaceId: string | null; + boardId: string | null; + repositoryIds: string[]; + preferredShell: string | null; + shellOptions: Array<{ value: string; label: string }>; + defaultEditorId: string | null; + enablePreviewOnClick: boolean; + loaded: boolean; +}; + +export type TerminalState = { + terminals: Array<{ id: string; output: string[] }>; +}; + +export type ShellState = { + // Map of sessionId to shell output buffer (raw bytes as string) + outputs: Record; + // Map of sessionId to shell status + statuses: Record< + string, + { + available: boolean; + running?: boolean; + shell?: string; + cwd?: string; + } + >; +}; + +export type ProcessStatusEntry = { + processId: string; + sessionId: string; + kind: string; + scriptName?: string; + status: string; + command?: string; + workingDir?: string; + exitCode?: number | null; + startedAt?: string; + updatedAt?: string; +}; + +export type ProcessState = { + outputsByProcessId: Record; + processesById: Record; + processIdsBySessionId: Record; + activeProcessBySessionId: Record; + devProcessBySessionId: Record; +}; + +export type PreviewStage = 'closed' | 'logs' | 'preview'; +export type PreviewViewMode = 'preview' | 'output'; +export type PreviewDevicePreset = 'desktop' | 'tablet' | 'mobile'; + +export type PreviewPanelState = { + openBySessionId: Record; + viewBySessionId: Record; + deviceBySessionId: Record; + stageBySessionId: Record; + urlBySessionId: Record; + urlDraftBySessionId: Record; +}; + +export type RightPanelState = { + activeTabBySessionId: Record; +}; + + +export type FileInfo = { + path: string; + status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'; + staged: boolean; + additions?: number; + deletions?: number; + old_path?: string; + diff?: string; +}; + +export type GitStatusEntry = { + branch: string | null; + remote_branch: string | null; + modified: string[]; + added: string[]; + deleted: string[]; + untracked: string[]; + renamed: string[]; + ahead: number; + behind: number; + files: Record; + timestamp: string | null; +}; + +export type GitStatusState = { + bySessionId: Record; +}; + +export type ContextWindowEntry = { + size: number; + used: number; + remaining: number; + efficiency: number; + timestamp?: string; +}; + +export type ContextWindowState = { + bySessionId: Record; +}; + +export type Worktree = { + id: string; + sessionId: string; + repositoryId?: string; + path?: string; + branch?: string; +}; + +export type WorktreesState = { + items: Record; +}; + +export type SessionWorktreesState = { + itemsBySessionId: Record; +}; + +export type DiffState = { + files: Array<{ path: string; status: 'A' | 'M' | 'D'; plus: number; minus: number }>; +}; + +export type ConnectionState = { + status: 'disconnected' | 'connecting' | 'connected' | 'error' | 'reconnecting'; + error: string | null; +}; + +export type MessagesState = { + bySession: Record; + metaBySession: Record< + string, + { + isLoading: boolean; + hasMore: boolean; + oldestCursor: string | null; + } + >; +}; + +export type TurnsState = { + bySession: Record; + activeBySession: Record; // sessionId -> active turnId +}; + +export type TaskSessionsState = { + items: Record; +}; + +export type TaskSessionsByTaskState = { + itemsByTaskId: Record; + loadingByTaskId: Record; + loadedByTaskId: Record; +}; + +export type SessionAgentctlStatus = { + status: 'starting' | 'ready' | 'error'; + errorMessage?: string; + agentExecutionId?: string; + updatedAt?: string; +}; + +export type SessionAgentctlState = { + itemsBySessionId: Record; +}; + +export type PendingModelState = { + bySessionId: Record; +}; + +export type ActiveModelState = { + bySessionId: Record; +}; + +export type AppState = { + kanban: KanbanState; + workspaces: WorkspaceState; + boards: BoardState; + executors: ExecutorsState; + environments: EnvironmentsState; + settingsAgents: SettingsAgentsState; + agentDiscovery: AgentDiscoveryState; + availableAgents: AvailableAgentsState; + repositories: RepositoriesState; + repositoryBranches: RepositoryBranchesState; + settingsData: SettingsDataState; + editors: EditorsState; + prompts: PromptsState; + notificationProviders: NotificationProvidersState; + tasks: TaskState; + agents: AgentState; + agentProfiles: AgentProfilesState; + userSettings: UserSettingsState; + terminal: TerminalState; + shell: ShellState; + processes: ProcessState; + previewPanel: PreviewPanelState; + rightPanel: RightPanelState; + diffs: DiffState; + gitStatus: GitStatusState; + contextWindow: ContextWindowState; + connection: ConnectionState; + messages: MessagesState; + turns: TurnsState; + taskSessions: TaskSessionsState; + taskSessionsByTask: TaskSessionsByTaskState; + sessionAgentctl: SessionAgentctlState; + worktrees: WorktreesState; + sessionWorktreesBySessionId: SessionWorktreesState; + pendingModel: PendingModelState; + activeModel: ActiveModelState; + hydrate: (state: Partial) => void; + setActiveWorkspace: (workspaceId: string | null) => void; + setWorkspaces: (workspaces: WorkspaceState['items']) => void; + setActiveBoard: (boardId: string | null) => void; + setBoards: (boards: BoardState['items']) => void; + setExecutors: (executors: ExecutorsState['items']) => void; + setEnvironments: (environments: EnvironmentsState['items']) => void; + setSettingsAgents: (agents: SettingsAgentsState['items']) => void; + setAgentDiscovery: (agents: AgentDiscoveryState['items']) => void; + setAvailableAgents: (agents: AvailableAgentsState['items']) => void; + setAvailableAgentsLoading: (loading: boolean) => void; + setAgentProfiles: (profiles: AgentProfilesState['items']) => void; + setRepositories: (workspaceId: string, repositories: Repository[]) => void; + setRepositoriesLoading: (workspaceId: string, loading: boolean) => void; + setRepositoryBranches: (repositoryId: string, branches: Branch[]) => void; + setRepositoryBranchesLoading: (repositoryId: string, loading: boolean) => void; + setSettingsData: (next: Partial) => void; + setEditors: (editors: EditorsState['items']) => void; + setEditorsLoading: (loading: boolean) => void; + setPrompts: (prompts: PromptsState['items']) => void; + setPromptsLoading: (loading: boolean) => void; + setNotificationProviders: (state: NotificationProvidersState) => void; + setNotificationProvidersLoading: (loading: boolean) => void; + setUserSettings: (settings: UserSettingsState) => void; + setTerminalOutput: (terminalId: string, data: string) => void; + appendShellOutput: (sessionId: string, data: string) => void; + setShellStatus: ( + sessionId: string, + status: { available: boolean; running?: boolean; shell?: string; cwd?: string } + ) => void; + clearShellOutput: (sessionId: string) => void; + appendProcessOutput: (processId: string, data: string) => void; + upsertProcessStatus: (status: ProcessStatusEntry) => void; + clearProcessOutput: (processId: string) => void; + setActiveProcess: (sessionId: string, processId: string) => void; + setPreviewOpen: (sessionId: string, open: boolean) => void; + togglePreviewOpen: (sessionId: string) => void; + setPreviewView: (sessionId: string, view: PreviewViewMode) => void; + setPreviewDevice: (sessionId: string, device: PreviewDevicePreset) => void; + setPreviewStage: (sessionId: string, stage: PreviewStage) => void; + setPreviewUrl: (sessionId: string, url: string) => void; + setPreviewUrlDraft: (sessionId: string, url: string) => void; + setRightPanelActiveTab: (sessionId: string, tab: string) => void; + setConnectionStatus: (status: ConnectionState['status'], error?: string | null) => void; + setMessages: ( + sessionId: string, + messages: Message[], + meta?: { hasMore?: boolean; oldestCursor?: string | null } + ) => void; + addMessage: (message: Message) => void; + addTurn: (turn: Turn) => void; + completeTurn: (sessionId: string, turnId: string, completedAt: string) => void; + setActiveTurn: (sessionId: string, turnId: string | null) => void; + updateMessage: (message: Message) => void; + prependMessages: ( + sessionId: string, + messages: Message[], + meta?: { hasMore?: boolean; oldestCursor?: string | null } + ) => void; + setMessagesMetadata: ( + sessionId: string, + meta: { hasMore?: boolean; isLoading?: boolean; oldestCursor?: string | null } + ) => void; + setMessagesLoading: (sessionId: string, loading: boolean) => void; + setActiveSession: (taskId: string, sessionId: string) => void; + setActiveTask: (taskId: string) => void; + clearActiveSession: () => void; + setTaskSession: (session: TaskSession) => void; + setTaskSessionsForTask: (taskId: string, sessions: TaskSession[]) => void; + setTaskSessionsLoading: (taskId: string, loading: boolean) => void; + setSessionAgentctlStatus: (sessionId: string, status: SessionAgentctlStatus) => void; + setWorktree: (worktree: Worktree) => void; + setSessionWorktrees: (sessionId: string, worktreeIds: string[]) => void; + setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void; + clearGitStatus: (sessionId: string) => void; + setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void; + bumpAgentProfilesVersion: () => void; + setPendingModel: (sessionId: string, modelId: string) => void; + clearPendingModel: (sessionId: string) => void; + setActiveModel: (sessionId: string, modelId: string) => void; +}; + +export type AppStore = ReturnType; + +const defaultState: AppState = { + kanban: { boardId: null, columns: [], tasks: [] }, + workspaces: { items: [], activeId: null }, + boards: { items: [], activeId: null }, + executors: { items: [] }, + environments: { items: [] }, + settingsAgents: { items: [] }, + agentDiscovery: { items: [] }, + availableAgents: { items: [], loading: false, loaded: false }, + repositories: { itemsByWorkspaceId: {}, loadingByWorkspaceId: {}, loadedByWorkspaceId: {} }, + repositoryBranches: { itemsByRepositoryId: {}, loadingByRepositoryId: {}, loadedByRepositoryId: {} }, + settingsData: { executorsLoaded: false, environmentsLoaded: false, agentsLoaded: false }, + editors: { items: [], loaded: false, loading: false }, + prompts: { items: [], loaded: false, loading: false }, + notificationProviders: { + items: [], + events: [], + appriseAvailable: false, + loaded: false, + loading: false, + }, + tasks: { activeTaskId: null, activeSessionId: null }, + agents: { agents: [] }, + agentProfiles: { items: [], version: 0 }, + userSettings: { + workspaceId: null, + boardId: null, + repositoryIds: [], + preferredShell: null, + shellOptions: [], + defaultEditorId: null, + enablePreviewOnClick: false, + loaded: false, + }, + terminal: { terminals: [] }, + shell: { outputs: {}, statuses: {} }, + processes: { + outputsByProcessId: {}, + processesById: {}, + processIdsBySessionId: {}, + activeProcessBySessionId: {}, + devProcessBySessionId: {}, + }, + previewPanel: { + openBySessionId: {}, + viewBySessionId: {}, + deviceBySessionId: {}, + stageBySessionId: {}, + urlBySessionId: {}, + urlDraftBySessionId: {}, + }, + rightPanel: { activeTabBySessionId: {} }, + diffs: { files: [] }, + gitStatus: { bySessionId: {} }, + contextWindow: { bySessionId: {} }, + connection: { status: 'disconnected', error: null }, + messages: { bySession: {}, metaBySession: {} }, + turns: { + bySession: {}, + activeBySession: {}, + }, + taskSessions: { items: {} }, + taskSessionsByTask: { itemsByTaskId: {}, loadingByTaskId: {}, loadedByTaskId: {} }, + sessionAgentctl: { itemsBySessionId: {} }, + worktrees: { items: {} }, + sessionWorktreesBySessionId: { itemsBySessionId: {} }, + pendingModel: { bySessionId: {} }, + activeModel: { bySessionId: {} }, + hydrate: () => undefined, + setActiveWorkspace: () => undefined, + setWorkspaces: () => undefined, + setActiveBoard: () => undefined, + setBoards: () => undefined, + setExecutors: () => undefined, + setEnvironments: () => undefined, + setSettingsAgents: () => undefined, + setAgentDiscovery: () => undefined, + setAvailableAgents: () => undefined, + setAvailableAgentsLoading: () => undefined, + setAgentProfiles: () => undefined, + setRepositories: () => undefined, + setRepositoriesLoading: () => undefined, + setRepositoryBranches: () => undefined, + setRepositoryBranchesLoading: () => undefined, + setSettingsData: () => undefined, + setEditors: () => undefined, + setEditorsLoading: () => undefined, + setPrompts: () => undefined, + setPromptsLoading: () => undefined, + setNotificationProviders: () => undefined, + setNotificationProvidersLoading: () => undefined, + setUserSettings: () => undefined, + setTerminalOutput: () => undefined, + appendShellOutput: () => undefined, + setShellStatus: () => undefined, + clearShellOutput: () => undefined, + appendProcessOutput: () => undefined, + upsertProcessStatus: () => undefined, + clearProcessOutput: () => undefined, + setActiveProcess: () => undefined, + setPreviewOpen: () => undefined, + togglePreviewOpen: () => undefined, + setPreviewView: () => undefined, + setPreviewDevice: () => undefined, + setPreviewStage: () => undefined, + setPreviewUrl: () => undefined, + setPreviewUrlDraft: () => undefined, + setRightPanelActiveTab: () => undefined, + setConnectionStatus: () => undefined, + setMessages: () => undefined, + addMessage: () => undefined, + addTurn: () => undefined, + completeTurn: () => undefined, + setActiveTurn: () => undefined, + updateMessage: () => undefined, + prependMessages: () => undefined, + setMessagesMetadata: () => undefined, + setMessagesLoading: () => undefined, + setActiveSession: () => undefined, + setActiveTask: () => undefined, + clearActiveSession: () => undefined, + setTaskSession: () => undefined, + setTaskSessionsForTask: () => undefined, + setTaskSessionsLoading: () => undefined, + setSessionAgentctlStatus: () => undefined, + setWorktree: () => undefined, + setSessionWorktrees: () => undefined, + setGitStatus: () => undefined, + clearGitStatus: () => undefined, + setContextWindow: () => undefined, + bumpAgentProfilesVersion: () => undefined, + setPendingModel: () => undefined, + clearPendingModel: () => undefined, + setActiveModel: () => undefined, +}; + +function mergeInitialState( + initialState?: Partial +): Omit< + AppState, + | 'hydrate' + | 'setActiveWorkspace' + | 'setWorkspaces' + | 'setActiveBoard' + | 'setBoards' + | 'setExecutors' + | 'setEnvironments' + | 'setSettingsAgents' + | 'setAgentDiscovery' + | 'setAvailableAgents' + | 'setAvailableAgentsLoading' + | 'setAgentProfiles' + | 'setRepositories' + | 'setRepositoriesLoading' + | 'setRepositoryBranches' + | 'setRepositoryBranchesLoading' + | 'setSettingsData' + | 'setEditors' + | 'setEditorsLoading' + | 'setPrompts' + | 'setPromptsLoading' + | 'setNotificationProviders' + | 'setNotificationProvidersLoading' + | 'setUserSettings' + | 'setTerminalOutput' + | 'appendShellOutput' + | 'setShellStatus' + | 'clearShellOutput' + | 'appendProcessOutput' + | 'upsertProcessStatus' + | 'clearProcessOutput' + | 'setActiveProcess' + | 'setPreviewOpen' + | 'togglePreviewOpen' + | 'setPreviewView' + | 'setPreviewDevice' + | 'setPreviewStage' + | 'setPreviewUrl' + | 'setPreviewUrlDraft' + | 'setRightPanelActiveTab' + | 'setConnectionStatus' + | 'setMessages' + | 'addMessage' + | 'addTurn' + | 'completeTurn' + | 'setActiveTurn' + | 'updateMessage' + | 'prependMessages' + | 'setMessagesMetadata' + | 'setMessagesLoading' + | 'setActiveSession' + | 'setActiveTask' + | 'clearActiveSession' + | 'setTaskSession' + | 'setTaskSessionsForTask' + | 'setTaskSessionsLoading' + | 'setSessionAgentctlStatus' + | 'setWorktree' + | 'setSessionWorktrees' + | 'setGitStatus' + | 'clearGitStatus' + | 'setContextWindow' + | 'bumpAgentProfilesVersion' + | 'setPendingModel' + | 'clearPendingModel' + | 'setActiveModel' +> { + if (!initialState) return defaultState; + return { + workspaces: { ...defaultState.workspaces, ...initialState.workspaces }, + boards: { ...defaultState.boards, ...initialState.boards }, + executors: { ...defaultState.executors, ...initialState.executors }, + environments: { ...defaultState.environments, ...initialState.environments }, + settingsAgents: { ...defaultState.settingsAgents, ...initialState.settingsAgents }, + agentDiscovery: { ...defaultState.agentDiscovery, ...initialState.agentDiscovery }, + availableAgents: { ...defaultState.availableAgents, ...initialState.availableAgents }, + repositories: { ...defaultState.repositories, ...initialState.repositories }, + repositoryBranches: { ...defaultState.repositoryBranches, ...initialState.repositoryBranches }, + settingsData: { ...defaultState.settingsData, ...initialState.settingsData }, + editors: { ...defaultState.editors, ...initialState.editors }, + prompts: { ...defaultState.prompts, ...initialState.prompts }, + notificationProviders: { + ...defaultState.notificationProviders, + ...initialState.notificationProviders, + }, + kanban: { ...defaultState.kanban, ...initialState.kanban }, + tasks: { ...defaultState.tasks, ...initialState.tasks }, + agents: { ...defaultState.agents, ...initialState.agents }, + agentProfiles: { ...defaultState.agentProfiles, ...initialState.agentProfiles }, + userSettings: { ...defaultState.userSettings, ...initialState.userSettings }, + terminal: { ...defaultState.terminal, ...initialState.terminal }, + shell: { ...defaultState.shell, ...initialState.shell }, + processes: { ...defaultState.processes, ...initialState.processes }, + previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel }, + rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel }, + diffs: { ...defaultState.diffs, ...initialState.diffs }, + gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus }, + contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow }, + connection: { ...defaultState.connection, ...initialState.connection }, + messages: { ...defaultState.messages, ...initialState.messages }, + turns: { ...defaultState.turns, ...initialState.turns }, + taskSessions: { ...defaultState.taskSessions, ...initialState.taskSessions }, + taskSessionsByTask: { ...defaultState.taskSessionsByTask, ...initialState.taskSessionsByTask }, + sessionAgentctl: { ...defaultState.sessionAgentctl, ...initialState.sessionAgentctl }, + worktrees: { ...defaultState.worktrees, ...initialState.worktrees }, + sessionWorktreesBySessionId: { + ...defaultState.sessionWorktreesBySessionId, + ...initialState.sessionWorktreesBySessionId, + }, + pendingModel: { ...defaultState.pendingModel, ...initialState.pendingModel }, + activeModel: { ...defaultState.activeModel, ...initialState.activeModel }, + }; +} + +export function createAppStore(initialState?: Partial) { + const merged = mergeInitialState(initialState); + return createStore()( + immer((set, get) => ({ + ...merged, + hydrate: (state) => + set((draft) => { + // Deep merge to avoid overwriting nested objects with undefined + if (state.workspaces) Object.assign(draft.workspaces, state.workspaces); + if (state.boards) Object.assign(draft.boards, state.boards); + if (state.executors) Object.assign(draft.executors, state.executors); + if (state.environments) Object.assign(draft.environments, state.environments); + if (state.settingsAgents) Object.assign(draft.settingsAgents, state.settingsAgents); + if (state.agentDiscovery) Object.assign(draft.agentDiscovery, state.agentDiscovery); + if (state.availableAgents) Object.assign(draft.availableAgents, state.availableAgents); + if (state.repositories) Object.assign(draft.repositories, state.repositories); + if (state.repositoryBranches) Object.assign(draft.repositoryBranches, state.repositoryBranches); + if (state.settingsData) Object.assign(draft.settingsData, state.settingsData); + if (state.editors) Object.assign(draft.editors, state.editors); + if (state.prompts) Object.assign(draft.prompts, state.prompts); + if (state.notificationProviders) { + Object.assign(draft.notificationProviders, state.notificationProviders); + } + if (state.kanban) Object.assign(draft.kanban, state.kanban); + if (state.tasks) Object.assign(draft.tasks, state.tasks); + if (state.agents) Object.assign(draft.agents, state.agents); + if (state.agentProfiles) Object.assign(draft.agentProfiles, state.agentProfiles); + if (state.userSettings) Object.assign(draft.userSettings, state.userSettings); + if (state.terminal) Object.assign(draft.terminal, state.terminal); + if (state.shell) Object.assign(draft.shell, state.shell); + if (state.processes) Object.assign(draft.processes, state.processes); + if (state.previewPanel) Object.assign(draft.previewPanel, state.previewPanel); + if (state.rightPanel) Object.assign(draft.rightPanel, state.rightPanel); + if (state.diffs) Object.assign(draft.diffs, state.diffs); + if (state.gitStatus) Object.assign(draft.gitStatus, state.gitStatus); + if (state.connection) Object.assign(draft.connection, state.connection); + if (state.messages) Object.assign(draft.messages, state.messages); + if (state.turns) Object.assign(draft.turns, state.turns); + if (state.taskSessions) Object.assign(draft.taskSessions, state.taskSessions); + if (state.taskSessionsByTask) { + Object.assign(draft.taskSessionsByTask, state.taskSessionsByTask); + } + if (state.sessionAgentctl) { + Object.assign(draft.sessionAgentctl, state.sessionAgentctl); + } + if (state.worktrees) Object.assign(draft.worktrees, state.worktrees); + if (state.sessionWorktreesBySessionId) { + Object.assign(draft.sessionWorktreesBySessionId, state.sessionWorktreesBySessionId); + } + }), + setActiveWorkspace: (workspaceId) => { + if (get().workspaces.activeId === workspaceId) { + return; + } + set((draft) => { + draft.workspaces.activeId = workspaceId; + }); + }, + setWorkspaces: (workspaces) => + set((draft) => { + draft.workspaces.items = workspaces; + if (!draft.workspaces.activeId && workspaces.length) { + draft.workspaces.activeId = workspaces[0].id; + } + }), + setBoards: (boards) => + set((draft) => { + draft.boards.items = boards; + if (!draft.boards.activeId && boards.length) { + draft.boards.activeId = boards[0].id; + } + }), + setActiveBoard: (boardId) => { + if (get().boards.activeId === boardId) { + return; + } + set((draft) => { + draft.boards.activeId = boardId; + }); + }, + setExecutors: (executors) => + set((draft) => { + draft.executors.items = executors; + }), + setEnvironments: (environments) => + set((draft) => { + draft.environments.items = environments; + }), + setSettingsAgents: (agents) => + set((draft) => { + draft.settingsAgents.items = agents; + }), + setAgentDiscovery: (agents) => + set((draft) => { + draft.agentDiscovery.items = agents; + }), + setAvailableAgents: (agents) => + set((draft) => { + draft.availableAgents.items = agents; + draft.availableAgents.loading = false; + draft.availableAgents.loaded = true; + }), + setAvailableAgentsLoading: (loading) => + set((draft) => { + draft.availableAgents.loading = loading; + }), + setAgentProfiles: (profiles) => + set((draft) => { + draft.agentProfiles.items = profiles; + }), + setRepositories: (workspaceId, repositories) => + set((draft) => { + draft.repositories.itemsByWorkspaceId[workspaceId] = repositories; + draft.repositories.loadingByWorkspaceId[workspaceId] = false; + draft.repositories.loadedByWorkspaceId[workspaceId] = true; + }), + setRepositoriesLoading: (workspaceId, loading) => + set((draft) => { + draft.repositories.loadingByWorkspaceId[workspaceId] = loading; + }), + setRepositoryBranches: (repositoryId, branches) => + set((draft) => { + draft.repositoryBranches.itemsByRepositoryId[repositoryId] = branches; + draft.repositoryBranches.loadingByRepositoryId[repositoryId] = false; + draft.repositoryBranches.loadedByRepositoryId[repositoryId] = true; + }), + setRepositoryBranchesLoading: (repositoryId, loading) => + set((draft) => { + draft.repositoryBranches.loadingByRepositoryId[repositoryId] = loading; + }), + setSettingsData: (next) => + set((draft) => { + draft.settingsData = { ...draft.settingsData, ...next }; + }), + setEditors: (editors) => + set((draft) => { + draft.editors.items = editors; + draft.editors.loaded = true; + }), + setEditorsLoading: (loading) => + set((draft) => { + draft.editors.loading = loading; + }), + setPrompts: (prompts) => + set((draft) => { + draft.prompts.items = prompts; + draft.prompts.loaded = true; + }), + setPromptsLoading: (loading) => + set((draft) => { + draft.prompts.loading = loading; + }), + setNotificationProviders: (state) => + set((draft) => { + draft.notificationProviders.items = state.items; + draft.notificationProviders.events = state.events; + draft.notificationProviders.appriseAvailable = state.appriseAvailable; + draft.notificationProviders.loaded = state.loaded; + draft.notificationProviders.loading = state.loading; + }), + setNotificationProvidersLoading: (loading) => + set((draft) => { + draft.notificationProviders.loading = loading; + }), + setUserSettings: (settings) => + set((draft) => { + draft.userSettings = settings; + }), + setTerminalOutput: (terminalId, data) => + set((draft) => { + const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId); + if (existing) { + existing.output.push(data); + } else { + draft.terminal.terminals.push({ id: terminalId, output: [data] }); + } + }), + appendShellOutput: (sessionId, data) => + set((draft) => { + draft.shell.outputs[sessionId] = (draft.shell.outputs[sessionId] || '') + data; + }), + setShellStatus: (sessionId, status) => + set((draft) => { + draft.shell.statuses[sessionId] = status; + }), + clearShellOutput: (sessionId) => + set((draft) => { + draft.shell.outputs[sessionId] = ''; + }), + appendProcessOutput: (processId, data) => + set((draft) => { + const next = + (draft.processes.outputsByProcessId[processId] || '') + data; + draft.processes.outputsByProcessId[processId] = trimProcessOutput(next); + }), + upsertProcessStatus: (status) => + set((draft) => { + draft.processes.processesById[status.processId] = status; + const list = draft.processes.processIdsBySessionId[status.sessionId] || []; + if (!list.includes(status.processId)) { + list.push(status.processId); + draft.processes.processIdsBySessionId[status.sessionId] = list; + } + if (!draft.processes.activeProcessBySessionId[status.sessionId]) { + draft.processes.activeProcessBySessionId[status.sessionId] = status.processId; + } + if (status.kind === 'dev') { + draft.processes.devProcessBySessionId[status.sessionId] = status.processId; + } + }), + clearProcessOutput: (processId) => + set((draft) => { + draft.processes.outputsByProcessId[processId] = ''; + }), + setActiveProcess: (sessionId, processId) => + set((draft) => { + draft.processes.activeProcessBySessionId[sessionId] = processId; + }), + setPreviewOpen: (sessionId, open) => + set((draft) => { + draft.previewPanel.openBySessionId[sessionId] = open; + if (!draft.previewPanel.viewBySessionId[sessionId]) { + draft.previewPanel.viewBySessionId[sessionId] = 'preview'; + } + if (!draft.previewPanel.deviceBySessionId[sessionId]) { + draft.previewPanel.deviceBySessionId[sessionId] = 'desktop'; + } + }), + togglePreviewOpen: (sessionId) => + set((draft) => { + const current = draft.previewPanel.openBySessionId[sessionId] ?? false; + draft.previewPanel.openBySessionId[sessionId] = !current; + if (!draft.previewPanel.viewBySessionId[sessionId]) { + draft.previewPanel.viewBySessionId[sessionId] = 'preview'; + } + if (!draft.previewPanel.deviceBySessionId[sessionId]) { + draft.previewPanel.deviceBySessionId[sessionId] = 'desktop'; + } + }), + setPreviewView: (sessionId, view) => + set((draft) => { + draft.previewPanel.viewBySessionId[sessionId] = view; + // Persist to localStorage + const key = `preview-view:${sessionId}`; + setLocalStorage(key, view); + }), + setPreviewDevice: (sessionId, device) => + set((draft) => { + draft.previewPanel.deviceBySessionId[sessionId] = device; + }), + setPreviewStage: (sessionId, stage) => + set((draft) => { + draft.previewPanel.stageBySessionId[sessionId] = stage; + // layout changes handled by layout-store + }), + setPreviewUrl: (sessionId, url) => + set((draft) => { + draft.previewPanel.urlBySessionId[sessionId] = url; + }), + setPreviewUrlDraft: (sessionId, url) => + set((draft) => { + draft.previewPanel.urlDraftBySessionId[sessionId] = url; + }), + setRightPanelActiveTab: (sessionId, tab) => + set((draft) => { + draft.rightPanel.activeTabBySessionId[sessionId] = tab; + }), + setConnectionStatus: (status, error = null) => + set((draft) => { + draft.connection.status = status; + draft.connection.error = error; + }), + setMessages: (sessionId, messages, meta) => + set((draft) => { + draft.messages.bySession[sessionId] = messages; + const existingMeta = draft.messages.metaBySession[sessionId]; + draft.messages.metaBySession[sessionId] = { + isLoading: false, + hasMore: meta?.hasMore ?? existingMeta?.hasMore ?? false, + oldestCursor: + meta?.oldestCursor ?? (messages.length ? messages[0].id : existingMeta?.oldestCursor ?? null), + }; + }), + addMessage: (message) => + set((draft) => { + if (!message.session_id) { + return; + } + const sessionId = message.session_id; + const list = draft.messages.bySession[sessionId] ?? []; + const exists = list.some((item) => item.id === message.id); + if (exists) { + return; + } + draft.messages.bySession[sessionId] = [...list, message]; + const meta = draft.messages.metaBySession[sessionId] ?? { + isLoading: false, + hasMore: false, + oldestCursor: null, + }; + if (!meta.oldestCursor) { + meta.oldestCursor = message.id; + } + draft.messages.metaBySession[sessionId] = meta; + }), + addTurn: (turn) => + set((state) => { + const sessionId = turn.session_id; + if (!state.turns.bySession[sessionId]) { + state.turns.bySession[sessionId] = []; + } + // Add turn if not already present + const existing = state.turns.bySession[sessionId].find((t) => t.id === turn.id); + if (!existing) { + state.turns.bySession[sessionId].push(turn); + } + // Set as active turn if not completed + if (!turn.completed_at) { + state.turns.activeBySession[sessionId] = turn.id; + } + }), + completeTurn: (sessionId, turnId, completedAt) => + set((state) => { + const turns = state.turns.bySession[sessionId]; + if (turns) { + const turn = turns.find((t) => t.id === turnId); + if (turn) { + turn.completed_at = completedAt; + turn.updated_at = completedAt; + } + } + // Clear active turn if it matches + if (state.turns.activeBySession[sessionId] === turnId) { + state.turns.activeBySession[sessionId] = null; + } + }), + setActiveTurn: (sessionId, turnId) => + set((state) => { + state.turns.activeBySession[sessionId] = turnId; + }), + updateMessage: (message) => + set((draft) => { + if (!message.session_id) { + return; + } + const sessionId = message.session_id; + const list = draft.messages.bySession[sessionId]; + if (!list) return; + const index = list.findIndex((item) => item.id === message.id); + if (index !== -1) { + list[index] = message; + } + }), + prependMessages: (sessionId, messages, meta) => + set((draft) => { + const existing = draft.messages.bySession[sessionId] ?? []; + const existingIds = new Set(existing.map((item) => item.id)); + const incoming = messages.filter((item) => !existingIds.has(item.id)); + const next = incoming.length ? [...incoming, ...existing] : existing; + draft.messages.bySession[sessionId] = next; + const currentMeta = draft.messages.metaBySession[sessionId] ?? { + isLoading: false, + hasMore: false, + oldestCursor: null, + }; + draft.messages.metaBySession[sessionId] = { + isLoading: false, + hasMore: meta?.hasMore ?? currentMeta.hasMore, + oldestCursor: + meta?.oldestCursor ?? (next.length ? next[0].id : currentMeta.oldestCursor ?? null), + }; + }), + setMessagesMetadata: (sessionId, meta) => + set((draft) => { + const currentMeta = draft.messages.metaBySession[sessionId] ?? { + isLoading: false, + hasMore: false, + oldestCursor: null, + }; + draft.messages.metaBySession[sessionId] = { + isLoading: meta.isLoading ?? currentMeta.isLoading, + hasMore: meta.hasMore ?? currentMeta.hasMore, + oldestCursor: meta.oldestCursor ?? currentMeta.oldestCursor, + }; + }), + setMessagesLoading: (sessionId, loading) => + set((draft) => { + const currentMeta = draft.messages.metaBySession[sessionId] ?? { + isLoading: false, + hasMore: false, + oldestCursor: null, + }; + currentMeta.isLoading = loading; + draft.messages.metaBySession[sessionId] = currentMeta; + }), + setActiveSession: (taskId, sessionId) => + set((draft) => { + draft.tasks.activeTaskId = taskId; + draft.tasks.activeSessionId = sessionId; + }), + setActiveTask: (taskId) => + set((draft) => { + draft.tasks.activeTaskId = taskId; + draft.tasks.activeSessionId = null; + }), + clearActiveSession: () => + set((draft) => { + draft.tasks.activeTaskId = null; + draft.tasks.activeSessionId = null; + }), + setTaskSession: (session) => + set((draft) => { + // Merge with existing session data to preserve fields like agent_profile_id + const existingSession = draft.taskSessions.items[session.id]; + draft.taskSessions.items[session.id] = existingSession + ? { ...existingSession, ...session } + : session; + if (session.worktree_id) { + draft.worktrees.items[session.worktree_id] = { + id: session.worktree_id, + sessionId: session.id, + repositoryId: session.repository_id ?? undefined, + path: session.worktree_path ?? undefined, + branch: session.worktree_branch ?? undefined, + }; + draft.sessionWorktreesBySessionId.itemsBySessionId[session.id] = [session.worktree_id]; + } + }), + setTaskSessionsForTask: (taskId, sessions) => + set((draft) => { + sessions.forEach((session) => { + draft.taskSessions.items[session.id] = session; + if (session.worktree_id) { + draft.worktrees.items[session.worktree_id] = { + id: session.worktree_id, + sessionId: session.id, + repositoryId: session.repository_id ?? undefined, + path: session.worktree_path ?? undefined, + branch: session.worktree_branch ?? undefined, + }; + draft.sessionWorktreesBySessionId.itemsBySessionId[session.id] = [session.worktree_id]; + } + }); + draft.taskSessionsByTask.itemsByTaskId[taskId] = sessions; + draft.taskSessionsByTask.loadedByTaskId[taskId] = true; + draft.taskSessionsByTask.loadingByTaskId[taskId] = false; + }), + setTaskSessionsLoading: (taskId, loading) => + set((draft) => { + draft.taskSessionsByTask.loadingByTaskId[taskId] = loading; + }), + setSessionAgentctlStatus: (sessionId, status) => + set((draft) => { + draft.sessionAgentctl.itemsBySessionId[sessionId] = status; + }), + setWorktree: (worktree) => + set((draft) => { + draft.worktrees.items[worktree.id] = worktree; + const existing = draft.sessionWorktreesBySessionId.itemsBySessionId[worktree.sessionId] ?? []; + if (!existing.includes(worktree.id)) { + draft.sessionWorktreesBySessionId.itemsBySessionId[worktree.sessionId] = [...existing, worktree.id]; + } + }), + setSessionWorktrees: (sessionId, worktreeIds) => + set((draft) => { + draft.sessionWorktreesBySessionId.itemsBySessionId[sessionId] = worktreeIds; + }), + setGitStatus: (sessionId, gitStatus) => + set((draft) => { + draft.gitStatus.bySessionId[sessionId] = gitStatus; + }), + clearGitStatus: (sessionId) => + set((draft) => { + delete draft.gitStatus.bySessionId[sessionId]; + }), + setContextWindow: (sessionId, contextWindow) => + set((draft) => { + draft.contextWindow.bySessionId[sessionId] = contextWindow; + }), + bumpAgentProfilesVersion: () => + set((draft) => { + draft.agentProfiles.version += 1; + }), + setPendingModel: (sessionId, modelId) => + set((draft) => { + draft.pendingModel.bySessionId[sessionId] = modelId; + }), + clearPendingModel: (sessionId) => + set((draft) => { + delete draft.pendingModel.bySessionId[sessionId]; + }), + setActiveModel: (sessionId, modelId) => + set((draft) => { + draft.activeModel.bySessionId[sessionId] = modelId; + }), + })) + ); +} + +export type StoreProviderProps = { + children: ReactNode; + initialState?: Partial; +};