feat(KB-503): merge kb/kb-503

- feat(KB-503): complete Step 5 other command project support
- fix(KB-503): align task resolution with shared project context
- fix(KB-503): preserve local task command fallback
- test(KB-503): complete Step 4 task command coverage
- test(KB-503): harden Step 3 CLI parser coverage
- fix(KB-503): keep settings set scope explicit
- fix(KB-503): allow implicit project resolution for project settings
- fix(KB-503): enforce explicit settings scope rules
- fix(KB-503): restore shared settings resolution behavior
- fix(KB-503): align settings scope and project flag behavior
- feat(KB-503): complete Step 5 — add project-aware settings, git, and backup behavior
- fix(KB-503): enforce settings scope for global and project modes
- fix(KB-503): preserve project-scoped settings and cwd-aware git helpers
- fix(KB-503): add scoped settings behavior and git project tests
- feat(KB-503): complete Step 5 — add project-aware git and backup coverage
- test(KB-503): cover remaining project-aware task command paths
- fix(KB-503): use shared resolution flow for all task commands
- fix(KB-503): restore legacy task fallback and correct task mocks
- fix(KB-503): align task resolution order and kb storage paths
- fix(KB-503): restore local task store fallback without project flag
- test(KB-503): cover remaining project-aware task handlers
- fix(KB-503): preserve branch naming and avoid duplicate log resolution
- test(KB-503): expand project-aware task command coverage
- fix(KB-503): restore cwd fallback for path-based task commands
- fix(KB-503): add project-aware task output and tests
- fix(KB-503): harden project command output and coverage
- fix(KB-503): preserve legacy task resolution without project flag
- test(KB-503): add bin routing coverage for project flag parsing
- feat(KB-503): complete Step 3 — CLI argument parsing updates
- fix(KB-503): add defaultProjectId to GlobalSettings type and fix TypeScript errors
- test(KB-503): fix resolveProject mock in task tests for runTaskLogs follow mode
- docs(KB-503): add multi-project CLI documentation and changeset
- test(KB-503): add project-context mock to task tests
- test(KB-503): update git tests for new cwd parameter
- feat(KB-503): Step 3 — CLI argument parsing updates with --project flag support
- feat(KB-503): Steps 1-2 — project context utilities and project subcommands
This commit is contained in:
gsxdsm
2026-04-01 15:06:58 -07:00
parent c6acedb432
commit 07adb42d64
37 changed files with 2500 additions and 5763 deletions

View File

@@ -8,7 +8,7 @@ import {
loginProvider,
logoutProvider,
fetchModels,
addComment,
addSteeringComment,
addTaskComment,
updateTaskComment,
deleteTaskComment,
@@ -28,11 +28,6 @@ import {
unregisterProject,
fetchProjectHealth,
fetchActivityFeed,
fetchScripts,
addScript,
removeScript,
runScript,
waitForScriptCompletion,
pauseProject,
resumeProject,
fetchFirstRunStatus,
@@ -499,7 +494,7 @@ describe("logoutProvider", () => {
});
});
describe("addComment", () => {
describe("addSteeringComment", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
@@ -516,7 +511,7 @@ describe("addComment", () => {
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
comments: [
steeringComments: [
{
id: "1234567890-abc123",
text: "Please handle the edge case",
@@ -529,12 +524,12 @@ describe("addComment", () => {
it("sends POST with text and returns updated task", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
const result = await addComment("FN-001", "Please handle the edge case");
const result = await addSteeringComment("FN-001", "Please handle the edge case");
expect(result.id).toBe("FN-001");
expect(result.comments).toHaveLength(1);
expect(result.comments![0].text).toBe("Please handle the edge case");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments", {
expect(result.steeringComments).toHaveLength(1);
expect(result.steeringComments![0].text).toBe("Please handle the edge case");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/steer", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ text: "Please handle the edge case" }),
@@ -546,7 +541,7 @@ describe("addComment", () => {
mockFetchResponse(false, { error: "Task not found" })
);
await expect(addComment("FN-001", "Test comment")).rejects.toThrow("Task not found");
await expect(addSteeringComment("FN-001", "Test comment")).rejects.toThrow("Task not found");
});
});
@@ -2017,7 +2012,6 @@ describe("fetchActivityFeed", () => {
expect(call[0]).toContain("since=2026-01-01T00%3A00%3A00.000Z");
expect(call[0]).toContain("projectId=proj_abc123");
expect(call[0]).toContain("type=task%3Acreated");
expect(call[0]).not.toContain("types=");
});
});
@@ -2161,94 +2155,3 @@ describe("fetchProjectConfig", () => {
expect(result.rootDir).toBe("/path/to/project");
});
});
describe("scripts API helpers", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
it("fetchScripts uses GET /api/scripts", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { build: "pnpm build" }));
const result = await fetchScripts();
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/scripts",
expect.objectContaining({ headers: { "Content-Type": "application/json" } }),
);
expect(result).toEqual({ build: "pnpm build" });
});
it("addScript posts the expected payload", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { build: "pnpm build" }, 201));
await addScript("build", "pnpm build");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/scripts",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ name: "build", command: "pnpm build" }),
}),
);
});
it("removeScript URL-encodes the script name", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}, 200));
await removeScript("build_script");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/scripts/build_script",
expect.objectContaining({ method: "DELETE" }),
);
});
it("runScript returns the terminal session handle", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { command: "pnpm test", sessionId: "sess-1" }, 201));
const result = await runScript("test", ["--watch"]);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/scripts/test/run",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ args: ["--watch"] }),
}),
);
expect(result).toEqual({ command: "pnpm test", sessionId: "sess-1" });
});
it("waitForScriptCompletion polls until the session finishes", async () => {
vi.useFakeTimers();
const fetchMock = vi
.fn()
.mockReturnValueOnce(mockFetchResponse(true, {
id: "sess-1",
command: "pnpm test",
running: true,
exitCode: null,
output: "starting",
startTime: new Date().toISOString(),
}))
.mockReturnValueOnce(mockFetchResponse(true, {
id: "sess-1",
command: "pnpm test",
running: false,
exitCode: 1,
output: "done",
startTime: new Date().toISOString(),
}));
globalThis.fetch = fetchMock;
const promise = waitForScriptCompletion("sess-1");
await vi.advanceTimersByTimeAsync(100);
const result = await promise;
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result).toEqual({ output: "done", exitCode: 1 });
});
});

View File

