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);
},
};
}