diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 842f8484eb..510b2c73be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,3 +24,15 @@ repos: entry: make lint-web language: system pass_filenames: false + + - id: web-typecheck + name: Web typecheck + entry: make typecheck-web + language: system + pass_filenames: false + + - id: typecheck + name: Typecheck all apps + entry: make typecheck + language: system + pass_filenames: false diff --git a/Makefile b/Makefile index fd0b752dc2..b97bdebc53 100644 --- a/Makefile +++ b/Makefile @@ -198,6 +198,16 @@ fmt-web: @echo "Formatting web code..." @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web lint -- --fix || true +.PHONY: typecheck-web +typecheck-web: + @echo "Type-checking web app..." + @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web exec tsc -p tsconfig.json --noEmit + +.PHONY: typecheck +typecheck: + @echo "Type-checking all apps..." + @cd $(APPS_DIR) && $(PNPM) -r exec tsc -p tsconfig.json --noEmit + # # Cleanup # diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index b5dcbd2c53..653bb68b80 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -159,13 +159,14 @@ func main() { log.Warn("Failed to initialize Docker client - agent features will be disabled", zap.Error(err)) dockerClient = nil } else { - defer func() { - _ = dockerClient.Close() - }() if err := dockerClient.Ping(ctx); err != nil { log.Warn("Docker daemon not available - agent features will be disabled", zap.Error(err)) + _ = dockerClient.Close() dockerClient = nil } else { + defer func() { + _ = dockerClient.Close() + }() log.Info("Connected to Docker daemon") } } diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index 542dba75d9..bebbad2e45 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -45,7 +45,7 @@ export function KanbanWithPreview() { state: task.state, description: task.description, position: task.position, - repositoryUrl: task.repositoryUrl, + repositoryId: task.repositoryId, }; }, [selectedTaskId, kanbanTasks]); diff --git a/apps/web/components/task-preview-panel.tsx b/apps/web/components/task-preview-panel.tsx index 8c90383c04..7622940f92 100644 --- a/apps/web/components/task-preview-panel.tsx +++ b/apps/web/components/task-preview-panel.tsx @@ -4,6 +4,8 @@ import { IconX, IconCircleCheck, IconCircleX, IconLoader2, IconAlertTriangle, Ic import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; import { getRepositoryDisplayName } from "@/lib/utils"; +import { useAppStore } from "@/components/state-provider"; +import { useMemo } from "react"; import type { Task } from "./kanban-card"; interface TaskPreviewPanelProps { @@ -66,6 +68,15 @@ function getStateDisplay(state?: string) { } export function TaskPreviewPanel({ task, onClose, onMaximize }: TaskPreviewPanelProps) { + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const repository = useMemo(() => { + if (!task?.repositoryId) return null; + return Object.values(repositoriesByWorkspace) + .flat() + .find((repo) => repo.id === task.repositoryId) ?? null; + }, [repositoriesByWorkspace, task]); + const repoName = getRepositoryDisplayName(repository?.local_path); + if (!task) { return (
@@ -83,7 +94,6 @@ export function TaskPreviewPanel({ task, onClose, onMaximize }: TaskPreviewPanel ); } - const repoName = getRepositoryDisplayName(task.repositoryUrl); const stateDisplay = getStateDisplay(task.state); return ( diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 12f2d3152e..fa38503636 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { ReactElement } from 'react'; import type { Message } from '@/lib/types/http'; import { ChatMessage } from '@/components/task/chat/messages/chat-message'; import { StatusMessage } from '@/components/task/chat/messages/status-message'; @@ -13,7 +14,7 @@ type AdapterContext = { type MessageAdapter = { matches: (comment: Message, ctx: AdapterContext) => boolean; - render: (comment: Message, ctx: AdapterContext) => JSX.Element; + render: (comment: Message, ctx: AdapterContext) => ReactElement; }; const adapters: MessageAdapter[] = [ diff --git a/apps/web/components/task/chat/messages/rich-blocks.tsx b/apps/web/components/task/chat/messages/rich-blocks.tsx index a2fd32a826..d9c4380c25 100644 --- a/apps/web/components/task/chat/messages/rich-blocks.tsx +++ b/apps/web/components/task/chat/messages/rich-blocks.tsx @@ -13,13 +13,13 @@ function resolveDiffPayload(diff: unknown): DiffPayload | null { return null; } if (Array.isArray(diff)) { - return { hunks: diff }; + return { hunks: diff.map((hunk) => String(hunk)) }; } if (typeof diff === 'object' && diff !== null) { const candidate = diff as Partial; if (Array.isArray(candidate.hunks)) { return { - hunks: candidate.hunks, + hunks: candidate.hunks.map((hunk) => String(hunk)), oldFile: candidate.oldFile, newFile: candidate.newFile, }; diff --git a/apps/web/components/task/chat/types.ts b/apps/web/components/task/chat/types.ts index 1c7a0ba7b5..74b6c447ab 100644 --- a/apps/web/components/task/chat/types.ts +++ b/apps/web/components/task/chat/types.ts @@ -27,7 +27,7 @@ export type RichMetadata = { }; export type DiffPayload = { - hunks: unknown[]; + hunks: string[]; oldFile?: { fileName?: string; fileLang?: string }; newFile?: { fileName?: string; fileLang?: string }; }; diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 22c03ef435..50bb71b375 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -177,7 +177,6 @@ export function TaskChatPanel({ const trimmed = messageInput.trim(); if (!trimmed || isSending) return; setIsSending(true); - setIsAwaitingResponse(true); flushSync(() => { setMessageInput(''); }); diff --git a/apps/web/hooks/use-kanban-preview.ts b/apps/web/hooks/use-kanban-preview.ts index 94a8d6d533..9eec2eaf00 100644 --- a/apps/web/hooks/use-kanban-preview.ts +++ b/apps/web/hooks/use-kanban-preview.ts @@ -12,7 +12,7 @@ export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) { // Always start with default values to avoid hydration mismatch const [selectedTaskId, setSelectedTaskId] = useState(null); const [isOpen, setIsOpen] = useState(false); - const [previewWidthPx, setPreviewWidthPx] = useState(PREVIEW_PANEL.DEFAULT_WIDTH_PX); + const [previewWidthPx, setPreviewWidthPx] = useState(PREVIEW_PANEL.DEFAULT_WIDTH_PX); const containerRef = useRef(null); // Load persisted state from localStorage AFTER hydration diff --git a/apps/web/hooks/use-task-messages.ts b/apps/web/hooks/use-task-messages.ts index b14c446e68..9003d1e6d5 100644 --- a/apps/web/hooks/use-task-messages.ts +++ b/apps/web/hooks/use-task-messages.ts @@ -1,8 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { getWebSocketClient } from '@/lib/ws/connection'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; -import type { Message } from '@/lib/types/http'; -import type { AgentSessionState } from '@/lib/types/http'; +import type { AgentSessionState, Message } from '@/lib/types/http'; interface UseTaskMessagesReturn { isLoading: boolean; @@ -130,8 +129,8 @@ export function useTaskMessages( if (!agentSessionId || !agentSessionState || !taskId) return; if (hasAgentMessage) return; - const terminalStates: AgentSessionState[] = ['WAITING_FOR_INPUT', 'COMPLETED', 'FAILED']; - if (!terminalStates.includes(agentSessionState)) { + const terminalStates = new Set(['WAITING_FOR_INPUT', 'COMPLETED', 'FAILED']); + if (!terminalStates.has(agentSessionState)) { return; } diff --git a/apps/web/lib/keyboard/constants.test.ts b/apps/web/lib/keyboard/constants.test.ts index 9b7cf83c9a..e9d76631d4 100644 --- a/apps/web/lib/keyboard/constants.test.ts +++ b/apps/web/lib/keyboard/constants.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { MODIFIER_KEYS, KEYS, SHORTCUTS } from './constants'; +import { MODIFIER_KEYS, KEYS, SHORTCUTS, KeyboardShortcut } from './constants'; describe('MODIFIER_KEYS', () => { it('defines all modifier keys', () => { @@ -69,8 +69,9 @@ describe('SHORTCUTS', () => { describe('CANCEL', () => { it('uses Escape key without modifiers', () => { - expect(SHORTCUTS.CANCEL.key).toBe('Escape'); - expect(SHORTCUTS.CANCEL.modifiers).toBeUndefined(); + const cancel = SHORTCUTS.CANCEL as KeyboardShortcut; + expect(cancel.key).toBe('Escape'); + expect(cancel.modifiers).toBeUndefined(); }); }); @@ -110,9 +111,10 @@ describe('SHORTCUTS', () => { it('all shortcuts with modifiers have valid modifier properties', () => { Object.values(SHORTCUTS).forEach((shortcut) => { - if (shortcut.modifiers) { + const typedShortcut = shortcut as KeyboardShortcut; + if (typedShortcut.modifiers) { const validModifiers = ['ctrl', 'cmd', 'alt', 'shift', 'ctrlOrCmd']; - Object.keys(shortcut.modifiers).forEach((modifier) => { + Object.keys(typedShortcut.modifiers).forEach((modifier) => { expect(validModifiers).toContain(modifier); }); } @@ -141,4 +143,3 @@ describe('Type definitions', () => { expect(shortcut3.modifiers?.ctrl).toBe(true); }); }); - diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 047ebadf6e..83140d103a 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import type { Agent, AgentDiscovery, + AgentSessionState, Branch, Environment, Executor, @@ -171,7 +172,7 @@ export type AppState = { gitStatus: GitStatusState; connection: ConnectionState; messages: MessagesState; - agentSessionStatesByTaskId: Record; + agentSessionStatesByTaskId: Record; hydrate: (state: Partial) => void; setActiveWorkspace: (workspaceId: string | null) => void; setWorkspaces: (workspaces: WorkspaceState['items']) => void; @@ -201,7 +202,7 @@ export type AppState = { prependMessages: (messages: Message[], meta?: { hasMore?: boolean; oldestCursor?: string | null }) => void; setMessagesMetadata: (meta: { hasMore?: boolean; isLoading?: boolean; oldestCursor?: string | null }) => void; setMessagesLoading: (loading: boolean) => void; - setAgentSessionState: (taskId: string, state: string) => void; + setAgentSessionState: (taskId: string, state: AgentSessionState) => void; setGitStatus: (taskId: string, gitStatus: Omit) => void; clearGitStatus: () => void; bumpAgentProfilesVersion: () => void; diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index 224c88820a..e0f1e635e1 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -1,6 +1,7 @@ import type { StoreApi } from 'zustand'; import type { AppState } from '@/lib/state/store'; import type { WsHandlers } from '@/lib/ws/handlers/types'; +import type { AgentSessionState } from '@/lib/types/http'; export function registerAgentSessionHandlers(store: StoreApi): WsHandlers { return { @@ -9,7 +10,7 @@ export function registerAgentSessionHandlers(store: StoreApi): WsHandl if (!payload?.task_id || !payload?.new_state) { return; } - store.getState().setAgentSessionState(payload.task_id, payload.new_state); + store.getState().setAgentSessionState(payload.task_id, payload.new_state as AgentSessionState); }, }; }