@@ -283,17 +283,6 @@ export function fetchSessionFiles(taskId: string): Promise<string[]> {
return api<string[]>(`/tasks/${taskId}/session-files`);
}
export interface TaskFileDiff {
path: string;
status: "added" | "modified" | "deleted" | "renamed";
diff: string;
oldPath?: string;
}
export function fetchTaskFileDiffs(taskId: string): Promise<TaskFileDiff[]> {
return api<TaskFileDiff[]>(`/tasks/${taskId}/file-diffs`);
}
export function fetchTaskComments(id: string): Promise<TaskComment[]> {
return api<TaskComment[]>(`/tasks/${id}/comments`);
}
@@ -318,15 +307,13 @@ export function deleteTaskComment(id: string, commentId: string): Promise<Task>
});
}
export function addComment(id: string, text: string): Promise<Task> {
return api<Task>(`/tasks/${id}/comments`, {
export function addSteeringComment(id: string, text: string): Promise<Task> {
return api<Task>(`/tasks/${id}/steer`, {
method: "POST",
body: JSON.stringify({ text }),
});
}
export const addSteeringComment = addComment;
export function requestSpecRevision(id: string, feedback: string): Promise<Task> {
return api<Task>(`/tasks/${id}/spec/revise`, {
method: "POST",
@@ -1344,56 +1331,6 @@ export function fetchWorkflowResults(taskId: string): Promise<WorkflowStepResult
return api<WorkflowStepResult[]>(`/tasks/${encodeURIComponent(taskId)}/workflow-results`);
}
// ── Scripts ─────────────────────────────────────────────────────────────
export type ScriptsMap = Record<string, string>;
export interface RunScriptResponse {
command: string;
sessionId: string;
}
export async function waitForScriptCompletion(sessionId: string): Promise<{ output: string; exitCode: number }> {
for (;;) {
const session = await getTerminalSession(sessionId);
if (!session.running) {
return {
output: session.output,
exitCode: session.exitCode ?? 0,
};
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/** Fetch all project-defined scripts */
export function fetchScripts(): Promise<ScriptsMap> {
return api<ScriptsMap>("/scripts");
}
/** Create a new project-defined script */
export async function addScript(name: string, command: string): Promise<void> {
await api<ScriptsMap>("/scripts", {
method: "POST",
body: JSON.stringify({ name, command }),
});
}
/** Remove a project-defined script */
export async function removeScript(name: string): Promise<void> {
await api<ScriptsMap>(`/scripts/${encodeURIComponent(name)}`, {
method: "DELETE",
});
}
/** Execute a script via the terminal service and return the created session */
export function runScript(name: string, args?: string[]): Promise<RunScriptResponse> {
return api<RunScriptResponse>(`/scripts/${encodeURIComponent(name)}/run`, {
method: "POST",
body: JSON.stringify({ args }),
});
}
// ── Workflow Step Templates ──────────────────────────────────────────────
/** Re-export WorkflowStepTemplate type from core */
@@ -1882,7 +1819,7 @@ export function fetchProjectHealth(id: string): Promise<ProjectHealth> {
return api<ProjectHealth>(`/projects/${encodeURIComponent(id)}/health`);
}
/** Fetch unified activity feed. Supports singular type filtering and server fallback to the current project's local activity log when the central feed is unavailable or empty. */
/** Fetch unified activity feed */
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit));
@@ -1908,37 +1845,6 @@ export function resumeProject(id: string): Promise<ProjectInfo> {
});
}
/** Fetch a specific project by ID */
export function fetchProject(id: string): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`);
}
/** Update a project */
export function updateProject(
id: string,
updates: { name?: string; isolationMode?: "in-process" | "child-process"; status?: "active" | "paused" | "errored" | "initializing" }
): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Detected project from auto-scan */
export interface DetectedProject {
path: string;
suggestedName: string;
existing: boolean;
}
/** Auto-detect kb projects in a given base path */
export function detectProjects(basePath?: string): Promise<{ projects: DetectedProject[] }> {
return api<{ projects: DetectedProject[] }>("/projects/detect", {
method: "POST",
body: JSON.stringify({ basePath }),
});
}
/** Fetch first run status to detect if user needs setup wizard */
export function fetchFirstRunStatus(): Promise<FirstRunStatus> {
return api<FirstRunStatus>("/first-run-status");
@@ -1962,15 +1868,3 @@ export function fetchProjectTasks(projectId: string, limit?: number, offset?: nu
export function fetchProjectConfig(projectId: string): Promise<{ maxConcurrent: number; rootDir: string }> {
return api<{ maxConcurrent: number; rootDir: string }>(`/projects/${encodeURIComponent(projectId)}/config`);
}
/** Diff information for a task */
export interface TaskDiff {
files: string[];
diffs: Record<string, { stat: string; patch: string }>;
}
/** Fetch diff information for a task */
export function fetchTaskDiff(taskId: string): Promise<TaskDiff> {
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff`);
}

View File

@@ -43,10 +43,6 @@ function truncatePath(path: string, maxLength: number = 40): string {
return `${start}...${end}`;
}
/**
* Compare two sets of ProjectCardProps for memo equality.
* Checks project properties and health metrics to determine if re-render is needed.
*/
function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardProps): boolean {
if (previous.project.id !== next.project.id) return false;
if (previous.project.status !== next.project.status) return false;
@@ -70,28 +66,6 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP
);
}
/**
* Individual project card component showing project status, health metrics, and actions.
*
* Displays:
* - Project name and truncated path
* - Status badge (active, paused, errored, initializing)
* - Health metrics: active tasks, running agents, completed tasks
* - Last activity timestamp
* - Action buttons: Open, Pause/Resume, Remove
*
* @example
* ```tsx
* <ProjectCard
* project={registeredProject}
* health={projectHealth}
* onSelect={(p) => setCurrentProject(p)}
* onPause={(p) => pauseProject(p.id)}
* onResume={(p) => resumeProject(p.id)}
* onRemove={(p) => unregisterProject(p.id)}
* />
* ```
*/
function ProjectCardInner({
project,
health,

View File

@@ -172,6 +172,9 @@ export function SettingsModal({
};
}, [activeSection, loadAuthStatus]);
/** Get the scope of the currently active section */
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
const handleLogin = useCallback(async (providerId: string) => {
setAuthActionInProgress(providerId);
try {

View File

@@ -396,7 +396,7 @@ export function SetupWizard({ isOpen, onClose, onProjectCreated, onRegisterProje
<button
className="btn btn-primary"
onClick={handleValidate}
disabled={state.isValidating || !!state.validationError}
disabled={state.isValidating || state.validationError}
>
{state.isValidating ? (
<>

View File

@@ -1,10 +1,8 @@
import { useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import type { Task, TaskComment } from "@fusion/core";
import { addComment, addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
import type { ToastType } from "../hooks/useToast";
type CommentType = "user" | "steering";
interface TaskCommentsProps {
task: Task;
onTaskUpdated?: (task: Task) => void;
@@ -18,64 +16,21 @@ function formatCommentTimestamp(comment: TaskComment): string {
return comment.updatedAt ? `${label} (edited)` : label;
}
function formatRelativeTimestamp(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();
}
const MAX_LENGTH = 2000;
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user" }: TaskCommentsProps) {
const [draft, setDraft] = useState("");
const [commentType, setCommentType] = useState<CommentType>("user");
const [editingId, setEditingId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Unified comments from task.comments (includes migrated steering comments)
const comments = useMemo(() => task.comments || [], [task.comments]);
// Legacy steering comments (if any still exist on the task)
const steeringComments = useMemo(() => task.steeringComments || [], [task.steeringComments]);
// All comments combined, sorted newest first
const allComments = useMemo(() => {
const combined = [...comments, ...steeringComments];
return combined.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [comments, steeringComments]);
// Determine if a comment is a steering/AI guidance comment
const isSteeringComment = useCallback((comment: TaskComment): boolean => {
// Check if from the steeringComments array
if (steeringComments.some(sc => sc.id === comment.id)) return true;
// Check if the author indicates it's an agent/AI comment
if (comment.author === "agent" || comment.author === "system") return true;
return false;
}, [steeringComments]);
const handleAddComment = useCallback(async () => {
async function handleAddComment() {
const text = draft.trim();
if (!text || text.length > MAX_LENGTH || submitting) return;
if (!text) return;
setSubmitting(true);
try {
let updated: Task;
if (commentType === "steering") {
updated = await addComment(task.id, text);
} else {
updated = await addTaskComment(task.id, text, currentAuthor);
}
const updated = await addTaskComment(task.id, text, currentAuthor);
setDraft("");
onTaskUpdated?.(updated);
addToast("Comment added", "success");
@@ -84,17 +39,7 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
} finally {
setSubmitting(false);
}
}, [draft, commentType, submitting, task.id, currentAuthor, onTaskUpdated, addToast]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
void handleAddComment();
}
},
[handleAddComment]
);
}
async function handleSaveEdit(commentId: string) {
const text = editingText.trim();
@@ -126,45 +71,23 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
}
}
const isValid = draft.trim().length > 0 && draft.length <= MAX_LENGTH;
return (
<div className="detail-section">
<h4>Comments</h4>
{allComments.length === 0 ? (
{comments.length === 0 ? (
<div className="detail-log-empty">No comments yet.</div>
) : (
<div className="detail-activity-list">
{allComments.map((comment) => {
const isSteering = isSteeringComment(comment);
const canEdit = !isSteering && comment.author === currentAuthor;
{comments.map((comment) => {
const canEdit = comment.author === currentAuthor;
const isEditing = editingId === comment.id;
return (
<div key={comment.id} className="detail-log-entry">
<div className="detail-log-header" style={{ justifyContent: "space-between", gap: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{isSteering ? (
<span
style={{
fontSize: "11px",
padding: "2px 6px",
borderRadius: "4px",
background: "var(--accent-secondary, #8b5cf6)",
color: "#fff",
fontWeight: 500,
}}
data-testid="ai-guidance-badge"
>
AI Guidance
</span>
) : (
<strong>{comment.author}</strong>
)}
<span className="detail-log-timestamp">
{isSteering
? formatRelativeTimestamp(comment.createdAt)
: formatCommentTimestamp(comment)}
<div>
<strong>{comment.author}</strong>
<span className="detail-log-timestamp" style={{ marginLeft: 8 }}>
{formatCommentTimestamp(comment)}
</span>
</div>
{canEdit && !isEditing ? (
@@ -214,16 +137,7 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
</div>
</div>
) : (
<div
className="detail-log-outcome"
style={{
whiteSpace: "pre-wrap",
...(isSteering ? {
borderLeft: "3px solid var(--accent-secondary, #8b5cf6)",
paddingLeft: "12px",
} : {}),
}}
>
<div className="detail-log-outcome" style={{ whiteSpace: "pre-wrap" }}>
{comment.text}
</div>
)}
@@ -234,61 +148,16 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
)}
<div style={{ display: "grid", gap: 8, marginTop: 12 }}>
{/* Comment type selector */}
<div style={{ display: "flex", gap: 4 }}>
<button
className={`btn btn-sm${commentType === "user" ? " btn-primary" : ""}`}
onClick={() => setCommentType("user")}
type="button"
>
Comment
</button>
<button
className={`btn btn-sm${commentType === "steering" ? " btn-primary" : ""}`}
onClick={() => setCommentType("steering")}
type="button"
>
AI Guidance
</button>
</div>
{commentType === "steering" && (
<p style={{ fontSize: "12px", opacity: 0.7, margin: 0 }}>
AI Guidance comments are injected into the task execution context to guide the agent.
</p>
)}
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
rows={3}
placeholder={commentType === "steering"
? "Add guidance for the AI agent… (Ctrl+Enter to submit)"
: "Add a comment… (Ctrl+Enter to submit)"}
placeholder="Add a comment"
className="spec-editor-feedback"
maxLength={MAX_LENGTH}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span
style={{
fontSize: "12px",
opacity: draft.length > MAX_LENGTH ? 0.9 : 0.5,
color: draft.length > MAX_LENGTH ? "var(--error, #ef4444)" : "inherit",
}}
>
{draft.length} / {MAX_LENGTH}
</span>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleAddComment()}
disabled={!isValid || submitting}
>
{submitting ? "Posting…" : commentType === "steering" ? "Add Guidance" : "Add Comment"}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button className="btn btn-primary btn-sm" onClick={() => void handleAddComment()} disabled={submitting || !draft.trim()}>
{submitting ? "Posting…" : "Add Comment"}
</button>
</div>
</div>

View File

@@ -17,8 +17,6 @@ vi.mock("lucide-react", () => ({
Lightbulb: () => null,
ListTree: () => null,
Zap: () => null,
ChevronDown: () => null,
ChevronUp: () => null,
}));
// Mock the api module
@@ -90,12 +88,6 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
fireEvent.click(screen.getByText(optionText));
}
// Helper to expand the InlineCreateCard by clicking the toggle button
function expandInlineCreate() {
const toggleButton = screen.getByTestId("inline-create-toggle");
fireEvent.click(toggleButton);
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
@@ -107,146 +99,47 @@ beforeEach(() => {
});
});
describe("InlineCreateCard toggle button", () => {
it("toggle button expands the view", () => {
renderCard();
const toggleButton = screen.getByTestId("inline-create-toggle");
describe("InlineCreateCard blur-to-cancel", () => {
it("calls onCancel when focus leaves the card with empty input", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Initially, footer controls are not visible
expect(document.querySelector(".inline-create-footer")).toBeNull();
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--collapsed")).toBe(true);
expect(document.querySelector(".inline-create-card")?.className).toContain("inline-create-card");
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
expect(toggleButton.getAttribute("aria-controls")).toBeNull();
expect(textarea.getAttribute("aria-controls")).toBeNull();
// Click toggle to expand
expandInlineCreate();
// Now footer controls should be visible
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--expanded")).toBe(true);
expect(screen.getByText(/Deps/)).toBeTruthy();
expect(screen.getByRole("button", { name: /Models/i })).toBeTruthy();
expect(screen.getByTestId("plan-button")).toBeTruthy();
expect(screen.getByTestId("subtask-button")).toBeTruthy();
expect(screen.getByRole("button", { name: /Save/i })).toBeTruthy();
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
expect(toggleButton.getAttribute("aria-controls")).toBe("inline-create-controls");
expect(textarea.getAttribute("aria-controls")).toBe("inline-create-controls");
});
it("toggle button collapses the view when expanded", () => {
renderCard();
const toggleButton = screen.getByTestId("inline-create-toggle");
// Expand first
expandInlineCreate();
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
// Click toggle again to collapse
expandInlineCreate();
// Footer should be hidden
expect(document.querySelector(".inline-create-footer")).toBeNull();
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--collapsed")).toBe(true);
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
});
it("maintains the collapsed and expanded styling contract on the inline create container", () => {
renderCard();
const card = document.querySelector(".inline-create-card");
expect(card?.classList.contains("inline-create-card--collapsed")).toBe(true);
expect(card?.classList.contains("inline-create-card--expanded")).toBe(false);
expect(document.querySelector(".inline-create-footer")).toBeNull();
expandInlineCreate();
expect(card?.classList.contains("inline-create-card--expanded")).toBe(true);
expect(card?.classList.contains("inline-create-card--collapsed")).toBe(false);
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
});
it("does NOT expand on focus", () => {
renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Focus should not expand the card
textarea.focus();
expect(document.querySelector(".inline-create-footer")).toBeNull();
});
it("calls onCancel on blur when collapsed and empty", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
it("does not cancel on blur when collapsed and has content", () => {
it("does NOT call onCancel when focus leaves with non-empty input", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Keep collapsed draft" } });
fireEvent.change(textarea, { target: { value: "Some task description" } });
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).not.toHaveBeenCalled();
});
it("calls onCancel on blur when expanded and empty", () => {
it("does NOT call onCancel when focus moves to another element inside the card", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
const depsButton = screen.getByText(/Deps/);
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: depsButton });
expect(props.onCancel).not.toHaveBeenCalled();
});
it("calls onCancel when blur with only whitespace input", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
expandInlineCreate();
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
fireEvent.change(textarea, { target: { value: " " } });
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
it("does not cancel on blur when expanded and has content", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
expandInlineCreate();
fireEvent.change(textarea, { target: { value: "Keep drafting" } });
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).not.toHaveBeenCalled();
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
});
});
describe("InlineCreateCard Escape key behavior", () => {
it("calls onCancel when Escape is pressed", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.keyDown(textarea, { key: "Escape" });
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
it("closes dropdowns on first Escape, cancels on second", () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Open a dropdown
fireEvent.click(screen.getByText(/Deps/));
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
// First Escape closes dropdown
fireEvent.keyDown(textarea, { key: "Escape" });
expect(document.querySelector(".dep-dropdown")).toBeNull();
expect(props.onCancel).not.toHaveBeenCalled();
});
});
describe("InlineCreateCard dep-dropdown focus retention", () => {
@@ -256,7 +149,6 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
it("dep-dropdown-item mouseDown calls preventDefault to retain focus", () => {
renderCard(testTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
expect(item).toBeTruthy();
@@ -264,12 +156,26 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
const prevented = !fireEvent.mouseDown(item);
expect(prevented).toBe(true);
});
it("does NOT call onCancel when focus leaves card with selected dependencies but empty description", () => {
const { props } = renderCard(testTasks);
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.click(screen.getByText(/Deps/));
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
expect(item).toBeTruthy();
fireEvent.click(item);
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).not.toHaveBeenCalled();
});
});
describe("InlineCreateCard model selector", () => {
it("opens and closes the model disclosure dropdown", () => {
renderCard();
expandInlineCreate();
openModelPanel();
expect(screen.getByText("Executor Model")).toBeTruthy();
@@ -279,22 +185,8 @@ describe("InlineCreateCard model selector", () => {
expect(screen.queryByText("Executor Model")).toBeNull();
});
it("renders the shared model dropdown in the portal layer from the inline create surface", async () => {
renderCard();
expandInlineCreate();
openModelPanel();
fireEvent.click(screen.getByRole("button", { name: "Executor Model" }));
const portal = await screen.findByTestId("model-combobox-portal");
expect(portal).toBeTruthy();
expect(portal.classList.contains("model-combobox-dropdown--portal")).toBe(true);
expect(document.body.contains(portal)).toBe(true);
});
it("updates executor selection and shows the selected model badge", () => {
renderCard();
expandInlineCreate();
openModelPanel();
chooseModel("Executor Model", "Claude Sonnet 4.5");
@@ -304,7 +196,6 @@ describe("InlineCreateCard model selector", () => {
it("updates validator selection and shows the selected model badge", () => {
renderCard();
expandInlineCreate();
openModelPanel();
chooseModel("Validator Model", "GPT-4o");
@@ -314,7 +205,6 @@ describe("InlineCreateCard model selector", () => {
it("clears the model selection when Use default is chosen", () => {
renderCard();
expandInlineCreate();
openModelPanel();
chooseModel("Executor Model", "Claude Sonnet 4.5");
@@ -330,7 +220,6 @@ describe("InlineCreateCard model selector", () => {
it("omits model fields from the submit payload after clearing back to default", async () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Task using defaults again" } });
@@ -357,7 +246,6 @@ describe("InlineCreateCard model selector", () => {
it("includes selected models in the submit payload", async () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Task with model overrides" } });
@@ -381,7 +269,6 @@ describe("InlineCreateCard model selector", () => {
it("does NOT call onCancel when focus leaves while the model dropdown is open", () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
openModelPanel();
@@ -391,17 +278,6 @@ describe("InlineCreateCard model selector", () => {
expect(props.onCancel).not.toHaveBeenCalled();
});
it("does NOT call onCancel when expanded, empty, and a dropdown is open", () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.click(screen.getByText(/Deps/));
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).not.toHaveBeenCalled();
});
it("does NOT call onCancel when focus leaves while the preset dropdown is open", () => {
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }],
@@ -409,7 +285,6 @@ describe("InlineCreateCard model selector", () => {
defaultPresetBySize: {},
});
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
@@ -419,21 +294,19 @@ describe("InlineCreateCard model selector", () => {
expect(props.onCancel).not.toHaveBeenCalled();
});
it.skip("includes selected preset id in the submit payload", async () => {
it("includes selected preset id in the submit payload", async () => {
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
autoSelectModelPreset: false,
defaultPresetBySize: {},
});
const { props } = renderCard([], { availableModels: undefined });
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Task with preset" } });
fireEvent.click(await screen.findByRole("button", { name: /Preset/i }));
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
fireEvent.click(await screen.findByRole("button", { name: "Budget" }));
fireEvent.click(await screen.findByRole("button", { name: /Save/i }));
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
@@ -447,9 +320,8 @@ describe("InlineCreateCard model selector", () => {
});
});
it("calls onCancel after a model override is selected and focus leaves the card while empty", () => {
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
openModelPanel();
@@ -459,12 +331,11 @@ describe("InlineCreateCard model selector", () => {
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).toHaveBeenCalledTimes(1);
expect(props.onCancel).not.toHaveBeenCalled();
});
it("prevents default on model option mouseDown to retain focus while selecting", () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
textarea.focus();
@@ -498,7 +369,6 @@ describe("InlineCreateCard model selector", () => {
.mockResolvedValueOnce(MOCK_MODELS);
renderCard([], { availableModels: undefined });
expandInlineCreate();
openModelPanel();
await waitFor(() => {
@@ -523,7 +393,6 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
it("renders dependency dropdown items sorted newest-first by createdAt", () => {
renderCard(scrambledTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
@@ -533,7 +402,6 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
it("preserves newest-first sort order when a search filter is applied", () => {
renderCard(scrambledTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "FN-00" } });
@@ -553,7 +421,6 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
it("renders tasks with identical createdAt sorted newest-ID-first (descending numeric ID)", () => {
renderCard(sameTimeTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
@@ -563,7 +430,6 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
it("preserves newest-ID-first order when search filter is applied with identical timestamps", () => {
renderCard(sameTimeTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "FN-00" } });
@@ -583,7 +449,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
it("shows search input when dropdown is opened", () => {
renderCard(testTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
expect(input).toBeTruthy();
@@ -592,7 +457,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
it("filters tasks by search term", () => {
renderCard(testTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "dark" } });
@@ -606,7 +470,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
describe("InlineCreateCard Plan and Subtask buttons", () => {
it("renders Plan and Subtask buttons disabled when description is empty", () => {
renderCard();
expandInlineCreate();
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
expect(planButton.disabled).toBe(true);
@@ -615,7 +478,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
it("enables Plan and Subtask buttons when description is entered", () => {
renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Test task" } });
@@ -625,41 +487,34 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
expect(subtaskButton.disabled).toBe(false);
});
it("calls onPlanningMode and collapses after Plan clicked", () => {
it("calls onPlanningMode with description and clears input when Plan clicked", () => {
const onPlanningMode = vi.fn();
renderCard([], { onPlanningMode });
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Plan this task" } });
fireEvent.click(screen.getByTestId("plan-button"));
expect(onPlanningMode).toHaveBeenCalledWith("Plan this task");
expect(textarea.value).toBe("");
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
expect(document.querySelector(".inline-create-footer")).toBeNull();
expect((textarea as HTMLTextAreaElement).value).toBe("");
});
it("calls onSubtaskBreakdown and collapses after Subtask clicked", () => {
it("calls onSubtaskBreakdown with description and clears input when Subtask clicked", () => {
const onSubtaskBreakdown = vi.fn();
renderCard([], { onSubtaskBreakdown });
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Break this down" } });
fireEvent.click(screen.getByTestId("subtask-button"));
expect(onSubtaskBreakdown).toHaveBeenCalledWith("Break this down");
expect(textarea.value).toBe("");
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
expect(document.querySelector(".inline-create-footer")).toBeNull();
expect((textarea as HTMLTextAreaElement).value).toBe("");
});
it("shows toast when Plan clicked with empty description (via direct handler call)", () => {
const addToast = vi.fn();
const onPlanningMode = vi.fn();
renderCard([], { addToast, onPlanningMode });
expandInlineCreate();
// When no description, button is disabled - verify that behavior
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
@@ -673,7 +528,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
const addToast = vi.fn();
const onSubtaskBreakdown = vi.fn();
renderCard([], { addToast, onSubtaskBreakdown });
expandInlineCreate();
// When no description, button is disabled - verify that behavior
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
@@ -717,10 +571,9 @@ describe("InlineCreateCard localStorage persistence", () => {
});
});
it("clears textarea and localStorage after successful task creation", async () => {
it("clears localStorage after successful task creation", async () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Type something to set localStorage
fireEvent.change(textarea, { target: { value: "Task to create" } });
@@ -735,30 +588,10 @@ describe("InlineCreateCard localStorage persistence", () => {
expect(props.onSubmit).toHaveBeenCalled();
});
expect(textarea.value).toBe("");
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
// localStorage should be cleared
expect(localStorage.getItem("kb-inline-create-text")).toBeNull();
});
it("allows immediately re-expanding after successful submit", async () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
const toggleButton = screen.getByTestId("inline-create-toggle");
fireEvent.change(textarea, { target: { value: "Task to create" } });
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalled();
});
fireEvent.click(toggleButton);
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
});
it("clears localStorage when cancelling via Escape key", async () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");

View File

@@ -46,7 +46,6 @@ const renderListView = (props: Partial<React.ComponentProps<typeof ListView>> =
describe("ListView", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("renders without crashing", () => {
@@ -227,10 +226,6 @@ describe("ListView", () => {
renderListView({ tasks });
// Click "Show Done" to reveal done tasks
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
const columnHeader = screen.getByText("Column");
fireEvent.click(columnHeader);
@@ -320,10 +315,6 @@ describe("ListView", () => {
renderListView({ tasks });
// Click "Show Done" to reveal done tasks in the table
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Check that all column badges are rendered in the table
// Use getAllByText and check length since column names appear in both drop zones and badges
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
@@ -569,10 +560,6 @@ describe("ListView", () => {
renderListView({ tasks });
// Click "Show Done" to show all column sections including Done and Archived
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Check that section headers are rendered with column names
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
@@ -590,10 +577,6 @@ describe("ListView", () => {
renderListView({ tasks });
// Click "Show Done" to show all column sections
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Find section headers by their structure
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(6); // One for each column
@@ -668,7 +651,6 @@ describe("ListView", () => {
describe("ListView Column Filtering", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("filters tasks by column when drop zone is clicked", () => {
@@ -718,10 +700,6 @@ describe("ListView Column Filtering", () => {
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.getByText("FN-002")).toBeDefined();
// Click "Show Done" to reveal all column sections
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// All 6 section headers should be visible (one for each column)
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(6);
@@ -1041,26 +1019,13 @@ describe("ListView Hide Done Tasks", () => {
localStorage.clear();
});
it("renders hide done tasks toggle button with 'Show Done' when done tasks are hidden by default", () => {
it("renders hide done tasks toggle button", () => {
renderListView();
const hideDoneButton = screen.getByRole("button", { name: /show done/i });
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
expect(hideDoneButton).toBeDefined();
});
it("hides done tasks by default when no localStorage value exists", () => {
const tasks = [
createMockTask({ id: "FN-001", column: "done" }),
createMockTask({ id: "FN-002", column: "triage" }),
];
renderListView({ tasks });
// Done task should be hidden by default
expect(screen.queryByText("FN-001")).toBeNull();
expect(screen.getByText("FN-002")).toBeDefined();
});
it("hides done tasks when toggle is activated", () => {
const tasks = [
createMockTask({ id: "FN-001", column: "done" }),
@@ -1069,15 +1034,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Click "Show Done" to show done tasks first
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Both tasks should be visible now
// Both tasks should be visible initially
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.getByText("FN-002")).toBeDefined();
// Click "Hide Done" to hide done tasks
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
@@ -1094,15 +1055,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Click "Show Done" to show archived tasks first
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Both tasks should be visible now
// Both tasks should be visible initially
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.getByText("FN-002")).toBeDefined();
// Click "Hide Done" to hide archived tasks
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
@@ -1120,16 +1077,12 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Click "Show Done" to show all completed tasks first
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// All tasks should be visible now
// All tasks should be visible initially
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.getByText("FN-002")).toBeDefined();
expect(screen.getByText("FN-003")).toBeDefined();
// Click "Hide Done" to hide completed tasks
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
@@ -1148,13 +1101,16 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Completed tasks should be hidden by default
// Click hide done button to hide completed tasks
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Completed tasks should be hidden
expect(screen.queryByText("FN-001")).toBeNull();
expect(screen.queryByText("FN-002")).toBeNull();
// Click "Show Done" to show all tasks
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Click again to show all tasks
fireEvent.click(hideDoneButton);
// All tasks should be visible again
expect(screen.getByText("FN-001")).toBeDefined();
@@ -1166,11 +1122,7 @@ describe("ListView Hide Done Tasks", () => {
const tasks = [createMockTask({ id: "FN-001", column: "done" })];
renderListView({ tasks });
// Click "Show Done" first (since default is now hidden)
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Click "Hide Done" to hide done tasks
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
@@ -1207,7 +1159,14 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Stats should show filtered count with hidden indicator (default is now hidden)
// Initial stats should show all tasks
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Stats should show filtered count with hidden indicator
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
expect(screen.getByText(/2 hidden/)).toBeDefined();
});
@@ -1221,7 +1180,15 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Done and Archived sections should be hidden by default
// All section headers should be visible initially
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeadersBefore.length).toBe(6); // All 6 columns
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Done and Archived sections should be hidden
const doneSection = screen.getAllByRole("row").find(r =>
r.className.includes("list-section-header") && r.textContent?.includes("Done")
);
@@ -1247,7 +1214,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Done drop zone should be visible with "X of Y" format (hide done is active by default)
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Done drop zone should still be visible with "X of Y" format
const doneZone = document.querySelector('[data-column="done"].list-drop-zone');
expect(doneZone).toBeDefined();
expect(doneZone?.textContent).toContain("0 of 2");
@@ -1261,7 +1232,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Archived drop zone should be visible with "X of Y" format (hide done is active by default)
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Archived drop zone should still be visible with "X of Y" format
const archivedZone = document.querySelector('[data-column="archived"].list-drop-zone');
expect(archivedZone).toBeDefined();
expect(archivedZone?.textContent).toContain("0 of 2");
@@ -1276,11 +1251,15 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Hide done tasks
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Apply filter
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Gamma" } });
// Completed tasks should remain hidden (hide done is active by default)
// Completed tasks should remain hidden
expect(screen.queryByText("FN-001")).toBeNull();
expect(screen.queryByText("FN-002")).toBeNull();
// Filtered task should be visible
@@ -1295,7 +1274,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Done task should be hidden by default
// Enable hide done
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Done task should be hidden
expect(screen.queryByText("FN-001")).toBeNull();
// Click on the done drop zone to select that column
@@ -1315,7 +1298,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Archived task should be hidden by default
// Enable hide done
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Archived task should be hidden
expect(screen.queryByText("FN-001")).toBeNull();
// Click on the archived drop zone to select that column
@@ -1331,7 +1318,6 @@ describe("ListView Hide Done Tasks", () => {
describe("ListView Quick Entry", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("renders QuickEntryBox when onQuickCreate is provided", () => {
@@ -1368,9 +1354,10 @@ describe("ListView Quick Entry", () => {
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
renderListView({ onQuickCreate: mockOnQuickCreate });
// Click the toggle button to expand the QuickEntryBox
const toggleButton = screen.getByTestId("quick-entry-toggle");
fireEvent.click(toggleButton);
const input = screen.getByTestId("quick-entry-input");
// Focus the input to expand the QuickEntryBox
fireEvent.focus(input);
// Model selector button should be visible
const modelButton = await screen.findByTestId("quick-entry-models-button");
@@ -1381,9 +1368,10 @@ describe("ListView Quick Entry", () => {
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
renderListView({ onQuickCreate: mockOnQuickCreate });
// Click the toggle button to expand the QuickEntryBox
const toggleButton = screen.getByTestId("quick-entry-toggle");
fireEvent.click(toggleButton);
const input = screen.getByTestId("quick-entry-input");
// Focus the input to expand the QuickEntryBox
fireEvent.focus(input);
// Dependency selector button should be visible
const depsButton = await screen.findByTestId("quick-entry-deps-button");
@@ -1777,10 +1765,6 @@ describe("ListView - Bulk Selection", () => {
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
// Click "Show Done" to make archived tasks visible
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
const checkbox = screen.getByLabelText("Select FN-001");
expect(checkbox).toBeDisabled();
});

View File

@@ -1,162 +1,284 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SetupWizardModal } from "../SetupWizardModal";
import { SetupWizard } from "../SetupWizard";
import type { ProjectInfo, ProjectCreateInput } from "../../api";
// Mock the API
const mockRegisterProject = vi.fn();
// Mock lucide-react
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...actual,
X: () => <span data-testid="close-icon">×</span>,
ChevronRight: () => <span data-testid="next-icon"></span>,
ChevronLeft: () => <span data-testid="back-icon"></span>,
Folder: () => <span data-testid="folder-icon">📁</span>,
Check: () => <span data-testid="check-icon"></span>,
Loader2: () => <span data-testid="loader-icon"></span>,
AlertCircle: () => <span data-testid="alert-icon"></span>,
};
});
vi.mock("../api", () => ({
registerProject: (...args: unknown[]) => mockRegisterProject(...args),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
X: () => <span data-testid="x-icon">×</span>,
Loader2: () => <span data-testid="loader-icon"></span>,
FolderPlus: () => <span data-testid="folder-icon">📁</span>,
CheckCircle: () => <span data-testid="check-icon"></span>,
}));
const noop = () => {};
describe("SetupWizardModal", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("renders the wizard with manual step by default", async () => {
describe("SetupWizard", () => {
it("does not render when isOpen is false", () => {
render(
<SetupWizardModal
onProjectRegistered={noop}
onClose={noop}
<SetupWizard
isOpen={false}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
// Should show welcome/manual screen
expect(await screen.findByText("Welcome to kb")).toBeDefined();
// Should show manual entry form
expect(screen.getByLabelText("Project Path")).toBeDefined();
expect(screen.getByLabelText("Project Name")).toBeDefined();
expect(screen.getByLabelText("Isolation Mode")).toBeDefined();
expect(screen.queryByText("Add New Project")).toBeNull();
});
it("allows entering project details in manual step", async () => {
it("renders when isOpen is true", () => {
render(
<SetupWizardModal
onProjectRegistered={noop}
onClose={noop}
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
const pathInput = await screen.findByLabelText("Project Path");
expect(screen.getByText("Add New Project")).toBeDefined();
});
it("starts at directory step", () => {
render(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
expect(screen.getByText("Select Project Directory")).toBeDefined();
});
it("shows step indicator with 5 steps", () => {
render(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
expect(screen.getByText("Directory")).toBeDefined();
expect(screen.getByText("Name")).toBeDefined();
expect(screen.getByText("Mode")).toBeDefined();
expect(screen.getByText("Validate")).toBeDefined();
expect(screen.getByText("Confirm")).toBeDefined();
});
it("disables Next button when directory is empty", () => {
render(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
const nextButton = screen.getByRole("button", { name: /Next/i });
expect(nextButton).toBeDisabled();
});
it("enables Next button when directory is filled", () => {
render(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
const input = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(input, { target: { value: "/home/user/project" } });
const nextButton = screen.getByRole("button", { name: /Next/i });
expect(nextButton).not.toBeDisabled();
});
it("navigates to next step when Next is clicked", () => {
render(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
const input = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(input, { target: { value: "/home/user/project" } });
// Find the primary button (Next) in the actions area
const nextButton = screen.getByRole("button", { name: /Next/i });
fireEvent.click(nextButton);
expect(screen.getByText("Project Name")).toBeDefined();
});
it("auto-suggests name from directory path", () => {
render(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
const input = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(input, { target: { value: "/home/user/my-awesome-project" } });
const nextButton = screen.getByRole("button", { name: /Next/i });
fireEvent.click(nextButton);
const nameInput = screen.getByPlaceholderText("My Project") as HTMLInputElement;
expect(nameInput.value).toBe("my-awesome-project");
});
it("allows navigation back to previous step", () => {
render(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
// Go to step 2
const input = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(input, { target: { value: "/home/user/project" } });
const nextButton = screen.getByRole("button", { name: /Next/i });
fireEvent.click(nextButton);
// Go back
const backButton = screen.getByRole("button", { name: /Back/i });
fireEvent.click(backButton);
expect(screen.getByText("Select Project Directory")).toBeDefined();
});
it("shows isolation mode options", () => {
render(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
// Navigate to step 3 (isolation)
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
fireEvent.change(pathInput, { target: { value: "/path/to/project" } });
// Go to name step
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
// Go to isolation step
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
expect(pathInput).toHaveValue("/path/to/project");
expect(screen.getByText("In-Process (Default)")).toBeDefined();
expect(screen.getByText("Child Process (Isolated)")).toBeDefined();
});
it("calls onClose when close button is clicked", async () => {
it("calls onClose when Cancel is clicked", () => {
const onClose = vi.fn();
render(
<SetupWizardModal
onProjectRegistered={noop}
<SetupWizard
isOpen={true}
onClose={onClose}
onProjectCreated={vi.fn()}
/>
);
const closeButton = await screen.findByLabelText("Close wizard");
const cancelButton = screen.getByRole("button", { name: /Cancel/i });
fireEvent.click(cancelButton);
expect(onClose).toHaveBeenCalled();
});
it("calls onClose when close icon is clicked", () => {
const onClose = vi.fn();
render(
<SetupWizard
isOpen={true}
onClose={onClose}
onProjectCreated={vi.fn()}
/>
);
const closeButton = screen.getByLabelText("Close");
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalled();
});
it("disables register button when form is incomplete", async () => {
it("submits project data when created", async () => {
const mockRegisterProject = vi.fn().mockResolvedValue({
id: "proj_123",
name: "My Project",
path: "/home/user/project",
status: "active",
isolationMode: "in-process",
} as ProjectInfo);
const onProjectCreated = vi.fn();
render(
<SetupWizardModal
onProjectRegistered={noop}
onClose={noop}
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={onProjectCreated}
onRegisterProject={mockRegisterProject}
/>
);
// Wait for form to render
await screen.findByLabelText("Project Path");
// Fill directory
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
// The wizard should be in directory step with a Next button
expect(screen.getByRole("button", { name: /Next/i })).toBeDefined();
const registerButton = screen.getByRole("button", { name: /register project/i });
expect(registerButton).toBeDisabled();
// Fill only path
fireEvent.change(screen.getByLabelText("Project Path"), {
target: { value: "/path/to/project" },
});
// Button should still be disabled
expect(registerButton).toBeDisabled();
// Note: Full wizard flow testing would require more complex setup
// including mocking the validation API call
});
it("enables register button when form is complete", async () => {
render(
<SetupWizardModal
onProjectRegistered={noop}
onClose={noop}
it("resets state when reopened", () => {
const { rerender } = render(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
// Wait for form to render
await screen.findByLabelText("Project Path");
// Fill some data
const input = screen.getByPlaceholderText("/path/to/your/project");
fireEvent.change(input, { target: { value: "/home/user/project" } });
// Fill the form
fireEvent.change(screen.getByLabelText("Project Path"), {
target: { value: "/path/to/project" },
});
fireEvent.change(screen.getByLabelText("Project Name"), {
target: { value: "test-project" },
});
// Wait for button to be enabled
const registerButton = screen.getByRole("button", { name: /register project/i });
await waitFor(() => expect(registerButton).not.toBeDisabled());
});
it("has isolation mode selector with correct options", async () => {
render(
<SetupWizardModal
onProjectRegistered={noop}
onClose={noop}
// Close and reopen
rerender(
<SetupWizard
isOpen={false}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
const select = await screen.findByLabelText("Isolation Mode") as HTMLSelectElement;
expect(select.value).toBe("in-process");
// Check options exist
const options = Array.from(select.options);
expect(options.some(opt => opt.value === "in-process")).toBe(true);
expect(options.some(opt => opt.value === "child-process")).toBe(true);
});
it("shows form hint for project path", async () => {
render(
<SetupWizardModal
onProjectRegistered={noop}
onClose={noop}
rerender(
<SetupWizard
isOpen={true}
onClose={vi.fn()}
onProjectCreated={vi.fn()}
/>
);
expect(await screen.findByText("Absolute path to your project directory")).toBeDefined();
});
it("has correct isolation mode default value", async () => {
render(
<SetupWizardModal
onProjectRegistered={noop}
onClose={noop}
/>
);
const select = await screen.findByLabelText("Isolation Mode") as HTMLSelectElement;
expect(select.value).toBe("in-process");
// Should be back at step 1 with empty fields
expect(screen.getByText("Select Project Directory")).toBeDefined();
const newInput = screen.getByPlaceholderText("/path/to/your/project") as HTMLInputElement;
expect(newInput.value).toBe("");
});
});

View File

@@ -382,7 +382,6 @@ body {
gap: var(--column-gap);
padding: var(--board-padding);
height: calc(100vh - 57px);
height: calc(100dvh - 57px);
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
@@ -1141,566 +1140,6 @@ body {
animation: spin 1s linear infinite;
}
/* === ProjectOverview Component === */
.project-overview {
display: flex;
flex-direction: column;
gap: var(--space-xl);
width: 100%;
max-width: 1440px;
margin: 0 auto;
padding: var(--space-xl);
}
.project-overview__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
flex-wrap: wrap;
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--border);
}
.project-overview__title {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--text);
}
.project-overview__stats {
display: flex;
align-items: stretch;
gap: var(--space-md);
flex: 1;
flex-wrap: wrap;
justify-content: center;
}
.project-stat {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
min-width: 120px;
padding: var(--space-sm) var(--space-md);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.project-stat--active {
border-color: rgba(88, 166, 255, 0.35);
background: rgba(88, 166, 255, 0.08);
}
.project-stat--completed {
border-color: rgba(63, 185, 80, 0.35);
background: rgba(63, 185, 80, 0.08);
}
.project-stat--error {
border-color: rgba(248, 81, 73, 0.35);
background: rgba(248, 81, 73, 0.08);
}
.project-stat__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text-muted);
flex-shrink: 0;
}
.project-stat--active .project-stat__icon {
color: var(--todo);
}
.project-stat--completed .project-stat__icon {
color: var(--color-success);
}
.project-stat--error .project-stat__icon {
color: var(--color-error);
}
.project-stat__content {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.project-stat__value {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 700;
line-height: 1.1;
color: var(--text);
}
.project-stat__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
}
.project-overview__add-btn {
flex-shrink: 0;
white-space: nowrap;
}
.project-overview__filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
flex-wrap: wrap;
}
.project-filter-tabs {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.project-filter-tab {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
}
.project-filter-tab:hover {
background: var(--card-hover);
border-color: var(--text-dim);
color: var(--text);
}
.project-filter-tab.active {
background: rgba(88, 166, 255, 0.12);
border-color: rgba(88, 166, 255, 0.35);
color: var(--todo);
}
.project-filter-tab.has-errors {
border-color: rgba(248, 81, 73, 0.35);
}
.project-filter-tab.has-errors:not(.active) {
color: var(--color-error);
}
.project-filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
color: inherit;
font-size: 11px;
font-family: var(--font-mono);
}
.project-sort {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
}
.project-sort-select {
appearance: none;
background: transparent;
border: none;
color: var(--text);
font-size: 13px;
font-weight: 500;
padding: 0;
min-width: 210px;
outline: none;
cursor: pointer;
}
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-lg);
align-items: stretch;
}
.project-overview--empty {
min-height: 60vh;
align-items: center;
justify-content: center;
}
.project-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
max-width: 560px;
padding: var(--space-2xl);
text-align: center;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
}
.project-empty-state__icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--surface);
color: var(--todo);
}
.project-empty-state__title {
margin: 0;
font-size: 28px;
font-weight: 700;
color: var(--text);
}
.project-empty-state__description {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--text-muted);
}
.project-empty-state__cta {
margin-top: var(--space-sm);
}
.project-overview__no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
padding: var(--space-2xl);
border: 1px dashed var(--border);
border-radius: var(--radius-xl);
color: var(--text-muted);
text-align: center;
background: color-mix(in srgb, var(--surface) 85%, transparent);
}
/* === ProjectSelector Component === */
.project-selector {
position: relative;
display: inline-block;
}
.project-selector__trigger {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
min-width: 220px;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text);
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.project-selector__trigger:hover,
.project-selector__trigger.open {
background: var(--card-hover);
border-color: var(--text-dim);
}
.project-selector__trigger.open {
box-shadow: var(--shadow-sm);
}
.project-selector__trigger-icon {
color: var(--todo);
flex-shrink: 0;
}
.project-selector__trigger-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
font-weight: 500;
}
.project-selector__trigger-chevron {
color: var(--text-muted);
flex-shrink: 0;
transition: transform var(--transition-fast);
}
.project-selector__trigger-chevron.rotate {
transform: rotate(180deg);
}
.project-selector__dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 50;
min-width: 320px;
max-width: min(420px, 90vw);
max-height: min(70vh, 520px);
overflow-y: auto;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
.project-selector__search {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--card);
z-index: 1;
}
.project-selector__search-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.project-selector__search-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: var(--text);
font-size: 13px;
outline: none;
}
.project-selector__search-input::placeholder {
color: var(--text-dim);
}
.project-selector__search-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
cursor: pointer;
}
.project-selector__search-clear:hover {
color: var(--text);
border-color: var(--text-dim);
}
.project-selector__section {
display: flex;
flex-direction: column;
padding: var(--space-sm);
}
.project-selector__section + .project-selector__section {
border-top: 1px solid var(--border);
}
.project-selector__section-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.project-selector__item,
.project-selector__view-all {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: 10px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--text);
text-align: left;
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.project-selector__item:hover,
.project-selector__item.highlighted,
.project-selector__view-all:hover,
.project-selector__view-all.highlighted {
background: var(--surface);
border-color: var(--border);
}
.project-selector__item-info {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.project-selector__item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
}
.project-selector__item-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: var(--text-muted);
}
.project-selector__item-check {
margin-left: auto;
color: var(--todo);
flex-shrink: 0;
}
.project-selector__no-results {
padding: var(--space-lg) var(--space-md);
color: var(--text-muted);
font-size: 13px;
text-align: center;
}
.project-selector__footer {
padding: var(--space-sm);
border-top: 1px solid var(--border);
}
.project-selector__view-all {
justify-content: center;
font-weight: 600;
color: var(--todo);
}
@media (max-width: 900px) {
.project-overview__header {
align-items: flex-start;
}
.project-overview__stats {
width: 100%;
justify-content: flex-start;
}
.project-overview__filters {
align-items: stretch;
}
.project-sort {
width: 100%;
}
.project-sort-select {
width: 100%;
min-width: 0;
}
}
@media (max-width: 640px) {
.project-overview {
padding: var(--space-lg);
}
.project-overview__title {
font-size: 20px;
}
.project-stat {
flex: 1 1 calc(50% - var(--space-sm));
min-width: 0;
}
.project-grid {
grid-template-columns: 1fr;
}
.project-selector,
.project-selector__trigger,
.project-selector__dropdown {
width: 100%;
min-width: 0;
}
.project-selector__dropdown {
left: 0;
right: 0;
max-width: none;
}
}
/* === ActivityFeed Component === */
.activity-feed {
display: flex;
@@ -3526,11 +2965,6 @@ body {
display: flex;
flex-direction: column;
gap: 8px;
transition:
padding var(--transition-normal),
gap var(--transition-normal),
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.inline-create-input {
@@ -3550,52 +2984,6 @@ body {
color: var(--text-dim);
}
/* Inline Create Card main row with toggle */
.inline-create-main-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.inline-create-main-row .inline-create-input {
flex: 1;
min-width: 0;
}
.inline-create-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
height: 32px;
width: 32px;
flex-shrink: 0;
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
}
/* Collapsed state - minimal appearance */
.inline-create--collapsed,
.inline-create-card--collapsed {
padding: 8px 10px;
gap: 4px;
}
.inline-create--collapsed .inline-create-main-row,
.inline-create-card--collapsed .inline-create-main-row {
align-items: center;
}
.inline-create--collapsed .inline-create-input,
.inline-create-card--collapsed .inline-create-input {
min-height: 32px;
max-height: 32px;
padding-top: 4px;
padding-bottom: 4px;
}
.inline-create-footer {
display: flex;
align-items: flex-start;
@@ -5404,18 +4792,11 @@ body {
white-space: nowrap;
}
/* Keep the select column compact so the title can use more horizontal space */
/* Reduce padding on ID column header to match data cells */
.list-table th:first-child.list-header-cell {
padding-right: 8px;
}
/* Match the ID header width to the fixed ID cell width */
.list-table th:nth-child(2).list-header-cell {
width: 70px;
min-width: 70px;
max-width: 70px;
}
.list-header-cell:hover {
color: var(--text);
}
@@ -5477,19 +4858,16 @@ body {
}
.list-cell-id {
width: 70px;
min-width: 70px;
max-width: 70px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
padding: 12px 8px 12px 16px;
padding: 12px 8px 12px 16px; /* Reduced right padding to tighten space with title */
}
.list-cell-title {
width: 100%;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -5749,10 +5127,7 @@ body {
}
.list-cell-title {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150px;
}
.list-cell-date {
@@ -6695,22 +6070,13 @@ body {
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
z-index: 500;
z-index: 100;
max-height: 320px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.model-combobox-dropdown--portal {
position: fixed;
top: 0;
left: 0;
right: auto;
margin-top: 0;
z-index: 1000;
}
.model-combobox-search-wrapper {
position: relative;
flex-shrink: 0;
@@ -10061,10 +9427,6 @@ html .column.drag-over * {
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 8px;
transition:
padding var(--transition-normal),
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.quick-entry-input {
@@ -10120,10 +9482,6 @@ html .column.drag-over * {
flex-wrap: wrap;
}
.quick-entry-controls[hidden] {
display: none;
}
.quick-entry-controls-left {
display: flex;
align-items: center;
@@ -10185,60 +9543,6 @@ html .column.drag-over * {
}
}
/* Quick Entry Box main row with toggle */
.quick-entry-main-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.quick-entry-main-row .quick-entry-input {
flex: 1;
min-width: 0;
}
.quick-entry-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
height: 32px;
width: 32px;
flex-shrink: 0;
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
}
/* Collapsed state - minimal padding */
.quick-entry--collapsed,
.quick-entry-box--collapsed {
padding: 6px 8px;
}
.quick-entry--collapsed .quick-entry-input,
.quick-entry-box--collapsed .quick-entry-input {
min-height: 32px;
border-bottom-color: transparent;
}
.quick-entry--collapsed .quick-entry-input:focus,
.quick-entry-box--collapsed .quick-entry-input:focus {
border-bottom-color: var(--triage);
box-shadow: 0 1px 0 0 var(--triage);
}
.quick-entry--expanded,
.quick-entry-box--expanded {
padding: 8px 10px;
}
.inline-create--expanded,
.inline-create-card--expanded {
padding: 10px 12px;
}
/* === New Task Modal === */
.new-task-modal .modal-body {
padding: 20px 24px;
@@ -11250,9 +10554,6 @@ html .column.drag-over * {
width: 900px;
max-width: 95vw;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Main layout: sidebar + content */
@@ -11731,41 +11032,6 @@ html .column.drag-over * {
font-size: 13px;
}
.changed-files-layout {
display: flex;
flex: 1;
min-height: 0;
}
.changed-files-sidebar {
width: 30%;
min-width: 260px;
max-width: 420px;
}
.changed-files-content {
flex: 1;
min-width: 0;
}
.changed-files-entry {
width: 100%;
border: 0;
background: transparent;
text-align: left;
}
.changed-files-entry.active {
background: var(--card-hover);
}
.changed-files-badge {
margin-left: auto;
flex-shrink: 0;
background: rgba(88, 166, 255, 0.15);
color: var(--todo);
}
/* ── Commit Form ── */
.gm-commit-form {
@@ -12385,7 +11651,7 @@ html .column.drag-over * {
.gm-modal {
width: 100%;
max-width: 100%;
height: 100vh;
max-height: 100vh;
border-radius: 0;
}
@@ -12422,7 +11688,7 @@ html .column.drag-over * {
}
.gm-content {
min-height: 200px;
min-height: 300px;
padding: var(--space-md);
}
@@ -12535,134 +11801,3 @@ html .column.drag-over * {
[data-theme="light"] .gm-load-more:hover {
background: rgba(0, 0, 0, 0.03);
}
/* ── Task Changes Tab Styles ─────────────────────────────────────────────── */
.task-changes-tab {
padding: 16px;
}
.changes-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.changes-header h4 {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
}
.changes-file-list {
border: 1px solid var(--border, #30363d);
border-radius: 8px;
overflow: hidden;
}
.changes-file-item {
border-bottom: 1px solid var(--border, #30363d);
}
.changes-file-item:last-child {
border-bottom: none;
}
.changes-file-item.expanded {
background: var(--bg-secondary, #161b22);
}
.changes-file-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: none;
border: none;
width: 100%;
text-align: left;
cursor: pointer;
color: var(--text-primary, #c9d1d9);
font-size: 13px;
transition: background 0.15s;
}
.changes-file-header:hover {
background: var(--bg-hover, #1f242c);
}
.changes-file-toggle {
display: flex;
align-items: center;
color: var(--text-secondary, #8b949e);
flex-shrink: 0;
}
.changes-file-status {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.changes-file-path {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
.changes-file-stat {
color: var(--text-secondary, #8b949e);
font-size: 11px;
flex-shrink: 0;
margin-left: 8px;
}
.changes-file-content {
border-top: 1px solid var(--border, #30363d);
background: var(--bg-primary, #0d1117);
}
.changes-diff-patch {
margin: 0;
padding: 12px;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
color: var(--text-primary, #c9d1d9);
}
.changes-diff-patch code {
background: none;
padding: 0;
}
/* Syntax highlighting for diff */
.changes-diff-patch .diff-add,
.changes-diff-patch [data-prefix="+"] {
color: #3fb950;
}
.changes-diff-patch .diff-del,
.changes-diff-patch [data-prefix="-"] {
color: #f85149;
}
.changes-diff-patch .diff-hunk,
.changes-diff-patch [data-prefix="@@"] {
color: #58a6ff;
}

View File

@@ -23,6 +23,10 @@ import type {
MilestoneCreateInput,
SliceCreateInput,
FeatureCreateInput,
MissionStatus,
MilestoneStatus,
SliceStatus,
FeatureStatus,
InterviewState,
} from "@fusion/core";
import {
@@ -39,29 +43,20 @@ function validateUuid(id: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
}
function validateMissionId(id: string | string[]): boolean {
const str = Array.isArray(id) ? id[0] : id;
return /^M-\d+$/.test(str);
function validateMissionId(id: string): boolean {
return /^M-\d+$/.test(id);
}
function validateMilestoneId(id: string | string[]): boolean {
const str = Array.isArray(id) ? id[0] : id;
return /^MS-\d+$/.test(str);
function validateMilestoneId(id: string): boolean {
return /^MS-\d+$/.test(id);
}
function validateSliceId(id: string | string[]): boolean {
const str = Array.isArray(id) ? id[0] : id;
return /^SL-\d+$/.test(str);
function validateSliceId(id: string): boolean {
return /^SL-\d+$/.test(id);
}
function validateFeatureId(id: string | string[]): boolean {
const str = Array.isArray(id) ? id[0] : id;
return /^F-\d+$/.test(str);
}
/** Helper to extract string from Express param (handles string | string[]) */
function paramString(value: string | string[]): string {
return Array.isArray(value) ? value[0] : value;
function validateFeatureId(id: string): boolean {
return /^F-\d+$/.test(id);
}
function validateTitle(title: unknown): string {
@@ -79,14 +74,14 @@ function validateDescription(desc: unknown): string | undefined {
return desc.trim() || undefined;
}
function validateStatus<TStatus extends string>(status: unknown, allowedStatuses: readonly TStatus[]): TStatus {
function validateStatus(status: unknown, allowedStatuses: readonly string[]): string {
if (!status || typeof status !== "string") {
throw new Error(`Status is required and must be one of: ${allowedStatuses.join(", ")}`);
}
if (!allowedStatuses.includes(status as TStatus)) {
if (!allowedStatuses.includes(status)) {
throw new Error(`Invalid status. Must be one of: ${allowedStatuses.join(", ")}`);
}
return status as TStatus;
return status;
}
function validateInterviewState(state: unknown): InterviewState {
@@ -183,7 +178,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId",
asyncHandler(async (req, res) => {
const missionId = paramString(req.params.missionId);
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -207,7 +202,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/:missionId",
asyncHandler(async (req, res) => {
const missionId = paramString(req.params.missionId);
const { missionId } = req.params;
const { title, description, status } = req.body;
if (!validateMissionId(missionId)) {
@@ -224,7 +219,7 @@ export function createMissionRouter(store: TaskStore): Router {
updates.description = validateDescription(description);
}
if (status !== undefined) {
updates.status = validateStatus(status, MISSION_STATUSES);
updates.status = validateStatus(status, MISSION_STATUSES) as MissionStatus;
}
if (Object.keys(updates).length === 0) {
@@ -252,7 +247,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/:missionId",
asyncHandler(async (req, res) => {
const missionId = paramString(req.params.missionId);
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -277,7 +272,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId/status",
asyncHandler(async (req, res) => {
const missionId = paramString(req.params.missionId);
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -304,7 +299,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId/interview-state",
asyncHandler(async (req, res) => {
const missionId = paramString(req.params.missionId);
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -328,7 +323,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/:missionId/interview-state",
asyncHandler(async (req, res) => {
const missionId = paramString(req.params.missionId);
const { missionId } = req.params;
const { state } = req.body;
if (!validateMissionId(missionId)) {
@@ -360,7 +355,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId/milestones",
asyncHandler(async (req, res) => {
const missionId = paramString(req.params.missionId);
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -387,7 +382,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/:missionId/milestones",
asyncHandler(async (req, res) => {
const missionId = paramString(req.params.missionId);
const { missionId } = req.params;
const { title, description, dependencies } = req.body;
if (!validateMissionId(missionId)) {
@@ -423,7 +418,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/:missionId/milestones/reorder",
asyncHandler(async (req, res) => {
const missionId = paramString(req.params.missionId);
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -465,7 +460,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/milestones/:milestoneId",
asyncHandler(async (req, res) => {
const milestoneId = paramString(req.params.milestoneId);
const { milestoneId } = req.params;
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -489,7 +484,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/milestones/:milestoneId",
asyncHandler(async (req, res) => {
const milestoneId = paramString(req.params.milestoneId);
const { milestoneId } = req.params;
const { title, description, status, dependencies } = req.body;
if (!validateMilestoneId(milestoneId)) {
@@ -506,7 +501,7 @@ export function createMissionRouter(store: TaskStore): Router {
updates.description = validateDescription(description);
}
if (status !== undefined) {
updates.status = validateStatus(status, MILESTONE_STATUSES);
updates.status = validateStatus(status, MILESTONE_STATUSES) as MilestoneStatus;
}
if (dependencies !== undefined) {
updates.dependencies = validateStringArray(dependencies, "dependencies");
@@ -537,7 +532,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/milestones/:milestoneId",
asyncHandler(async (req, res) => {
const milestoneId = paramString(req.params.milestoneId);
const { milestoneId } = req.params;
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -564,7 +559,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/milestones/:milestoneId/interview-state",
asyncHandler(async (req, res) => {
const milestoneId = paramString(req.params.milestoneId);
const { milestoneId } = req.params;
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -588,7 +583,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/milestones/:milestoneId/interview-state",
asyncHandler(async (req, res) => {
const milestoneId = paramString(req.params.milestoneId);
const { milestoneId } = req.params;
const { state } = req.body;
if (!validateMilestoneId(milestoneId)) {
@@ -620,7 +615,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/milestones/:milestoneId/slices",
asyncHandler(async (req, res) => {
const milestoneId = paramString(req.params.milestoneId);
const { milestoneId } = req.params;
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -647,7 +642,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/milestones/:milestoneId/slices",
asyncHandler(async (req, res) => {
const milestoneId = paramString(req.params.milestoneId);
const { milestoneId } = req.params;
const { title, description } = req.body;
if (!validateMilestoneId(milestoneId)) {
@@ -681,7 +676,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/milestones/:milestoneId/slices/reorder",
asyncHandler(async (req, res) => {
const milestoneId = paramString(req.params.milestoneId);
const { milestoneId } = req.params;
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -723,7 +718,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/slices/:sliceId",
asyncHandler(async (req, res) => {
const sliceId = paramString(req.params.sliceId);
const { sliceId } = req.params;
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -747,7 +742,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/slices/:sliceId",
asyncHandler(async (req, res) => {
const sliceId = paramString(req.params.sliceId);
const { sliceId } = req.params;
const { title, description, status } = req.body;
if (!validateSliceId(sliceId)) {
@@ -764,7 +759,7 @@ export function createMissionRouter(store: TaskStore): Router {
updates.description = validateDescription(description);
}
if (status !== undefined) {
updates.status = validateStatus(status, SLICE_STATUSES);
updates.status = validateStatus(status, SLICE_STATUSES) as SliceStatus;
}
if (Object.keys(updates).length === 0) {
@@ -792,7 +787,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/slices/:sliceId",
asyncHandler(async (req, res) => {
const sliceId = paramString(req.params.sliceId);
const { sliceId } = req.params;
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -817,7 +812,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/slices/:sliceId/activate",
asyncHandler(async (req, res) => {
const sliceId = paramString(req.params.sliceId);
const { sliceId } = req.params;
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -846,7 +841,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/slices/:sliceId/features",
asyncHandler(async (req, res) => {
const sliceId = paramString(req.params.sliceId);
const { sliceId } = req.params;
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -871,7 +866,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/slices/:sliceId/features",
asyncHandler(async (req, res) => {
const sliceId = paramString(req.params.sliceId);
const { sliceId } = req.params;
const { title, description, acceptanceCriteria } = req.body;
if (!validateSliceId(sliceId)) {
@@ -907,7 +902,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/features/:featureId",
asyncHandler(async (req, res) => {
const featureId = paramString(req.params.featureId);
const { featureId } = req.params;
if (!validateFeatureId(featureId)) {
res.status(400).json({ error: "Invalid feature ID format" });
@@ -931,7 +926,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/features/:featureId",
asyncHandler(async (req, res) => {
const featureId = paramString(req.params.featureId);
const { featureId } = req.params;
const { title, description, acceptanceCriteria, status } = req.body;
if (!validateFeatureId(featureId)) {
@@ -951,7 +946,7 @@ export function createMissionRouter(store: TaskStore): Router {
updates.acceptanceCriteria = validateDescription(acceptanceCriteria);
}
if (status !== undefined) {
updates.status = validateStatus(status, FEATURE_STATUSES);
updates.status = validateStatus(status, FEATURE_STATUSES) as FeatureStatus;
}
if (Object.keys(updates).length === 0) {
@@ -979,7 +974,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/features/:featureId",
asyncHandler(async (req, res) => {
const featureId = paramString(req.params.featureId);
const { featureId } = req.params;
if (!validateFeatureId(featureId)) {
res.status(400).json({ error: "Invalid feature ID format" });
@@ -1004,7 +999,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/features/:featureId/link-task",
asyncHandler(async (req, res) => {
const featureId = paramString(req.params.featureId);
const { featureId } = req.params;
const { taskId } = req.body;
if (!validateFeatureId(featureId)) {
@@ -1043,7 +1038,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/features/:featureId/unlink-task",
asyncHandler(async (req, res) => {
const featureId = paramString(req.params.featureId);
const { featureId } = req.params;
if (!validateFeatureId(featureId)) {
res.status(400).json({ error: "Invalid feature ID format" });

View File

@@ -7,7 +7,7 @@ import { EventEmitter } from "node:events";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync, execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { createApiRoutes } from "./routes.js";
import { GitHubClient } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -25,24 +25,12 @@ vi.mock("@fusion/core", async () => {
return {
...actual,
isGhAuthenticated: vi.fn(),
CentralCore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProject: vi.fn().mockResolvedValue(null),
})),
TaskStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
close: vi.fn(),
})),
};
});
import { isGhAuthenticated, CentralCore, TaskStore as TaskStoreClass } from "@fusion/core";
import { isGhAuthenticated } from "@fusion/core";
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
const MockCentralCore = vi.mocked(CentralCore);
const MockTaskStoreClass = vi.mocked(TaskStoreClass);
function createMockGlobalSettingsStore() {
return {
@@ -53,18 +41,6 @@ function createMockGlobalSettingsStore() {
};
}
function createMockMissionStore() {
return {
createSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active" }),
getSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active", answers: [] }),
updateSession: vi.fn().mockResolvedValue(undefined),
addAnswer: vi.fn().mockResolvedValue(undefined),
deleteSession: vi.fn().mockResolvedValue(undefined),
listSessions: vi.fn().mockResolvedValue([]),
generatePlan: vi.fn().mockResolvedValue({ plan: "Test plan", steps: [] }),
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
@@ -81,11 +57,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
updateGlobalSettings: vi.fn(),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
getActivityLog: vi.fn().mockResolvedValue([]),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
getActivityLog: vi.fn().mockResolvedValue([]),
addComment: vi.fn(),
addSteeringComment: vi.fn(),
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
@@ -97,7 +71,24 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
getWorkflowStep: vi.fn(),
updateWorkflowStep: vi.fn(),
deleteWorkflowStep: vi.fn(),
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
...overrides,
} as unknown as TaskStore;
}
@@ -140,163 +131,6 @@ function buildMultipart(fieldName: string, filename: string, contentType: string
return { body, boundary };
}
describe("GET /activity-feed", () => {
function mockCentralCoreModule(options?: {
entries?: unknown[];
getRecentActivityError?: Error;
}) {
const close = vi.fn().mockResolvedValue(undefined);
const getRecentActivity = options?.getRecentActivityError
? vi.fn().mockRejectedValue(options.getRecentActivityError)
: vi.fn().mockResolvedValue(options?.entries ?? []);
class MockCentralCore {
init = vi.fn().mockResolvedValue(undefined);
getRecentActivity = getRecentActivity;
close = close;
}
return { MockCentralCore, getRecentActivity, close };
}
function buildApp(store: TaskStore) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
afterEach(() => {
vi.doUnmock("@fusion/core");
});
it("returns central activity when available", async () => {
const store = createMockStore();
const centralEntry = {
id: "act_1",
timestamp: "2026-01-01T00:00:00.000Z",
type: "task:created",
projectId: "proj_123",
projectName: "Central Project",
taskId: "FN-001",
taskTitle: "Test Task",
details: "Created task",
metadata: { source: "central" },
};
const mockCentral = mockCentralCoreModule({ entries: [centralEntry] });
vi.doMock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return { ...actual, CentralCore: mockCentral.MockCentralCore };
});
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
const app = express();
app.use(express.json());
app.use("/api", createRoutesWithMock(store));
const res = await GET(app, "/api/activity-feed?type=task:created");
expect(res.status).toBe(200);
expect(res.body).toEqual([centralEntry]);
expect(mockCentral.getRecentActivity).toHaveBeenCalledWith({
limit: 50,
projectId: undefined,
types: ["task:created"],
});
expect(mockCentral.close).toHaveBeenCalled();
expect(store.getActivityLog).not.toHaveBeenCalled();
});
it("falls back to local activity log when central feed is empty", async () => {
const store = createMockStore({
getRootDir: vi.fn().mockReturnValue("/fake/projects/dashboard-app"),
getActivityLog: vi.fn().mockResolvedValue([
{
id: "local_1",
timestamp: "2026-01-01T00:00:00.000Z",
type: "task:created",
taskId: "FN-001",
details: "Task created locally",
metadata: { from: "triage", to: "todo" },
},
]),
});
const mockCentral = mockCentralCoreModule({ entries: [] });
vi.doMock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return { ...actual, CentralCore: mockCentral.MockCentralCore };
});
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
const app = express();
app.use(express.json());
app.use("/api", createRoutesWithMock(store));
const res = await GET(app, "/api/activity-feed?type=task:created");
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
id: "local_1",
timestamp: "2026-01-01T00:00:00.000Z",
type: "task:created",
projectId: "local-project",
projectName: "dashboard-app",
taskId: "FN-001",
details: "Task created locally",
metadata: { from: "triage", to: "todo" },
},
]);
expect(store.getActivityLog).toHaveBeenCalledWith({ limit: 50, type: "task:created" });
expect(mockCentral.close).toHaveBeenCalled();
});
it("falls back to local activity log when central feed throws", async () => {
const store = createMockStore({
getRootDir: vi.fn().mockReturnValue("/fake/projects/local-root"),
getActivityLog: vi.fn().mockResolvedValue([
{
id: "local_2",
timestamp: "2026-01-02T00:00:00.000Z",
type: "task:updated",
taskId: "FN-002",
details: "Task updated locally",
},
]),
});
const mockCentral = mockCentralCoreModule({ getRecentActivityError: new Error("require is not defined") });
vi.doMock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return { ...actual, CentralCore: mockCentral.MockCentralCore };
});
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
const app = express();
app.use(express.json());
app.use("/api", createRoutesWithMock(store));
const res = await GET(app, "/api/activity-feed?type=task:updated");
expect(res.status).toBe(200);
expect(res.body[0].projectName).toBe("local-root");
expect(store.getActivityLog).toHaveBeenCalledWith({ limit: 50, type: "task:updated" });
expect(mockCentral.close).toHaveBeenCalled();
});
it("returns 400 for invalid type filters", async () => {
const store = createMockStore();
const app = buildApp(store);
const res = await GET(app, "/api/activity-feed?type=not-real");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid type");
});
});
describe("GET /tasks", () => {
let store: TaskStore;
@@ -327,118 +161,6 @@ describe("GET /tasks", () => {
expect(res.status).toBe(400);
expect(res.body.error).toContain("limit");
});
describe("with projectId query parameter", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 404 when project is not found", async () => {
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProject: vi.fn().mockResolvedValue(null),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const res = await GET(buildApp(), "/api/tasks?projectId=nonexistent");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Project not found");
});
it("returns tasks from the project store when project is found", async () => {
const projectPath = "/test/project/path";
const projectTasks = [FAKE_TASK_DETAIL];
const mockProjectStoreInstance = {
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue(projectTasks),
close: vi.fn(),
};
MockTaskStoreClass.mockImplementation(() => mockProjectStoreInstance as any);
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProject: vi.fn().mockResolvedValue({
id: "proj_abc",
name: "Test Project",
path: projectPath,
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe("FN-001");
// Verify project store was initialized with correct path
expect(MockTaskStoreClass).toHaveBeenCalledWith(projectPath);
expect(mockProjectStoreInstance.init).toHaveBeenCalled();
expect(mockProjectStoreInstance.listTasks).toHaveBeenCalledWith({ limit: undefined, offset: undefined });
expect(mockProjectStoreInstance.close).toHaveBeenCalled();
// Verify CentralCore was properly closed
expect(mockCentralInstance.close).toHaveBeenCalled();
});
it("passes limit and offset to the project store", async () => {
const mockProjectStoreInstance = {
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
close: vi.fn(),
};
MockTaskStoreClass.mockImplementation(() => mockProjectStoreInstance as any);
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProject: vi.fn().mockResolvedValue({
id: "proj_abc",
name: "Test Project",
path: "/test/path",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc&limit=5&offset=10");
expect(res.status).toBe(200);
expect(mockProjectStoreInstance.listTasks).toHaveBeenCalledWith({ limit: 5, offset: 10 });
});
it("returns 200 with empty array on graceful degradation when CentralCore is unavailable", async () => {
MockCentralCore.mockImplementation(() => {
throw new Error("CentralCore unavailable");
});
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("still uses default store when projectId is not provided", async () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
const res = await GET(buildApp(), "/api/tasks");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(store.listTasks).toHaveBeenCalled();
// CentralCore should not be used when no projectId
expect(MockCentralCore).not.toHaveBeenCalled();
});
});
});
describe("GET /tasks/:id", () => {
@@ -2124,16 +1846,16 @@ describe("Pause/Unpause endpoints", () => {
it("adds a steering comment to a task", async () => {
const mockComment = {
id: "FN-001",
comments: [
steeringComments: [
{
id: "1234567890-abc123",
text: "Please handle the edge case",
createdAt: "2026-01-01T00:00:00.000Z",
author: "user",
author: "user" as const,
},
],
};
(store.addComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
const res = await REQUEST(
buildApp(),
@@ -2145,7 +1867,7 @@ describe("Pause/Unpause endpoints", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual(mockComment);
expect(store.addComment).toHaveBeenCalledWith(
expect(store.addSteeringComment).toHaveBeenCalledWith(
"KB-001",
"Please handle the edge case",
"user"
@@ -2192,7 +1914,7 @@ describe("Pause/Unpause endpoints", () => {
it("returns 404 when task not found", async () => {
const error = new Error("Task not found") as Error & { code?: string };
error.code = "ENOENT";
(store.addComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
const res = await REQUEST(
buildApp(),
@@ -2206,7 +1928,7 @@ describe("Pause/Unpause endpoints", () => {
});
it("returns 500 on unexpected errors", async () => {
(store.addComment as ReturnType<typeof vi.fn>).mockRejectedValue(
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Database error")
);
@@ -3934,142 +3656,6 @@ describe("POST /tasks/:id/reject-plan", () => {
// --- Git Management route tests ---
// These are integration tests that run against the actual git repository
describe("GET /tasks/:id/file-diffs", () => {
let store: TaskStore;
let worktreeDir: string;
let testRoot: string;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z"));
testRoot = mkdtempSync(join(tmpdir(), "kb-dashboard-file-diffs-"));
worktreeDir = join(testRoot, "repo");
mkdirSync(worktreeDir, { recursive: true });
execFileSync("git", ["init", "-b", "main", worktreeDir]);
execFileSync("git", ["-C", worktreeDir, "config", "user.email", "kb-tests@example.com"]);
execFileSync("git", ["-C", worktreeDir, "config", "user.name", "KB Tests"]);
writeFileSync(join(worktreeDir, "README.md"), "base\n");
writeFileSync(join(worktreeDir, "keep.txt"), "keep\n");
execFileSync("git", ["-C", worktreeDir, "add", "."]);
execFileSync("git", ["-C", worktreeDir, "commit", "-m", "base"]);
store = createMockStore({
getTask: vi.fn().mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-651",
worktree: worktreeDir,
baseBranch: "main",
}),
getRootDir: vi.fn().mockReturnValue(worktreeDir),
});
});
afterEach(() => {
vi.useRealTimers();
rmSync(testRoot, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns changed files with statuses and diffs", async () => {
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged\n");
writeFileSync(join(worktreeDir, "added.txt"), "new file\n");
execFileSync("git", ["-C", worktreeDir, "rm", "keep.txt"]);
expect(execSync("git status --short", { cwd: worktreeDir, encoding: "utf-8" })).not.toBe("");
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "README.md", status: "modified", diff: expect.stringContaining("+changed") }),
expect.objectContaining({ path: "added.txt", status: "added", diff: expect.stringContaining("+++ b/added.txt") }),
expect.objectContaining({ path: "keep.txt", status: "deleted", diff: expect.stringContaining("--- a/keep.txt") }),
]),
);
});
it("returns renamed files with oldPath and diff content", async () => {
execFileSync("git", ["-C", worktreeDir, "mv", "keep.txt", "renamed.txt"]);
expect(execSync("git status --short", { cwd: worktreeDir, encoding: "utf-8" })).not.toBe("");
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({
path: "renamed.txt",
oldPath: "keep.txt",
status: "renamed",
diff: expect.stringContaining("rename from keep.txt"),
}),
]);
});
it.skip("caches results for 10 seconds before refreshing", async () => {
const originalDateNow = Date.now;
let now = 1_000;
Date.now = vi.fn(() => now);
try {
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged once\n");
const first = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(first.status).toBe(200);
expect(first.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed once") }),
]),
);
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged twice\n");
now += 5_000;
const cached = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(cached.status).toBe(200);
expect(cached.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed once") }),
]),
);
now += 5_001;
const refreshed = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(refreshed.status).toBe(200);
expect(refreshed.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed twice") }),
]),
);
} finally {
Date.now = originalDateNow;
}
});
it("returns empty array when worktree is missing", async () => {
store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "KB-651", worktree: join(testRoot, "missing"), baseBranch: "main" }),
});
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns empty array when there are no changes", async () => {
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
describe("Git Management endpoints", () => {
let store: TaskStore;
let gitRepoDir: string;
@@ -5150,13 +4736,9 @@ describe("Terminal session routes", () => {
});
describe("POST /api/terminal/sessions", () => {
it("returns 503 when max sessions reached", async () => {
it("returns 503 when max sessions reached (session is null)", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: false,
code: "max_sessions",
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
}),
createSession: vi.fn().mockResolvedValue(null),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
@@ -5169,102 +4751,7 @@ describe("Terminal session routes", () => {
);
expect(res.status).toBe(503);
expect(res.body.error).toBe("Maximum terminal sessions reached. Please close an existing terminal and try again.");
vi.restoreAllMocks();
});
it("returns 400 when shell is not allowed", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: false,
code: "invalid_shell",
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toBe("Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).");
vi.restoreAllMocks();
});
it("returns 503 when PTY module fails to load", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: false,
code: "pty_load_failed",
error: "Terminal service unavailable. The PTY module could not be loaded.",
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(503);
expect(res.body.error).toBe("Terminal service unavailable. The PTY module could not be loaded.");
vi.restoreAllMocks();
});
it("returns 500 when PTY spawn fails", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: false,
code: "pty_spawn_failed",
error: "Failed to start terminal shell process.",
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toBe("Failed to start terminal shell process.");
vi.restoreAllMocks();
});
it("returns 201 when session creation succeeds", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: true,
session: { id: "term-123", shell: "/bin/zsh", cwd: "/test" },
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body).toEqual({ sessionId: "term-123", shell: "/bin/zsh", cwd: "/test" });
expect(res.body.error).toContain("Max sessions");
vi.restoreAllMocks();
});
@@ -6319,143 +5806,3 @@ describe("POST /workflow-step-templates/:id/create", () => {
expect(res.body.error).toContain("already exists");
});
});
// ── Activity Feed Tests ─────────────────────────────────────────────
describe("GET /api/activity-feed", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns empty array when both central and per-project activities are empty", async () => {
const store = createMockStore();
// Override the module-level CentralCore mock to return empty array
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn().mockResolvedValue([]),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns central activity when available", async () => {
const store = createMockStore();
store.getActivityLog.mockResolvedValue([]);
const centralEntries = [
{
id: "central-1",
timestamp: "2026-04-01T11:00:00.000Z",
type: "task:moved" as const,
projectId: "proj-123",
projectName: "Test Project",
taskId: "KB-002",
details: "Moved task to done",
},
];
// Override the module-level CentralCore mock to return data
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn().mockResolvedValue(centralEntries),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed");
expect(res.status).toBe(200);
expect(res.body).toEqual(centralEntries);
// Should not call per-project activity when central data exists
expect(store.getActivityLog).not.toHaveBeenCalled();
});
it("handles CentralCore initialization failure gracefully", async () => {
const store = createMockStore();
// Override the module-level CentralCore mock to throw error on init
const mockCentralInstance = {
init: vi.fn().mockRejectedValue(new Error("CentralCore init failed")),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn(),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed");
expect(res.status).toBe(200);
expect(res.body).toEqual([]); // Fallback returns empty array from mock store
});
it("passes through limit query parameter", async () => {
const store = createMockStore();
// Override the module-level CentralCore mock to return empty
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn().mockResolvedValue([]),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed?limit=25");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("handles type filter query parameter", async () => {
const store = createMockStore();
// Override the module-level CentralCore mock to return empty
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn().mockResolvedValue([]),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed?types=task:moved");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("validates fallback route path exists", async () => {
const store = createMockStore();
// Override the module-level CentralCore mock to return empty - this ensures fallback path is taken
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn().mockResolvedValue([]),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
});

File diff suppressed because it is too large Load Diff