feat(KB-048): add collapsible list sections to dashboard
- Add section expansion state management with localStorage persistence - Update section headers with chevron toggle controls - Implement conditional task row rendering based on section state - Add Expand All / Collapse All toolbar controls - Add CSS styles for chevron rotation animation and section headers - Add comprehensive tests for collapsible section behavior
This commit is contained in:
116
packages/dashboard/README.md
Normal file
116
packages/dashboard/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# @kb/dashboard
|
||||
|
||||
Web-based dashboard for managing kb tasks. Provides a visual kanban board, list view, and git repository management tools.
|
||||
|
||||
## Features
|
||||
|
||||
### Task Management
|
||||
- **Kanban Board**: Drag-and-drop task management across columns (Triage, Todo, In Progress, In Review, Done)
|
||||
- **Inline Editing**: Quick-edit task title and description directly on the board for Triage and Todo columns. Double-click a card or use the pencil icon that appears on hover.
|
||||
- **List View**: Alternative tabular view for tasks with sorting, filtering, and collapsible column sections. Click section headers to expand/collapse each column group. Section expansion state is persisted to localStorage.
|
||||
- **Task Details**: View full task specifications, agent logs, and attachments
|
||||
- **GitHub Import**: Import issues directly from GitHub repositories
|
||||
- **PR Management**: Create and track pull requests for in-review tasks
|
||||
|
||||
### Git Manager
|
||||
The Git Manager provides comprehensive repository visualization and management directly from the web UI. Access it via the Git Branch icon in the header.
|
||||
|
||||
**Status Tab**: View current repository state including:
|
||||
- Current branch name and commit hash
|
||||
- Working directory status (clean/dirty)
|
||||
- Ahead/behind counts relative to remote
|
||||
|
||||
**Commits Tab**: Browse recent commits with:
|
||||
- Commit list with message, author, and date
|
||||
- Expandable diff view for each commit
|
||||
- Pagination support (load more commits)
|
||||
|
||||
**Branches Tab**: Manage local branches:
|
||||
- List all branches with current indicator
|
||||
- Create new branches with optional base
|
||||
- Checkout existing branches
|
||||
- Delete branches (with confirmation)
|
||||
|
||||
**Worktrees Tab**: Visualize worktree layout:
|
||||
- List all worktrees with paths
|
||||
- See which tasks own which worktrees
|
||||
- Identify main vs linked worktrees
|
||||
- Track free/used worktree count
|
||||
|
||||
**Remotes Tab**: Perform remote operations:
|
||||
- Fetch from origin
|
||||
- Pull latest changes
|
||||
- Push current branch
|
||||
- View operation results and error states
|
||||
|
||||
### Configuration
|
||||
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences
|
||||
- **Authentication**: OAuth provider management for AI model access
|
||||
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
|
||||
# Start development server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The dashboard server exposes a REST API at `/api`:
|
||||
|
||||
### Tasks
|
||||
- `GET /api/tasks` - List all tasks
|
||||
- `GET /api/tasks/:id` - Get task details
|
||||
- `POST /api/tasks` - Create new task
|
||||
- `PATCH /api/tasks/:id` - Update task
|
||||
- `POST /api/tasks/:id/move` - Move task to column
|
||||
- `POST /api/tasks/:id/pause` - Pause task
|
||||
- `POST /api/tasks/:id/unpause` - Unpause task
|
||||
- `DELETE /api/tasks/:id` - Delete task
|
||||
|
||||
### Git Operations
|
||||
- `GET /api/git/status` - Current branch and status
|
||||
- `GET /api/git/commits` - Recent commits (with optional `?limit=`)
|
||||
- `GET /api/git/commits/:hash/diff` - Commit diff
|
||||
- `GET /api/git/branches` - List branches
|
||||
- `GET /api/git/worktrees` - List worktrees with task associations
|
||||
- `POST /api/git/branches` - Create branch (`{ name, base? }`)
|
||||
- `POST /api/git/branches/:name/checkout` - Checkout branch
|
||||
- `DELETE /api/git/branches/:name` - Delete branch (`?force=true`)
|
||||
- `POST /api/git/fetch` - Fetch from remote (`{ remote? }`)
|
||||
- `POST /api/git/pull` - Pull current branch
|
||||
- `POST /api/git/push` - Push current branch
|
||||
|
||||
### GitHub Integration
|
||||
- `GET /api/git/remotes` - List GitHub remotes
|
||||
- `POST /api/github/issues/fetch` - Fetch issues (`{ owner, repo, limit?, labels? }`)
|
||||
- `POST /api/github/issues/import` - Import issue (`{ owner, repo, issueNumber }`)
|
||||
- `POST /api/tasks/:id/pr/create` - Create PR
|
||||
- `GET /api/tasks/:id/pr/status` - Get PR status
|
||||
- `POST /api/tasks/:id/pr/refresh` - Refresh PR status
|
||||
|
||||
### Configuration
|
||||
- `GET /api/config` - Server configuration
|
||||
- `GET /api/settings` - User settings
|
||||
- `PUT /api/settings` - Update settings
|
||||
- `GET /api/models` - Available AI models
|
||||
- `GET /api/auth/status` - OAuth provider status
|
||||
- `POST /api/auth/login` - Initiate OAuth login
|
||||
- `POST /api/auth/logout` - Logout from provider
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend**: React + Vite, TypeScript, CSS custom properties for theming
|
||||
- **Backend**: Express server with REST API and Server-Sent Events (SSE) for live updates
|
||||
- **State Management**: Custom hooks with EventSource for real-time task updates
|
||||
- **Git Integration**: Server-side git command execution with validation
|
||||
@@ -3,10 +3,14 @@ import type { TaskDetail, TaskCreateInput, Task } from "@kb/core";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings } from "./api";
|
||||
import { Header } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
import { ListView } from "./components/ListView";
|
||||
import { TaskDetailModal } from "./components/TaskDetailModal";
|
||||
import { TerminalModal } from "./components/TerminalModal";
|
||||
import { SettingsModal } from "./components/SettingsModal";
|
||||
import type { SectionId } from "./components/SettingsModal";
|
||||
import { ToastContainer } from "./components/ToastContainer";
|
||||
import { GitHubImportModal } from "./components/GitHubImportModal";
|
||||
import { GitManagerModal } from "./components/GitManagerModal";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
|
||||
@@ -14,12 +18,25 @@ function AppInner() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [githubImportOpen, setGitHubImportOpen] = useState(false);
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||
const [autoMerge, setAutoMerge] = useState(true);
|
||||
const [globalPaused, setGlobalPaused] = useState(false);
|
||||
const [enginePaused, setEnginePaused] = useState(false);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask } = useTasks();
|
||||
const [view, setView] = useState<"board" | "list">(() => {
|
||||
// Initialize from localStorage if available
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("kb-dashboard-view");
|
||||
if (saved === "list" || saved === "board") {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
return "board";
|
||||
});
|
||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask } = useTasks();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
@@ -30,6 +47,7 @@ function AppInner() {
|
||||
setAutoMerge(!!s.autoMerge);
|
||||
setGlobalPaused(!!s.globalPause);
|
||||
setEnginePaused(!!s.enginePaused);
|
||||
setGithubTokenConfigured(!!s.githubTokenConfigured);
|
||||
})
|
||||
.catch(() => {/* keep default */});
|
||||
fetchAuthStatus()
|
||||
@@ -43,6 +61,15 @@ function AppInner() {
|
||||
}, []);
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
|
||||
// Persist view preference to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("kb-dashboard-view", view);
|
||||
}, [view]);
|
||||
|
||||
const handleChangeView = useCallback((newView: "board" | "list") => {
|
||||
setView(newView);
|
||||
}, []);
|
||||
|
||||
const handleCreateOpen = useCallback(() => setIsCreating(true), []);
|
||||
const handleCancelCreate = useCallback(() => setIsCreating(false), []);
|
||||
|
||||
@@ -91,39 +118,75 @@ function AppInner() {
|
||||
|
||||
const handleDetailClose = useCallback(() => setDetailTask(null), []);
|
||||
|
||||
const handleGitHubImport = useCallback((task: Task) => {
|
||||
addToast(`Imported ${task.id} from GitHub`, "success");
|
||||
}, [addToast]);
|
||||
|
||||
const handleToggleTerminal = useCallback(() => {
|
||||
setTerminalOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleTerminalClose = useCallback(() => {
|
||||
setTerminalOpen(false);
|
||||
}, []);
|
||||
|
||||
// Filter tasks to get only in-progress tasks for terminal
|
||||
const inProgressTasks = tasks.filter((t) => t.column === "in-progress");
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenGitHubImport={() => setGitHubImportOpen(true)}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
inProgressCount={inProgressTasks.length}
|
||||
globalPaused={globalPaused}
|
||||
enginePaused={enginePaused}
|
||||
onToggleGlobalPause={handleToggleGlobalPause}
|
||||
onToggleEnginePause={handleToggleEnginePause}
|
||||
view={view}
|
||||
onChangeView={handleChangeView}
|
||||
/>
|
||||
<Board
|
||||
tasks={tasks}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
addToast={addToast}
|
||||
isCreating={isCreating}
|
||||
onCancelCreate={handleCancelCreate}
|
||||
onCreateTask={handleCreateTask}
|
||||
onNewTask={handleCreateOpen}
|
||||
autoMerge={autoMerge}
|
||||
onToggleAutoMerge={handleToggleAutoMerge}
|
||||
globalPaused={globalPaused}
|
||||
/>
|
||||
{view === "board" ? (
|
||||
<Board
|
||||
tasks={tasks}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
addToast={addToast}
|
||||
isCreating={isCreating}
|
||||
onCancelCreate={handleCancelCreate}
|
||||
onCreateTask={handleCreateTask}
|
||||
onNewTask={handleCreateOpen}
|
||||
autoMerge={autoMerge}
|
||||
onToggleAutoMerge={handleToggleAutoMerge}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={updateTask}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
isCreating={isCreating}
|
||||
onCancelCreate={handleCancelCreate}
|
||||
onCreateTask={handleCreateTask}
|
||||
onNewTask={handleCreateOpen}
|
||||
/>
|
||||
)}
|
||||
{detailTask && (
|
||||
<TaskDetailModal
|
||||
task={detailTask}
|
||||
tasks={tasks}
|
||||
onClose={handleDetailClose}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
onMoveTask={moveTask}
|
||||
onDeleteTask={deleteTask}
|
||||
onMergeTask={mergeTask}
|
||||
onRetryTask={retryTask}
|
||||
addToast={addToast}
|
||||
githubTokenConfigured={githubTokenConfigured}
|
||||
/>
|
||||
)}
|
||||
{settingsOpen && (
|
||||
@@ -136,6 +199,17 @@ function AppInner() {
|
||||
initialSection={settingsInitialSection}
|
||||
/>
|
||||
)}
|
||||
<GitHubImportModal
|
||||
isOpen={githubImportOpen}
|
||||
onClose={() => setGitHubImportOpen(false)}
|
||||
onImport={handleGitHubImport}
|
||||
tasks={tasks}
|
||||
/>
|
||||
<TerminalModal
|
||||
isOpen={terminalOpen}
|
||||
onClose={handleTerminalClose}
|
||||
tasks={inProgressTasks}
|
||||
/>
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "./api";
|
||||
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes } from "./api";
|
||||
import type { Task, TaskDetail } from "@kb/core";
|
||||
|
||||
const FAKE_DETAIL: TaskDetail = {
|
||||
@@ -211,3 +211,430 @@ describe("logoutProvider", () => {
|
||||
await expect(logoutProvider("anthropic")).rejects.toThrow("logout failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSteeringComment", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const FAKE_TASK: Task = {
|
||||
id: "KB-001",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
steeringComments: [
|
||||
{
|
||||
id: "1234567890-abc123",
|
||||
text: "Please handle the edge case",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("sends POST with text and returns updated task", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
||||
|
||||
const result = await addSteeringComment("KB-001", "Please handle the edge case");
|
||||
|
||||
expect(result.id).toBe("KB-001");
|
||||
expect(result.steeringComments).toHaveLength(1);
|
||||
expect(result.steeringComments![0].text).toBe("Please handle the edge case");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/steer", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "Please handle the edge case" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Task not found" })
|
||||
);
|
||||
|
||||
await expect(addSteeringComment("KB-001", "Test comment")).rejects.toThrow("Task not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitRemotes", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns array of GitHub remotes", async () => {
|
||||
const remotes = [
|
||||
{ name: "origin", owner: "dustinbyrne", repo: "kb", url: "https://github.com/dustinbyrne/kb.git" },
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, remotes));
|
||||
|
||||
const result = await fetchGitRemotes();
|
||||
|
||||
expect(result).toEqual(remotes);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty array when no remotes", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
const result = await fetchGitRemotes();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Failed to execute git command" }));
|
||||
|
||||
await expect(fetchGitRemotes()).rejects.toThrow("Failed to execute git command");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Plan approval API tests ---
|
||||
|
||||
import { approvePlan, rejectPlan } from "./api";
|
||||
|
||||
describe("approvePlan", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("approves plan and returns updated task", async () => {
|
||||
const approvedTask: Task = {
|
||||
...FAKE_DETAIL,
|
||||
column: "todo",
|
||||
status: undefined,
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, approvedTask));
|
||||
|
||||
const result = await approvePlan("KB-001");
|
||||
|
||||
expect(result.column).toBe("todo");
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/approve-plan", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Task must be in 'triage' column to approve plan" }, 400)
|
||||
);
|
||||
|
||||
await expect(approvePlan("KB-001")).rejects.toThrow("triage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejectPlan", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("rejects plan and returns updated task", async () => {
|
||||
const rejectedTask: Task = {
|
||||
...FAKE_DETAIL,
|
||||
column: "triage",
|
||||
status: undefined,
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, rejectedTask));
|
||||
|
||||
const result = await rejectPlan("KB-001");
|
||||
|
||||
expect(result.column).toBe("triage");
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/reject-plan", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Task must have status 'awaiting-approval' to reject plan" }, 400)
|
||||
);
|
||||
|
||||
await expect(rejectPlan("KB-001")).rejects.toThrow("awaiting-approval");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Git Management API tests ---
|
||||
|
||||
import {
|
||||
fetchGitStatus,
|
||||
fetchGitCommits,
|
||||
fetchCommitDiff,
|
||||
fetchGitBranches,
|
||||
fetchGitWorktrees,
|
||||
createBranch,
|
||||
checkoutBranch,
|
||||
deleteBranch,
|
||||
fetchRemote,
|
||||
pullBranch,
|
||||
pushBranch,
|
||||
} from "./api";
|
||||
|
||||
describe("Git Management API", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("fetchGitStatus", () => {
|
||||
it("returns git status", async () => {
|
||||
const status = { branch: "main", commit: "abc1234", isDirty: false, ahead: 0, behind: 0 };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, status));
|
||||
|
||||
const result = await fetchGitStatus();
|
||||
|
||||
expect(result).toEqual(status);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/status", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Not a git repository" }, 400));
|
||||
|
||||
await expect(fetchGitStatus()).rejects.toThrow("Not a git repository");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitCommits", () => {
|
||||
it("returns commits without limit", async () => {
|
||||
const commits = [
|
||||
{ hash: "abc123", shortHash: "abc", message: "Test commit", author: "User", date: "2026-01-01", parents: [] },
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, commits));
|
||||
|
||||
const result = await fetchGitCommits();
|
||||
|
||||
expect(result).toEqual(commits);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/commits", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes limit in query string", async () => {
|
||||
const commits = [{ hash: "abc123", shortHash: "abc", message: "Test", author: "User", date: "2026-01-01", parents: [] }];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, commits));
|
||||
|
||||
const result = await fetchGitCommits(50);
|
||||
|
||||
expect(result).toEqual(commits);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/commits?limit=50", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCommitDiff", () => {
|
||||
it("returns diff for a commit", async () => {
|
||||
const diff = { stat: "1 file changed", patch: "diff content" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, diff));
|
||||
|
||||
const result = await fetchCommitDiff("abc123");
|
||||
|
||||
expect(result).toEqual(diff);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/commits/abc123/diff", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on 404", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Commit not found" }, 404));
|
||||
|
||||
await expect(fetchCommitDiff("invalid")).rejects.toThrow("Commit not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitBranches", () => {
|
||||
it("returns branches array", async () => {
|
||||
const branches = [{ name: "main", isCurrent: true, remote: "origin/main" }];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, branches));
|
||||
|
||||
const result = await fetchGitBranches();
|
||||
|
||||
expect(result).toEqual(branches);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitWorktrees", () => {
|
||||
it("returns worktrees array", async () => {
|
||||
const worktrees = [{ path: "/path/to/repo", branch: "main", isMain: true, isBare: false }];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, worktrees));
|
||||
|
||||
const result = await fetchGitWorktrees();
|
||||
|
||||
expect(result).toEqual(worktrees);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBranch", () => {
|
||||
it("sends POST to create branch", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { created: true }, 201));
|
||||
|
||||
await createBranch("feature-branch");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "feature-branch", base: undefined }),
|
||||
});
|
||||
});
|
||||
|
||||
it("sends base when provided", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { created: true }, 201));
|
||||
|
||||
await createBranch("feature-branch", "main");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "feature-branch", base: "main" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Invalid branch name" }, 400));
|
||||
|
||||
await expect(createBranch("invalid")).rejects.toThrow("Invalid branch name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkoutBranch", () => {
|
||||
it("sends POST to checkout branch", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { checkedOut: "main" }));
|
||||
|
||||
await checkoutBranch("main");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/main/checkout", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes branch name", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
|
||||
|
||||
await checkoutBranch("feature/test");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/feature%2Ftest/checkout", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteBranch", () => {
|
||||
it("sends DELETE to remove branch", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { deleted: "feature" }));
|
||||
|
||||
await deleteBranch("feature");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/feature", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes force query param when true", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { deleted: "feature" }));
|
||||
|
||||
await deleteBranch("feature", true);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/feature?force=true", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRemote", () => {
|
||||
it("sends POST to fetch origin by default", async () => {
|
||||
const result = { fetched: true, message: "Fetched" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
const response = await fetchRemote();
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/fetch", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ remote: undefined }),
|
||||
});
|
||||
});
|
||||
|
||||
it("sends custom remote when provided", async () => {
|
||||
const result = { fetched: true, message: "Fetched from upstream" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
await fetchRemote("upstream");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/fetch", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ remote: "upstream" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pullBranch", () => {
|
||||
it("sends POST to pull", async () => {
|
||||
const result = { success: true, message: "Pulled 2 commits" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
const response = await pullBranch();
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/pull", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns conflict info when there are conflicts", async () => {
|
||||
const result = { success: false, message: "Merge conflict", conflict: true };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result, 409));
|
||||
|
||||
const response = await pullBranch();
|
||||
|
||||
expect(response.conflict).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushBranch", () => {
|
||||
it("sends POST to push", async () => {
|
||||
const result = { success: true, message: "Pushed to origin" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
const response = await pushBranch();
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/push", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on rejection", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Push rejected" }, 409));
|
||||
|
||||
await expect(pushBranch()).rejects.toThrow("Push rejected");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[] }): Promise<Task> {
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; modelProvider?: string; modelId?: string; validatorModelProvider?: string; validatorModelId?: string }): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
@@ -63,6 +63,10 @@ export function retryTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/retry`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function duplicateTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/duplicate`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function pauseTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/pause`, { method: "POST" });
|
||||
}
|
||||
@@ -71,6 +75,14 @@ export function unpauseTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/unpause`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function approvePlan(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/approve-plan`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function rejectPlan(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/reject-plan`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function fetchConfig(): Promise<{ maxConcurrent: number }> {
|
||||
return api<{ maxConcurrent: number }>("/config");
|
||||
}
|
||||
@@ -106,6 +118,20 @@ export function fetchAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
|
||||
return api<AgentLogEntry[]>(`/tasks/${taskId}/logs`);
|
||||
}
|
||||
|
||||
export function addSteeringComment(id: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/steer`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
export function requestSpecRevision(id: string, feedback: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/spec/revise`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ feedback }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Models API ---
|
||||
|
||||
/** Available AI model info returned by the models endpoint */
|
||||
@@ -151,3 +177,216 @@ export function logoutProvider(provider: string): Promise<{ success: boolean }>
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- GitHub Import API ---
|
||||
|
||||
/** GitHub issue returned by the fetch endpoint */
|
||||
export interface GitHubIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
labels: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
/** Fetch open GitHub issues from a repository */
|
||||
export function apiFetchGitHubIssues(
|
||||
owner: string,
|
||||
repo: string,
|
||||
limit?: number,
|
||||
labels?: string[]
|
||||
): Promise<GitHubIssue[]> {
|
||||
return api<GitHubIssue[]>("/github/issues/fetch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ owner, repo, limit, labels }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Import a specific GitHub issue as a kb task */
|
||||
export function apiImportGitHubIssue(owner: string, repo: string, issueNumber: number): Promise<Task> {
|
||||
return api<Task>("/github/issues/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ owner, repo, issueNumber }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Git Remote Detection API ---
|
||||
|
||||
/** Git remote info returned by the remotes endpoint */
|
||||
export interface GitRemote {
|
||||
name: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Fetch GitHub remotes from the current git repository */
|
||||
export function fetchGitRemotes(): Promise<GitRemote[]> {
|
||||
return api<GitRemote[]>("/git/remotes");
|
||||
}
|
||||
|
||||
// --- PR Management API ---
|
||||
|
||||
/** PR info returned by PR endpoints */
|
||||
export interface PrInfo {
|
||||
url: string;
|
||||
number: number;
|
||||
status: "open" | "closed" | "merged";
|
||||
title: string;
|
||||
headBranch: string;
|
||||
baseBranch: string;
|
||||
commentCount: number;
|
||||
lastCommentAt?: string;
|
||||
lastCheckedAt?: string;
|
||||
}
|
||||
|
||||
/** Create a GitHub PR for a task */
|
||||
export function createPr(
|
||||
id: string,
|
||||
params: { title: string; body?: string; base?: string }
|
||||
): Promise<PrInfo> {
|
||||
return api<PrInfo>(`/tasks/${id}/pr/create`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch cached PR status for a task */
|
||||
export function fetchPrStatus(id: string): Promise<{ prInfo: PrInfo; stale: boolean }> {
|
||||
return api<{ prInfo: PrInfo; stale: boolean }>(`/tasks/${id}/pr/status`);
|
||||
}
|
||||
|
||||
/** Force refresh PR status from GitHub */
|
||||
export function refreshPrStatus(id: string): Promise<PrInfo> {
|
||||
return api<PrInfo>(`/tasks/${id}/pr/refresh`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// --- Git Management API ---
|
||||
|
||||
/** Current git status */
|
||||
export interface GitStatus {
|
||||
branch: string;
|
||||
commit: string;
|
||||
isDirty: boolean;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
/** Git commit info */
|
||||
export interface GitCommit {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
parents: string[];
|
||||
}
|
||||
|
||||
/** Git branch info */
|
||||
export interface GitBranch {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
remote?: string;
|
||||
lastCommitDate?: string;
|
||||
}
|
||||
|
||||
/** Git worktree info */
|
||||
export interface GitWorktree {
|
||||
path: string;
|
||||
branch?: string;
|
||||
isMain: boolean;
|
||||
isBare: boolean;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
/** Result of a fetch operation */
|
||||
export interface GitFetchResult {
|
||||
fetched: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Result of a pull operation */
|
||||
export interface GitPullResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
conflict?: boolean;
|
||||
}
|
||||
|
||||
/** Result of a push operation */
|
||||
export interface GitPushResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Fetch current git status */
|
||||
export function fetchGitStatus(): Promise<GitStatus> {
|
||||
return api<GitStatus>("/git/status");
|
||||
}
|
||||
|
||||
/** Fetch recent commits */
|
||||
export function fetchGitCommits(limit?: number): Promise<GitCommit[]> {
|
||||
const query = limit ? `?limit=${limit}` : "";
|
||||
return api<GitCommit[]>(`/git/commits${query}`);
|
||||
}
|
||||
|
||||
/** Fetch diff for a specific commit */
|
||||
export function fetchCommitDiff(hash: string): Promise<{ stat: string; patch: string }> {
|
||||
return api<{ stat: string; patch: string }>(`/git/commits/${hash}/diff`);
|
||||
}
|
||||
|
||||
/** Fetch all local branches */
|
||||
export function fetchGitBranches(): Promise<GitBranch[]> {
|
||||
return api<GitBranch[]>("/git/branches");
|
||||
}
|
||||
|
||||
/** Fetch all worktrees */
|
||||
export function fetchGitWorktrees(): Promise<GitWorktree[]> {
|
||||
return api<GitWorktree[]>("/git/worktrees");
|
||||
}
|
||||
|
||||
/** Create a new branch */
|
||||
export function createBranch(name: string, base?: string): Promise<void> {
|
||||
return api<void>("/git/branches", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, base }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Checkout an existing branch */
|
||||
export function checkoutBranch(name: string): Promise<void> {
|
||||
return api<void>(`/git/branches/${encodeURIComponent(name)}/checkout`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a branch */
|
||||
export function deleteBranch(name: string, force?: boolean): Promise<void> {
|
||||
const query = force ? "?force=true" : "";
|
||||
return api<void>(`/git/branches/${encodeURIComponent(name)}${query}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch from remote */
|
||||
export function fetchRemote(remote?: string): Promise<GitFetchResult> {
|
||||
return api<GitFetchResult>("/git/fetch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ remote }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Pull current branch */
|
||||
export function pullBranch(): Promise<GitPullResult> {
|
||||
return api<GitPullResult>("/git/pull", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Push current branch */
|
||||
export function pushBranch(): Promise<GitPushResult> {
|
||||
return api<GitPushResult>("/git/push", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,9 +16,13 @@ interface BoardProps {
|
||||
autoMerge: boolean;
|
||||
onToggleAutoMerge: () => void;
|
||||
globalPaused?: boolean;
|
||||
onUpdateTask?: (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused }: BoardProps) {
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask }: BoardProps) {
|
||||
return (
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
@@ -43,6 +47,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
{...(col === "triage" ? { isCreating, onCancelCreate, onCreateTask, onNewTask } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
/>
|
||||
|
||||
@@ -23,9 +23,13 @@ interface ColumnProps {
|
||||
autoMerge?: boolean;
|
||||
onToggleAutoMerge?: () => void;
|
||||
globalPaused?: boolean;
|
||||
onUpdateTask?: (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused }: ColumnProps) {
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
|
||||
@@ -109,6 +113,8 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={allTasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
))
|
||||
);
|
||||
@@ -117,7 +123,7 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} />
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} tasks={allTasks} onUpdateTask={onUpdateTask} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
326
packages/dashboard/app/components/GitHubImportModal.tsx
Normal file
326
packages/dashboard/app/components/GitHubImportModal.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Task } from "@kb/core";
|
||||
import { apiFetchGitHubIssues, apiImportGitHubIssue, fetchGitRemotes, type GitHubIssue, type GitRemote } from "../api";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface GitHubImportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (task: Task) => void;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubImportModalProps) {
|
||||
const [owner, setOwner] = useState("");
|
||||
const [repo, setRepo] = useState("");
|
||||
const [labels, setLabels] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [issues, setIssues] = useState<GitHubIssue[]>([]);
|
||||
const [selectedIssueNumber, setSelectedIssueNumber] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
// Git remotes state
|
||||
const [remotes, setRemotes] = useState<GitRemote[]>([]);
|
||||
const [loadingRemotes, setLoadingRemotes] = useState(false);
|
||||
const [selectedRemoteName, setSelectedRemoteName] = useState<string>("");
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
// Build set of already imported URLs from existing tasks
|
||||
const importedUrls = new Set<string>();
|
||||
for (const task of tasks) {
|
||||
const match = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/);
|
||||
if (match) {
|
||||
importedUrls.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state when modal opens and fetch remotes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setOwner("");
|
||||
setRepo("");
|
||||
setLabels("");
|
||||
setIssues([]);
|
||||
setSelectedIssueNumber(null);
|
||||
setError(null);
|
||||
setImporting(false);
|
||||
setRemotes([]);
|
||||
setLoadingRemotes(true);
|
||||
setSelectedRemoteName("");
|
||||
|
||||
mountedRef.current = true;
|
||||
|
||||
// Fetch git remotes
|
||||
fetchGitRemotes()
|
||||
.then((fetchedRemotes) => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
setRemotes(fetchedRemotes);
|
||||
setLoadingRemotes(false);
|
||||
|
||||
if (fetchedRemotes.length === 1) {
|
||||
// Single remote: auto-select it
|
||||
const remote = fetchedRemotes[0];
|
||||
setOwner(remote.owner);
|
||||
setRepo(remote.repo);
|
||||
setSelectedRemoteName(remote.name);
|
||||
} else if (fetchedRemotes.length > 1) {
|
||||
// Multiple remotes: don't auto-select, user must choose
|
||||
setOwner("");
|
||||
setRepo("");
|
||||
setSelectedRemoteName("");
|
||||
}
|
||||
// If no remotes, owner/repo remain empty
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setLoadingRemotes(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle remote selection change
|
||||
const handleRemoteChange = useCallback((remoteName: string) => {
|
||||
setSelectedRemoteName(remoteName);
|
||||
if (remoteName === "") {
|
||||
setOwner("");
|
||||
setRepo("");
|
||||
} else {
|
||||
const remote = remotes.find((r) => r.name === remoteName);
|
||||
if (remote) {
|
||||
setOwner(remote.owner);
|
||||
setRepo(remote.repo);
|
||||
}
|
||||
}
|
||||
}, [remotes]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleLoad = useCallback(async () => {
|
||||
if (!owner.trim() || !repo.trim()) {
|
||||
setError("Repository must be selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setIssues([]);
|
||||
setSelectedIssueNumber(null);
|
||||
|
||||
try {
|
||||
const labelArray = labels
|
||||
.split(",")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const fetchedIssues = await apiFetchGitHubIssues(owner.trim(), repo.trim(), 30, labelArray.length > 0 ? labelArray : undefined);
|
||||
setIssues(fetchedIssues);
|
||||
if (fetchedIssues.length === 0) {
|
||||
setError("No open issues found");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch issues");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [owner, repo, labels]);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (selectedIssueNumber === null) return;
|
||||
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const task = await apiImportGitHubIssue(owner.trim(), repo.trim(), selectedIssueNumber);
|
||||
onImport(task);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("already imported")) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError(err.message || "Failed to import issue");
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [selectedIssueNumber, owner, repo, onImport, onClose]);
|
||||
|
||||
const selectedIssue = issues.find((i) => i.number === selectedIssueNumber);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Determine the repository selection UI state
|
||||
const hasRemotes = remotes.length > 0;
|
||||
const singleRemote = remotes.length === 1;
|
||||
const multipleRemotes = remotes.length > 1;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>Import from GitHub</h3>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{/* Repository Selection Section */}
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="gh-remote">
|
||||
Repository
|
||||
{loadingRemotes && <Loader2 size={12} className="spin" style={{ marginLeft: 8, display: "inline" }} />}
|
||||
</label>
|
||||
|
||||
{/* Loading state */}
|
||||
{loadingRemotes && (
|
||||
<span className="text-muted">Loading remotes...</span>
|
||||
)}
|
||||
|
||||
{/* No remotes */}
|
||||
{!loadingRemotes && !hasRemotes && (
|
||||
<div className="form-error">
|
||||
No GitHub remotes detected. Add a remote with:
|
||||
<code style={{ display: "block", marginTop: 4, padding: 4, background: "#f5f5f5", borderRadius: 4 }}>
|
||||
git remote add origin https://github.com/owner/repo.git
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single remote - read only display */}
|
||||
{!loadingRemotes && singleRemote && (
|
||||
<span className="text-muted">
|
||||
{remotes[0].name} ({remotes[0].owner}/{remotes[0].repo})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Multiple remotes - dropdown */}
|
||||
{!loadingRemotes && multipleRemotes && (
|
||||
<select
|
||||
id="gh-remote"
|
||||
value={selectedRemoteName}
|
||||
onChange={(e) => handleRemoteChange(e.target.value)}
|
||||
disabled={loading || importing}
|
||||
>
|
||||
<option value="">Select a remote...</option>
|
||||
{remotes.map((remote) => (
|
||||
<option key={remote.name} value={remote.name}>
|
||||
{remote.name} ({remote.owner}/{remote.repo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="gh-labels">Labels (optional)</label>
|
||||
<input
|
||||
id="gh-labels"
|
||||
type="text"
|
||||
placeholder="bug,enhancement"
|
||||
value={labels}
|
||||
onChange={(e) => setLabels(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleLoad()}
|
||||
disabled={loading || importing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group form-group--action">
|
||||
<label> </label>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleLoad}
|
||||
disabled={loading || importing || !owner.trim() || !repo.trim()}
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="spin" /> : "Load"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
{/* Issues List */}
|
||||
{issues.length > 0 && (
|
||||
<>
|
||||
<div className="issues-list">
|
||||
<h4>Found {issues.length} issues:</h4>
|
||||
{issues.map((issue) => {
|
||||
const isImported = importedUrls.has(issue.html_url);
|
||||
return (
|
||||
<div
|
||||
key={issue.number}
|
||||
className={`issue-item ${selectedIssueNumber === issue.number ? "selected" : ""} ${isImported ? "imported" : ""}`}
|
||||
onClick={() => !isImported && setSelectedIssueNumber(issue.number)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="issue"
|
||||
checked={selectedIssueNumber === issue.number}
|
||||
onChange={() => setSelectedIssueNumber(issue.number)}
|
||||
disabled={isImported}
|
||||
/>
|
||||
<span className="issue-number">#{issue.number}</span>
|
||||
<span className="issue-title">{issue.title}</span>
|
||||
{issue.labels.length > 0 && (
|
||||
<span className="issue-labels">
|
||||
{issue.labels.map((l) => (
|
||||
<span key={l.name} className="label-chip">
|
||||
{l.name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{isImported && <span className="imported-badge">Imported</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{selectedIssue && (
|
||||
<div className="issue-preview">
|
||||
<h4>Preview</h4>
|
||||
<div className="preview-title">{selectedIssue.title}</div>
|
||||
<div className="preview-body">
|
||||
{selectedIssue.body
|
||||
? selectedIssue.body.slice(0, 200) + (selectedIssue.body.length > 200 ? "…" : "")
|
||||
: "(no description)"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={onClose} disabled={importing}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleImport}
|
||||
disabled={selectedIssueNumber === null || importing}
|
||||
>
|
||||
{importing ? <Loader2 size={14} className="spin" /> : "Import"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
603
packages/dashboard/app/components/GitManagerModal.tsx
Normal file
603
packages/dashboard/app/components/GitManagerModal.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Task } from "@kb/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import type {
|
||||
GitStatus,
|
||||
GitCommit,
|
||||
GitBranch,
|
||||
GitWorktree,
|
||||
GitFetchResult,
|
||||
GitPullResult,
|
||||
GitPushResult,
|
||||
} from "../api";
|
||||
import {
|
||||
fetchGitStatus,
|
||||
fetchGitCommits,
|
||||
fetchCommitDiff,
|
||||
fetchGitBranches,
|
||||
fetchGitWorktrees,
|
||||
createBranch,
|
||||
checkoutBranch,
|
||||
deleteBranch,
|
||||
fetchRemote,
|
||||
pullBranch,
|
||||
pushBranch,
|
||||
} from "../api";
|
||||
import {
|
||||
GitBranch as GitBranchIcon,
|
||||
GitCommit as GitCommitIcon,
|
||||
GitPullRequest,
|
||||
GitMerge,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
HardDrive,
|
||||
Radio,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
type SectionId = "status" | "commits" | "branches" | "worktrees" | "remotes";
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "status" as SectionId, label: "Status", icon: Radio },
|
||||
{ id: "commits" as SectionId, label: "Commits", icon: GitCommitIcon },
|
||||
{ id: "branches" as SectionId, label: "Branches", icon: GitBranchIcon },
|
||||
{ id: "worktrees" as SectionId, label: "Worktrees", icon: HardDrive },
|
||||
{ id: "remotes" as SectionId, label: "Remotes", icon: GitMerge },
|
||||
];
|
||||
|
||||
interface GitManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tasks: Task[];
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function GitManagerModal({ isOpen, onClose, tasks, addToast }: GitManagerModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SectionId>("status");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<GitStatus | null>(null);
|
||||
const [commits, setCommits] = useState<GitCommit[]>([]);
|
||||
const [branches, setBranches] = useState<GitBranch[]>([]);
|
||||
const [worktrees, setWorktrees] = useState<GitWorktree[]>([]);
|
||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||
const [commitDiff, setCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [branchBase, setBranchBase] = useState("");
|
||||
const [loadingDiff, setLoadingDiff] = useState(false);
|
||||
const [remoteLoading, setRemoteLoading] = useState<string | null>(null);
|
||||
const [lastRemoteResult, setLastRemoteResult] = useState<GitFetchResult | GitPullResult | GitPushResult | null>(null);
|
||||
const [commitsLimit, setCommitsLimit] = useState(20);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch data when section changes or modal opens
|
||||
const fetchSectionData = useCallback(async () => {
|
||||
if (!isOpen) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
switch (activeSection) {
|
||||
case "status":
|
||||
const statusData = await fetchGitStatus();
|
||||
setStatus(statusData);
|
||||
break;
|
||||
case "commits":
|
||||
const commitsData = await fetchGitCommits(commitsLimit);
|
||||
setCommits(commitsData);
|
||||
break;
|
||||
case "branches":
|
||||
const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(), fetchGitStatus()]);
|
||||
setBranches(branchesData);
|
||||
setStatus(statusForBranch);
|
||||
break;
|
||||
case "worktrees":
|
||||
const worktreesData = await fetchGitWorktrees();
|
||||
setWorktrees(worktreesData);
|
||||
break;
|
||||
case "remotes":
|
||||
// Just refresh status for remote section
|
||||
const remoteStatus = await fetchGitStatus();
|
||||
setStatus(remoteStatus);
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to fetch git data", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeSection, isOpen, commitsLimit, addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchSectionData();
|
||||
}
|
||||
}, [fetchSectionData, isOpen]);
|
||||
|
||||
// Keyboard support
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Handle commit selection and diff loading
|
||||
const handleCommitClick = useCallback(async (hash: string) => {
|
||||
if (selectedCommit === hash) {
|
||||
setSelectedCommit(null);
|
||||
setCommitDiff(null);
|
||||
return;
|
||||
}
|
||||
setSelectedCommit(hash);
|
||||
setLoadingDiff(true);
|
||||
try {
|
||||
const diff = await fetchCommitDiff(hash);
|
||||
setCommitDiff(diff);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load diff", "error");
|
||||
setCommitDiff(null);
|
||||
} finally {
|
||||
setLoadingDiff(false);
|
||||
}
|
||||
}, [selectedCommit, addToast]);
|
||||
|
||||
// Handle branch creation
|
||||
const handleCreateBranch = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newBranchName.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await createBranch(newBranchName.trim(), branchBase.trim() || undefined);
|
||||
addToast(`Created branch ${newBranchName}`, "success");
|
||||
setNewBranchName("");
|
||||
setBranchBase("");
|
||||
// Refresh branches
|
||||
const branchesData = await fetchGitBranches();
|
||||
setBranches(branchesData);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to create branch", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [newBranchName, branchBase, addToast]);
|
||||
|
||||
// Handle branch checkout
|
||||
const handleCheckoutBranch = useCallback(async (name: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await checkoutBranch(name);
|
||||
addToast(`Switched to ${name}`, "success");
|
||||
// Refresh status and branches
|
||||
const [statusData, branchesData] = await Promise.all([fetchGitStatus(), fetchGitBranches()]);
|
||||
setStatus(statusData);
|
||||
setBranches(branchesData);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to checkout branch", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// Handle branch deletion
|
||||
const handleDeleteBranch = useCallback(async (name: string) => {
|
||||
if (!confirm(`Delete branch "${name}"?`)) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteBranch(name);
|
||||
addToast(`Deleted branch ${name}`, "success");
|
||||
// Refresh branches
|
||||
const branchesData = await fetchGitBranches();
|
||||
setBranches(branchesData);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not fully merged")) {
|
||||
if (confirm("Branch has unmerged commits. Force delete?")) {
|
||||
try {
|
||||
await deleteBranch(name, true);
|
||||
addToast(`Force deleted branch ${name}`, "success");
|
||||
const branchesData = await fetchGitBranches();
|
||||
setBranches(branchesData);
|
||||
} catch (forceErr: any) {
|
||||
addToast(forceErr.message || "Failed to delete branch", "error");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addToast(err.message || "Failed to delete branch", "error");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// Handle fetch
|
||||
const handleFetch = useCallback(async () => {
|
||||
setRemoteLoading("fetch");
|
||||
try {
|
||||
const result = await fetchRemote();
|
||||
setLastRemoteResult(result);
|
||||
addToast(result.message || "Fetch completed", result.fetched ? "success" : "info");
|
||||
// Refresh status
|
||||
const statusData = await fetchGitStatus();
|
||||
setStatus(statusData);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Fetch failed", "error");
|
||||
} finally {
|
||||
setRemoteLoading(null);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// Handle pull
|
||||
const handlePull = useCallback(async () => {
|
||||
setRemoteLoading("pull");
|
||||
try {
|
||||
const result = await pullBranch();
|
||||
setLastRemoteResult(result);
|
||||
if (result.conflict) {
|
||||
addToast("Merge conflict detected. Resolve manually.", "error");
|
||||
} else {
|
||||
addToast(result.message || "Pull completed", "success");
|
||||
}
|
||||
// Refresh status
|
||||
const statusData = await fetchGitStatus();
|
||||
setStatus(statusData);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Pull failed", "error");
|
||||
} finally {
|
||||
setRemoteLoading(null);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// Handle push
|
||||
const handlePush = useCallback(async () => {
|
||||
setRemoteLoading("push");
|
||||
try {
|
||||
const result = await pushBranch();
|
||||
setLastRemoteResult(result);
|
||||
addToast(result.message || "Push completed", "success");
|
||||
// Refresh status
|
||||
const statusData = await fetchGitStatus();
|
||||
setStatus(statusData);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Push failed", "error");
|
||||
} finally {
|
||||
setRemoteLoading(null);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// Load more commits
|
||||
const handleLoadMoreCommits = useCallback(() => {
|
||||
setCommitsLimit((prev) => Math.min(prev + 20, 100));
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal modal-lg" ref={modalRef}>
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
<GitBranchIcon size={18} style={{ marginRight: 8, verticalAlign: "middle" }} />
|
||||
Git Manager
|
||||
</h3>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="git-manager-layout">
|
||||
{/* Sidebar */}
|
||||
<nav className="git-manager-sidebar">
|
||||
{SECTIONS.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
className={`git-manager-nav-item${activeSection === section.id ? " active" : ""}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{section.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<div className="git-manager-content">
|
||||
{loading && (
|
||||
<div className="git-manager-loading">
|
||||
<Loader2 size={24} className="spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Tab */}
|
||||
{activeSection === "status" && status && (
|
||||
<div className="git-status-panel">
|
||||
<h4>Repository Status</h4>
|
||||
<div className="git-status-grid">
|
||||
<div className="git-status-item">
|
||||
<span className="git-status-label">Branch</span>
|
||||
<span className="git-status-value">
|
||||
<GitBranchIcon size={14} />
|
||||
{status.branch}
|
||||
</span>
|
||||
</div>
|
||||
<div className="git-status-item">
|
||||
<span className="git-status-label">Commit</span>
|
||||
<code className="git-status-commit">{status.commit}</code>
|
||||
</div>
|
||||
<div className="git-status-item">
|
||||
<span className="git-status-label">Status</span>
|
||||
<span className={`git-status-badge ${status.isDirty ? "dirty" : "clean"}`}>
|
||||
{status.isDirty ? "Modified" : "Clean"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="git-status-item">
|
||||
<span className="git-status-label">Remote</span>
|
||||
<span className="git-status-value">
|
||||
{status.ahead > 0 && (
|
||||
<span className="git-ahead" title={`${status.ahead} commit(s) ahead`}>
|
||||
<ArrowUp size={12} />
|
||||
{status.ahead}
|
||||
</span>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<span className="git-behind" title={`${status.behind} commit(s) behind`}>
|
||||
<ArrowDown size={12} />
|
||||
{status.behind}
|
||||
</span>
|
||||
)}
|
||||
{status.ahead === 0 && status.behind === 0 && (
|
||||
<span className="git-in-sync">Up to date</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commits Tab */}
|
||||
{activeSection === "commits" && (
|
||||
<div className="git-commits-panel">
|
||||
<h4>Recent Commits</h4>
|
||||
<div className="git-commits-list">
|
||||
{commits.map((commit) => (
|
||||
<div key={commit.hash} className="git-commit-item">
|
||||
<button
|
||||
className="git-commit-header"
|
||||
onClick={() => handleCommitClick(commit.hash)}
|
||||
>
|
||||
{selectedCommit === commit.hash ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
<code className="git-commit-hash">{commit.shortHash}</code>
|
||||
<span className="git-commit-message" title={commit.message}>
|
||||
{commit.message}
|
||||
</span>
|
||||
<span className="git-commit-meta">
|
||||
{commit.author} • {new Date(commit.date).toLocaleDateString()}
|
||||
</span>
|
||||
</button>
|
||||
{selectedCommit === commit.hash && (
|
||||
<div className="git-commit-diff">
|
||||
{loadingDiff ? (
|
||||
<div className="git-diff-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
Loading diff...
|
||||
</div>
|
||||
) : commitDiff ? (
|
||||
<>
|
||||
<pre className="git-diff-stat">{commitDiff.stat}</pre>
|
||||
<pre className="git-diff-patch">{commitDiff.patch}</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className="git-diff-error">Failed to load diff</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{commits.length >= commitsLimit && commitsLimit < 100 && (
|
||||
<button className="git-load-more" onClick={handleLoadMoreCommits}>
|
||||
Load more commits
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branches Tab */}
|
||||
{activeSection === "branches" && (
|
||||
<div className="git-branches-panel">
|
||||
<h4>Branches</h4>
|
||||
|
||||
{/* Create branch form */}
|
||||
<form className="git-create-branch-form" onSubmit={handleCreateBranch}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New branch name"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Base branch (optional)"
|
||||
value={branchBase}
|
||||
onChange={(e) => setBranchBase(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={loading || !newBranchName.trim()}>
|
||||
<Plus size={14} />
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Branches list */}
|
||||
<div className="git-branches-list">
|
||||
{branches.map((branch) => (
|
||||
<div
|
||||
key={branch.name}
|
||||
className={`git-branch-item ${branch.isCurrent ? "current" : ""}`}
|
||||
>
|
||||
<span className="git-branch-name">
|
||||
{branch.isCurrent && <Check size={14} className="git-branch-current-icon" />}
|
||||
{branch.name}
|
||||
{branch.remote && (
|
||||
<span className="git-branch-remote">→ {branch.remote}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="git-branch-actions">
|
||||
{!branch.isCurrent && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleCheckoutBranch(branch.name)}
|
||||
disabled={loading}
|
||||
title="Checkout"
|
||||
>
|
||||
<GitBranchIcon size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDeleteBranch(branch.name)}
|
||||
disabled={loading}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Worktrees Tab */}
|
||||
{activeSection === "worktrees" && (
|
||||
<div className="git-worktrees-panel">
|
||||
<h4>Worktrees</h4>
|
||||
<div className="git-worktrees-stats">
|
||||
<span>{worktrees.length} total</span>
|
||||
<span>{worktrees.filter((w) => w.taskId).length} in use by tasks</span>
|
||||
</div>
|
||||
<div className="git-worktrees-list">
|
||||
{worktrees.map((worktree) => (
|
||||
<div
|
||||
key={worktree.path}
|
||||
className={`git-worktree-item ${worktree.isMain ? "main" : ""}`}
|
||||
>
|
||||
<div className="git-worktree-info">
|
||||
<span className="git-worktree-path" title={worktree.path}>
|
||||
{worktree.isMain && <span className="git-worktree-badge main">main</span>}
|
||||
{worktree.isBare && <span className="git-worktree-badge bare">bare</span>}
|
||||
{worktree.path}
|
||||
</span>
|
||||
{worktree.branch && (
|
||||
<span className="git-worktree-branch">
|
||||
<GitBranchIcon size={12} />
|
||||
{worktree.branch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{worktree.taskId && (
|
||||
<span className="git-worktree-task">{worktree.taskId}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remotes Tab */}
|
||||
{activeSection === "remotes" && (
|
||||
<div className="git-remotes-panel">
|
||||
<h4>Remote Operations</h4>
|
||||
|
||||
{status && (status.ahead > 0 || status.behind > 0) && (
|
||||
<div className="git-remote-status">
|
||||
{status.ahead > 0 && (
|
||||
<div className="git-remote-ahead">
|
||||
<AlertCircle size={16} />
|
||||
{status.ahead} commit(s) to push
|
||||
</div>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<div className="git-remote-behind">
|
||||
<AlertCircle size={16} />
|
||||
{status.behind} commit(s) to pull
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="git-remote-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleFetch}
|
||||
disabled={remoteLoading !== null}
|
||||
>
|
||||
{remoteLoading === "fetch" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Fetch
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handlePull}
|
||||
disabled={remoteLoading !== null}
|
||||
>
|
||||
{remoteLoading === "pull" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<GitPullRequest size={14} />
|
||||
)}
|
||||
Pull
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handlePush}
|
||||
disabled={remoteLoading !== null || (status?.ahead === 0 && false)}
|
||||
>
|
||||
{remoteLoading === "push" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<ArrowUp size={14} />
|
||||
)}
|
||||
Push
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastRemoteResult && (
|
||||
<div className={`git-remote-result ${"fetched" in lastRemoteResult ? "fetch" : "success" in lastRemoteResult ? (lastRemoteResult as GitPullResult).conflict ? "conflict" : (lastRemoteResult as GitPullResult).success ? "success" : "error" : "success" in lastRemoteResult ? (lastRemoteResult as GitPushResult).success ? "success" : "error" : ""}`}>
|
||||
{lastRemoteResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
packages/dashboard/app/components/Header.test.tsx
Normal file
178
packages/dashboard/app/components/Header.test.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { Header } from "./Header";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function renderHeader(props = {}) {
|
||||
return render(
|
||||
<Header
|
||||
onOpenSettings={noop}
|
||||
onOpenGitHubImport={noop}
|
||||
globalPaused={false}
|
||||
enginePaused={false}
|
||||
onToggleGlobalPause={noop}
|
||||
onToggleEnginePause={noop}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Header", () => {
|
||||
it("renders the logo and brand", () => {
|
||||
renderHeader();
|
||||
expect(screen.getByText("kb")).toBeDefined();
|
||||
expect(screen.getByText("board")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders action buttons", () => {
|
||||
renderHeader();
|
||||
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
|
||||
expect(screen.getByTitle("Settings")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenSettings when settings button is clicked", () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
renderHeader({ onOpenSettings });
|
||||
fireEvent.click(screen.getByTitle("Settings"));
|
||||
expect(onOpenSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenGitHubImport when import button is clicked", () => {
|
||||
const onOpenGitHubImport = vi.fn();
|
||||
renderHeader({ onOpenGitHubImport });
|
||||
fireEvent.click(screen.getByTitle("Import from GitHub"));
|
||||
expect(onOpenGitHubImport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("view toggle", () => {
|
||||
it("does not render view toggle when onChangeView is not provided", () => {
|
||||
renderHeader();
|
||||
expect(screen.queryByTitle("Board view")).toBeNull();
|
||||
expect(screen.queryByTitle("List view")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders view toggle when onChangeView is provided", () => {
|
||||
renderHeader({ onChangeView: noop });
|
||||
expect(screen.getByTitle("Board view")).toBeDefined();
|
||||
expect(screen.getByTitle("List view")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows board view as active by default", () => {
|
||||
renderHeader({ onChangeView: noop });
|
||||
const boardBtn = screen.getByTitle("Board view");
|
||||
const listBtn = screen.getByTitle("List view");
|
||||
expect(boardBtn.className).toContain("active");
|
||||
expect(listBtn.className).not.toContain("active");
|
||||
});
|
||||
|
||||
it("shows list view as active when view is 'list'", () => {
|
||||
renderHeader({ onChangeView: noop, view: "list" });
|
||||
const boardBtn = screen.getByTitle("Board view");
|
||||
const listBtn = screen.getByTitle("List view");
|
||||
expect(boardBtn.className).not.toContain("active");
|
||||
expect(listBtn.className).toContain("active");
|
||||
});
|
||||
|
||||
it("calls onChangeView with 'board' when clicking board view button", () => {
|
||||
const onChangeView = vi.fn();
|
||||
renderHeader({ onChangeView, view: "list" });
|
||||
fireEvent.click(screen.getByTitle("Board view"));
|
||||
expect(onChangeView).toHaveBeenCalledWith("board");
|
||||
});
|
||||
|
||||
it("calls onChangeView with 'list' when clicking list view button", () => {
|
||||
const onChangeView = vi.fn();
|
||||
renderHeader({ onChangeView, view: "board" });
|
||||
fireEvent.click(screen.getByTitle("List view"));
|
||||
expect(onChangeView).toHaveBeenCalledWith("list");
|
||||
});
|
||||
|
||||
it("has correct aria attributes for accessibility", () => {
|
||||
renderHeader({ onChangeView: noop, view: "board" });
|
||||
const boardBtn = screen.getByTitle("Board view");
|
||||
const listBtn = screen.getByTitle("List view");
|
||||
expect(boardBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(listBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal button", () => {
|
||||
it("renders terminal button with correct title", () => {
|
||||
renderHeader({ onToggleTerminal: noop });
|
||||
expect(screen.getByTitle("Open Terminal View")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onToggleTerminal when terminal button is clicked", () => {
|
||||
const onToggleTerminal = vi.fn();
|
||||
renderHeader({ onToggleTerminal, inProgressCount: 1 });
|
||||
fireEvent.click(screen.getByTitle("Open Terminal View"));
|
||||
expect(onToggleTerminal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows badge with count when in-progress tasks exist", () => {
|
||||
renderHeader({ onToggleTerminal: noop, inProgressCount: 3 });
|
||||
expect(screen.getByTestId("terminal-badge")).toBeDefined();
|
||||
expect(screen.getByTestId("terminal-badge").textContent).toBe("3");
|
||||
});
|
||||
|
||||
it("shows badge with 9+ when count exceeds 9", () => {
|
||||
renderHeader({ onToggleTerminal: noop, inProgressCount: 15 });
|
||||
expect(screen.getByTestId("terminal-badge")).toBeDefined();
|
||||
expect(screen.getByTestId("terminal-badge").textContent).toBe("9+");
|
||||
});
|
||||
|
||||
it("does not show badge when no in-progress tasks", () => {
|
||||
renderHeader({ onToggleTerminal: noop, inProgressCount: 0 });
|
||||
expect(screen.queryByTestId("terminal-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("is disabled when no in-progress tasks", () => {
|
||||
renderHeader({ onToggleTerminal: noop, inProgressCount: 0 });
|
||||
const btn = screen.getByTitle("Open Terminal View");
|
||||
expect(btn.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("is enabled when in-progress tasks exist", () => {
|
||||
renderHeader({ onToggleTerminal: noop, inProgressCount: 2 });
|
||||
const btn = screen.getByTitle("Open Terminal View");
|
||||
expect(btn.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pause controls", () => {
|
||||
it("renders pause button for engine pause", () => {
|
||||
renderHeader();
|
||||
expect(screen.getByTitle("Pause scheduling")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders stop button for global pause", () => {
|
||||
renderHeader();
|
||||
expect(screen.getByTitle("Stop AI engine")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onToggleEnginePause when pause button is clicked", () => {
|
||||
const onToggleEnginePause = vi.fn();
|
||||
renderHeader({ onToggleEnginePause });
|
||||
fireEvent.click(screen.getByTitle("Pause scheduling"));
|
||||
expect(onToggleEnginePause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onToggleGlobalPause when stop button is clicked", () => {
|
||||
const onToggleGlobalPause = vi.fn();
|
||||
renderHeader({ onToggleGlobalPause });
|
||||
fireEvent.click(screen.getByTitle("Stop AI engine"));
|
||||
expect(onToggleGlobalPause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows resume text when engine is paused", () => {
|
||||
renderHeader({ enginePaused: true });
|
||||
expect(screen.getByTitle("Resume scheduling")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows start text when global is paused", () => {
|
||||
renderHeader({ globalPaused: true });
|
||||
expect(screen.getByTitle("Start AI engine")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,32 @@
|
||||
import { Settings, Pause, Play, Square } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal } from "lucide-react";
|
||||
|
||||
interface HeaderProps {
|
||||
onOpenSettings?: () => void;
|
||||
onOpenGitHubImport?: () => void;
|
||||
onToggleTerminal?: () => void;
|
||||
inProgressCount?: number;
|
||||
globalPaused?: boolean;
|
||||
enginePaused?: boolean;
|
||||
onToggleGlobalPause?: () => void;
|
||||
onToggleEnginePause?: () => void;
|
||||
view?: "board" | "list";
|
||||
onChangeView?: (view: "board" | "list") => void;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
onOpenSettings,
|
||||
onOpenGitHubImport,
|
||||
onToggleTerminal,
|
||||
inProgressCount = 0,
|
||||
globalPaused,
|
||||
enginePaused,
|
||||
onToggleGlobalPause,
|
||||
onToggleEnginePause,
|
||||
view = "board",
|
||||
onChangeView,
|
||||
}: HeaderProps) {
|
||||
const hasInProgressTasks = inProgressCount > 0;
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
@@ -23,6 +35,48 @@ export function Header({
|
||||
<span className="logo-sub">board</span>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
{/* View Toggle */}
|
||||
{onChangeView && (
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={`view-toggle-btn${view === "board" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("board")}
|
||||
title="Board view"
|
||||
aria-label="Board view"
|
||||
aria-pressed={view === "board"}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${view === "list" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("list")}
|
||||
title="List view"
|
||||
aria-label="List view"
|
||||
aria-pressed={view === "list"}
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Import from GitHub */}
|
||||
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
||||
<Download size={16} />
|
||||
</button>
|
||||
{/* Terminal button - shows badge with count when in-progress tasks exist */}
|
||||
<button
|
||||
className={`btn-icon btn-icon--terminal${hasInProgressTasks ? " has-badge" : ""}`}
|
||||
onClick={onToggleTerminal}
|
||||
title="Open Terminal View"
|
||||
disabled={!hasInProgressTasks}
|
||||
data-testid="terminal-toggle-btn"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
{hasInProgressTasks && (
|
||||
<span className="btn-badge" data-testid="terminal-badge">
|
||||
{inProgressCount > 9 ? "9+" : inProgressCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Pause button (soft pause): stops new work, lets agents finish */}
|
||||
<button
|
||||
className={`btn-icon${enginePaused ? " btn-icon--paused" : ""}`}
|
||||
|
||||
620
packages/dashboard/app/components/ListView.tsx
Normal file
620
packages/dashboard/app/components/ListView.tsx
Normal file
@@ -0,0 +1,620 @@
|
||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, ChevronRight } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
|
||||
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
|
||||
import { fetchTaskDetail } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
triage: "var(--triage)",
|
||||
todo: "var(--todo)",
|
||||
"in-progress": "var(--in-progress)",
|
||||
"in-review": "var(--in-review)",
|
||||
done: "var(--done)",
|
||||
};
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
|
||||
|
||||
type SortField = "id" | "title" | "status" | "column" | "createdAt" | "updatedAt";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
// Column visibility types
|
||||
const ALL_LIST_COLUMNS = ["id", "title", "status", "column", "createdAt", "updatedAt", "dependencies", "progress"] as const;
|
||||
type ListColumn = typeof ALL_LIST_COLUMNS[number];
|
||||
|
||||
interface ListViewProps {
|
||||
tasks: Task[];
|
||||
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
isCreating?: boolean;
|
||||
onCancelCreate?: () => void;
|
||||
onCreateTask?: (input: { description: string; column: Column; dependencies?: string[] }) => Promise<Task>;
|
||||
onNewTask?: () => void;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function getStepProgress(steps: TaskStep[]): string {
|
||||
if (steps.length === 0) return "-";
|
||||
const done = steps.filter((s) => s.status === "done").length;
|
||||
return `${done}/${steps.length}`;
|
||||
}
|
||||
|
||||
function getStepProgressPercent(steps: TaskStep[]): number {
|
||||
if (steps.length === 0) return 0;
|
||||
const done = steps.filter((s) => s.status === "done").length;
|
||||
return (done / steps.length) * 100;
|
||||
}
|
||||
|
||||
export function ListView({
|
||||
tasks,
|
||||
onMoveTask,
|
||||
onOpenDetail,
|
||||
addToast,
|
||||
globalPaused,
|
||||
onNewTask,
|
||||
}: ListViewProps) {
|
||||
const [sortField, setSortField] = useState<SortField>("createdAt");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
|
||||
|
||||
// Column visibility state - initialize from localStorage or default to all columns
|
||||
const [visibleColumns, setVisibleColumns] = useState<Set<ListColumn>>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const saved = localStorage.getItem("kb-dashboard-list-columns");
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as ListColumn[];
|
||||
// Validate that all saved columns are valid ListColumn values
|
||||
const validColumns = parsed.filter((col): col is ListColumn =>
|
||||
ALL_LIST_COLUMNS.includes(col as ListColumn)
|
||||
);
|
||||
if (validColumns.length > 0) {
|
||||
return new Set(validColumns);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid localStorage data - fall through to default
|
||||
}
|
||||
}
|
||||
return new Set(ALL_LIST_COLUMNS);
|
||||
});
|
||||
|
||||
// Persist column visibility changes to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("kb-dashboard-list-columns", JSON.stringify([...visibleColumns]));
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
/**
|
||||
* Section expansion state - tracks which column sections are expanded/collapsed.
|
||||
* Initialized from localStorage with key "kb-dashboard-list-sections".
|
||||
* Defaults to all sections expanded if no saved state exists.
|
||||
* Persists changes to localStorage whenever sections are expanded/collapsed.
|
||||
*/
|
||||
const [expandedSections, setExpandedSections] = useState<Set<Column>>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const saved = localStorage.getItem("kb-dashboard-list-sections");
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as Column[];
|
||||
// Validate that all saved values are valid Column values
|
||||
const validColumns = parsed.filter((col): col is Column =>
|
||||
COLUMNS.includes(col as Column)
|
||||
);
|
||||
return new Set(validColumns);
|
||||
}
|
||||
} catch {
|
||||
// Invalid localStorage data - fall through to default
|
||||
}
|
||||
}
|
||||
// Default: all sections expanded
|
||||
return new Set<Column>(COLUMNS);
|
||||
});
|
||||
|
||||
// Persist section expansion state to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("kb-dashboard-list-sections", JSON.stringify([...expandedSections]));
|
||||
}
|
||||
}, [expandedSections]);
|
||||
|
||||
// Toggle a section's expansion state
|
||||
const toggleSection = useCallback((column: Column) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(column)) {
|
||||
next.delete(column);
|
||||
} else {
|
||||
next.add(column);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Expand all sections
|
||||
const expandAll = useCallback(() => {
|
||||
setExpandedSections(new Set<Column>(COLUMNS));
|
||||
}, []);
|
||||
|
||||
// Collapse all sections
|
||||
const collapseAll = useCallback(() => {
|
||||
setExpandedSections(new Set<Column>());
|
||||
}, []);
|
||||
|
||||
// Column dropdown state
|
||||
const [columnDropdownOpen, setColumnDropdownOpen] = useState(false);
|
||||
const columnDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Toggle a column's visibility
|
||||
const toggleColumn = useCallback((column: ListColumn) => {
|
||||
setVisibleColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(column)) {
|
||||
// Prevent hiding the last visible column
|
||||
if (next.size > 1) {
|
||||
next.delete(column);
|
||||
}
|
||||
} else {
|
||||
next.add(column);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!columnDropdownOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (columnDropdownRef.current && !columnDropdownRef.current.contains(e.target as Node)) {
|
||||
setColumnDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setColumnDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [columnDropdownOpen]);
|
||||
|
||||
// Column display labels
|
||||
const COLUMN_LABELS_MAP: Record<ListColumn, string> = {
|
||||
id: "ID",
|
||||
title: "Title",
|
||||
status: "Status",
|
||||
column: "Column",
|
||||
createdAt: "Created",
|
||||
updatedAt: "Updated",
|
||||
dependencies: "Dependencies",
|
||||
progress: "Progress",
|
||||
};
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
}, [sortField]);
|
||||
|
||||
const groupedTasks = useMemo(() => {
|
||||
const filtered = filter
|
||||
? tasks.filter(
|
||||
(t) =>
|
||||
t.id.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(t.title && t.title.toLowerCase().includes(filter.toLowerCase())) ||
|
||||
t.description.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
: tasks;
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortField) {
|
||||
case "id":
|
||||
comparison = a.id.localeCompare(b.id);
|
||||
break;
|
||||
case "title":
|
||||
comparison = (a.title || a.description).localeCompare(b.title || b.description);
|
||||
break;
|
||||
case "status":
|
||||
comparison = (a.status || "").localeCompare(b.status || "");
|
||||
break;
|
||||
case "column":
|
||||
comparison = a.column.localeCompare(b.column);
|
||||
break;
|
||||
case "createdAt":
|
||||
comparison = a.createdAt.localeCompare(b.createdAt);
|
||||
break;
|
||||
case "updatedAt":
|
||||
comparison = a.updatedAt.localeCompare(b.updatedAt);
|
||||
break;
|
||||
}
|
||||
return sortDirection === "asc" ? comparison : -comparison;
|
||||
});
|
||||
|
||||
// Group by column while preserving sort order within each group
|
||||
const groups: Record<Column, Task[]> = {
|
||||
triage: [],
|
||||
todo: [],
|
||||
"in-progress": [],
|
||||
"in-review": [],
|
||||
done: []
|
||||
};
|
||||
sorted.forEach(task => groups[task.column].push(task));
|
||||
return groups;
|
||||
}, [tasks, filter, sortField, sortDirection]);
|
||||
|
||||
// Calculate total filtered count from groups
|
||||
const filteredCount = useMemo(() => {
|
||||
return Object.values(groupedTasks).reduce((sum, group) => sum + group.length, 0);
|
||||
}, [groupedTasks]);
|
||||
const handleRowClick = useCallback(
|
||||
async (task: Task) => {
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
addToast("Failed to load task details", "error");
|
||||
}
|
||||
},
|
||||
[onOpenDetail, addToast]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, task: Task) => {
|
||||
if (task.paused) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
setDraggingTaskId(task.id);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggingTaskId(null);
|
||||
setDragOverColumn(null);
|
||||
}, []);
|
||||
|
||||
const handleColumnDragOver = useCallback(
|
||||
(e: React.DragEvent, column: Column) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverColumn(column);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleColumnDragLeave = useCallback(() => {
|
||||
setDragOverColumn(null);
|
||||
}, []);
|
||||
|
||||
const handleColumnDrop = useCallback(
|
||||
async (e: React.DragEvent, column: Column) => {
|
||||
e.preventDefault();
|
||||
setDragOverColumn(null);
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
if (!taskId) return;
|
||||
|
||||
try {
|
||||
await onMoveTask(taskId, column);
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
},
|
||||
[onMoveTask, addToast]
|
||||
);
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return <ArrowUpDown size={14} className="sort-icon" />;
|
||||
return sortDirection === "asc" ? (
|
||||
<ArrowUp size={14} className="sort-icon active" />
|
||||
) : (
|
||||
<ArrowDown size={14} className="sort-icon active" />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="list-view">
|
||||
<div className="list-toolbar">
|
||||
<div className="list-filter">
|
||||
<Search size={14} className="filter-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by ID or title..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="filter-input"
|
||||
/>
|
||||
{filter && (
|
||||
<button className="filter-clear" onClick={() => setFilter("")}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-section-controls">
|
||||
<button className="btn btn-sm" onClick={expandAll} title="Expand all sections">
|
||||
Expand All
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={collapseAll} title="Collapse all sections">
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-column-toggle" ref={columnDropdownRef}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => setColumnDropdownOpen((prev) => !prev)}
|
||||
aria-expanded={columnDropdownOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
Columns
|
||||
</button>
|
||||
{columnDropdownOpen && (
|
||||
<div className="list-column-dropdown" role="menu">
|
||||
{ALL_LIST_COLUMNS.map((column) => {
|
||||
const isVisible = visibleColumns.has(column);
|
||||
const isLastVisible = isVisible && visibleColumns.size === 1;
|
||||
return (
|
||||
<label
|
||||
key={column}
|
||||
className={`list-column-dropdown-item${isLastVisible ? " disabled" : ""}`}
|
||||
role="menuitem"
|
||||
title={isLastVisible ? "At least one column must be visible" : ""}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
onChange={() => toggleColumn(column)}
|
||||
disabled={isLastVisible}
|
||||
/>
|
||||
<span>{COLUMN_LABELS_MAP[column]}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-stats">
|
||||
{filteredCount} of {tasks.length} tasks
|
||||
</div>
|
||||
{onNewTask && (
|
||||
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
|
||||
+ New Task
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="list-drop-zones">
|
||||
{COLUMNS.map((column) => (
|
||||
<div
|
||||
key={column}
|
||||
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}`}
|
||||
onDragOver={(e) => handleColumnDragOver(e, column)}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
onDrop={(e) => handleColumnDrop(e, column)}
|
||||
data-column={column}
|
||||
>
|
||||
<span className="drop-zone-dot" style={{ background: COLUMN_COLOR_MAP[column] }} />
|
||||
<span className="drop-zone-label">{COLUMN_LABELS[column]}</span>
|
||||
<span className="drop-zone-count">
|
||||
{tasks.filter((t) => t.column === column).length}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="list-table-container">
|
||||
{filteredCount === 0 ? (
|
||||
<div className="list-empty">
|
||||
{filter ? "No tasks match your filter" : "No tasks yet"}
|
||||
</div>
|
||||
) : (
|
||||
<table className="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{visibleColumns.has("id") && (
|
||||
<th className="list-header-cell" onClick={() => handleSort("id")}>
|
||||
ID {getSortIcon("id")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("title") && (
|
||||
<th className="list-header-cell" onClick={() => handleSort("title")}>
|
||||
Title {getSortIcon("title")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("status") && (
|
||||
<th className="list-header-cell" onClick={() => handleSort("status")}>
|
||||
Status {getSortIcon("status")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("column") && (
|
||||
<th className="list-header-cell" onClick={() => handleSort("column")}>
|
||||
Column {getSortIcon("column")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("createdAt") && (
|
||||
<th className="list-header-cell" onClick={() => handleSort("createdAt")}>
|
||||
Created {getSortIcon("createdAt")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("updatedAt") && (
|
||||
<th className="list-header-cell" onClick={() => handleSort("updatedAt")}>
|
||||
Updated {getSortIcon("updatedAt")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("dependencies") && (
|
||||
<th className="list-header-cell">Dependencies</th>
|
||||
)}
|
||||
{visibleColumns.has("progress") && (
|
||||
<th className="list-header-cell">Progress</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COLUMNS.map((column) => {
|
||||
const columnTasks = groupedTasks[column];
|
||||
const isEmpty = columnTasks.length === 0;
|
||||
|
||||
// When filtering, hide empty sections entirely
|
||||
if (filter && isEmpty) return null;
|
||||
|
||||
return (
|
||||
<Fragment key={column}>
|
||||
{/* Section Header */}
|
||||
<tr
|
||||
className={`list-section-header${expandedSections.has(column) ? "" : " list-section-header--collapsed"}`}
|
||||
onClick={() => toggleSection(column)}
|
||||
>
|
||||
<th colSpan={visibleColumns.size} className="list-section-cell">
|
||||
<span className={`list-section-chevron${expandedSections.has(column) ? " list-section-chevron--expanded" : ""}`}>
|
||||
<ChevronRight size={16} />
|
||||
</span>
|
||||
<span className={`list-section-dot dot-${column}`} />
|
||||
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
|
||||
<span className="list-section-count">{columnTasks.length} {columnTasks.length === 1 ? "task" : "tasks"}</span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
{/* Task Rows - only render if section is expanded */}
|
||||
{expandedSections.has(column) && (
|
||||
<>
|
||||
{isEmpty ? (
|
||||
<tr className="list-section-empty">
|
||||
<td colSpan={visibleColumns.size} className="list-empty-cell">
|
||||
No tasks
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
columnTasks.map((task) => {
|
||||
const isFailed = task.status === "failed";
|
||||
const isPaused = task.paused === true;
|
||||
const isAgentActive =
|
||||
!globalPaused &&
|
||||
!isFailed &&
|
||||
!isPaused &&
|
||||
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={task.id}
|
||||
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
|
||||
isAgentActive ? " agent-active" : ""
|
||||
}${isDragging ? " dragging" : ""}`}
|
||||
onClick={() => handleRowClick(task)}
|
||||
draggable={!isPaused}
|
||||
onDragStart={(e) => handleDragStart(e, task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-id={task.id}
|
||||
>
|
||||
{visibleColumns.has("id") && (
|
||||
<td className="list-cell list-cell-id">{task.id}</td>
|
||||
)}
|
||||
{visibleColumns.has("title") && (
|
||||
<td className="list-cell list-cell-title">
|
||||
{task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "")}
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.has("status") && (
|
||||
<td className="list-cell">
|
||||
{task.status ? (
|
||||
<span
|
||||
className={`list-status-badge${isFailed ? " failed" : ""}${
|
||||
isAgentActive ? " pulsing" : ""
|
||||
}`}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
) : (
|
||||
<span className="list-status-badge">-</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.has("column") && (
|
||||
<td className="list-cell">
|
||||
<span
|
||||
className="list-column-badge"
|
||||
style={{
|
||||
background: `${COLUMN_COLOR_MAP[task.column]}20`,
|
||||
color: COLUMN_COLOR_MAP[task.column],
|
||||
}}
|
||||
>
|
||||
{COLUMN_LABELS[task.column]}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.has("createdAt") && (
|
||||
<td className="list-cell list-cell-date">{formatDate(task.createdAt)}</td>
|
||||
)}
|
||||
{visibleColumns.has("updatedAt") && (
|
||||
<td className="list-cell list-cell-date">{formatDate(task.updatedAt)}</td>
|
||||
)}
|
||||
{visibleColumns.has("dependencies") && (
|
||||
<td className="list-cell list-cell-deps">
|
||||
{task.dependencies && task.dependencies.length > 0 ? (
|
||||
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
|
||||
<Link size={12} /> {task.dependencies.length}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.has("progress") && (
|
||||
<td className="list-cell list-cell-progress">
|
||||
{task.steps.length > 0 ? (
|
||||
<div className="list-progress">
|
||||
<div className="list-progress-bar">
|
||||
<div
|
||||
className="list-progress-fill"
|
||||
style={{
|
||||
width: `${getStepProgressPercent(task.steps)}%`,
|
||||
backgroundColor: COLUMN_COLOR_MAP[task.column],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="list-progress-label">{getStepProgress(task.steps)}</span>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
packages/dashboard/app/components/ModelSelectorTab.tsx
Normal file
244
packages/dashboard/app/components/ModelSelectorTab.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { fetchModels, updateTask } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import type { Task, TaskDetail } from "@kb/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface ModelSelectorTabProps {
|
||||
task: Task | TaskDetail;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
|
||||
// Local state for selections (not saved until user clicks Save)
|
||||
const [executorProvider, setExecutorProvider] = useState<string | undefined>(task.modelProvider);
|
||||
const [executorModelId, setExecutorModelId] = useState<string | undefined>(task.modelId);
|
||||
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(task.validatorModelProvider);
|
||||
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(task.validatorModelId);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Load available models on mount
|
||||
useEffect(() => {
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
fetchModels()
|
||||
.then((models) => {
|
||||
setAvailableModels(models);
|
||||
})
|
||||
.catch((err) => {
|
||||
setModelsError(err.message || "Failed to load models");
|
||||
})
|
||||
.finally(() => {
|
||||
setModelsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Track if selections differ from task's saved values
|
||||
useEffect(() => {
|
||||
const executorChanged =
|
||||
executorProvider !== task.modelProvider ||
|
||||
executorModelId !== task.modelId;
|
||||
const validatorChanged =
|
||||
validatorProvider !== task.validatorModelProvider ||
|
||||
validatorModelId !== task.validatorModelId;
|
||||
setHasChanges(executorChanged || validatorChanged);
|
||||
}, [executorProvider, executorModelId, validatorProvider, validatorModelId, task]);
|
||||
|
||||
// Group models by provider
|
||||
const modelsByProvider = availableModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
|
||||
(acc[m.provider] ??= []).push(m);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Build select values (provider/id combination or empty for default)
|
||||
const executorValue = executorProvider && executorModelId
|
||||
? `${executorProvider}/${executorModelId}`
|
||||
: "";
|
||||
const validatorValue = validatorProvider && validatorModelId
|
||||
? `${validatorProvider}/${validatorModelId}`
|
||||
: "";
|
||||
|
||||
const handleExecutorChange = useCallback((value: string) => {
|
||||
if (!value) {
|
||||
setExecutorProvider(undefined);
|
||||
setExecutorModelId(undefined);
|
||||
} else {
|
||||
const slashIdx = value.indexOf("/");
|
||||
setExecutorProvider(value.slice(0, slashIdx));
|
||||
setExecutorModelId(value.slice(slashIdx + 1));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleValidatorChange = useCallback((value: string) => {
|
||||
if (!value) {
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
} else {
|
||||
const slashIdx = value.indexOf("/");
|
||||
setValidatorProvider(value.slice(0, slashIdx));
|
||||
setValidatorModelId(value.slice(slashIdx + 1));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateTask(task.id, {
|
||||
modelProvider: executorProvider,
|
||||
modelId: executorModelId,
|
||||
validatorModelProvider: validatorProvider,
|
||||
validatorModelId: validatorModelId,
|
||||
});
|
||||
addToast("Model settings saved", "success");
|
||||
setHasChanges(false);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to save model settings", "error");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [task.id, executorProvider, executorModelId, validatorProvider, validatorModelId, addToast]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setExecutorProvider(task.modelProvider);
|
||||
setExecutorModelId(task.modelId);
|
||||
setValidatorProvider(task.validatorModelProvider);
|
||||
setValidatorModelId(task.validatorModelId);
|
||||
}, [task]);
|
||||
|
||||
// Check if using defaults (both provider and modelId are undefined)
|
||||
const executorUsingDefault = !task.modelProvider && !task.modelId;
|
||||
const validatorUsingDefault = !task.validatorModelProvider && !task.validatorModelId;
|
||||
|
||||
return (
|
||||
<div className="model-selector-tab">
|
||||
<h4>Model Configuration</h4>
|
||||
<p className="model-selector-intro">
|
||||
Override the AI models used for this task. When not specified, global default settings are used.
|
||||
</p>
|
||||
|
||||
{modelsLoading ? (
|
||||
<div className="model-selector-loading">Loading available models…</div>
|
||||
) : modelsError ? (
|
||||
<div className="model-selector-error">
|
||||
Error loading models: {modelsError}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
fetchModels()
|
||||
.then(setAvailableModels)
|
||||
.catch((err) => setModelsError(err.message))
|
||||
.finally(() => setModelsLoading(false));
|
||||
}}
|
||||
style={{ marginLeft: "8px" }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
<div className="model-selector-empty">
|
||||
No models available. Configure authentication in Settings to enable model selection.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Executor Model Selector */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="executorModel">Executor Model</label>
|
||||
<div className="model-selector-current">
|
||||
{executorUsingDefault ? (
|
||||
<span className="model-badge model-badge-default">Using default</span>
|
||||
) : (
|
||||
<span className="model-badge model-badge-custom">
|
||||
{task.modelProvider}/{task.modelId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
id="executorModel"
|
||||
value={executorValue}
|
||||
onChange={(e) => handleExecutorChange(e.target.value)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<option value="">Use default</option>
|
||||
{Object.entries(modelsByProvider).map(([provider, models]) => (
|
||||
<optgroup key={provider} label={provider}>
|
||||
{models.map((m) => (
|
||||
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<small>The AI model used to implement this task.</small>
|
||||
</div>
|
||||
|
||||
{/* Validator Model Selector */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="validatorModel">Validator Model</label>
|
||||
<div className="model-selector-current">
|
||||
{validatorUsingDefault ? (
|
||||
<span className="model-badge model-badge-default">Using default</span>
|
||||
) : (
|
||||
<span className="model-badge model-badge-custom">
|
||||
{task.validatorModelProvider}/{task.validatorModelId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
id="validatorModel"
|
||||
value={validatorValue}
|
||||
onChange={(e) => handleValidatorChange(e.target.value)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<option value="">Use default</option>
|
||||
{Object.entries(modelsByProvider).map(([provider, models]) => (
|
||||
<optgroup key={provider} label={provider}>
|
||||
{models.map((m) => (
|
||||
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<small>The AI model used to review code and plans for this task.</small>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="model-selector-actions">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasChanges && (
|
||||
<div className="model-selector-status">
|
||||
{executorUsingDefault && validatorUsingDefault
|
||||
? "Using global default models."
|
||||
: "Model settings are up to date."}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
packages/dashboard/app/components/PrSection.tsx
Normal file
227
packages/dashboard/app/components/PrSection.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare } from "lucide-react";
|
||||
import type { PrInfo } from "@kb/core";
|
||||
import { createPr, refreshPrStatus } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface PrSectionProps {
|
||||
taskId: string;
|
||||
prInfo?: PrInfo;
|
||||
hasGitHubToken: boolean;
|
||||
onPrCreated: (prInfo: PrInfo) => void;
|
||||
onPrUpdated: (prInfo: PrInfo) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
open: { bg: "rgba(63,185,80,0.15)", text: "#3fb950", icon: "🔵" },
|
||||
closed: { bg: "rgba(218,54,51,0.15)", text: "#da3633", icon: "⚪" },
|
||||
merged: { bg: "rgba(188,140,255,0.15)", text: "#bc8cff", icon: "🟣" },
|
||||
};
|
||||
|
||||
export function PrSection({
|
||||
taskId,
|
||||
prInfo,
|
||||
hasGitHubToken,
|
||||
onPrCreated,
|
||||
onPrUpdated,
|
||||
addToast,
|
||||
}: PrSectionProps) {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [prTitle, setPrTitle] = useState("");
|
||||
const [prBody, setPrBody] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!prTitle.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const newPr = await createPr(taskId, {
|
||||
title: prTitle.trim(),
|
||||
body: prBody.trim() || undefined,
|
||||
});
|
||||
onPrCreated(newPr);
|
||||
setShowCreateForm(false);
|
||||
setPrTitle("");
|
||||
setPrBody("");
|
||||
addToast(`Created PR #${newPr.number}`, "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to create PR", "error");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [taskId, prTitle, prBody, onPrCreated, addToast]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!prInfo) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const updated = await refreshPrStatus(taskId);
|
||||
onPrUpdated(updated);
|
||||
addToast("PR status refreshed", "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to refresh PR", "error");
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [taskId, prInfo, onPrUpdated, addToast]);
|
||||
|
||||
// No PR yet - show create button
|
||||
if (!prInfo) {
|
||||
if (showCreateForm) {
|
||||
return (
|
||||
<div className="pr-section">
|
||||
<h4>
|
||||
<GitPullRequest size={16} style={{ verticalAlign: "middle", marginRight: 8 }} />
|
||||
Create Pull Request
|
||||
</h4>
|
||||
<div className="pr-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="PR title"
|
||||
value={prTitle}
|
||||
onChange={(e) => setPrTitle(e.target.value)}
|
||||
disabled={isCreating}
|
||||
className="pr-input"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="PR description (optional)"
|
||||
value={prBody}
|
||||
onChange={(e) => setPrBody(e.target.value)}
|
||||
disabled={isCreating}
|
||||
className="pr-textarea"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="pr-actions">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!prTitle.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? "Creating…" : "Create PR"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pr-section">
|
||||
<h4>
|
||||
<GitPullRequest size={16} style={{ verticalAlign: "middle", marginRight: 8 }} />
|
||||
Pull Request
|
||||
</h4>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
disabled={!hasGitHubToken}
|
||||
title={hasGitHubToken ? "Create a PR for this task" : "GitHub token not configured"}
|
||||
>
|
||||
<Plus size={14} style={{ verticalAlign: "middle", marginRight: 4 }} />
|
||||
Create PR
|
||||
</button>
|
||||
{!hasGitHubToken && (
|
||||
<div className="pr-hint" style={{ marginTop: 8, opacity: 0.7, fontSize: 12 }}>
|
||||
Set GITHUB_TOKEN env var to enable PR creation
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PR exists - show PR card
|
||||
const statusStyle = STATUS_COLORS[prInfo.status];
|
||||
|
||||
return (
|
||||
<div className="pr-section">
|
||||
<h4>
|
||||
<GitPullRequest size={16} style={{ verticalAlign: "middle", marginRight: 8 }} />
|
||||
Pull Request
|
||||
</h4>
|
||||
<div
|
||||
className="pr-card"
|
||||
style={{
|
||||
border: "1px solid var(--border, #333)",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
background: statusStyle.bg,
|
||||
}}
|
||||
>
|
||||
<div className="pr-header" style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 16 }}>{statusStyle.icon}</span>
|
||||
<span
|
||||
className="pr-status-badge"
|
||||
style={{
|
||||
background: statusStyle.bg,
|
||||
color: statusStyle.text,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{prInfo.status}
|
||||
</span>
|
||||
<span className="pr-number" style={{ fontSize: 14, opacity: 0.8 }}>
|
||||
#{prInfo.number}
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh PR status"
|
||||
style={{ padding: "4px 8px" }}
|
||||
>
|
||||
<RefreshCw size={14} style={{ verticalAlign: "middle", opacity: isRefreshing ? 0.5 : 1 }} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="pr-title" style={{ fontWeight: 500, marginBottom: 8 }}>
|
||||
{prInfo.title}
|
||||
</div>
|
||||
<div className="pr-meta" style={{ fontSize: 12, opacity: 0.7, marginBottom: 8 }}>
|
||||
<span>{prInfo.headBranch}</span>
|
||||
<span style={{ margin: "0 8px" }}>→</span>
|
||||
<span>{prInfo.baseBranch}</span>
|
||||
</div>
|
||||
<div className="pr-footer" style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{prInfo.commentCount > 0 && (
|
||||
<span className="pr-comments" style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<MessageSquare size={14} />
|
||||
{prInfo.commentCount}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={prInfo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="pr-link"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
color: "var(--link, #58a6ff)",
|
||||
textDecoration: "none",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -203,6 +203,20 @@ export function SettingsModal({ onClose, addToast, initialSection }: SettingsMod
|
||||
{prefixError && <small className="field-error">{prefixError}</small>}
|
||||
{!prefixError && <small>Prefix for new task IDs (e.g. KB, PROJ)</small>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="requirePlanApproval" className="checkbox-label">
|
||||
<input
|
||||
id="requirePlanApproval"
|
||||
type="checkbox"
|
||||
checked={form.requirePlanApproval || false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, requirePlanApproval: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Require plan approval
|
||||
</label>
|
||||
<small>When enabled, AI-generated task specifications require manual approval before moving to Todo</small>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "model": {
|
||||
@@ -444,6 +458,34 @@ export function SettingsModal({ onClose, addToast, initialSection }: SettingsMod
|
||||
</label>
|
||||
<small>When disabled, merge commit messages omit the task ID from the scope (e.g. <code>feat: ...</code> instead of <code>feat(KB-001): ...</code>)</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoResolveConflicts" className="checkbox-label">
|
||||
<input
|
||||
id="autoResolveConflicts"
|
||||
type="checkbox"
|
||||
checked={form.autoResolveConflicts !== false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, autoResolveConflicts: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Auto-resolve conflicts in lock files and generated files
|
||||
</label>
|
||||
<small>When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.), generated files (dist/*, *.gen.ts), and trivial whitespace conflicts are resolved automatically without AI intervention. Complex code conflicts still require AI review.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="smartConflictResolution" className="checkbox-label">
|
||||
<input
|
||||
id="smartConflictResolution"
|
||||
type="checkbox"
|
||||
checked={form.smartConflictResolution !== false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, smartConflictResolution: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Smart conflict resolution
|
||||
</label>
|
||||
<small>When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review.</small>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "authentication":
|
||||
|
||||
184
packages/dashboard/app/components/SpecEditor.tsx
Normal file
184
packages/dashboard/app/components/SpecEditor.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface SpecEditorProps {
|
||||
content: string;
|
||||
readOnly?: boolean;
|
||||
onSave?: (content: string) => Promise<void>;
|
||||
onRequestRevision?: (feedback: string) => Promise<void>;
|
||||
isSaving?: boolean;
|
||||
isRequesting?: boolean;
|
||||
}
|
||||
|
||||
export function SpecEditor({
|
||||
content,
|
||||
readOnly = false,
|
||||
onSave,
|
||||
onRequestRevision,
|
||||
isSaving = false,
|
||||
isRequesting = false,
|
||||
}: SpecEditorProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(content);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const feedbackRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Update edit content when external content changes (only when not editing)
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setEditContent(content);
|
||||
}
|
||||
}, [content, isEditing]);
|
||||
|
||||
const hasChanges = editContent !== content;
|
||||
const canSave = isEditing && hasChanges && !isSaving && onSave;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
await onSave(editContent);
|
||||
setIsEditing(false);
|
||||
}, [canSave, onSave, editContent]);
|
||||
|
||||
const handleRequestRevision = useCallback(async () => {
|
||||
if (!onRequestRevision || !feedback.trim() || isRequesting) return;
|
||||
await onRequestRevision(feedback.trim());
|
||||
setFeedback("");
|
||||
}, [onRequestRevision, feedback, isRequesting]);
|
||||
|
||||
// Keyboard shortcut: Ctrl/Cmd+Enter to save in edit mode
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isEditing && (e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (canSave) {
|
||||
void handleSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isEditing, canSave, handleSave]);
|
||||
|
||||
const handleEnterEditMode = () => {
|
||||
setIsEditing(true);
|
||||
setEditContent(content);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setEditContent(content);
|
||||
};
|
||||
|
||||
const stripLeadingHeading = (text: string): string => {
|
||||
return text.replace(/^#\s+[^\n]*\n+/, "");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="spec-editor">
|
||||
{/* Toolbar */}
|
||||
<div className="spec-editor-toolbar">
|
||||
<div className="spec-editor-mode-toggle">
|
||||
<button
|
||||
className={`btn btn-sm ${!isEditing ? "btn-primary" : ""}`}
|
||||
onClick={() => isEditing ? handleCancelEdit() : undefined}
|
||||
disabled={!isEditing}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
className={`btn btn-sm ${isEditing ? "btn-primary" : ""}`}
|
||||
onClick={handleEnterEditMode}
|
||||
disabled={isEditing}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<div className="spec-editor-actions">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="spec-editor-content">
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="spec-editor-textarea"
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
disabled={isSaving}
|
||||
placeholder="Enter task specification in Markdown..."
|
||||
rows={20}
|
||||
/>
|
||||
) : content ? (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{stripLeadingHeading(content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="spec-editor-empty">(no specification)</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard hint for edit mode */}
|
||||
{isEditing && (
|
||||
<div className="spec-editor-hint">
|
||||
Press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> (or <kbd>Cmd</kbd>+<kbd>Enter</kbd>) to save
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Revision Section */}
|
||||
{!readOnly && onRequestRevision && (
|
||||
<div className="spec-editor-revision">
|
||||
<h4>Ask AI to Revise</h4>
|
||||
<p className="spec-editor-revision-help">
|
||||
Provide feedback for the AI to improve this specification. The task will move to triage for re-specification.
|
||||
</p>
|
||||
<textarea
|
||||
ref={feedbackRef}
|
||||
className="spec-editor-feedback"
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="e.g., 'Add more details about error handling', 'Split this into smaller steps', 'Include tests for the API endpoints'..."
|
||||
disabled={isRequesting}
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<div className="spec-editor-revision-actions">
|
||||
<span className="spec-editor-char-count">
|
||||
{feedback.length}/2000
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleRequestRevision()}
|
||||
disabled={!feedback.trim() || isRequesting}
|
||||
>
|
||||
{isRequesting ? "Requesting…" : "Request AI Revision"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
packages/dashboard/app/components/SteeringTab.tsx
Normal file
169
packages/dashboard/app/components/SteeringTab.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { TaskDetail } from "@kb/core";
|
||||
import { addSteeringComment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
interface SteeringTabProps {
|
||||
task: TaskDetail;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function SteeringTab({ task, addToast }: SteeringTabProps) {
|
||||
const [comments, setComments] = useState(task.steeringComments || []);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const MAX_LENGTH = 2000;
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!newComment.trim() || newComment.length > MAX_LENGTH || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const updated = await addSteeringComment(task.id, newComment.trim());
|
||||
setComments(updated.steeringComments || []);
|
||||
setNewComment("");
|
||||
addToast("Steering comment added", "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [task.id, newComment, isSubmitting, addToast]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
const isValid = newComment.trim().length > 0 && newComment.length <= MAX_LENGTH;
|
||||
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<h4>Steering Comments</h4>
|
||||
<p style={{ fontSize: "13px", opacity: 0.7, marginBottom: "12px" }}>
|
||||
Add comments to guide the AI during task execution. These are injected into the execution context.
|
||||
</p>
|
||||
|
||||
{comments.length > 0 ? (
|
||||
<div className="detail-activity-list" style={{ marginBottom: "16px" }}>
|
||||
{[...comments].reverse().map((comment) => (
|
||||
<div key={comment.id} className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span
|
||||
className="detail-log-timestamp"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
background:
|
||||
comment.author === "user"
|
||||
? "var(--accent-primary, #6366f1)"
|
||||
: "var(--accent-secondary, #8b5cf6)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{comment.author}
|
||||
</span>
|
||||
{formatTimestamp(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "4px",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-secondary, #1a1a2e)",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border, #333)",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{comment.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ opacity: 0.5, marginBottom: "16px" }}>(no steering comments yet)</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a steering comment... (Ctrl+Enter to submit)"
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
fontSize: "14px",
|
||||
fontFamily: "inherit",
|
||||
background: "var(--bg-secondary, #1a1a2e)",
|
||||
border: "1px solid var(--border, #333)",
|
||||
borderRadius: "6px",
|
||||
color: "inherit",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
opacity: newComment.length > MAX_LENGTH ? 0.9 : 0.5,
|
||||
color:
|
||||
newComment.length > MAX_LENGTH
|
||||
? "var(--error, #ef4444)"
|
||||
: "inherit",
|
||||
}}
|
||||
>
|
||||
{newComment.length} / {MAX_LENGTH}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Adding…" : "Add Steering Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Link, Clock, Layers } from "lucide-react";
|
||||
import { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { Link, Clock, Layers, GitPullRequest, Pencil, ChevronDown } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column } from "@kb/core";
|
||||
import { fetchTaskDetail, uploadAttachment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -20,6 +20,8 @@ const COLUMN_TEXT_COLOR_MAP: Record<Column, string> = {
|
||||
done: "var(--done)",
|
||||
};
|
||||
|
||||
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
|
||||
|
||||
interface TaskCardProps {
|
||||
@@ -28,11 +30,46 @@ interface TaskCardProps {
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
tasks?: Task[]; // All tasks for dependency lookup
|
||||
onUpdateTask?: (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, queued, onOpenDetail, addToast, globalPaused }: TaskCardProps) {
|
||||
export function TaskCard({
|
||||
task,
|
||||
queued,
|
||||
onOpenDetail,
|
||||
addToast,
|
||||
globalPaused,
|
||||
tasks = [],
|
||||
onUpdateTask,
|
||||
}: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(task.title || "");
|
||||
const [editDescription, setEditDescription] = useState(task.description || "");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showSteps, setShowSteps] = useState(false);
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Reset edit state when task changes
|
||||
useEffect(() => {
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
}, [task.id, task.title, task.description]);
|
||||
|
||||
// Auto-focus on title when entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
titleInputRef.current?.focus();
|
||||
titleInputRef.current?.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent) => {
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
@@ -81,19 +118,177 @@ export function TaskCard({ task, queued, onOpenDetail, addToast, globalPaused }:
|
||||
}, [task.id, isFileDrag, addToast]);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (isEditing) return; // Don't open detail when editing
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
addToast("Failed to load task details", "error");
|
||||
}
|
||||
}, [task.id, onOpenDetail, addToast]);
|
||||
}, [task.id, onOpenDetail, addToast, isEditing]);
|
||||
|
||||
const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => {
|
||||
e.stopPropagation(); // Prevent card click
|
||||
try {
|
||||
const detail = await fetchTaskDetail(depId);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to load dependency ${depId}`, "error");
|
||||
}
|
||||
}, [onOpenDetail, addToast]);
|
||||
|
||||
const isFailed = task.status === "failed";
|
||||
const isPaused = task.paused === true;
|
||||
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const isDraggable = !queued && !isPaused;
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${fileDragOver ? " file-drop-target" : ""}`;
|
||||
const isDraggable = !queued && !isPaused && !isEditing; // Disable drag during edit
|
||||
|
||||
// Check if this card can be edited inline
|
||||
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
|
||||
|
||||
const enterEditMode = useCallback((e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
if (!canEdit || isSaving) return;
|
||||
setIsEditing(true);
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
}, [canEdit, isSaving, task.title, task.description]);
|
||||
|
||||
const exitEditMode = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
}, [task.title, task.description]);
|
||||
|
||||
const hasChanges = useCallback(() => {
|
||||
return editTitle !== (task.title || "") || editDescription !== (task.description || "");
|
||||
}, [editTitle, editDescription, task.title, task.description]);
|
||||
|
||||
const saveChanges = useCallback(async () => {
|
||||
if (!onUpdateTask || isSaving) return;
|
||||
if (!hasChanges()) {
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onUpdateTask(task.id, {
|
||||
title: editTitle.trim() || undefined,
|
||||
description: editDescription.trim() || undefined,
|
||||
});
|
||||
addToast(`Updated ${task.id}`, "success");
|
||||
setIsEditing(false);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to update ${task.id}: ${err.message}`, "error");
|
||||
// Stay in edit mode on error so user can retry
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [onUpdateTask, task.id, editTitle, editDescription, isSaving, hasChanges, exitEditMode, addToast]);
|
||||
|
||||
const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
// Move focus to description textarea
|
||||
descTextareaRef.current?.focus();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
exitEditMode();
|
||||
}
|
||||
}, [exitEditMode]);
|
||||
|
||||
const handleDescKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
saveChanges();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
exitEditMode();
|
||||
}
|
||||
}, [saveChanges, exitEditMode]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// Small delay to allow focus to move between title and description inputs
|
||||
// before checking if we should save or cancel
|
||||
setTimeout(() => {
|
||||
const activeElement = document.activeElement;
|
||||
const isFocusInEditArea =
|
||||
activeElement === titleInputRef.current ||
|
||||
activeElement === descTextareaRef.current ||
|
||||
activeElement?.closest(".card-editing-content");
|
||||
|
||||
if (!isFocusInEditArea) {
|
||||
if (hasChanges()) {
|
||||
saveChanges();
|
||||
} else {
|
||||
exitEditMode();
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}, [hasChanges, saveChanges, exitEditMode]);
|
||||
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
if (canEdit) {
|
||||
e.stopPropagation();
|
||||
enterEditMode(e);
|
||||
}
|
||||
}, [canEdit, enterEditMode]);
|
||||
|
||||
const handleEditClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
enterEditMode(e);
|
||||
}, [enterEditMode]);
|
||||
|
||||
// Auto-resize textarea (similar to InlineCreateCard)
|
||||
const handleDescChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEditDescription(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}, []);
|
||||
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div
|
||||
className={cardClass}
|
||||
data-id={task.id}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div className="card-editing-content">
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
className="card-edit-title-input"
|
||||
placeholder="Task title (optional)"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<textarea
|
||||
ref={descTextareaRef}
|
||||
className="card-edit-desc-textarea"
|
||||
placeholder="Task description"
|
||||
value={editDescription}
|
||||
onChange={handleDescChange}
|
||||
onKeyDown={handleDescKeyDown}
|
||||
onBlur={handleBlur}
|
||||
disabled={isSaving}
|
||||
rows={1}
|
||||
/>
|
||||
{isSaving && (
|
||||
<div className="card-edit-loading">
|
||||
<span className="card-edit-loading-spinner" />
|
||||
<span className="card-edit-loading-text">Saving...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -106,6 +301,7 @@ export function TaskCard({ task, queued, onOpenDetail, addToast, globalPaused }:
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div className="card-header">
|
||||
<span className="card-id">{task.id}</span>
|
||||
@@ -128,6 +324,51 @@ export function TaskCard({ task, queued, onOpenDetail, addToast, globalPaused }:
|
||||
{task.status}
|
||||
</span>
|
||||
)}
|
||||
{/* PR Status Indicator for in-review tasks */}
|
||||
{task.column === "in-review" && task.prInfo && (
|
||||
<span
|
||||
className="card-pr-badge"
|
||||
title={`PR #${task.prInfo.number}: ${task.prInfo.status}`}
|
||||
style={{
|
||||
background: task.prInfo.status === "merged"
|
||||
? "rgba(188,140,255,0.2)"
|
||||
: task.prInfo.status === "closed"
|
||||
? "rgba(139,148,158,0.2)"
|
||||
: "rgba(63,185,80,0.2)",
|
||||
color: task.prInfo.status === "merged"
|
||||
? "#bc8cff"
|
||||
: task.prInfo.status === "closed"
|
||||
? "#8b949e"
|
||||
: "#3fb950",
|
||||
fontSize: "11px",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
<GitPullRequest size={12} />
|
||||
#{task.prInfo.number}
|
||||
</span>
|
||||
)}
|
||||
{/* Size Indicator */}
|
||||
{task.size && (
|
||||
<span className={`card-size-badge size-${task.size.toLowerCase()}`}>
|
||||
{task.size}
|
||||
</span>
|
||||
)}
|
||||
{/* Edit button - visible on hover for editable cards */}
|
||||
{canEdit && (
|
||||
<button
|
||||
className="card-edit-btn"
|
||||
onClick={handleEditClick}
|
||||
title="Edit task"
|
||||
aria-label="Edit task"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-title">
|
||||
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
|
||||
@@ -136,27 +377,69 @@ export function TaskCard({ task, queued, onOpenDetail, addToast, globalPaused }:
|
||||
const completedSteps = task.steps.filter(s => s.status === "done").length;
|
||||
const totalSteps = task.steps.length;
|
||||
return (
|
||||
<div className="card-progress">
|
||||
<div className="card-progress-bar">
|
||||
<div
|
||||
className="card-progress-fill"
|
||||
style={{
|
||||
width: `${(completedSteps / totalSteps) * 100}%`,
|
||||
backgroundColor: COLUMN_TEXT_COLOR_MAP[task.column],
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<div className="card-progress">
|
||||
<div className="card-progress-bar">
|
||||
<div
|
||||
className="card-progress-fill"
|
||||
style={{
|
||||
width: `${(completedSteps / totalSteps) * 100}%`,
|
||||
backgroundColor: COLUMN_TEXT_COLOR_MAP[task.column],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="card-progress-label">{completedSteps}/{totalSteps}</span>
|
||||
</div>
|
||||
<span className="card-progress-label">{completedSteps}/{totalSteps}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="card-steps-toggle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSteps(!showSteps);
|
||||
}}
|
||||
aria-expanded={showSteps}
|
||||
aria-label={showSteps ? "Hide steps" : "Show steps"}
|
||||
>
|
||||
<span>{totalSteps} steps</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`card-steps-toggle-icon${showSteps ? " expanded" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{showSteps && (
|
||||
<div className="card-steps-list">
|
||||
{task.steps.map((step, index) => (
|
||||
<div key={index} className="card-step-item">
|
||||
<span
|
||||
className={`card-step-dot card-step-dot--${step.status}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={`card-step-name${step.status === "done" ? " completed" : ""}`}>
|
||||
{step.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
||||
<div className="card-meta">
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<span className="card-dep-badge" data-tooltip={task.dependencies.join(", ")}>
|
||||
<Link size={12} style={{ verticalAlign: 'middle' }} /> {task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""}
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<div className="card-dep-list">
|
||||
{task.dependencies.map((depId) => (
|
||||
<span
|
||||
key={depId}
|
||||
className="card-dep-badge clickable"
|
||||
onClick={(e) => handleDepClick(e, depId)}
|
||||
title={`Click to view ${depId}`}
|
||||
>
|
||||
<Link size={12} style={{ verticalAlign: 'middle' }} /> {depId}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{task.blockedBy && (
|
||||
<span className="card-scope-badge" data-tooltip={`Blocked by ${task.blockedBy} (file overlap)`}>
|
||||
<Layers size={12} style={{ verticalAlign: 'middle' }} /> {task.blockedBy}
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult } from "@kb/core";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo } from "@kb/core";
|
||||
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@kb/core";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask } from "../api";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, requestSpecRevision, approvePlan, rejectPlan } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { SteeringTab } from "./SteeringTab";
|
||||
import { ModelSelectorTab } from "./ModelSelectorTab";
|
||||
import { PrSection } from "./PrSection";
|
||||
import { SpecEditor } from "./SpecEditor";
|
||||
|
||||
function getStepStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "done":
|
||||
return "var(--color-success, #3fb950)";
|
||||
case "in-progress":
|
||||
return "var(--todo, #58a6ff)";
|
||||
case "skipped":
|
||||
return "var(--text-dim, #484f58)";
|
||||
case "pending":
|
||||
default:
|
||||
return "var(--border, #30363d)";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
@@ -33,11 +51,13 @@ interface TaskDetailModalProps {
|
||||
task: TaskDetail;
|
||||
tasks?: Task[];
|
||||
onClose: () => void;
|
||||
onOpenDetail: (task: TaskDetail) => void; // For clicking dependencies
|
||||
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
||||
onDeleteTask: (id: string) => Promise<Task>;
|
||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
githubTokenConfigured?: boolean;
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
@@ -48,18 +68,22 @@ export function TaskDetailModal({
|
||||
task,
|
||||
tasks = [],
|
||||
onClose,
|
||||
onOpenDetail,
|
||||
onMoveTask,
|
||||
onDeleteTask,
|
||||
onMergeTask,
|
||||
onRetryTask,
|
||||
addToast,
|
||||
githubTokenConfigured,
|
||||
}: TaskDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "agent-log">("definition");
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "model" | "spec">("definition");
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
const [isSavingSpec, setIsSavingSpec] = useState(false);
|
||||
const [isRequestingRevision, setIsRequestingRevision] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!showDepDropdown) setDepSearch("");
|
||||
}, [showDepDropdown]);
|
||||
@@ -149,6 +173,27 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [task.id, task.paused, onClose, addToast]);
|
||||
|
||||
const handleApprovePlan = useCallback(async () => {
|
||||
try {
|
||||
await approvePlan(task.id);
|
||||
addToast(`Plan approved — ${task.id} moved to Todo`, "success");
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [task.id, onClose, addToast]);
|
||||
|
||||
const handleRejectPlan = useCallback(async () => {
|
||||
if (!confirm("Reject this plan? The specification will be discarded and regenerated.")) return;
|
||||
try {
|
||||
await rejectPlan(task.id);
|
||||
addToast(`Plan rejected — ${task.id} returned to Triage for re-specification`, "info");
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [task.id, onClose, addToast]);
|
||||
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
@@ -226,7 +271,8 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [task.id, dependencies, addToast]);
|
||||
|
||||
const handleRemoveDep = useCallback(async (depId: string) => {
|
||||
const handleRemoveDep = useCallback(async (e: React.MouseEvent, depId: string) => {
|
||||
e.stopPropagation(); // Prevent triggering dependency click
|
||||
const newDeps = dependencies.filter((d) => d !== depId);
|
||||
setDependencies(newDeps);
|
||||
try {
|
||||
@@ -237,6 +283,48 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [task.id, dependencies, addToast]);
|
||||
|
||||
const handleDepClick = useCallback(async (depId: string) => {
|
||||
try {
|
||||
const detail = await fetchTaskDetail(depId);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to load dependency ${depId}`, "error");
|
||||
}
|
||||
}, [onOpenDetail, addToast]);
|
||||
|
||||
const handleSaveSpec = useCallback(async (newContent: string) => {
|
||||
setIsSavingSpec(true);
|
||||
try {
|
||||
await updateTask(task.id, { prompt: newContent });
|
||||
addToast("Spec updated", "success");
|
||||
// Update local task data
|
||||
task.prompt = newContent;
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSavingSpec(false);
|
||||
}
|
||||
}, [task, addToast]);
|
||||
|
||||
const handleRequestSpecRevision = useCallback(async (feedback: string) => {
|
||||
setIsRequestingRevision(true);
|
||||
try {
|
||||
await requestSpecRevision(task.id, feedback);
|
||||
addToast("AI revision requested. Task moved to triage.", "success");
|
||||
// Task has been moved to triage, close modal
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("in-review") || err.message?.includes("done")) {
|
||||
addToast("Cannot request revision: Task must be in 'todo' or 'in-progress' column.", "error");
|
||||
} else {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
} finally {
|
||||
setIsRequestingRevision(false);
|
||||
}
|
||||
}, [task.id, addToast, onClose]);
|
||||
|
||||
const availableTasks = tasks
|
||||
.filter((t) => t.id !== task.id && !dependencies.includes(t.id))
|
||||
.sort((a, b) => {
|
||||
@@ -276,17 +364,80 @@ export function TaskDetailModal({
|
||||
>
|
||||
Definition
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "activity" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("activity")}
|
||||
>
|
||||
Activity
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "agent-log" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("agent-log")}
|
||||
>
|
||||
Agent Log
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "steering" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("steering")}
|
||||
>
|
||||
Steering
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "model" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("model")}
|
||||
>
|
||||
Model
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "spec" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("spec")}
|
||||
>
|
||||
Spec
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === "agent-log" ? (
|
||||
{activeTab === "spec" ? (
|
||||
<div className="detail-section">
|
||||
<SpecEditor
|
||||
content={task.prompt || ""}
|
||||
onSave={handleSaveSpec}
|
||||
onRequestRevision={handleRequestSpecRevision}
|
||||
isSaving={isSavingSpec}
|
||||
isRequesting={isRequestingRevision}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "model" ? (
|
||||
<div className="detail-section">
|
||||
<ModelSelectorTab task={task} addToast={addToast} />
|
||||
</div>
|
||||
) : activeTab === "agent-log" ? (
|
||||
<div className="detail-section">
|
||||
<AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} />
|
||||
</div>
|
||||
) : activeTab === "steering" ? (
|
||||
<SteeringTab task={task} addToast={addToast} />
|
||||
) : activeTab === "activity" ? (
|
||||
<div className="detail-section detail-activity">
|
||||
<h4>Activity</h4>
|
||||
{task.log && task.log.length > 0 ? (
|
||||
<div className="detail-activity-list">
|
||||
{[...task.log].reverse().map((entry, i) => (
|
||||
<div key={i} className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-timestamp">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
<span className="detail-log-action">{entry.action}</span>
|
||||
</div>
|
||||
{entry.outcome && (
|
||||
<div className="detail-log-outcome">{entry.outcome}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="detail-log-empty">(no activity)</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="detail-section">
|
||||
@@ -377,11 +528,25 @@ export function TaskDetailModal({
|
||||
{dependencies.length > 0 ? (
|
||||
<ul className="detail-dep-list">
|
||||
{dependencies.map((dep) => (
|
||||
<li key={dep}>
|
||||
{dep}
|
||||
<li key={dep} className="detail-dep-item">
|
||||
<span
|
||||
className="detail-dep-link"
|
||||
onClick={() => handleDepClick(dep)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleDepClick(dep);
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
title={`Click to view ${dep}`}
|
||||
>
|
||||
{dep}
|
||||
</span>
|
||||
<button
|
||||
className="dep-remove-btn"
|
||||
onClick={() => handleRemoveDep(dep)}
|
||||
onClick={(e) => handleRemoveDep(e, dep)}
|
||||
title={`Remove dependency ${dep}`}
|
||||
style={{
|
||||
marginLeft: "6px",
|
||||
@@ -450,28 +615,45 @@ export function TaskDetailModal({
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-section detail-activity">
|
||||
<h4>Activity</h4>
|
||||
{task.log && task.log.length > 0 ? (
|
||||
<div className="detail-activity-list">
|
||||
{[...task.log].reverse().map((entry, i) => (
|
||||
<div key={i} className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-timestamp">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
<span className="detail-log-action">{entry.action}</span>
|
||||
</div>
|
||||
{entry.outcome && (
|
||||
<div className="detail-log-outcome">{entry.outcome}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="detail-section detail-step-progress">
|
||||
<h4>Progress</h4>
|
||||
{task.steps && task.steps.length > 0 ? (
|
||||
<div className="step-progress-wrapper">
|
||||
<div className="step-progress-bar">
|
||||
{task.steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`step-progress-segment step-progress-segment--${step.status}`}
|
||||
data-tooltip={`${step.name} (${step.status})`}
|
||||
style={{ backgroundColor: getStepStatusColor(step.status) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="step-progress-label">
|
||||
{task.steps.filter(s => s.status === "done").length}/{task.steps.length} steps
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="detail-log-empty">(no activity)</div>
|
||||
<div className="step-progress-empty">(no steps defined)</div>
|
||||
)}
|
||||
</div>
|
||||
{/* PR Section - only for in-review tasks */}
|
||||
{task.column === "in-review" && (
|
||||
<PrSection
|
||||
taskId={task.id}
|
||||
prInfo={task.prInfo}
|
||||
hasGitHubToken={githubTokenConfigured ?? false}
|
||||
onPrCreated={(prInfo) => {
|
||||
// Update task locally to show new PR
|
||||
(task as TaskDetail).prInfo = prInfo;
|
||||
addToast(`PR #${prInfo.number} created`, "success");
|
||||
}}
|
||||
onPrUpdated={(prInfo) => {
|
||||
(task as TaskDetail).prInfo = prInfo;
|
||||
}}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -489,6 +671,17 @@ export function TaskDetailModal({
|
||||
{task.paused ? "Unpause" : "Pause"}
|
||||
</button>
|
||||
)}
|
||||
{/* Approve/Reject Plan buttons for tasks awaiting approval */}
|
||||
{task.column === "triage" && task.status === "awaiting-approval" && task.prompt && (
|
||||
<>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleApprovePlan}>
|
||||
Approve Plan
|
||||
</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={handleRejectPlan}>
|
||||
Reject Plan
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{task.column === "in-review" ? (
|
||||
<>
|
||||
|
||||
137
packages/dashboard/app/components/TerminalModal.tsx
Normal file
137
packages/dashboard/app/components/TerminalModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, Trash2 } from "lucide-react";
|
||||
import type { Task, AgentLogEntry } from "@kb/core";
|
||||
import { useMultiAgentLogs } from "../hooks/useMultiAgentLogs";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
|
||||
interface TerminalModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
interface LogEntryWithTask extends AgentLogEntry {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export function TerminalModal({ isOpen, onClose, tasks }: TerminalModalProps) {
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
|
||||
// Get task IDs for all in-progress tasks
|
||||
const inProgressTaskIds = tasks.map((t) => t.id);
|
||||
|
||||
// Get log state for all tasks
|
||||
const logState = useMultiAgentLogs(inProgressTaskIds);
|
||||
|
||||
// Set initial active task when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && tasks.length > 0) {
|
||||
// If no active task or active task not in current list, set first task
|
||||
if (!activeTaskId || !tasks.find((t) => t.id === activeTaskId)) {
|
||||
setActiveTaskId(tasks[0].id);
|
||||
}
|
||||
}
|
||||
// Reset when modal closes
|
||||
if (!isOpen) {
|
||||
setActiveTaskId(null);
|
||||
}
|
||||
}, [isOpen, tasks, activeTaskId]);
|
||||
|
||||
// Handle escape key to close modal
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Handle overlay click to close
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Get active task info
|
||||
const activeTask = tasks.find((t) => t.id === activeTaskId);
|
||||
const activeLogState = activeTaskId ? logState[activeTaskId] : null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="terminal-modal-overlay">
|
||||
<div className="modal terminal-modal" data-testid="terminal-modal">
|
||||
{/* Header with tabs and close button */}
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-tabs" data-testid="terminal-tabs">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="terminal-tab terminal-tab--empty" data-testid="terminal-no-tasks">
|
||||
No active tasks
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
className={`terminal-tab ${activeTaskId === task.id ? "terminal-tab--active" : ""}`}
|
||||
onClick={() => setActiveTaskId(task.id)}
|
||||
data-testid={`terminal-tab-${task.id}`}
|
||||
title={task.title || task.description}
|
||||
>
|
||||
<span className="terminal-tab-label">{task.id}</span>
|
||||
{activeTaskId === task.id && (
|
||||
<span
|
||||
className="terminal-tab-indicator"
|
||||
data-testid={`terminal-tab-indicator-${task.id}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<button className="terminal-close" onClick={onClose} data-testid="terminal-close-btn" title="Close terminal">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Log content area */}
|
||||
<div className="terminal-content" data-testid="terminal-content">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="terminal-empty-state" data-testid="terminal-empty-state">
|
||||
<p>No tasks currently in progress.</p>
|
||||
<p>Start a task to see live logs here.</p>
|
||||
</div>
|
||||
) : activeTask && activeLogState ? (
|
||||
<>
|
||||
<div className="terminal-toolbar" data-testid="terminal-toolbar">
|
||||
<div className="terminal-task-info">
|
||||
<span className="terminal-task-id" data-testid="terminal-active-task-id">
|
||||
{activeTask.id}
|
||||
</span>
|
||||
<span className="terminal-task-title" data-testid="terminal-active-task-title">
|
||||
{activeTask.title || activeTask.description}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="terminal-clear-btn"
|
||||
onClick={activeLogState.clear}
|
||||
data-testid="terminal-clear-btn"
|
||||
title="Clear log buffer"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="terminal-log-container" data-testid="terminal-log-container">
|
||||
<AgentLogViewer entries={activeLogState.entries} loading={activeLogState.loading} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,11 @@ interface WorktreeGroupProps {
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
tasks?: Task[]; // All tasks for dependency lookup
|
||||
onUpdateTask?: (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function WorktreeGroup({
|
||||
@@ -19,6 +24,8 @@ export function WorktreeGroup({
|
||||
onOpenDetail,
|
||||
addToast,
|
||||
globalPaused,
|
||||
tasks = [],
|
||||
onUpdateTask,
|
||||
}: WorktreeGroupProps) {
|
||||
return (
|
||||
<div className="worktree-group">
|
||||
@@ -29,7 +36,7 @@ export function WorktreeGroup({
|
||||
<span className="worktree-label">{label}</span>
|
||||
</div>
|
||||
{activeTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} />
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} tasks={tasks} onUpdateTask={onUpdateTask} />
|
||||
))}
|
||||
{queuedTasks.map((task) => (
|
||||
<TaskCard
|
||||
@@ -39,6 +46,8 @@ export function WorktreeGroup({
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={tasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -15,23 +15,28 @@ const defaultSettings: Settings = {
|
||||
buildCommand: "",
|
||||
};
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTasks: vi.fn(() => Promise.resolve([])),
|
||||
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2 })),
|
||||
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
fetchAuthStatus: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
||||
{ id: "github", name: "GitHub", authenticated: false },
|
||||
],
|
||||
}),
|
||||
),
|
||||
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
|
||||
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
||||
fetchModels: vi.fn(() => Promise.resolve([])),
|
||||
}));
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchTasks: vi.fn(() => Promise.resolve([])),
|
||||
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2 })),
|
||||
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
fetchAuthStatus: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
||||
{ id: "github", name: "GitHub", authenticated: false },
|
||||
],
|
||||
}),
|
||||
),
|
||||
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
|
||||
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
||||
fetchModels: vi.fn(() => Promise.resolve([])),
|
||||
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../hooks/useTasks", () => ({
|
||||
useTasks: () => ({
|
||||
@@ -261,3 +266,141 @@ describe("App engine pause (soft pause)", () => {
|
||||
expect(updateSettings).toHaveBeenCalledWith({ enginePaused: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("App view switching", () => {
|
||||
it("renders Board view by default", async () => {
|
||||
render(<App />);
|
||||
|
||||
// Wait for the app to render and check that the board is visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("main")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Board should be rendered
|
||||
expect(screen.getByRole("main").className).toContain("board");
|
||||
});
|
||||
|
||||
it("renders ListView when view is switched to list", async () => {
|
||||
render(<App />);
|
||||
|
||||
// Wait for the header to render with view toggle
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("List view")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click to switch to list view
|
||||
fireEvent.click(screen.getByTitle("List view"));
|
||||
|
||||
// List view should be rendered (it has a different structure)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".list-view")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("switches back to Board view from list view", async () => {
|
||||
render(<App />);
|
||||
|
||||
// Wait for the header to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("List view")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Switch to list view
|
||||
fireEvent.click(screen.getByTitle("List view"));
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".list-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Switch back to board view
|
||||
fireEvent.click(screen.getByTitle("Board view"));
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".board")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("persists view preference to localStorage", async () => {
|
||||
// Clear any previous value
|
||||
localStorage.removeItem("kb-dashboard-view");
|
||||
|
||||
render(<App />);
|
||||
|
||||
// Wait for the header to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("List view")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Switch to list view
|
||||
fireEvent.click(screen.getByTitle("List view"));
|
||||
|
||||
// Should have saved to localStorage
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-dashboard-view")).toBe("list");
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes view from localStorage if available", async () => {
|
||||
// Set localStorage to list view
|
||||
localStorage.setItem("kb-dashboard-view", "list");
|
||||
|
||||
render(<App />);
|
||||
|
||||
// Wait for the app to render
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".list-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
// List view should be active
|
||||
expect(screen.getByTitle("List view").className).toContain("active");
|
||||
|
||||
// Cleanup
|
||||
localStorage.removeItem("kb-dashboard-view");
|
||||
});
|
||||
|
||||
it("shows view toggle buttons in header", async () => {
|
||||
render(<App />);
|
||||
|
||||
// Wait for the header to render with view toggle
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Board view")).toBeTruthy();
|
||||
expect(screen.getByTitle("List view")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("App GitHub import", () => {
|
||||
it("opens GitHub import modal when import button is clicked", async () => {
|
||||
render(<App />);
|
||||
|
||||
// Wait for the header to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Import from GitHub")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click the import button
|
||||
fireEvent.click(screen.getByTitle("Import from GitHub"));
|
||||
|
||||
// Modal should be visible
|
||||
expect(screen.getByText("Import from GitHub")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes GitHub import modal on cancel", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Import from GitHub")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Open the modal
|
||||
fireEvent.click(screen.getByTitle("Import from GitHub"));
|
||||
expect(screen.getByText("Import from GitHub")).toBeTruthy();
|
||||
|
||||
// Close the modal - use getAllByRole since there might be multiple buttons
|
||||
const cancelButtons = screen.getAllByRole("button", { name: /Cancel/i });
|
||||
fireEvent.click(cancelButtons[cancelButtons.length - 1]);
|
||||
|
||||
// Modal should be closed - the Load button from the modal should be gone
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /^Load$/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,10 +25,12 @@ function makeTask(id: string): Task {
|
||||
column: "triage" as ColumnType,
|
||||
status: undefined as any,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
dependencies: [],
|
||||
description: "",
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { GitHubImportModal } from "../GitHubImportModal";
|
||||
import { apiFetchGitHubIssues, apiImportGitHubIssue, fetchGitRemotes } from "../../api";
|
||||
import type { Task } from "@kb/core";
|
||||
import type { GitRemote } from "../../api";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
apiFetchGitHubIssues: vi.fn(),
|
||||
apiImportGitHubIssue: vi.fn(),
|
||||
fetchGitRemotes: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockTask: Task = {
|
||||
id: "KB-001",
|
||||
title: "Test Issue",
|
||||
description: "Test body\n\nSource: https://github.com/owner/repo/issues/1",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const singleRemote: GitRemote[] = [
|
||||
{ name: "origin", owner: "dustinbyrne", repo: "kb", url: "https://github.com/dustinbyrne/kb.git" },
|
||||
];
|
||||
|
||||
const multipleRemotes: GitRemote[] = [
|
||||
{ name: "origin", owner: "dustinbyrne", repo: "kb", url: "https://github.com/dustinbyrne/kb.git" },
|
||||
{ name: "upstream", owner: "upstream", repo: "kb", url: "https://github.com/upstream/kb.git" },
|
||||
];
|
||||
|
||||
describe("GitHubImportModal", () => {
|
||||
const onClose = vi.fn();
|
||||
const onImport = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders when isOpen is true", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Import from GitHub")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render when isOpen is false", () => {
|
||||
render(<GitHubImportModal isOpen={false} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
expect(screen.queryByText("Import from GitHub")).toBeNull();
|
||||
});
|
||||
|
||||
it("has optional labels input", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Labels/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("with no remotes", () => {
|
||||
it("shows 'No GitHub remotes detected' message", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No GitHub remotes detected/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables Load button when no remotes available", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
|
||||
expect(loadButton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with single remote", () => {
|
||||
it("auto-selects the remote and shows as read-only text", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin \(dustinbyrne\/kb\)/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show a dropdown", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("enables Load button when remote is auto-selected", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
|
||||
expect(loadButton.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls apiFetchGitHubIssues when Load is clicked", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([]);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin \(dustinbyrne\/kb\)/)).toBeTruthy();
|
||||
});
|
||||
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i });
|
||||
fireEvent.click(loadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with multiple remotes", () => {
|
||||
it("shows a dropdown with all remotes", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("dropdown has placeholder and all remote options", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
// Check options are rendered
|
||||
const options = Array.from(select.options).map(o => o.text);
|
||||
expect(options).toContain("Select a remote...");
|
||||
expect(options).toContain("origin (dustinbyrne/kb)");
|
||||
expect(options).toContain("upstream (upstream/kb)");
|
||||
});
|
||||
});
|
||||
|
||||
it("disables Load button when no remote is selected", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
|
||||
expect(loadButton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables Load button after selecting a remote", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
});
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
fireEvent.change(select, { target: { value: "origin" } });
|
||||
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
|
||||
expect(loadButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("switches owner/repo when changing remote selection", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
|
||||
vi.mocked(apiFetchGitHubIssues)
|
||||
.mockResolvedValueOnce([{ number: 1, title: "Issue from origin", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] }])
|
||||
.mockResolvedValueOnce([{ number: 2, title: "Issue from upstream", body: "", html_url: "https://github.com/upstream/kb/issues/2", labels: [] }]);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select origin and load
|
||||
const select = screen.getByRole("combobox");
|
||||
fireEvent.change(select, { target: { value: "origin" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
|
||||
expect(screen.getByText("Issue from origin")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Switch to upstream and load again
|
||||
fireEvent.change(select, { target: { value: "upstream" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiFetchGitHubIssues).toHaveBeenLastCalledWith("upstream", "kb", 30, undefined);
|
||||
expect(screen.getByText("Issue from upstream")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue loading and import", () => {
|
||||
it("displays fetched issues after loading", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
{ number: 2, title: "Second Issue", body: "Body 2", html_url: "https://github.com/owner/repo/issues/2", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
expect(screen.getByText("Second Issue")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("selects an issue when clicked", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
});
|
||||
|
||||
const radio = screen.getByRole("radio") as HTMLInputElement;
|
||||
fireEvent.click(radio);
|
||||
|
||||
expect(radio.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("disables Import button when no issue is selected", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
});
|
||||
|
||||
const importButton = screen.getByRole("button", { name: /Import$/i }) as HTMLButtonElement;
|
||||
expect(importButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("calls apiImportGitHubIssue and onImport when Import is clicked", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
]);
|
||||
vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce(mockTask);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("radio"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Import$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiImportGitHubIssue).toHaveBeenCalledWith("dustinbyrne", "kb", 1);
|
||||
expect(onImport).toHaveBeenCalledWith(mockTask);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'Imported' badge for already imported issues", async () => {
|
||||
const existingTask: Task = {
|
||||
...mockTask,
|
||||
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
|
||||
};
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Imported")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables radio buttons for already imported issues", async () => {
|
||||
const existingTask: Task = {
|
||||
...mockTask,
|
||||
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
|
||||
};
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const radio = screen.getByRole("radio") as HTMLInputElement;
|
||||
expect(radio.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("displays error on fetch failure", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockRejectedValueOnce(new Error("Repository not found"));
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Repository not found")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays label chips for issues with labels", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "Bug Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/1", labels: [{ name: "bug" }, { name: "urgent" }] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("bug")).toBeTruthy();
|
||||
expect(screen.getByText("urgent")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("modal actions", () => {
|
||||
it("closes modal on Cancel button click", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Cancel/i })).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cancel/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes modal on X button click", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("×")).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByText("×"));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GitManagerModal } from "../GitManagerModal";
|
||||
import type { Task } from "@kb/core";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", async () => {
|
||||
return {
|
||||
fetchGitStatus: vi.fn(),
|
||||
fetchGitCommits: vi.fn(),
|
||||
fetchCommitDiff: vi.fn(),
|
||||
fetchGitBranches: vi.fn(),
|
||||
fetchGitWorktrees: vi.fn(),
|
||||
createBranch: vi.fn(),
|
||||
checkoutBranch: vi.fn(),
|
||||
deleteBranch: vi.fn(),
|
||||
fetchRemote: vi.fn(),
|
||||
pullBranch: vi.fn(),
|
||||
pushBranch: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
fetchGitStatus,
|
||||
fetchGitCommits,
|
||||
fetchGitBranches,
|
||||
fetchGitWorktrees,
|
||||
createBranch,
|
||||
checkoutBranch,
|
||||
deleteBranch,
|
||||
fetchRemote,
|
||||
pullBranch,
|
||||
pushBranch,
|
||||
} from "../../api";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
{ id: "KB-001", description: "Test task 1", column: "in-progress", dependencies: [], worktree: "/worktrees/kb-001", steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
{ id: "KB-002", description: "Test task 2", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
describe("GitManagerModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock implementations
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
});
|
||||
(fetchGitCommits as any).mockResolvedValue([
|
||||
{ hash: "abc1234", shortHash: "abc1", message: "Test commit", author: "User", date: "2026-01-01", parents: [] },
|
||||
]);
|
||||
(fetchGitBranches as any).mockResolvedValue([
|
||||
{ name: "main", isCurrent: true, remote: "origin/main" },
|
||||
{ name: "feature", isCurrent: false },
|
||||
]);
|
||||
(fetchGitWorktrees as any).mockResolvedValue([
|
||||
{ path: "/worktrees/kb-001", branch: "kb/kb-001", isMain: false, isBare: false, taskId: "KB-001" },
|
||||
{ path: "/repo", branch: "main", isMain: true, isBare: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders nothing when not open", () => {
|
||||
const { container } = render(
|
||||
<GitManagerModal
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders modal when open", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Git Manager")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches status on mount", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchGitStatus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("switches tabs when clicking navigation", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Repository Status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click Commits tab
|
||||
fireEvent.click(screen.getByText("Commits"));
|
||||
await waitFor(() => {
|
||||
expect(fetchGitCommits).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click Branches tab
|
||||
fireEvent.click(screen.getByText("Branches"));
|
||||
await waitFor(() => {
|
||||
expect(fetchGitBranches).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes on Escape key", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Git Manager")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows status information", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
expect(screen.getByText("Clean")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("loads commits and shows them", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Commits"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test commit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("loads branches and shows current branch", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Branches"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
expect(screen.getByText("feature")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("loads worktrees and shows task associations", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Worktrees"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("KB-001")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 total")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls createBranch when form is submitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
(createBranch as any).mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Branches"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("New branch name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("New branch name");
|
||||
await user.type(nameInput, "new-feature");
|
||||
|
||||
const createButton = screen.getByRole("button", { name: /create/i });
|
||||
await user.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createBranch).toHaveBeenCalledWith("new-feature", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls checkoutBranch when checkout button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
(checkoutBranch as any).mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Branches"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("feature")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows remote operations buttons", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Remotes"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /fetch/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /pull/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /push/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls fetchRemote when Fetch button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchRemote as any).mockResolvedValue({ fetched: true, message: "Fetched" });
|
||||
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Remotes"));
|
||||
|
||||
const fetchButton = await screen.findByRole("button", { name: /fetch/i });
|
||||
await user.click(fetchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRemote).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when fetch fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchRemote as any).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Remotes"));
|
||||
|
||||
const fetchButton = await screen.findByRole("button", { name: /fetch/i });
|
||||
await user.click(fetchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Network error", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows ahead/behind indicators in status", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 2,
|
||||
behind: 3,
|
||||
});
|
||||
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,21 @@ describe("Header", () => {
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the import button", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<Header onOpenGitHubImport={onOpen} />);
|
||||
const btn = screen.getByTitle("Import from GitHub");
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenGitHubImport when import button is clicked", () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<Header onOpenGitHubImport={onOpen} />);
|
||||
const btn = screen.getByTitle("Import from GitHub");
|
||||
fireEvent.click(btn);
|
||||
expect(onOpen).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// ── Pause button (soft pause) ────────────────────────────────────
|
||||
|
||||
it("renders pause button with 'Pause scheduling' title when not paused", () => {
|
||||
@@ -113,4 +128,64 @@ describe("Header", () => {
|
||||
const svg = btn.querySelector("svg");
|
||||
expect(svg).toBeDefined();
|
||||
});
|
||||
|
||||
// ── View Toggle ────────────────────────────────────────────────────
|
||||
|
||||
it("renders view toggle when onChangeView is provided", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const boardBtn = screen.getByTitle("Board view");
|
||||
const listBtn = screen.getByTitle("List view");
|
||||
expect(boardBtn).toBeDefined();
|
||||
expect(listBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render view toggle when onChangeView is not provided", () => {
|
||||
render(<Header />);
|
||||
const boardBtn = screen.queryByTitle("Board view");
|
||||
const listBtn = screen.queryByTitle("List view");
|
||||
expect(boardBtn).toBeNull();
|
||||
expect(listBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onChangeView with 'board' when board view button is clicked", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="list" onChangeView={onChangeView} />);
|
||||
const boardBtn = screen.getByTitle("Board view");
|
||||
fireEvent.click(boardBtn);
|
||||
expect(onChangeView).toHaveBeenCalledWith("board");
|
||||
});
|
||||
|
||||
it("calls onChangeView with 'list' when list view button is clicked", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const listBtn = screen.getByTitle("List view");
|
||||
fireEvent.click(listBtn);
|
||||
expect(onChangeView).toHaveBeenCalledWith("list");
|
||||
});
|
||||
|
||||
it("marks board view button as active when view is 'board'", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const boardBtn = screen.getByTitle("Board view");
|
||||
expect(boardBtn.className).toContain("active");
|
||||
expect(boardBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("marks list view button as active when view is 'list'", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="list" onChangeView={onChangeView} />);
|
||||
const listBtn = screen.getByTitle("List view");
|
||||
expect(listBtn.className).toContain("active");
|
||||
expect(listBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not mark board view button as active when view is 'list'", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="list" onChangeView={onChangeView} />);
|
||||
const boardBtn = screen.getByTitle("Board view");
|
||||
expect(boardBtn.className).not.toContain("active");
|
||||
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
1117
packages/dashboard/app/components/__tests__/ListView.test.tsx
Normal file
1117
packages/dashboard/app/components/__tests__/ListView.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,281 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ModelSelectorTab } from "../ModelSelectorTab";
|
||||
import type { Task } from "@kb/core";
|
||||
import * as api from "../../api";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", async () => {
|
||||
const actual = await vi.importActual<typeof api>("../../api");
|
||||
return {
|
||||
...actual,
|
||||
fetchModels: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetchModels = api.fetchModels as ReturnType<typeof vi.fn>;
|
||||
const mockUpdateTask = api.updateTask as ReturnType<typeof vi.fn>;
|
||||
|
||||
const FAKE_TASK: Task = {
|
||||
id: "KB-001",
|
||||
description: "Test task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const MOCK_MODELS = [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "anthropic", id: "claude-opus-4", name: "Claude Opus 4", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
];
|
||||
|
||||
describe("ModelSelectorTab", () => {
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchModels.mockResolvedValue(MOCK_MODELS);
|
||||
});
|
||||
|
||||
it("renders loading state initially", () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
expect(screen.getByText("Loading available models…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders model selectors after loading", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("Validator Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Save")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reset")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Using default' when no model overrides are set", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
|
||||
expect(within(executorSection!).getByText("Using default")).toBeInTheDocument();
|
||||
|
||||
const validatorSection = screen.getByLabelText("Validator Model").closest(".form-group");
|
||||
expect(within(validatorSection!).getByText("Using default")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows current custom model when overrides are set", async () => {
|
||||
const taskWithModels = {
|
||||
...FAKE_TASK,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
};
|
||||
|
||||
render(<ModelSelectorTab task={taskWithModels} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("anthropic/claude-sonnet-4-5")).toBeInTheDocument();
|
||||
expect(screen.getByText("openai/gpt-4o")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups models by provider in select options", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
|
||||
expect(executorSelect).toBeInTheDocument();
|
||||
|
||||
// Check options exist with model names
|
||||
expect(screen.getAllByText("Claude Sonnet 4.5").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Claude Opus 4").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("GPT-4o").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("enables Save button when selections change", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText("Save");
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Select a model
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
|
||||
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("calls updateTask with correct model fields on save", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateTask.mockResolvedValue({ ...FAKE_TASK });
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select executor model
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
|
||||
|
||||
// Select validator model
|
||||
const validatorSelect = screen.getByLabelText("Validator Model");
|
||||
await user.selectOptions(validatorSelect, "openai/gpt-4o");
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Model settings saved", "success");
|
||||
});
|
||||
|
||||
it("calls updateTask with null to clear models on 'Use default' selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
const taskWithModels = {
|
||||
...FAKE_TASK,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
};
|
||||
mockUpdateTask.mockResolvedValue({ ...taskWithModels });
|
||||
|
||||
render(<ModelSelectorTab task={taskWithModels} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select "Use default" (empty value)
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "");
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
|
||||
modelProvider: undefined,
|
||||
modelId: undefined,
|
||||
validatorModelProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("resets selections to original values when Reset is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change selection
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
|
||||
|
||||
// Reset
|
||||
await user.click(screen.getByText("Reset"));
|
||||
|
||||
// Selection should be back to empty (Use default)
|
||||
expect(executorSelect).toHaveValue("");
|
||||
});
|
||||
|
||||
it("shows error state when fetchModels fails", async () => {
|
||||
mockFetchModels.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Error loading models:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Retry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no models available", async () => {
|
||||
mockFetchModels.mockResolvedValue([]);
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No models available/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables inputs while saving", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateTask.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve({ ...FAKE_TASK }), 100)));
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select a model
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
|
||||
|
||||
// Start save
|
||||
await user.click(screen.getByText("Save"));
|
||||
|
||||
// Should show saving state
|
||||
expect(screen.getByText("Saving…")).toBeInTheDocument();
|
||||
expect(executorSelect).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows error toast when save fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateTask.mockRejectedValue(new Error("Save failed"));
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select a model
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Save failed", "error");
|
||||
});
|
||||
});
|
||||
});
|
||||
267
packages/dashboard/app/components/__tests__/PrSection.test.tsx
Normal file
267
packages/dashboard/app/components/__tests__/PrSection.test.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { PrSection } from "../PrSection";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
createPr: vi.fn(),
|
||||
refreshPrStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createPr, refreshPrStatus } from "../../api";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
const mockOnPrCreated = vi.fn();
|
||||
const mockOnPrUpdated = vi.fn();
|
||||
|
||||
const mockPrInfo = {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open" as const,
|
||||
title: "Fix the bug",
|
||||
headBranch: "kb/kb-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 3,
|
||||
lastCommentAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("PrSection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("when task has no PR", () => {
|
||||
it("shows create PR button when GitHub token is available", () => {
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
hasGitHubToken={true}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Create PR")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows disabled button and hint when GitHub token is missing", () => {
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
hasGitHubToken={false}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByText("Create PR") as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(screen.getByText(/GITHUB_TOKEN env var/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows create form when clicking create button", () => {
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
hasGitHubToken={true}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Create PR"));
|
||||
|
||||
expect(screen.getByPlaceholderText("PR title")).toBeDefined();
|
||||
expect(screen.getByPlaceholderText("PR description (optional)")).toBeDefined();
|
||||
expect(screen.getByText("Cancel")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides form when clicking cancel", () => {
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
hasGitHubToken={true}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Create PR"));
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
|
||||
expect(screen.queryByPlaceholderText("PR title")).toBeNull();
|
||||
});
|
||||
|
||||
it("creates PR when form is submitted", async () => {
|
||||
(createPr as ReturnType<typeof vi.fn>).mockResolvedValue(mockPrInfo);
|
||||
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
hasGitHubToken={true}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Create PR"));
|
||||
fireEvent.change(screen.getByPlaceholderText("PR title"), {
|
||||
target: { value: "My PR Title" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create PR"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createPr).toHaveBeenCalledWith("KB-001", {
|
||||
title: "My PR Title",
|
||||
body: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockOnPrCreated).toHaveBeenCalledWith(mockPrInfo);
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Created PR #42", "success");
|
||||
});
|
||||
|
||||
it("shows error when PR creation fails", async () => {
|
||||
(createPr as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("API error"));
|
||||
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
hasGitHubToken={true}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Create PR"));
|
||||
fireEvent.change(screen.getByPlaceholderText("PR title"), {
|
||||
target: { value: "My PR Title" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Create PR"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("API error", "error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when task has a PR", () => {
|
||||
it("displays PR info for open PR", () => {
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
prInfo={mockPrInfo}
|
||||
hasGitHubToken={true}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("#42")).toBeDefined();
|
||||
expect(screen.getByText("Fix the bug")).toBeDefined();
|
||||
expect(screen.getByText("open")).toBeDefined();
|
||||
expect(screen.getByText("View on GitHub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows correct status badge for merged PR", () => {
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
prInfo={{ ...mockPrInfo, status: "merged" }}
|
||||
hasGitHubToken={true}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("merged")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows correct status badge for closed PR", () => {
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
prInfo={{ ...mockPrInfo, status: "closed" }}
|
||||
hasGitHubToken={true}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("closed")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays comment count when PR has comments", () => {
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
prInfo={{ ...mockPrInfo, commentCount: 5 }}
|
||||
hasGitHubToken={true}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// The comment count should be rendered
|
||||
expect(screen.getByText("5")).toBeDefined();
|
||||
});
|
||||
|
||||
it("refreshes PR status when refresh button is clicked", async () => {
|
||||
const updatedPr = { ...mockPrInfo, status: "merged" as const };
|
||||
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue(updatedPr);
|
||||
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
prInfo={mockPrInfo}
|
||||
hasGitHubToken={true}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByTitle("Refresh PR status");
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshPrStatus).toHaveBeenCalledWith("KB-001");
|
||||
});
|
||||
|
||||
expect(mockOnPrUpdated).toHaveBeenCalledWith(updatedPr);
|
||||
expect(mockAddToast).toHaveBeenCalledWith("PR status refreshed", "success");
|
||||
});
|
||||
|
||||
it("shows error when refresh fails", async () => {
|
||||
(refreshPrStatus as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(
|
||||
<PrSection
|
||||
taskId="KB-001"
|
||||
prInfo={mockPrInfo}
|
||||
hasGitHubToken={true}
|
||||
onPrCreated={mockOnPrCreated}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByTitle("Refresh PR status");
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Network error", "error");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@ const defaultSettings: Settings = {
|
||||
worktreeInitCommand: "",
|
||||
testCommand: "",
|
||||
buildCommand: "",
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
};
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -103,6 +105,8 @@ describe("SettingsModal", () => {
|
||||
fireEvent.click(screen.getByText("Merge"));
|
||||
expect(screen.getByText("Auto-merge completed tasks")).toBeTruthy();
|
||||
expect(screen.getByText("Include task ID in commit scope")).toBeTruthy();
|
||||
expect(screen.getByText("Auto-resolve conflicts in lock files and generated files")).toBeTruthy();
|
||||
expect(screen.getByText("Smart conflict resolution")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Recycle worktrees checkbox in Worktrees section", async () => {
|
||||
@@ -191,6 +195,16 @@ describe("SettingsModal", () => {
|
||||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||
});
|
||||
|
||||
it("shows Auto-resolve conflicts checkbox in Merge section", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Merge"));
|
||||
const checkbox = screen.getByLabelText("Auto-resolve conflicts in lock files and generated files");
|
||||
expect(checkbox).toBeTruthy();
|
||||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||
});
|
||||
|
||||
it("toggling includeTaskIdInCommit checkbox sends false in save payload", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
@@ -207,6 +221,97 @@ describe("SettingsModal", () => {
|
||||
expect(payload.includeTaskIdInCommit).toBe(false);
|
||||
});
|
||||
|
||||
it("toggling autoResolveConflicts checkbox sends false in save payload when unchecked", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Merge"));
|
||||
const checkbox = screen.getByLabelText("Auto-resolve conflicts in lock files and generated files");
|
||||
// Default is checked (true), click to uncheck
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.autoResolveConflicts).toBe(false);
|
||||
});
|
||||
|
||||
it("autoResolveConflicts defaults to enabled (true) when setting is true", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
autoResolveConflicts: true,
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Merge"));
|
||||
const checkbox = screen.getByLabelText("Auto-resolve conflicts in lock files and generated files") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("shows Smart conflict resolution checkbox in Merge section", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Merge"));
|
||||
const checkbox = screen.getByLabelText("Smart conflict resolution");
|
||||
expect(checkbox).toBeTruthy();
|
||||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||
});
|
||||
|
||||
it("toggling smartConflictResolution checkbox sends false in save payload when unchecked", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Merge"));
|
||||
const checkbox = screen.getByLabelText("Smart conflict resolution");
|
||||
// Default is checked (true), click to uncheck
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.smartConflictResolution).toBe(false);
|
||||
});
|
||||
|
||||
it("smartConflictResolution defaults to enabled (true) when setting is true", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
smartConflictResolution: true,
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Merge"));
|
||||
const checkbox = screen.getByLabelText("Smart conflict resolution") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("smartConflictResolution checkbox submits true in save payload when checked", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
smartConflictResolution: false,
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Merge"));
|
||||
const checkbox = screen.getByLabelText("Smart conflict resolution");
|
||||
// Default is unchecked (false), click to check
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.smartConflictResolution).toBe(true);
|
||||
});
|
||||
|
||||
it("groupOverlappingFiles input has type checkbox", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
455
packages/dashboard/app/components/__tests__/SpecEditor.test.tsx
Normal file
455
packages/dashboard/app/components/__tests__/SpecEditor.test.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { SpecEditor } from "../SpecEditor";
|
||||
|
||||
describe("SpecEditor", () => {
|
||||
const mockContent = `# KB-001 - Test Task
|
||||
|
||||
**Created:** 2026-01-01
|
||||
**Size:** M
|
||||
|
||||
## Mission
|
||||
|
||||
Test mission description.
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 1: Implementation
|
||||
- [ ] Do something
|
||||
`;
|
||||
|
||||
const mockOnSave = vi.fn().mockResolvedValue(undefined);
|
||||
const mockOnRequestRevision = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders markdown content in view mode by default", () => {
|
||||
render(<SpecEditor content={mockContent} />);
|
||||
|
||||
// Should show "View" button as active
|
||||
const viewButton = screen.getByText("View");
|
||||
expect(viewButton.classList.contains("btn-primary")).toBe(true);
|
||||
|
||||
// Should render markdown content (without leading heading)
|
||||
expect(screen.getByText("Test mission description.")).toBeTruthy();
|
||||
|
||||
// Should not show textarea in view mode
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("strips leading heading from markdown rendering", () => {
|
||||
render(<SpecEditor content={mockContent} />);
|
||||
|
||||
// The h1 heading should be stripped
|
||||
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
||||
|
||||
// But other content should be rendered
|
||||
expect(screen.getByText("Mission")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when no content", () => {
|
||||
render(<SpecEditor content="" />);
|
||||
|
||||
expect(screen.getByText("(no specification)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches to edit mode when clicking Edit button", () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
// Edit button should now be active
|
||||
const editButton = screen.getByText("Edit");
|
||||
expect(editButton.classList.contains("btn-primary")).toBe(true);
|
||||
|
||||
// Textarea should be visible with full content (including heading)
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(textarea.value).toBe(mockContent);
|
||||
});
|
||||
|
||||
it("switches back to view mode when clicking View button", () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
expect(screen.getByRole("textbox")).toBeTruthy();
|
||||
|
||||
// Click View to cancel
|
||||
fireEvent.click(screen.getByText("View"));
|
||||
|
||||
// Should be back in view mode
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(screen.getByText("Test mission description.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables Edit button when already in edit mode", () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
const editButton = screen.getByText("Edit") as HTMLButtonElement;
|
||||
expect(editButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables View button when in view mode", () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
const viewButton = screen.getByText("View") as HTMLButtonElement;
|
||||
expect(viewButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onSave with edited content when clicking Save", async () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
const newContent = "# Updated Content\n\nNew description.";
|
||||
|
||||
fireEvent.change(textarea, { target: { value: newContent } });
|
||||
|
||||
// Save button should be enabled after changes
|
||||
const saveButton = screen.getByText("Save") as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(false);
|
||||
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(newContent);
|
||||
});
|
||||
});
|
||||
|
||||
it("disables save button when content is unchanged", () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
const saveButton = screen.getByText("Save") as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables save button when isSaving is true", () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} isSaving={true} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
// Make a change
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "Changed" } });
|
||||
|
||||
const saveButton = screen.getByText("Saving…") as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows saving state on save button", () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} isSaving={true} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
expect(screen.getByText("Saving…")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resets content to original when canceling edit", () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "Changed content" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
|
||||
// Re-enter edit mode
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
// Should show original content
|
||||
expect((screen.getByRole("textbox") as HTMLTextAreaElement). value).toBe(mockContent);
|
||||
});
|
||||
|
||||
it("does not show edit controls in readOnly mode", () => {
|
||||
render(<SpecEditor content={mockContent} readOnly={true} />);
|
||||
|
||||
expect(screen.queryByText("Edit")).toBeNull();
|
||||
expect(screen.queryByText("Save")).toBeNull();
|
||||
expect(screen.queryByText("Cancel")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows AI revision section when onRequestRevision is provided", () => {
|
||||
render(
|
||||
<SpecEditor
|
||||
content={mockContent}
|
||||
onSave={mockOnSave}
|
||||
onRequestRevision={mockOnRequestRevision}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Ask AI to Revise")).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText(/e.g., 'Add more details/)).toBeTruthy();
|
||||
expect(screen.getByText("Request AI Revision")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show AI revision section when onRequestRevision is not provided", () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
expect(screen.queryByText("Ask AI to Revise")).toBeNull();
|
||||
expect(screen.queryByText("Request AI Revision")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show AI revision section in readOnly mode", () => {
|
||||
render(
|
||||
<SpecEditor
|
||||
content={mockContent}
|
||||
readOnly={true}
|
||||
onRequestRevision={mockOnRequestRevision}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Ask AI to Revise")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onRequestRevision with feedback when clicking Request AI Revision", async () => {
|
||||
render(
|
||||
<SpecEditor
|
||||
content={mockContent}
|
||||
onSave={mockOnSave}
|
||||
onRequestRevision={mockOnRequestRevision}
|
||||
/>
|
||||
);
|
||||
|
||||
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/) as HTMLTextAreaElement;
|
||||
const feedbackText = "Please add more details about error handling";
|
||||
|
||||
fireEvent.change(feedbackInput, { target: { value: feedbackText } });
|
||||
fireEvent.click(screen.getByText("Request AI Revision"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnRequestRevision).toHaveBeenCalledWith(feedbackText);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears feedback after successful revision request", async () => {
|
||||
render(
|
||||
<SpecEditor
|
||||
content={mockContent}
|
||||
onSave={mockOnSave}
|
||||
onRequestRevision={mockOnRequestRevision}
|
||||
/>
|
||||
);
|
||||
|
||||
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(feedbackInput, { target: { value: "Some feedback" } });
|
||||
fireEvent.click(screen.getByText("Request AI Revision"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(feedbackInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("disables revision request button when feedback is empty", () => {
|
||||
render(
|
||||
<SpecEditor
|
||||
content={mockContent}
|
||||
onSave={mockOnSave}
|
||||
onRequestRevision={mockOnRequestRevision}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByText("Request AI Revision") as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables revision request button when isRequesting is true", () => {
|
||||
render(
|
||||
<SpecEditor
|
||||
content={mockContent}
|
||||
onSave={mockOnSave}
|
||||
onRequestRevision={mockOnRequestRevision}
|
||||
isRequesting={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/) as HTMLTextAreaElement;
|
||||
fireEvent.change(feedbackInput, { target: { value: "Some feedback" } });
|
||||
|
||||
const button = screen.getByText("Requesting…") as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows requesting state on revision button", () => {
|
||||
render(
|
||||
<SpecEditor
|
||||
content={mockContent}
|
||||
onSave={mockOnSave}
|
||||
onRequestRevision={mockOnRequestRevision}
|
||||
isRequesting={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Requesting…")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows character count for feedback", () => {
|
||||
render(
|
||||
<SpecEditor
|
||||
content={mockContent}
|
||||
onSave={mockOnSave}
|
||||
onRequestRevision={mockOnRequestRevision}
|
||||
/>
|
||||
);
|
||||
|
||||
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(feedbackInput, { target: { value: "Hello" } });
|
||||
|
||||
expect(screen.getByText("5/2000")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enforces maxLength on feedback textarea", () => {
|
||||
render(
|
||||
<SpecEditor
|
||||
content={mockContent}
|
||||
onSave={mockOnSave}
|
||||
onRequestRevision={mockOnRequestRevision}
|
||||
/>
|
||||
);
|
||||
|
||||
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/) as HTMLTextAreaElement;
|
||||
|
||||
expect(feedbackInput.getAttribute("maxLength")).toBe("2000");
|
||||
});
|
||||
|
||||
it("triggers save on Ctrl+Enter keyboard shortcut in edit mode", async () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
const newContent = "# Updated Content\n\nNew description.";
|
||||
|
||||
fireEvent.change(textarea, { target: { value: newContent } });
|
||||
|
||||
// Simulate Ctrl+Enter
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(newContent);
|
||||
});
|
||||
});
|
||||
|
||||
it("triggers save on Cmd+Enter keyboard shortcut in edit mode", async () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
const newContent = "# Updated Content\n\nNew description.";
|
||||
|
||||
fireEvent.change(textarea, { target: { value: newContent } });
|
||||
|
||||
// Simulate Cmd+Enter
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: "Enter", metaKey: true });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(newContent);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not trigger save on Ctrl+Enter when not in edit mode", async () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
// Not in edit mode
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
|
||||
});
|
||||
|
||||
expect(mockOnSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not trigger save on Ctrl+Enter when save button is disabled", async () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
// No changes made, so save should be disabled
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: "Enter", ctrlKey: true });
|
||||
});
|
||||
|
||||
expect(mockOnSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents default on Ctrl+Enter keyboard shortcut", async () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "Changed" } });
|
||||
|
||||
const keyDownEvent = new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
});
|
||||
const preventDefaultSpy = vi.spyOn(keyDownEvent, "preventDefault");
|
||||
|
||||
document.dispatchEvent(keyDownEvent);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates content when prop changes while in view mode", () => {
|
||||
const { rerender } = render(<SpecEditor content={mockContent} />);
|
||||
|
||||
expect(screen.getByText("Test mission description.")).toBeTruthy();
|
||||
|
||||
const newContent = "# New Task\n\nUpdated description.";
|
||||
rerender(<SpecEditor content={newContent} />);
|
||||
|
||||
expect(screen.getByText("Updated description.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not update textarea content when prop changes while in edit mode", () => {
|
||||
const { rerender } = render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "User edited content" } });
|
||||
|
||||
// Re-render with different content
|
||||
rerender(<SpecEditor content="# Different\n\nDifferent content." onSave={mockOnSave} />);
|
||||
|
||||
// Textarea should still show user's edits
|
||||
expect(textarea.value).toBe("User edited content");
|
||||
});
|
||||
|
||||
it("shows keyboard hint in edit mode", () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Edit"));
|
||||
|
||||
// Check for keyboard hint text (use a more specific query)
|
||||
expect(screen.getByText(/Press/)).toBeTruthy();
|
||||
// Check for individual kbd elements
|
||||
const kbdElements = screen.getAllByText("Enter");
|
||||
expect(kbdElements.length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText(/Ctrl/)).toBeTruthy();
|
||||
expect(screen.getByText(/Cmd/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show keyboard hint in view mode", () => {
|
||||
render(<SpecEditor content={mockContent} onSave={mockOnSave} />);
|
||||
|
||||
expect(screen.queryByText(/Ctrl/)).toBeNull();
|
||||
});
|
||||
});
|
||||
290
packages/dashboard/app/components/__tests__/SteeringTab.test.tsx
Normal file
290
packages/dashboard/app/components/__tests__/SteeringTab.test.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SteeringTab } from "../SteeringTab";
|
||||
import type { TaskDetail } from "@kb/core";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
addSteeringComment: vi.fn(),
|
||||
}));
|
||||
|
||||
import { addSteeringComment } from "../../api";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
return {
|
||||
id: "KB-001",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
prompt: "# Test\n\nTest prompt",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SteeringTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty state when no comments", () => {
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
expect(screen.getByText("Steering Comments")).toBeTruthy();
|
||||
expect(screen.getByText(/no steering comments yet/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders comments in reverse chronological order", () => {
|
||||
const task = makeTask({
|
||||
steeringComments: [
|
||||
{
|
||||
id: "1",
|
||||
text: "First comment",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
text: "Second comment",
|
||||
createdAt: "2024-01-02T00:00:00Z",
|
||||
author: "agent",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={task} addToast={mockAddToast} />);
|
||||
|
||||
// Comments should be in reverse order (newest first)
|
||||
const comments = screen.getAllByText(/comment$/);
|
||||
expect(comments.length).toBe(2);
|
||||
expect(comments[0].textContent).toBe("Second comment");
|
||||
expect(comments[1].textContent).toBe("First comment");
|
||||
});
|
||||
|
||||
it("shows author badges for comments", () => {
|
||||
const task = makeTask({
|
||||
steeringComments: [
|
||||
{ id: "1", text: "User comment", createdAt: "2024-01-01T00:00:00Z", author: "user" },
|
||||
{ id: "2", text: "Agent comment", createdAt: "2024-01-02T00:00:00Z", author: "agent" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={task} addToast={mockAddToast} />);
|
||||
|
||||
expect(screen.getByText("user")).toBeTruthy();
|
||||
expect(screen.getByText("agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows character count", () => {
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Hello" } });
|
||||
|
||||
expect(screen.getByText("5 / 2000")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables submit button when textarea is empty", () => {
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
expect(button.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("disables submit button when text exceeds 2000 characters", () => {
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
const longText = "a".repeat(2001);
|
||||
fireEvent.change(textarea, { target: { value: longText } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
expect(button.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("enables submit button when text is valid", () => {
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Valid comment" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
expect(button.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("submits comment on button click", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockResolvedValue({
|
||||
...makeTask(),
|
||||
steeringComments: [
|
||||
{
|
||||
id: "new-1",
|
||||
text: "New comment",
|
||||
createdAt: "2024-01-03T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "New comment" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApi).toHaveBeenCalledWith("KB-001", "New comment");
|
||||
});
|
||||
});
|
||||
|
||||
it("submits comment on Ctrl+Enter", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockResolvedValue({
|
||||
...makeTask(),
|
||||
steeringComments: [
|
||||
{
|
||||
id: "new-1",
|
||||
text: "Keyboard comment",
|
||||
createdAt: "2024-01-03T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Keyboard comment" } });
|
||||
|
||||
// Ctrl+Enter should submit
|
||||
fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApi).toHaveBeenCalledWith("KB-001", "Keyboard comment");
|
||||
});
|
||||
});
|
||||
|
||||
it("submits comment on Cmd+Enter (Mac)", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockResolvedValue({
|
||||
...makeTask(),
|
||||
steeringComments: [
|
||||
{
|
||||
id: "new-1",
|
||||
text: "Mac keyboard comment",
|
||||
createdAt: "2024-01-03T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Mac keyboard comment" } });
|
||||
|
||||
// Cmd+Enter should submit (metaKey is Cmd on Mac)
|
||||
fireEvent.keyDown(textarea, { key: "Enter", metaKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApi).toHaveBeenCalledWith("KB-001", "Mac keyboard comment");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears textarea after successful submission", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockResolvedValue({
|
||||
...makeTask(),
|
||||
steeringComments: [
|
||||
{
|
||||
id: "new-1",
|
||||
text: "Cleared comment",
|
||||
createdAt: "2024-01-03T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/) as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "Cleared comment" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textarea.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state during submission", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
// Delay the resolution to see loading state
|
||||
mockApi.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Loading test" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
// Should show loading text
|
||||
expect(screen.getByRole("button", { name: /Adding…/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error toast on API failure", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Error test" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Network error", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates comment list after successful submission", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockResolvedValue({
|
||||
...makeTask(),
|
||||
steeringComments: [
|
||||
{
|
||||
id: "new-1",
|
||||
text: "Added comment",
|
||||
createdAt: "2024-01-03T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Added comment" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Added comment")).toBeTruthy();
|
||||
expect(screen.getByText("user")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Column } from "@kb/core";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Column, Task, TaskDetail } from "@kb/core";
|
||||
import { TaskCard } from "../TaskCard";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTaskDetail: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Tests for the agent-active class logic in TaskCard.
|
||||
*
|
||||
* Since no DOM environment (jsdom/happy-dom) or @testing-library/react is available,
|
||||
* we extract and test the class computation logic directly.
|
||||
* These tests use extracted helper functions to test class computation logic directly.
|
||||
*/
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
|
||||
@@ -286,3 +293,948 @@ describe("TaskCard queued badge logic", () => {
|
||||
expect(shouldShowStatusBadge(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Component tests for clickable dependencies in TaskCard.
|
||||
*/
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "KB-099",
|
||||
description: "Test task",
|
||||
column: "in-progress" as Column,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TaskCard clickable dependencies", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders dependency badges as clickable when dependencies exist", () => {
|
||||
const task = makeTask({ dependencies: ["KB-001", "KB-002"] });
|
||||
const allTasks: Task[] = [
|
||||
makeTask({ id: "KB-001", description: "Dep 1" }),
|
||||
makeTask({ id: "KB-002", description: "Dep 2" }),
|
||||
];
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
tasks={allTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const depBadges = screen.getAllByTitle(/Click to view/);
|
||||
expect(depBadges).toHaveLength(2);
|
||||
expect(depBadges[0].classList.contains("clickable")).toBe(true);
|
||||
expect(depBadges[1].classList.contains("clickable")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not render dependency badges when no dependencies", () => {
|
||||
const task = makeTask({ dependencies: [] });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const depBadges = screen.queryAllByTitle(/Click to view/);
|
||||
expect(depBadges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("calls fetchTaskDetail and onOpenDetail when clicking a dependency", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
...makeTask({ id: "KB-001", description: "Dep 1" }),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||
const onOpenDetail = vi.fn();
|
||||
|
||||
const task = makeTask({ dependencies: ["KB-001"] });
|
||||
const allTasks: Task[] = [makeTask({ id: "KB-001", description: "Dep 1" })];
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noopToast}
|
||||
tasks={allTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const depBadge = screen.getByTitle(/Click to view/);
|
||||
fireEvent.click(depBadge);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("KB-001");
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when dependency fetch fails", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
mockFetch.mockRejectedValueOnce(new Error("Task not found"));
|
||||
const onOpenDetail = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
|
||||
const task = makeTask({ dependencies: ["KB-001"] });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const depBadge = screen.getByTitle(/Click to view/);
|
||||
fireEvent.click(depBadge);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency KB-001", "error");
|
||||
});
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses stopPropagation so card click is not triggered", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
mockFetch.mockRejectedValueOnce(new Error("Stop here"));
|
||||
const addToast = vi.fn();
|
||||
|
||||
const task = makeTask({ dependencies: ["KB-001"] });
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={addToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const depBadge = screen.getByTitle(/Click to view/);
|
||||
const clickEvent = new MouseEvent("click", { bubbles: true });
|
||||
const stopPropagationSpy = vi.spyOn(clickEvent, "stopPropagation");
|
||||
|
||||
fireEvent(depBadge, clickEvent);
|
||||
|
||||
// The click handler is async, so we just verify the badge is clickable
|
||||
expect(depBadge.classList.contains("clickable")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Component tests for size badge rendering in TaskCard.
|
||||
*/
|
||||
describe("TaskCard size badge", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders size badge when task.size is 'S'", () => {
|
||||
const task = makeTask({ size: "S" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const sizeBadge = screen.getByText("S");
|
||||
expect(sizeBadge).toBeDefined();
|
||||
expect(sizeBadge.classList.contains("card-size-badge")).toBe(true);
|
||||
expect(sizeBadge.classList.contains("size-s")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders size badge when task.size is 'M'", () => {
|
||||
const task = makeTask({ size: "M" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const sizeBadge = screen.getByText("M");
|
||||
expect(sizeBadge).toBeDefined();
|
||||
expect(sizeBadge.classList.contains("card-size-badge")).toBe(true);
|
||||
expect(sizeBadge.classList.contains("size-m")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders size badge when task.size is 'L'", () => {
|
||||
const task = makeTask({ size: "L" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const sizeBadge = screen.getByText("L");
|
||||
expect(sizeBadge).toBeDefined();
|
||||
expect(sizeBadge.classList.contains("card-size-badge")).toBe(true);
|
||||
expect(sizeBadge.classList.contains("size-l")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT render size badge when task.size is undefined", () => {
|
||||
const task = makeTask({ size: undefined });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const sizeBadge = screen.queryByText(/^[SML]$/);
|
||||
expect(sizeBadge).toBeNull();
|
||||
});
|
||||
|
||||
it("positioned in card-header with other badges", () => {
|
||||
const task = makeTask({ size: "M", status: "executing" });
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const cardHeader = container.querySelector(".card-header");
|
||||
expect(cardHeader).toBeDefined();
|
||||
|
||||
const sizeBadge = cardHeader?.querySelector(".card-size-badge");
|
||||
expect(sizeBadge).toBeDefined();
|
||||
expect(sizeBadge?.textContent).toBe("M");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for inline editing functionality in TaskCard.
|
||||
*/
|
||||
describe("TaskCard inline editing", () => {
|
||||
const noopToast = vi.fn();
|
||||
const noopUpdateTask = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Helper to make editable task (triage or todo column)
|
||||
function makeEditableTask(overrides: Partial<Task> = {}): Task {
|
||||
return makeTask({
|
||||
column: "triage",
|
||||
status: undefined,
|
||||
paused: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to make non-editable task (in-progress, in-review, done, or agent active)
|
||||
function makeNonEditableTask(overrides: Partial<Task> = {}): Task {
|
||||
return makeTask({
|
||||
column: "in-progress",
|
||||
status: "executing",
|
||||
paused: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it("shows edit button on hover for editable cards", () => {
|
||||
const task = makeEditableTask({ column: "triage" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.getByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeDefined();
|
||||
expect(editBtn.classList.contains("card-edit-btn")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows edit button for todo column tasks", () => {
|
||||
const task = makeEditableTask({ column: "todo" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.getByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT show edit button for in-progress column", () => {
|
||||
const task = makeTask({
|
||||
column: "in-progress",
|
||||
status: "executing",
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show edit button for in-review column", () => {
|
||||
const task = makeTask({
|
||||
column: "in-review",
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show edit button for done column", () => {
|
||||
const task = makeTask({
|
||||
column: "done",
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show edit button when agent is active", () => {
|
||||
const task = makeTask({
|
||||
column: "in-progress",
|
||||
status: "executing",
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show edit button when task is paused", () => {
|
||||
const task = makeEditableTask({ paused: true });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT show edit button when onUpdateTask callback is not provided", () => {
|
||||
const task = makeEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
|
||||
expect(editBtn).toBeNull();
|
||||
});
|
||||
|
||||
it("enters edit mode on double-click for editable cards", () => {
|
||||
const task = makeEditableTask({ title: "Test Title", description: "Test Description" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
expect(card).toBeDefined();
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
// Should show editing UI
|
||||
const titleInput = screen.getByPlaceholderText(/Task title/i);
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i);
|
||||
|
||||
expect(titleInput).toBeDefined();
|
||||
expect(descTextarea).toBeDefined();
|
||||
expect((titleInput as HTMLInputElement).value).toBe("Test Title");
|
||||
expect((descTextarea as HTMLTextAreaElement).value).toBe("Test Description");
|
||||
});
|
||||
|
||||
it("enters edit mode when clicking edit button", () => {
|
||||
const task = makeEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const editBtn = screen.getByRole("button", { name: /Edit task/i });
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
const titleInput = screen.getByPlaceholderText(/Task title/i);
|
||||
expect(titleInput).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT enter edit mode on double-click for non-editable cards", () => {
|
||||
const task = makeNonEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
// Should NOT show editing UI
|
||||
const titleInput = screen.queryByPlaceholderText(/Task title/i);
|
||||
expect(titleInput).toBeNull();
|
||||
});
|
||||
|
||||
it("Escape key cancels edit mode", () => {
|
||||
const task = makeEditableTask({ title: "Original Title" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const titleInput = screen.getByPlaceholderText(/Task title/i) as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: "Changed Title" } });
|
||||
|
||||
// Press Escape
|
||||
fireEvent.keyDown(titleInput, { key: "Escape" });
|
||||
|
||||
// Should exit edit mode without saving
|
||||
expect(screen.queryByPlaceholderText(/Task title/i)).toBeNull();
|
||||
expect(noopUpdateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blurring with no changes cancels edit mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const task = makeEditableTask({ title: "Original Title", description: "Original Desc" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
await user.dblClick(card!);
|
||||
|
||||
const titleInput = screen.getByPlaceholderText(/Task title/i);
|
||||
|
||||
// Tab out to move focus outside the editing area
|
||||
await user.tab();
|
||||
await user.tab(); // Second tab to move past the textarea
|
||||
|
||||
// Wait for the blur handler to execute
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Should have exited edit mode
|
||||
expect(screen.queryByPlaceholderText(/Task title/i)).toBeNull();
|
||||
expect(noopUpdateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Enter in description saves changes", async () => {
|
||||
const task = makeEditableTask({ title: "Title", description: "Old Desc" });
|
||||
const mockUpdateTask = vi.fn().mockResolvedValue({ ...task, description: "New Desc" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={mockUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(descTextarea, { target: { value: "New Desc" } });
|
||||
|
||||
// Press Enter (not Shift+Enter)
|
||||
fireEvent.keyDown(descTextarea, { key: "Enter", shiftKey: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-099", {
|
||||
title: "Title",
|
||||
description: "New Desc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Enter in title moves focus to description", () => {
|
||||
const task = makeEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const titleInput = screen.getByPlaceholderText(/Task title/i) as HTMLInputElement;
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
|
||||
|
||||
// Press Enter in title - should move focus to description
|
||||
fireEvent.keyDown(titleInput, { key: "Enter" });
|
||||
|
||||
// Description should receive focus
|
||||
expect(document.activeElement).toBe(descTextarea);
|
||||
});
|
||||
|
||||
it("Shift+Enter in description adds newline", () => {
|
||||
const task = makeEditableTask({ description: "Line 1" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
|
||||
|
||||
// Press Shift+Enter - should not trigger save
|
||||
fireEvent.keyDown(descTextarea, { key: "Enter", shiftKey: true });
|
||||
|
||||
// updateTask should not be called
|
||||
expect(noopUpdateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows loading state during save", async () => {
|
||||
const task = makeEditableTask({ title: "Title" });
|
||||
// Create a promise that we can resolve manually
|
||||
let resolveUpdate: (value: Task) => void;
|
||||
const updatePromise = new Promise<Task>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
const mockUpdateTask = vi.fn().mockReturnValue(updatePromise);
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={mockUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(descTextarea, { target: { value: "New Desc" } });
|
||||
|
||||
// Trigger save
|
||||
fireEvent.keyDown(descTextarea, { key: "Enter" });
|
||||
|
||||
// Should show loading state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Saving/i)).toBeDefined();
|
||||
});
|
||||
|
||||
// Resolve the update
|
||||
resolveUpdate!({ ...task, description: "New Desc" });
|
||||
|
||||
// Wait for save to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Saving/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when save fails", async () => {
|
||||
const task = makeEditableTask({ title: "Title" });
|
||||
const mockUpdateTask = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
const addToast = vi.fn();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={addToast}
|
||||
onUpdateTask={mockUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]');
|
||||
fireEvent.doubleClick(card!);
|
||||
|
||||
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
|
||||
fireEvent.change(descTextarea, { target: { value: "New Desc" } });
|
||||
|
||||
// Trigger save
|
||||
fireEvent.keyDown(descTextarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Failed to update"), "error");
|
||||
});
|
||||
|
||||
// Should stay in edit mode on error
|
||||
expect(screen.getByPlaceholderText(/Task description/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it("prevents drag during edit mode", () => {
|
||||
const task = makeEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
const card = document.querySelector('[data-id="KB-099"]') as HTMLElement;
|
||||
fireEvent.doubleClick(card);
|
||||
|
||||
// Card should have editing class
|
||||
expect(card.classList.contains("card-editing")).toBe(true);
|
||||
});
|
||||
|
||||
it("card has card-editing class when in edit mode", () => {
|
||||
const task = makeEditableTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
onUpdateTask={noopUpdateTask}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = document.querySelector('[data-id="KB-099"]') as HTMLElement;
|
||||
expect(card.classList.contains("card-editing")).toBe(false);
|
||||
|
||||
fireEvent.doubleClick(card);
|
||||
|
||||
const editingCard = document.querySelector(".card-editing");
|
||||
expect(editingCard).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for collapsible steps toggle in TaskCard.
|
||||
*/
|
||||
describe("TaskCard steps toggle", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not show steps toggle when task has no steps", () => {
|
||||
const task = makeTask({ steps: [] });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggle = screen.queryByRole("button", { name: /steps/i });
|
||||
expect(toggle).toBeNull();
|
||||
});
|
||||
|
||||
it("shows steps toggle with count when task has steps", () => {
|
||||
const task = makeTask({
|
||||
steps: [
|
||||
{ name: "Step 1", status: "done" },
|
||||
{ name: "Step 2", status: "pending" },
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /Show steps/i });
|
||||
expect(toggle).toBeDefined();
|
||||
expect(toggle.textContent).toContain("2 steps");
|
||||
});
|
||||
|
||||
it("clicking toggle expands and shows step list", () => {
|
||||
const task = makeTask({
|
||||
steps: [
|
||||
{ name: "First step", status: "done" },
|
||||
{ name: "Second step", status: "in-progress" },
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /Show steps/i });
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Step list should be visible
|
||||
expect(screen.getByText("First step")).toBeDefined();
|
||||
expect(screen.getByText("Second step")).toBeDefined();
|
||||
});
|
||||
|
||||
it("clicking toggle again collapses step list", () => {
|
||||
const task = makeTask({
|
||||
steps: [{ name: "Single step", status: "pending" }],
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /Show steps/i });
|
||||
|
||||
// Expand
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.getByText("Single step")).toBeDefined();
|
||||
|
||||
// Collapse
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.queryByText("Single step")).toBeNull();
|
||||
});
|
||||
|
||||
it("step list renders correct number of steps", () => {
|
||||
const task = makeTask({
|
||||
steps: [
|
||||
{ name: "Step 1", status: "done" },
|
||||
{ name: "Step 2", status: "in-progress" },
|
||||
{ name: "Step 3", status: "pending" },
|
||||
{ name: "Step 4", status: "skipped" },
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /Show steps/i });
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Should show all 4 steps
|
||||
const stepItems = document.querySelectorAll(".card-step-item");
|
||||
expect(stepItems.length).toBe(4);
|
||||
});
|
||||
|
||||
it("completed steps have strikethrough style", () => {
|
||||
const task = makeTask({
|
||||
steps: [
|
||||
{ name: "Done step", status: "done" },
|
||||
{ name: "Pending step", status: "pending" },
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /Show steps/i });
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const doneStepName = screen.getByText("Done step");
|
||||
expect(doneStepName.classList.contains("completed")).toBe(true);
|
||||
|
||||
const pendingStepName = screen.getByText("Pending step");
|
||||
expect(pendingStepName.classList.contains("completed")).toBe(false);
|
||||
});
|
||||
|
||||
it("toggle does not trigger card click when clicked", () => {
|
||||
const task = makeTask({
|
||||
steps: [{ name: "Test step", status: "pending" }],
|
||||
});
|
||||
const onOpenDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /Show steps/i });
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Card click should not be triggered
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aria-expanded reflects toggle state", () => {
|
||||
const task = makeTask({
|
||||
steps: [{ name: "Test step", status: "pending" }],
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /Show steps/i });
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("chevron icon rotates when expanded", () => {
|
||||
const task = makeTask({
|
||||
steps: [{ name: "Test step", status: "pending" }],
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /Show steps/i });
|
||||
const chevron = toggle.querySelector(".card-steps-toggle-icon");
|
||||
expect(chevron).toBeDefined();
|
||||
expect(chevron?.classList.contains("expanded")).toBe(false);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(chevron?.classList.contains("expanded")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||
import { TerminalModal } from "../TerminalModal";
|
||||
import type { Task } from "@kb/core";
|
||||
import * as useMultiAgentLogsModule from "../../hooks/useMultiAgentLogs";
|
||||
|
||||
// Mock the useMultiAgentLogs hook
|
||||
vi.mock("../../hooks/useMultiAgentLogs", () => ({
|
||||
useMultiAgentLogs: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseMultiAgentLogs = vi.mocked(useMultiAgentLogsModule.useMultiAgentLogs);
|
||||
|
||||
describe("TerminalModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
const createMockTask = (id: string, title?: string): Task => ({
|
||||
id,
|
||||
title: title || `Task ${id}`,
|
||||
description: `Description for ${id}`,
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
status: undefined,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnClose.mockClear();
|
||||
mockUseMultiAgentLogs.mockReturnValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders without crashing when open with empty task list", () => {
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={[]} />
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-no-tasks").textContent).toContain("No active tasks");
|
||||
expect(screen.getByTestId("terminal-empty-state").textContent).toContain("No tasks currently in progress");
|
||||
});
|
||||
|
||||
it("renders without crashing when open with multiple in-progress tasks", () => {
|
||||
const tasks = [
|
||||
createMockTask("KB-001", "First Task"),
|
||||
createMockTask("KB-002", "Second Task"),
|
||||
];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
||||
"KB-002": { entries: [], loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-tab-KB-001")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-tab-KB-002")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render when closed", () => {
|
||||
const tasks = [createMockTask("KB-001")];
|
||||
|
||||
const { container } = render(
|
||||
<TerminalModal isOpen={false} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("shows appropriate empty state when no in-progress tasks", () => {
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={[]} />
|
||||
);
|
||||
|
||||
expect(screen.getByText("No active tasks")).toBeTruthy();
|
||||
expect(screen.getByText("No tasks currently in progress.")).toBeTruthy();
|
||||
expect(screen.getByText("Start a task to see live logs here.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tab switching changes which task's logs are displayed", async () => {
|
||||
const tasks = [
|
||||
createMockTask("KB-001", "First Task"),
|
||||
createMockTask("KB-002", "Second Task"),
|
||||
];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
||||
"KB-002": { entries: [], loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
// First task should be active by default
|
||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
|
||||
|
||||
// Click on second tab
|
||||
fireEvent.click(screen.getByTestId("terminal-tab-KB-002"));
|
||||
|
||||
// Second task should now be active
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-002");
|
||||
});
|
||||
});
|
||||
|
||||
it("active tab has correct styling with indicator", () => {
|
||||
const tasks = [
|
||||
createMockTask("KB-001", "First Task"),
|
||||
createMockTask("KB-002", "Second Task"),
|
||||
];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
||||
"KB-002": { entries: [], loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
// First tab should be active by default
|
||||
const tab1 = screen.getByTestId("terminal-tab-KB-001");
|
||||
const tab2 = screen.getByTestId("terminal-tab-KB-002");
|
||||
|
||||
expect(tab1.className).toContain("terminal-tab--active");
|
||||
expect(tab2.className).not.toContain("terminal-tab--active");
|
||||
|
||||
// Active tab should have indicator
|
||||
expect(screen.getByTestId("terminal-tab-indicator-KB-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking clear button clears that tab's log entries", () => {
|
||||
const mockClear = vi.fn();
|
||||
const tasks = [createMockTask("KB-001")];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log", type: "text" as const }], loading: false, clear: mockClear },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
const clearBtn = screen.getByTestId("terminal-clear-btn");
|
||||
fireEvent.click(clearBtn);
|
||||
|
||||
expect(mockClear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("modal closes on Escape key press", () => {
|
||||
const tasks = [createMockTask("KB-001")];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("modal closes on overlay click", () => {
|
||||
const tasks = [createMockTask("KB-001")];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
const overlay = screen.getByTestId("terminal-modal-overlay");
|
||||
fireEvent.click(overlay);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("modal does not close when clicking inside modal content", () => {
|
||||
const tasks = [createMockTask("KB-001")];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
fireEvent.click(modal);
|
||||
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("modal closes on close button click", () => {
|
||||
const tasks = [createMockTask("KB-001")];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
const closeBtn = screen.getByTestId("terminal-close-btn");
|
||||
fireEvent.click(closeBtn);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("displays task information in toolbar", () => {
|
||||
const tasks = [createMockTask("KB-001", "My Test Task")];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
|
||||
expect(screen.getByTestId("terminal-active-task-title").textContent).toBe("My Test Task");
|
||||
});
|
||||
|
||||
it("uses description as title fallback when title is not provided", () => {
|
||||
const tasks = [{
|
||||
...createMockTask("KB-001"),
|
||||
title: undefined,
|
||||
description: "My Description",
|
||||
}];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("terminal-active-task-title").textContent).toBe("My Description");
|
||||
});
|
||||
|
||||
it("passes correct entries to AgentLogViewer", () => {
|
||||
const tasks = [createMockTask("KB-001")];
|
||||
const entries = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log1", type: "text" as const },
|
||||
{ timestamp: "2026-01-01T00:01:00Z", taskId: "KB-001", text: "log2", type: "tool" as const },
|
||||
];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries, loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-log-viewer")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("passes loading state to AgentLogViewer", () => {
|
||||
const tasks = [createMockTask("KB-001")];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: true, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-log-viewer")).toBeTruthy();
|
||||
expect(screen.getByText("Loading agent logs…")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches to first task when active task is removed from list", () => {
|
||||
const tasks = [
|
||||
createMockTask("KB-001"),
|
||||
createMockTask("KB-002"),
|
||||
];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
||||
"KB-002": { entries: [], loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
// Initially KB-001 should be active
|
||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
|
||||
|
||||
// Click KB-002 to make it active
|
||||
act(() => {
|
||||
screen.getByTestId("terminal-tab-KB-002").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-002");
|
||||
|
||||
// Rerender with only KB-001
|
||||
rerender(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={[tasks[0]]} />
|
||||
);
|
||||
|
||||
// Should switch back to KB-001
|
||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
|
||||
});
|
||||
|
||||
it("tab labels show task IDs", () => {
|
||||
const tasks = [
|
||||
createMockTask("KB-001"),
|
||||
createMockTask("KB-002"),
|
||||
];
|
||||
|
||||
mockUseMultiAgentLogs.mockReturnValue({
|
||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
||||
"KB-002": { entries: [], loading: false, clear: vi.fn() },
|
||||
});
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
||||
);
|
||||
|
||||
const tab1 = screen.getByTestId("terminal-tab-KB-001");
|
||||
const tab2 = screen.getByTestId("terminal-tab-KB-002");
|
||||
|
||||
expect(tab1.textContent).toContain("KB-001");
|
||||
expect(tab2.textContent).toContain("KB-002");
|
||||
});
|
||||
});
|
||||
420
packages/dashboard/app/hooks/__tests__/useMultiAgentLogs.test.ts
Normal file
420
packages/dashboard/app/hooks/__tests__/useMultiAgentLogs.test.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useMultiAgentLogs } from "../useMultiAgentLogs";
|
||||
import { fetchAgentLogs } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const mockFetchAgentLogs = vi.mocked(fetchAgentLogs);
|
||||
|
||||
// Mock EventSource - track instances per hook render, not globally
|
||||
class MockEventSource {
|
||||
url: string;
|
||||
listeners: Record<string, ((e: { data: string }) => void)[]> = {};
|
||||
readyState = 0;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
this.readyState = 1;
|
||||
}
|
||||
|
||||
addEventListener(event: string, fn: (e: { data: string }) => void) {
|
||||
if (!this.listeners[event]) this.listeners[event] = [];
|
||||
this.listeners[event].push(fn);
|
||||
}
|
||||
|
||||
// Helper to simulate a server event
|
||||
_emit(event: string, data: unknown) {
|
||||
for (const fn of this.listeners[event] || []) {
|
||||
fn({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const originalEventSource = globalThis.EventSource;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = MockEventSource;
|
||||
mockFetchAgentLogs.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = originalEventSource;
|
||||
});
|
||||
|
||||
function getActiveConnections(): MockEventSource[] {
|
||||
// Get all MockEventSource instances that haven't been closed
|
||||
// We need to track this ourselves since the mock is recreated each time
|
||||
const allSources: MockEventSource[] = [];
|
||||
|
||||
// Hook into the constructor to track instances
|
||||
const OriginalMock = MockEventSource;
|
||||
const instances: MockEventSource[] = [];
|
||||
|
||||
// Override to capture instances
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
describe("useMultiAgentLogs", () => {
|
||||
it("initializes with empty entries for all provided task IDs", () => {
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
expect(result.current["KB-001"]).toBeDefined();
|
||||
expect(result.current["KB-001"].entries).toEqual([]);
|
||||
expect(result.current["KB-001"].loading).toBe(true);
|
||||
|
||||
expect(result.current["KB-002"]).toBeDefined();
|
||||
expect(result.current["KB-002"].entries).toEqual([]);
|
||||
expect(result.current["KB-002"].loading).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty object when no task IDs provided", () => {
|
||||
const { result } = renderHook(() => useMultiAgentLogs([]));
|
||||
|
||||
expect(Object.keys(result.current)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fetches historical logs for each task on mount", async () => {
|
||||
const logs1 = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log1", type: "text" as const },
|
||||
];
|
||||
const logs2 = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-002", text: "log2", type: "text" as const },
|
||||
];
|
||||
|
||||
mockFetchAgentLogs.mockImplementation((taskId) => {
|
||||
if (taskId === "KB-001") return Promise.resolve(logs1);
|
||||
if (taskId === "KB-002") return Promise.resolve(logs2);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toEqual(logs1);
|
||||
expect(result.current["KB-002"].entries).toEqual(logs2);
|
||||
});
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("KB-001");
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("KB-002");
|
||||
});
|
||||
|
||||
it("opens SSE EventSource for each task ID", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
|
||||
// Override to capture instances
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
await waitFor(() => {
|
||||
// Filter to unique URLs (Strict Mode may create duplicates)
|
||||
const urls = [...new Set(instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/KB-001/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
it("merges live SSE events with historical entries", async () => {
|
||||
const historical = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "old", type: "text" as const },
|
||||
];
|
||||
// Use mockResolvedValue (not Once) to handle Strict Mode double-run
|
||||
mockFetchAgentLogs.mockResolvedValue(historical);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = instances.find((e) => e.url.includes("KB-001"));
|
||||
expect(es).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
es!._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "KB-001",
|
||||
text: "new",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current["KB-001"].entries[1].text).toBe("new");
|
||||
});
|
||||
|
||||
it("closes all SSE connections on unmount (memory leak prevention)", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { unmount } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
// Wait for connections to be established
|
||||
await waitFor(() => {
|
||||
expect(instances.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Get unique instances by URL (handling Strict Mode duplicates)
|
||||
const uniqueByUrl = new Map<string, MockEventSource>();
|
||||
for (const es of instances) {
|
||||
if (!uniqueByUrl.has(es.url) || !es.close.mock?.calls?.length) {
|
||||
uniqueByUrl.set(es.url, es);
|
||||
}
|
||||
}
|
||||
const finalInstances = Array.from(uniqueByUrl.values());
|
||||
|
||||
unmount();
|
||||
|
||||
// Verify all final connections are closed
|
||||
for (const es of finalInstances) {
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes specific connection when task ID removed from array", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
|
||||
{ initialProps: { taskIds: ["KB-001", "KB-002"] } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// Get the last connection for each URL
|
||||
const getConnection = (taskId: string) => {
|
||||
const url = `/api/tasks/${taskId}/logs/stream`;
|
||||
const matching = instances.filter((e) => e.url === url);
|
||||
return matching[matching.length - 1];
|
||||
};
|
||||
|
||||
const es1 = getConnection("KB-001");
|
||||
const es2 = getConnection("KB-002");
|
||||
|
||||
rerender({ taskIds: ["KB-001"] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(es2.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(es1.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens new connection when task ID added to array", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
|
||||
{ initialProps: { taskIds: ["KB-001"] } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
rerender({ taskIds: ["KB-001", "KB-002"] });
|
||||
|
||||
await waitFor(() => {
|
||||
const urls = [...new Set(instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
it("provides per-task clear function that resets entries", async () => {
|
||||
const logs = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log1", type: "text" as const },
|
||||
{ timestamp: "2026-01-01T00:01:00Z", taskId: "KB-001", text: "log2", type: "text" as const },
|
||||
];
|
||||
// Use mockResolvedValue (not Once) to handle Strict Mode double-run
|
||||
mockFetchAgentLogs.mockResolvedValue(logs);
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Clear only KB-001
|
||||
act(() => {
|
||||
result.current["KB-001"].clear();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles errors gracefully when fetching historical logs", async () => {
|
||||
mockFetchAgentLogs.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current["KB-001"].entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("only opens one connection per task ID (no duplicates)", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
|
||||
{ initialProps: { taskIds: ["KB-001"] } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
const initialCount = instances.length;
|
||||
|
||||
// Re-render with same task ID (should not create new connection)
|
||||
rerender({ taskIds: ["KB-001"] });
|
||||
|
||||
// Wait a bit
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// In strict mode, we may have more instances due to double-run,
|
||||
// but the active (non-closed) connections should remain stable
|
||||
const activeConnections = instances.filter((es) => !es.close.mock?.calls?.length);
|
||||
expect(activeConnections.length).toBeLessThanOrEqual(initialCount);
|
||||
});
|
||||
|
||||
it("handles SSE events for multiple tasks independently", async () => {
|
||||
const logs1 = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "task1-old", type: "text" as const },
|
||||
];
|
||||
const logs2 = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-002", text: "task2-old", type: "text" as const },
|
||||
];
|
||||
|
||||
mockFetchAgentLogs.mockImplementation((taskId) => {
|
||||
if (taskId === "KB-001") return Promise.resolve(logs1);
|
||||
if (taskId === "KB-002") return Promise.resolve(logs2);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001", "KB-002"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(1);
|
||||
expect(result.current["KB-002"].entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Get the last connection for each URL
|
||||
const getConnection = (taskId: string) => {
|
||||
const url = `/api/tasks/${taskId}/logs/stream`;
|
||||
const matching = instances.filter((e) => e.url === url);
|
||||
return matching[matching.length - 1];
|
||||
};
|
||||
|
||||
const es1 = getConnection("KB-001");
|
||||
const es2 = getConnection("KB-002");
|
||||
expect(es1).toBeDefined();
|
||||
expect(es2).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
es1._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "KB-001",
|
||||
text: "task1-new",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(2);
|
||||
expect(result.current["KB-002"].entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
es2._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "KB-002",
|
||||
text: "task2-new",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(2);
|
||||
expect(result.current["KB-002"].entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current["KB-001"].entries[1].text).toBe("task1-new");
|
||||
expect(result.current["KB-002"].entries[1].text).toBe("task2-new");
|
||||
});
|
||||
});
|
||||
566
packages/dashboard/app/hooks/__tests__/useTasks.test.ts
Normal file
566
packages/dashboard/app/hooks/__tests__/useTasks.test.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useTasks } from "../useTasks";
|
||||
import * as api from "../../api";
|
||||
import type { Task, Column } from "@kb/core";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchTasks = vi.mocked(api.fetchTasks);
|
||||
const mockUpdateTask = vi.mocked(api.updateTask);
|
||||
|
||||
// Mock EventSource
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
url: string;
|
||||
listeners: Record<string, ((e: any) => void)[]> = {};
|
||||
readyState = 0;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
this.readyState = 1;
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(event: string, fn: (e: any) => void) {
|
||||
if (!this.listeners[event]) this.listeners[event] = [];
|
||||
this.listeners[event].push(fn);
|
||||
}
|
||||
|
||||
// Helper to simulate a server event
|
||||
_emit(event: string, data: unknown) {
|
||||
for (const fn of this.listeners[event] || []) {
|
||||
fn({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const originalEventSource = globalThis.EventSource;
|
||||
|
||||
beforeEach(() => {
|
||||
MockEventSource.instances = [];
|
||||
(globalThis as any).EventSource = MockEventSource;
|
||||
mockFetchTasks.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).EventSource = originalEventSource;
|
||||
});
|
||||
|
||||
function createMockTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "KB-001",
|
||||
description: "Test task",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("useTasks", () => {
|
||||
it("fetches initial tasks on mount", async () => {
|
||||
const mockTasks = [createMockTask()];
|
||||
mockFetchTasks.mockResolvedValueOnce(mockTasks);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].id).toBe("KB-001");
|
||||
});
|
||||
|
||||
describe("SSE event: task:created", () => {
|
||||
it("adds new task to the list", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const newTask = createMockTask({ id: "KB-002", column: "triage" });
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:created", newTask);
|
||||
});
|
||||
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
expect(result.current.tasks[0].id).toBe("KB-002");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE event: task:moved", () => {
|
||||
it("updates task column using the 'to' field", async () => {
|
||||
const initialTask = createMockTask({ id: "KB-001", column: "in-progress" as Column });
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("in-progress");
|
||||
});
|
||||
|
||||
const movedTaskData = {
|
||||
task: createMockTask({
|
||||
id: "KB-001",
|
||||
column: "in-progress", // task object may have stale column
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
}),
|
||||
from: "in-progress" as Column,
|
||||
to: "done" as Column,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:moved", movedTaskData);
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].column).toBe("done");
|
||||
expect(result.current.tasks[0].columnMovedAt).toBe("2026-01-02T00:00:00Z");
|
||||
});
|
||||
|
||||
it("task moved from in-progress to done appears only in done column", async () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "KB-001", column: "in-progress" as Column }),
|
||||
createMockTask({ id: "KB-002", column: "in-progress" as Column }),
|
||||
];
|
||||
mockFetchTasks.mockResolvedValueOnce(tasks);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Move KB-001 to done
|
||||
const movedTaskData = {
|
||||
task: createMockTask({
|
||||
id: "KB-001",
|
||||
column: "in-progress",
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
}),
|
||||
from: "in-progress" as Column,
|
||||
to: "done" as Column,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:moved", movedTaskData);
|
||||
});
|
||||
|
||||
const inProgressTasks = result.current.tasks.filter((t) => t.column === "in-progress");
|
||||
const doneTasks = result.current.tasks.filter((t) => t.column === "done");
|
||||
|
||||
expect(inProgressTasks).toHaveLength(1);
|
||||
expect(inProgressTasks[0].id).toBe("KB-002");
|
||||
expect(doneTasks).toHaveLength(1);
|
||||
expect(doneTasks[0].id).toBe("KB-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE event: task:updated", () => {
|
||||
it("updates task fields", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "Old Title",
|
||||
column: "in-progress" as Column,
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].title).toBe("Old Title");
|
||||
});
|
||||
|
||||
const updatedTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "New Title",
|
||||
column: "in-progress" as Column,
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:updated", updatedTask);
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].title).toBe("New Title");
|
||||
expect(result.current.tasks[0].column).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("does not overwrite newer column with stale data (timestamp comparison)", async () => {
|
||||
// Start with task in in-progress
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
column: "in-progress" as Column,
|
||||
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("in-progress");
|
||||
});
|
||||
|
||||
// First, move to done (newer timestamp)
|
||||
const movedTaskData = {
|
||||
task: createMockTask({
|
||||
id: "KB-001",
|
||||
column: "in-progress",
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
}),
|
||||
from: "in-progress" as Column,
|
||||
to: "done" as Column,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:moved", movedTaskData);
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].column).toBe("done");
|
||||
expect(result.current.tasks[0].columnMovedAt).toBe("2026-01-02T00:00:00Z");
|
||||
|
||||
// Then, stale update arrives with old column and older timestamp
|
||||
const staleUpdate = createMockTask({
|
||||
id: "KB-001",
|
||||
column: "in-progress" as Column, // stale column
|
||||
columnMovedAt: "2026-01-01T00:00:00Z", // older timestamp
|
||||
title: "Some other update", // but other fields are new
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:updated", staleUpdate);
|
||||
});
|
||||
|
||||
// Column should remain 'done' (not revert to in-progress)
|
||||
expect(result.current.tasks[0].column).toBe("done");
|
||||
expect(result.current.tasks[0].columnMovedAt).toBe("2026-01-02T00:00:00Z");
|
||||
// But other fields should be updated
|
||||
expect(result.current.tasks[0].title).toBe("Some other update");
|
||||
});
|
||||
|
||||
it("preserves current column when incoming has no columnMovedAt (legacy data)", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
column: "done" as Column,
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("done");
|
||||
});
|
||||
|
||||
// Incoming update has no columnMovedAt (legacy) and different column
|
||||
const legacyUpdate = {
|
||||
...createMockTask({
|
||||
id: "KB-001",
|
||||
column: "in-progress" as Column,
|
||||
}),
|
||||
columnMovedAt: undefined,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:updated", legacyUpdate);
|
||||
});
|
||||
|
||||
// Should preserve the done column since we have timestamp and incoming doesn't
|
||||
expect(result.current.tasks[0].column).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE event: task:deleted", () => {
|
||||
it("removes task from the list", async () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "KB-001" }),
|
||||
createMockTask({ id: "KB-002" }),
|
||||
];
|
||||
mockFetchTasks.mockResolvedValueOnce(tasks);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:deleted", { id: "KB-001" });
|
||||
});
|
||||
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
expect(result.current.tasks[0].id).toBe("KB-002");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSE event: task:merged", () => {
|
||||
it("ensures column is always done after merge", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
column: "in-review" as Column,
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("in-review");
|
||||
});
|
||||
|
||||
const mergeResult = {
|
||||
task: createMockTask({
|
||||
id: "KB-001",
|
||||
column: "in-review" as Column, // might have stale column
|
||||
}),
|
||||
branch: "kb/kb-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:merged", mergeResult);
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].column).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Race condition scenarios", () => {
|
||||
it("rapid task:moved + task:updated events maintain correct column state", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
column: "todo" as Column,
|
||||
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("todo");
|
||||
});
|
||||
|
||||
// Simulate rapid succession: moved then stale update
|
||||
const movedData = {
|
||||
task: createMockTask({
|
||||
id: "KB-001",
|
||||
column: "todo",
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
title: "Original Title",
|
||||
}),
|
||||
from: "todo" as Column,
|
||||
to: "in-progress" as Column,
|
||||
};
|
||||
|
||||
const staleUpdate = createMockTask({
|
||||
id: "KB-001",
|
||||
column: "todo" as Column, // stale
|
||||
columnMovedAt: "2026-01-01T00:00:00Z", // older
|
||||
title: "Updated Title", // fresh
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:moved", movedData);
|
||||
MockEventSource.instances[0]._emit("task:updated", staleUpdate);
|
||||
});
|
||||
|
||||
// Should have in-progress column (from move) but updated title
|
||||
expect(result.current.tasks[0].column).toBe("in-progress");
|
||||
expect(result.current.tasks[0].title).toBe("Updated Title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("closes EventSource on unmount", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
unmount();
|
||||
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTask", () => {
|
||||
it("updates task optimistically and returns server response", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "Old Title",
|
||||
description: "Old Description",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
const updatedTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "New Title",
|
||||
description: "New Description",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockUpdateTask.mockResolvedValueOnce(updatedTask);
|
||||
|
||||
let returnedTask: Task | undefined;
|
||||
await act(async () => {
|
||||
returnedTask = await result.current.updateTask("KB-001", {
|
||||
title: "New Title",
|
||||
description: "New Description",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
|
||||
title: "New Title",
|
||||
description: "New Description",
|
||||
});
|
||||
expect(returnedTask).toEqual(updatedTask);
|
||||
expect(result.current.tasks[0].title).toBe("New Title");
|
||||
expect(result.current.tasks[0].description).toBe("New Description");
|
||||
});
|
||||
|
||||
it("rolls back on error and rethrows", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "Original Title",
|
||||
description: "Original Description",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
mockUpdateTask.mockRejectedValueOnce(new Error("Update failed"));
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.updateTask("KB-001", {
|
||||
title: "New Title",
|
||||
description: "New Description",
|
||||
});
|
||||
})
|
||||
).rejects.toThrow("Update failed");
|
||||
|
||||
// Should have rolled back to original
|
||||
expect(result.current.tasks[0].title).toBe("Original Title");
|
||||
expect(result.current.tasks[0].description).toBe("Original Description");
|
||||
});
|
||||
|
||||
it("supports updating only title", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "Old Title",
|
||||
description: "Description",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
const updatedTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "New Title",
|
||||
description: "Description",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockUpdateTask.mockResolvedValueOnce(updatedTask);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateTask("KB-001", { title: "New Title" });
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].title).toBe("New Title");
|
||||
expect(result.current.tasks[0].description).toBe("Description");
|
||||
});
|
||||
|
||||
it("supports updating only description", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "Title",
|
||||
description: "Old Description",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
const updatedTask = createMockTask({
|
||||
id: "KB-001",
|
||||
title: "Title",
|
||||
description: "New Description",
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockUpdateTask.mockResolvedValueOnce(updatedTask);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateTask("KB-001", { description: "New Description" });
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].title).toBe("Title");
|
||||
expect(result.current.tasks[0].description).toBe("New Description");
|
||||
});
|
||||
|
||||
it("supports updating dependencies", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "KB-001",
|
||||
dependencies: ["KB-002"],
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
const updatedTask = createMockTask({
|
||||
id: "KB-001",
|
||||
dependencies: ["KB-002", "KB-003"],
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockUpdateTask.mockResolvedValueOnce(updatedTask);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateTask("KB-001", { dependencies: ["KB-002", "KB-003"] });
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].dependencies).toEqual(["KB-002", "KB-003"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
154
packages/dashboard/app/hooks/useMultiAgentLogs.ts
Normal file
154
packages/dashboard/app/hooks/useMultiAgentLogs.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { AgentLogEntry } from "@kb/core";
|
||||
import { fetchAgentLogs } from "../api";
|
||||
|
||||
export interface TaskLogState {
|
||||
entries: AgentLogEntry[];
|
||||
loading: boolean;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export type LogStateMap = Record<string, TaskLogState>;
|
||||
|
||||
interface InitState {
|
||||
entries: AgentLogEntry[];
|
||||
loading: boolean;
|
||||
es?: EventSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages agent log fetching and live SSE streaming for multiple tasks.
|
||||
*
|
||||
* For each task ID in the provided array:
|
||||
* 1. Fetches historical logs via GET /api/tasks/:id/logs
|
||||
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
|
||||
* 3. Merges historical + live entries in order
|
||||
*
|
||||
* When task IDs are added or removed, connections are opened/closed accordingly.
|
||||
* When the component unmounts, all EventSources are closed to prevent memory leaks.
|
||||
*/
|
||||
export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
// Store state per task
|
||||
const [stateMap, setStateMap] = useState<Record<string, InitState>>({});
|
||||
|
||||
// Ref to track active EventSources
|
||||
const sourcesRef = useRef<Record<string, EventSource>>({});
|
||||
|
||||
// Create clear function for a specific task
|
||||
const createClearFn = useCallback((taskId: string) => {
|
||||
return () => {
|
||||
setStateMap((prev) => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: { ...current, entries: [] },
|
||||
};
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Main effect to manage connections
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(taskIds);
|
||||
const sources = sourcesRef.current;
|
||||
|
||||
// Close connections for tasks no longer in the list
|
||||
for (const [taskId, es] of Object.entries(sources)) {
|
||||
if (!currentIds.has(taskId)) {
|
||||
es.close();
|
||||
delete sources[taskId];
|
||||
// Remove state for disconnected task
|
||||
setStateMap((prev) => {
|
||||
const { [taskId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize state and connections for current tasks
|
||||
for (const taskId of taskIds) {
|
||||
// Initialize state if not present
|
||||
setStateMap((prev) => {
|
||||
if (prev[taskId]) return prev;
|
||||
return { ...prev, [taskId]: { entries: [], loading: true } };
|
||||
});
|
||||
|
||||
// Skip if already connected
|
||||
if (sources[taskId]) continue;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// Fetch historical logs and open SSE
|
||||
const init = async () => {
|
||||
try {
|
||||
const historical = await fetchAgentLogs(taskId);
|
||||
if (cancelled) return;
|
||||
|
||||
setStateMap((prev) => ({
|
||||
...prev,
|
||||
[taskId]: { ...prev[taskId], entries: historical, loading: false },
|
||||
}));
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setStateMap((prev) => ({
|
||||
...prev,
|
||||
[taskId]: { ...prev[taskId], entries: [], loading: false },
|
||||
}));
|
||||
}
|
||||
|
||||
// Open SSE connection
|
||||
const es = new EventSource(`/api/tasks/${taskId}/logs/stream`);
|
||||
sources[taskId] = es;
|
||||
|
||||
es.addEventListener("agent:log", (e) => {
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setStateMap((prev) => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: { ...current, entries: [...current.entries, entry] },
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
// Cleanup on effect re-run or unmount
|
||||
return () => {
|
||||
// In Strict Mode, React runs effects twice.
|
||||
// We only want to close connections on actual unmount, not on every cleanup.
|
||||
// The actual closing of connections for removed tasks is handled above.
|
||||
};
|
||||
}, [taskIds]);
|
||||
|
||||
// Close all connections on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const es of Object.values(sourcesRef.current)) {
|
||||
es.close();
|
||||
}
|
||||
sourcesRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build result map
|
||||
const result: LogStateMap = {};
|
||||
for (const taskId of taskIds) {
|
||||
const state = stateMap[taskId];
|
||||
result[taskId] = {
|
||||
entries: state?.entries ?? [],
|
||||
loading: state?.loading ?? true,
|
||||
clear: createClearFn(taskId),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -2,6 +2,17 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Task, Column, TaskCreateInput, MergeResult } from "@kb/core";
|
||||
import * as api from "../api";
|
||||
|
||||
/**
|
||||
* Compare two ISO timestamp strings.
|
||||
* Returns positive if a is newer than b, negative if b is newer, 0 if equal.
|
||||
*/
|
||||
function compareTimestamps(a: string | undefined, b: string | undefined): number {
|
||||
if (!a && !b) return 0;
|
||||
if (!a) return -1; // b is newer if a has no timestamp
|
||||
if (!b) return 1; // a is newer if b has no timestamp
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
export function useTasks() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const tasksRef = useRef(tasks);
|
||||
@@ -22,13 +33,41 @@ export function useTasks() {
|
||||
});
|
||||
|
||||
es.addEventListener("task:moved", (e) => {
|
||||
const { task }: { task: Task } = JSON.parse(e.data);
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
|
||||
// Payload: { task, from, to } - task object includes server-set columnMovedAt
|
||||
// We use 'to' as the authoritative column and trust the server's columnMovedAt
|
||||
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === task.id ? { ...task, column: to } : t
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
es.addEventListener("task:updated", (e) => {
|
||||
const task: Task = JSON.parse(e.data);
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
|
||||
const incoming: Task = JSON.parse(e.data);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== incoming.id) return t;
|
||||
|
||||
// Race condition prevention: If the incoming update has stale column data,
|
||||
// preserve the current column. A task:moved event always carries the
|
||||
// authoritative column in the 'to' field and updates columnMovedAt.
|
||||
// If current state has a newer columnMovedAt, reject the column change.
|
||||
const columnTimestampCompare = compareTimestamps(t.columnMovedAt, incoming.columnMovedAt);
|
||||
if (columnTimestampCompare > 0 && t.column !== incoming.column) {
|
||||
// Current state is newer - preserve column, merge other fields
|
||||
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
|
||||
}
|
||||
|
||||
// Edge case: current has columnMovedAt but incoming doesn't (legacy data)
|
||||
// Preserve the column information we have
|
||||
if (t.columnMovedAt && !incoming.columnMovedAt && t.column !== incoming.column) {
|
||||
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
|
||||
}
|
||||
|
||||
return incoming;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
es.addEventListener("task:deleted", (e) => {
|
||||
@@ -37,8 +76,15 @@ export function useTasks() {
|
||||
});
|
||||
|
||||
es.addEventListener("task:merged", (e) => {
|
||||
// Payload: { task, branch, merged, worktreeRemoved, branchDeleted, ... }
|
||||
// The task object has already been moved to 'done' by the server
|
||||
const { task }: { task: Task } = JSON.parse(e.data);
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
// Ensure column is 'done' since that's where merged tasks always go
|
||||
t.id === task.id ? { ...task, column: "done" as Column } : t
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
@@ -72,5 +118,39 @@ export function useTasks() {
|
||||
return api.retryTask(id);
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask };
|
||||
const updateTask = useCallback(async (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
): Promise<Task> => {
|
||||
// Optimistic update: apply changes immediately
|
||||
const previousTask = tasksRef.current.find((t) => t.id === id);
|
||||
const optimisticTask = previousTask
|
||||
? { ...previousTask, ...updates, updatedAt: new Date().toISOString() }
|
||||
: undefined;
|
||||
|
||||
if (optimisticTask) {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? optimisticTask : t))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedTask = await api.updateTask(id, updates);
|
||||
// Replace with server response
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? updatedTask : t))
|
||||
);
|
||||
return updatedTask;
|
||||
} catch (err) {
|
||||
// Rollback on error: restore previous state
|
||||
if (previousTask) {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? previousTask : t))
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
"build": "vite build && tsc",
|
||||
"build:client": "vite build",
|
||||
"dev": "vite build --watch",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -35,15 +36,17 @@
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"@vitest/coverage-v8": "^3.1.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.1"
|
||||
"vitest": "^3.1.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
259
packages/dashboard/src/github.ts
Normal file
259
packages/dashboard/src/github.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { PrInfo } from "@kb/core";
|
||||
|
||||
export interface CreatePrParams {
|
||||
owner: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
head: string;
|
||||
base?: string;
|
||||
}
|
||||
|
||||
export interface PrComment {
|
||||
id: number;
|
||||
body: string;
|
||||
user: { login: string };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
export class GitHubClient {
|
||||
private token: string | undefined;
|
||||
private baseUrl = "https://api.github.com";
|
||||
|
||||
constructor(token?: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to create a PR using the `gh` CLI if available, otherwise fall back
|
||||
* to the REST API. Returns the created PR info.
|
||||
*/
|
||||
async createPr(params: CreatePrParams): Promise<PrInfo> {
|
||||
// Try gh CLI first (preferred for auth handling)
|
||||
try {
|
||||
return this.createPrWithGh(params);
|
||||
} catch {
|
||||
// Fall back to REST API
|
||||
return this.createPrWithApi(params);
|
||||
}
|
||||
}
|
||||
|
||||
private createPrWithGh(params: CreatePrParams): PrInfo {
|
||||
const { owner, repo, title, body, head, base } = params;
|
||||
|
||||
// Build gh pr create command arguments (as array for safety)
|
||||
const args = [
|
||||
"pr", "create",
|
||||
"--repo", `${owner}/${repo}`,
|
||||
"--title", title,
|
||||
"--head", head,
|
||||
];
|
||||
|
||||
if (body) {
|
||||
args.push("--body", body);
|
||||
}
|
||||
if (base) {
|
||||
args.push("--base", base);
|
||||
}
|
||||
|
||||
// Execute gh command using execFileSync for proper argument handling
|
||||
const result = execFileSync("gh", args, {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
});
|
||||
|
||||
// Extract PR URL from output (gh outputs the PR URL on success)
|
||||
const prUrl = result.trim();
|
||||
const match = prUrl.match(/\/pull\/(\d+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Failed to parse PR URL from gh output: ${prUrl}`);
|
||||
}
|
||||
|
||||
const number = parseInt(match[1], 10);
|
||||
|
||||
return {
|
||||
url: prUrl,
|
||||
number,
|
||||
status: "open",
|
||||
title,
|
||||
headBranch: head,
|
||||
baseBranch: base || "main",
|
||||
commentCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async createPrWithApi(params: CreatePrParams): Promise<PrInfo> {
|
||||
const { owner, repo, title, body, head, base = "main" } = params;
|
||||
|
||||
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`;
|
||||
|
||||
const headers = this.buildHeaders();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body: body || "",
|
||||
head,
|
||||
base,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: response.statusText }));
|
||||
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
number: number;
|
||||
html_url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
head: { ref: string };
|
||||
base: { ref: string };
|
||||
comments: number;
|
||||
};
|
||||
|
||||
return {
|
||||
url: data.html_url,
|
||||
number: data.number,
|
||||
status: this.mapPrState(data.state),
|
||||
title: data.title,
|
||||
headBranch: data.head.ref,
|
||||
baseBranch: data.base.ref,
|
||||
commentCount: data.comments,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current PR status from GitHub API.
|
||||
*/
|
||||
async getPrStatus(owner: string, repo: string, number: number): Promise<PrInfo> {
|
||||
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`;
|
||||
|
||||
const headers = this.buildHeaders();
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`PR #${number} not found in ${owner}/${repo}`);
|
||||
}
|
||||
const error = await response.json().catch(() => ({ message: response.statusText }));
|
||||
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
number: number;
|
||||
html_url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
merged: boolean;
|
||||
head: { ref: string };
|
||||
base: { ref: string };
|
||||
comments: number;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
return {
|
||||
url: data.html_url,
|
||||
number: data.number,
|
||||
status: data.merged ? "merged" : this.mapPrState(data.state),
|
||||
title: data.title,
|
||||
headBranch: data.head.ref,
|
||||
baseBranch: data.base.ref,
|
||||
commentCount: data.comments,
|
||||
lastCommentAt: data.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List PR comments since a specific timestamp.
|
||||
*/
|
||||
async listPrComments(
|
||||
owner: string,
|
||||
repo: string,
|
||||
number: number,
|
||||
since?: string,
|
||||
): Promise<PrComment[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("per_page", "100");
|
||||
if (since) {
|
||||
params.append("since", since);
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}/comments?${params}`;
|
||||
|
||||
const headers = this.buildHeaders();
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return []; // PR might not exist or have no comments
|
||||
}
|
||||
const error = await response.json().catch(() => ({ message: response.statusText }));
|
||||
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<PrComment[]>;
|
||||
}
|
||||
|
||||
private buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "kb-dashboard/1.0",
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private mapPrState(state: string): "open" | "closed" {
|
||||
return state === "open" ? "open" : "closed";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract owner/repo from a GitHub remote URL or return null if not a GitHub remote.
|
||||
*/
|
||||
export function parseGitHubRemote(remoteUrl: string): { owner: string; repo: string } | null {
|
||||
// Handle HTTPS: https://github.com/owner/repo.git or https://github.com/owner/repo
|
||||
const httpsMatch = remoteUrl.match(/github\.com\/([^\/]+)\/([^\/\.]+)(?:\.git)?$/);
|
||||
if (httpsMatch) {
|
||||
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
||||
}
|
||||
|
||||
// Handle SSH: git@github.com:owner/repo.git or git@github.com:owner/repo
|
||||
const sshMatch = remoteUrl.match(/github\.com:([^\/]+)\/([^\/\.]+)(?:\.git)?$/);
|
||||
if (sshMatch) {
|
||||
return { owner: sshMatch[1], repo: sshMatch[2] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current GitHub remote owner/repo from the git config.
|
||||
*/
|
||||
export function getCurrentGitHubRepo(cwd: string): { owner: string; repo: string } | null {
|
||||
try {
|
||||
const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
}).trim();
|
||||
|
||||
return parseGitHubRemote(remoteUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@ export interface ServerOptions {
|
||||
onMerge?: (taskId: string) => Promise<MergeResult>;
|
||||
/** Maximum concurrent worktrees / execution slots (default 2) */
|
||||
maxConcurrent?: number;
|
||||
/** Optional GitHub token for PR operations — falls back to GITHUB_TOKEN env var */
|
||||
githubToken?: string;
|
||||
/** Optional AuthStorage instance for auth routes — if not provided, one is created internally */
|
||||
authStorage?: AuthStorageLike;
|
||||
/** Optional ModelRegistry instance for the models API — if not provided, the endpoint returns an empty list */
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"noEmit": true
|
||||
"noEmit": true,
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom", "node"]
|
||||
},
|
||||
"include": ["app"]
|
||||
}
|
||||
|
||||
@@ -13,5 +13,13 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["app/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
coverage: {
|
||||
enabled: false,
|
||||
reporter: ["text", "html", "json"],
|
||||
reportsDirectory: "./coverage",
|
||||
include: ["app/**/*.{ts,tsx}", "src/**/*.{ts,tsx}"],
|
||||
exclude: ["**/*.test.{ts,tsx}", "**/*.d.ts", "dist/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
1
packages/dashboard/vitest.setup.ts
Normal file
1
packages/dashboard/vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
Reference in New Issue
Block a user