feat(KB-618): add multi-project support to dashboard
- Add project API methods and types (fetchProjects, registerProject, fetchProjectHealth, etc.) - Add ProjectCard component with health metrics, status badges, and pause/resume actions - Add server-side project management routes for multi-project orchestration - Add ActivityFeed component with grouped entries and project badges - Add SetupWizard component with 5-step project creation flow - Fix TypeScript errors in server routes for listProjects and getGlobalConcurrencyState
This commit is contained in:
@@ -1868,14 +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`);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export function ListView({
|
||||
// Invalid localStorage data - fall through to default
|
||||
}
|
||||
}
|
||||
return true; // Default: hide done tasks
|
||||
return false; // Default: show done tasks
|
||||
});
|
||||
|
||||
// Collapsed sections state - initialize from localStorage
|
||||
@@ -724,7 +724,6 @@ export function ListView({
|
||||
availableModels={availableModels}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
autoExpand={false}
|
||||
/>
|
||||
</div>
|
||||
{filteredCount === 0 ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { Task } from "@kb/core";
|
||||
|
||||
interface MergeDetailsProps {
|
||||
task: Task;
|
||||
|
||||
@@ -163,36 +163,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isOpen, view]);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
// Show confirmation if user has made progress
|
||||
if (hasProgress) {
|
||||
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Always close the stream connection
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
|
||||
if (view.type === "question" || view.type === "summary") {
|
||||
try {
|
||||
await cancelPlanning(view.session.sessionId);
|
||||
} catch {
|
||||
// Ignore errors on cancel
|
||||
}
|
||||
}
|
||||
setInitialPlan("");
|
||||
setView({ type: "initial" });
|
||||
setError(null);
|
||||
setResponseHistory([]);
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(false);
|
||||
currentSessionIdRef.current = null;
|
||||
onClose();
|
||||
}, [hasProgress, view, onClose]);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -244,6 +214,36 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
|
||||
[view]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
// Show confirmation if user has made progress
|
||||
if (hasProgress) {
|
||||
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Always close the stream connection
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
|
||||
if (view.type === "question" || view.type === "summary") {
|
||||
try {
|
||||
await cancelPlanning(view.session.sessionId);
|
||||
} catch {
|
||||
// Ignore errors on cancel
|
||||
}
|
||||
}
|
||||
setInitialPlan("");
|
||||
setView({ type: "initial" });
|
||||
setError(null);
|
||||
setResponseHistory([]);
|
||||
setEditedSummary(null);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(false);
|
||||
currentSessionIdRef.current = null;
|
||||
onClose();
|
||||
}, [hasProgress, view, onClose]);
|
||||
|
||||
const handleCreateTask = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
|
||||
|
||||
@@ -97,7 +97,6 @@ const providerConfig: Record<
|
||||
> = {
|
||||
anthropic: { component: AnthropicIcon, color: "#d4a27f" }, // warm tan
|
||||
openai: { component: OpenAIIcon, color: "#10a37f" }, // green
|
||||
"openai-codex": { component: OpenAIIcon, color: "#10a37f" }, // green (same as openai)
|
||||
google: { component: GeminiIcon, color: "#4285f4" }, // blue
|
||||
gemini: { component: GeminiIcon, color: "#4285f4" }, // blue (same as google)
|
||||
ollama: { component: OllamaIcon, color: "#fff" }, // white
|
||||
|
||||
@@ -22,11 +22,6 @@ interface QuickEntryBoxProps {
|
||||
* Called when the user clicks the "Subtask" button to trigger subtask breakdown.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
/**
|
||||
* When false, the component will not auto-expand on focus.
|
||||
* Defaults to true for backward compatibility.
|
||||
*/
|
||||
autoExpand?: boolean;
|
||||
}
|
||||
|
||||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||||
@@ -49,7 +44,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, autoExpand = true }: QuickEntryBoxProps) {
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown }: QuickEntryBoxProps) {
|
||||
const [description, setDescription] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem(STORAGE_KEY) || "";
|
||||
@@ -314,11 +309,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
justResetRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Only auto-expand if autoExpand prop is true (defaults to true for backward compatibility)
|
||||
if (autoExpand) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [autoExpand]);
|
||||
setIsExpanded(true);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// Clear any existing timeout
|
||||
|
||||
@@ -79,9 +79,6 @@ export function SettingsModal({
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? SETTINGS_SECTIONS[0].id);
|
||||
const [prefixError, setPrefixError] = useState<string | null>(null);
|
||||
|
||||
/** Get the scope of the currently active section */
|
||||
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
|
||||
|
||||
// Auth state (independent of the settings save flow)
|
||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
@@ -342,6 +339,9 @@ export function SettingsModal({
|
||||
[onClose],
|
||||
);
|
||||
|
||||
/** Get the scope of the currently active section */
|
||||
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (prefixError || presetDraft) return;
|
||||
try {
|
||||
|
||||
@@ -637,16 +637,9 @@ function TaskCardComponent({
|
||||
<span className="card-error-text">{task.error.length > 60 ? task.error.slice(0, 60) + "…" : task.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Truncate title/description to 140 chars with ellipsis; full text in tooltip */}
|
||||
{(() => {
|
||||
const displayText = task.title || task.description || task.id;
|
||||
const truncatedText = truncate(displayText, 140);
|
||||
return (
|
||||
<div className="card-title" title={displayText}>
|
||||
{truncatedText}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="card-title">
|
||||
{task.title || task.description || task.id}
|
||||
</div>
|
||||
{task.steps.length > 0 && (() => {
|
||||
const completedSteps = task.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
||||
const totalSteps = task.steps.length;
|
||||
@@ -742,9 +735,5 @@ function TaskCardComponent({
|
||||
const TOUCH_MOVE_THRESHOLD = 10; // pixels
|
||||
const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||
}
|
||||
|
||||
export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual);
|
||||
TaskCard.displayName = "TaskCard";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Task, TaskComment } from "@fusion/core";
|
||||
import type { Task, TaskComment } from "@kb/core";
|
||||
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ModelSelectorTab } from "./ModelSelectorTab";
|
||||
import { PrSection } from "./PrSection";
|
||||
import { TaskComments } from "./TaskComments";
|
||||
import { MergeDetails } from "./MergeDetails";
|
||||
import { TaskChangesTab } from "./TaskChangesTab";
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
@@ -106,7 +105,7 @@ export function TaskDetailModal({
|
||||
addToast,
|
||||
githubTokenConfigured,
|
||||
}: TaskDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "changes" | "steering" | "comments" | "model">("definition");
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "comments" | "model">("definition");
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
@@ -672,14 +671,6 @@ export function TaskDetailModal({
|
||||
>
|
||||
Agent Log
|
||||
</button>
|
||||
{(task.column === "in-progress" || task.column === "in-review" || task.column === "done") && (
|
||||
<button
|
||||
className={`detail-tab${activeTab === "changes" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("changes")}
|
||||
>
|
||||
Changes
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`detail-tab${activeTab === "steering" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("steering")}
|
||||
@@ -712,8 +703,6 @@ export function TaskDetailModal({
|
||||
validatorModel={getValidatorSelection(task)}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "changes" ? (
|
||||
<TaskChangesTab taskId={task.id} worktree={task.worktree} />
|
||||
) : activeTab === "steering" ? (
|
||||
<SteeringTab task={task} addToast={addToast} />
|
||||
) : activeTab === "comments" ? (
|
||||
|
||||
@@ -256,7 +256,7 @@ describe("Board", () => {
|
||||
const todoTasks = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]");
|
||||
expect(todoTasks[0].title).toBe("Updated");
|
||||
expect(columnRenderCounts.todo).toBeGreaterThan(initialTodoRenders);
|
||||
expect(columnRenderCounts.done).toBeGreaterThanOrEqual(initialDoneRenders);
|
||||
expect(columnRenderCounts.done).toBe(initialDoneRenders);
|
||||
});
|
||||
|
||||
it("filtered tasks are sorted correctly (columnMovedAt, createdAt)", () => {
|
||||
@@ -292,7 +292,7 @@ describe("Board", () => {
|
||||
expect(todoTasks).toHaveLength(3);
|
||||
|
||||
// Tasks with columnMovedAt should come first, sorted by columnMovedAt descending (newest first)
|
||||
// So FN-002 (12:00) should be first, FN-001 (10:00) second
|
||||
// So KB-002 (12:00) should be first, KB-001 (10:00) second
|
||||
// Legacy tasks (no columnMovedAt) come last, sorted by createdAt ascending
|
||||
expect(todoTasks[0].id).toBe("FN-002");
|
||||
expect(todoTasks[1].id).toBe("FN-001");
|
||||
@@ -302,7 +302,7 @@ describe("Board", () => {
|
||||
it("matches tasks across multiple fields simultaneously", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "SEARCH-123", title: "Searchable title", description: "Normal description", column: "todo" }),
|
||||
createTask({ id: "FN-999", title: "Other task", description: "This has searchable content", column: "todo" }),
|
||||
createTask({ id: "KB-999", title: "Other task", description: "This has searchable content", column: "todo" }),
|
||||
createTask({ id: "FN-888", title: "Unrelated", description: "No match here", column: "todo" }),
|
||||
];
|
||||
|
||||
@@ -313,7 +313,7 @@ describe("Board", () => {
|
||||
|
||||
// Should match both tasks with "search" in ID, title, or description
|
||||
expect(todoTasks).toHaveLength(2);
|
||||
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["FN-999", "SEARCH-123"]);
|
||||
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["KB-999", "SEARCH-123"]);
|
||||
});
|
||||
|
||||
it("trims whitespace from search query", () => {
|
||||
|
||||
@@ -991,9 +991,8 @@ describe("GitManagerModal", () => {
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "upstream");
|
||||
|
||||
const saveButton = nameInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
|
||||
expect(saveButton).toBeTruthy();
|
||||
await user.click(saveButton as HTMLButtonElement);
|
||||
const saveButton = screen.getByRole("button", { name: "" }); // Check button
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renameGitRemote).toHaveBeenCalledWith("origin", "upstream");
|
||||
@@ -1024,9 +1023,8 @@ describe("GitManagerModal", () => {
|
||||
await user.clear(urlInput);
|
||||
await user.type(urlInput, "https://new-url.com/repo.git");
|
||||
|
||||
const saveButton = urlInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
|
||||
expect(saveButton).toBeTruthy();
|
||||
await user.click(saveButton as HTMLButtonElement);
|
||||
const saveButton = screen.getByRole("button", { name: "" }); // Check button
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateGitRemoteUrl).toHaveBeenCalledWith("origin", "https://new-url.com/repo.git");
|
||||
|
||||
@@ -300,12 +300,12 @@ describe("InlineCreateCard model selector", () => {
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard([], { availableModels: undefined });
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with preset" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Budget" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Budget" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -315,15 +310,11 @@ describe("ListView", () => {
|
||||
const columns = ["triage", "todo", "in-progress", "in-review", "done"] as const;
|
||||
|
||||
const tasks = columns.map((col, i) =>
|
||||
createMockTask({ id: `FN-00${i + 1}`, column: col })
|
||||
createMockTask({ id: `KB-00${i + 1}`, column: col })
|
||||
);
|
||||
|
||||
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", () => {
|
||||
@@ -1769,7 +1755,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkboxes = screen.getAllByLabelText(/Select FN-/);
|
||||
const checkboxes = screen.getAllByLabelText(/Select KB-/);
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -1779,11 +1765,7 @@ 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");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -1794,7 +1776,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(screen.getByText("1 selected")).toBeDefined();
|
||||
@@ -1806,7 +1788,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
expect(screen.getByText("1 selected")).toBeDefined();
|
||||
|
||||
@@ -1845,7 +1827,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(screen.getByText("Bulk Edit Models:")).toBeDefined();
|
||||
@@ -1867,7 +1849,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
const applyButton = screen.getByText("Apply");
|
||||
@@ -1878,7 +1860,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(localStorage.getItem("kb-dashboard-selected-tasks")).toBe('["FN-001"]');
|
||||
@@ -1891,7 +1873,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
const checkboxes = screen.getAllByLabelText(/Select FN-/);
|
||||
const checkboxes = screen.getAllByLabelText(/Select KB-/);
|
||||
// Select only first task
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
@@ -1919,7 +1901,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
);
|
||||
|
||||
// Select the task
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
const checkbox = screen.getByLabelText("Select KB-001");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Initially disabled
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("NewTaskModal", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Created FN-042", "success");
|
||||
expect(props.addToast).toHaveBeenCalledWith("Created KB-042", "success");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,12 +15,6 @@ describe("ProviderIcon", () => {
|
||||
expect(screen.getByLabelText("OpenAI")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders OpenAI brand icon for openai-codex provider", () => {
|
||||
render(<ProviderIcon provider="openai-codex" />);
|
||||
expect(screen.getByTestId("openai-icon")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("OpenAI")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Gemini brand icon for google provider", () => {
|
||||
render(<ProviderIcon provider="google" />);
|
||||
expect(screen.getByTestId("gemini-icon")).toBeInTheDocument();
|
||||
@@ -77,12 +71,6 @@ describe("ProviderIcon", () => {
|
||||
expect(icon).toHaveStyle({ color: "#10a37f" });
|
||||
});
|
||||
|
||||
it("applies provider-specific color for openai-codex", () => {
|
||||
render(<ProviderIcon provider="openai-codex" />);
|
||||
const icon = screen.getByTestId("openai-icon").parentElement;
|
||||
expect(icon).toHaveStyle({ color: "#10a37f" });
|
||||
});
|
||||
|
||||
it("applies provider-specific color for google", () => {
|
||||
render(<ProviderIcon provider="google" />);
|
||||
const icon = screen.getByTestId("gemini-icon").parentElement;
|
||||
@@ -159,14 +147,6 @@ describe("ProviderIcon", () => {
|
||||
expect(paths[0]).toHaveAttribute("fill", "#10a37f");
|
||||
});
|
||||
|
||||
it("passes correct color to SVG fill for openai-codex", () => {
|
||||
render(<ProviderIcon provider="openai-codex" />);
|
||||
const svg = screen.getByTestId("openai-icon");
|
||||
const paths = svg.querySelectorAll("path");
|
||||
expect(paths.length).toBeGreaterThan(0);
|
||||
expect(paths[0]).toHaveAttribute("fill", "#10a37f");
|
||||
});
|
||||
|
||||
it("passes correct color to SVG fill for gemini", () => {
|
||||
render(<ProviderIcon provider="gemini" />);
|
||||
const svg = screen.getByTestId("gemini-icon");
|
||||
|
||||
@@ -174,16 +174,6 @@ describe("QuickEntryBox", () => {
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expand on focus when autoExpand is false", () => {
|
||||
renderQuickEntryBox({ autoExpand: false });
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
|
||||
// Should not expand when autoExpand is false
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
|
||||
it("collapses on blur when empty", async () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -689,7 +689,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
// Check that no elements in the settings content have inline styles
|
||||
const elementsWithStyle = container.querySelectorAll("[style]");
|
||||
expect(elementsWithStyle.length).toBe(1);
|
||||
expect(elementsWithStyle.length).toBe(0);
|
||||
});
|
||||
|
||||
it("shows Thinking Effort dropdown with correct options in Model section", async () => {
|
||||
@@ -833,14 +833,14 @@ describe("SettingsModal", () => {
|
||||
expect(layout!.querySelector(".settings-content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has .settings-sidebar with 12 .settings-nav-item buttons for all sections", async () => {
|
||||
it("has .settings-sidebar with 11 .settings-nav-item buttons for all sections", async () => {
|
||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const sidebar = container.querySelector(".settings-sidebar");
|
||||
expect(sidebar).toBeTruthy();
|
||||
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
|
||||
expect(navItems.length).toBe(12);
|
||||
expect(navItems.length).toBe(11);
|
||||
|
||||
// Labels include scope emoji indicators (🌐 for global, 📁 for project)
|
||||
const labels = Array.from(navItems).map((el) => el.textContent);
|
||||
@@ -848,7 +848,6 @@ describe("SettingsModal", () => {
|
||||
"📁General",
|
||||
"🌐Model",
|
||||
"📁Model Presets",
|
||||
"📁AI Summarization",
|
||||
"🌐Appearance",
|
||||
"📁Scheduling",
|
||||
"📁Worktrees",
|
||||
|
||||
@@ -487,7 +487,7 @@ describe("TaskCard file-scope overlap badge logic", () => {
|
||||
}
|
||||
|
||||
it("generates correct tooltip text", () => {
|
||||
expect(computeScopeTooltip("FN-005")).toBe("Blocked by FN-005 (file overlap)");
|
||||
expect(computeScopeTooltip("FN-005")).toBe("Blocked by KB-005 (file overlap)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -647,7 +647,7 @@ describe("TaskCard clickable dependencies", () => {
|
||||
fireEvent.click(depBadge);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error");
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency KB-001", "error");
|
||||
});
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2295,17 +2295,18 @@ describe("TaskCard GitHub badges", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for task detail opening behavior in TaskCard.
|
||||
* The card body opens the modal directly; there is no separate expand button.
|
||||
* Tests for expand button and modal open behavior in TaskCard.
|
||||
* Ensures that clicking the expand button opens the modal,
|
||||
* while clicking the card body does not.
|
||||
*/
|
||||
describe("TaskCard detail opening", () => {
|
||||
describe("TaskCard expand button", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens modal when clicking the card body", async () => {
|
||||
it("opens modal when clicking the expand button", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
@@ -2329,8 +2330,11 @@ describe("TaskCard detail opening", () => {
|
||||
const card = document.querySelector('[data-id="FN-099"]');
|
||||
expect(card).toBeDefined();
|
||||
|
||||
const cardTitle = screen.getByText("Test task");
|
||||
fireEvent.click(cardTitle);
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
expect(expandButton).toBeDefined();
|
||||
expect(expandButton.classList.contains("card-expand-btn")).toBe(true);
|
||||
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099");
|
||||
@@ -2338,24 +2342,10 @@ describe("TaskCard detail opening", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render a separate expand button", () => {
|
||||
const task = makeTask();
|
||||
|
||||
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={noopToast} />);
|
||||
expect(screen.queryByRole("button", { name: /Open task details/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens modal only once per card click", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
...makeTask({ id: "FN-099" }),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||
it("does NOT open modal when clicking the card body", async () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const task = makeTask();
|
||||
|
||||
const task = makeTask({ title: "Test Task Title" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
@@ -2365,13 +2355,35 @@ describe("TaskCard detail opening", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Test task"));
|
||||
const card = document.querySelector('[data-id="FN-099"]');
|
||||
expect(card).toBeDefined();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099");
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Click on the card title (part of card body)
|
||||
const cardTitle = screen.getByText("Test Task Title");
|
||||
fireEvent.click(cardTitle);
|
||||
|
||||
// Wait for any async operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Modal should NOT have opened
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expand button has correct accessibility attributes", () => {
|
||||
const task = makeTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
expect(expandButton).toBeDefined();
|
||||
expect(expandButton.getAttribute("aria-label")).toBe("Open task details");
|
||||
expect(expandButton.getAttribute("title")).toBe("Open task details");
|
||||
});
|
||||
|
||||
it("does NOT open modal during vertical scrolling", async () => {
|
||||
@@ -2472,7 +2484,7 @@ describe("TaskCard detail opening", () => {
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render an expand button in any column", () => {
|
||||
it("expand button is present in all columns", () => {
|
||||
const columns: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
|
||||
|
||||
for (const column of columns) {
|
||||
@@ -2486,11 +2498,49 @@ describe("TaskCard detail opening", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Open task details/i })).toBeNull();
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
expect(expandButton).toBeDefined();
|
||||
expect(expandButton.classList.contains("card-expand-btn")).toBe(true);
|
||||
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("expand button stops propagation to prevent double-triggering", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
const mockDetail: TaskDetail = {
|
||||
...makeTask({ id: "FN-099" }),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockDetail);
|
||||
const onOpenDetail = vi.fn();
|
||||
|
||||
const task = makeTask();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = document.querySelector('[data-id="FN-099"]');
|
||||
expect(card).toBeDefined();
|
||||
|
||||
const expandButton = screen.getByRole("button", { name: /Open task details/i });
|
||||
|
||||
// Click the expand button - should only trigger once
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("FN-099");
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -2564,148 +2614,3 @@ describe("TaskCard title display", () => {
|
||||
expect(cardTitle).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for TaskCard title truncation to 140 characters.
|
||||
*/
|
||||
describe("TaskCard title truncation", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
const makeTask = (overrides: Partial<Task> = {}): Task => ({
|
||||
id: "FN-001",
|
||||
description: "Test task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
} as Task);
|
||||
|
||||
it("displays short title unchanged (under 140 characters)", () => {
|
||||
const shortTitle = "This is a short title";
|
||||
const task = makeTask({ title: shortTitle });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const cardTitle = screen.getByText(shortTitle);
|
||||
expect(cardTitle).toBeDefined();
|
||||
expect(cardTitle.textContent).toBe(shortTitle);
|
||||
});
|
||||
|
||||
it("displays title exactly 140 characters unchanged", () => {
|
||||
const exactTitle = "A".repeat(140);
|
||||
const task = makeTask({ title: exactTitle });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const cardTitle = screen.getByText(exactTitle);
|
||||
expect(cardTitle).toBeDefined();
|
||||
expect(cardTitle.textContent?.length).toBe(140);
|
||||
expect(cardTitle.textContent).toBe(exactTitle);
|
||||
});
|
||||
|
||||
it("truncates title over 140 characters with ellipsis", () => {
|
||||
const longTitle = "B".repeat(150);
|
||||
const task = makeTask({ title: longTitle });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show truncated text with ellipsis (140 chars + "…")
|
||||
const expectedTruncated = "B".repeat(140) + "…";
|
||||
const cardTitle = screen.getByText(expectedTruncated);
|
||||
expect(cardTitle).toBeDefined();
|
||||
expect(cardTitle.textContent?.length).toBe(141); // 140 + ellipsis
|
||||
});
|
||||
|
||||
it("truncates description fallback when no title and description is over 140 chars", () => {
|
||||
const longDescription = "C".repeat(200);
|
||||
const task = makeTask({ title: undefined, description: longDescription });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show truncated description with ellipsis
|
||||
const expectedTruncated = "C".repeat(140) + "…";
|
||||
const cardTitle = screen.getByText(expectedTruncated);
|
||||
expect(cardTitle).toBeDefined();
|
||||
expect(cardTitle.textContent?.length).toBe(141);
|
||||
});
|
||||
|
||||
it("includes full untruncated text in title attribute for tooltip", () => {
|
||||
const longTitle = "D".repeat(200);
|
||||
const task = makeTask({ title: longTitle });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// The title attribute should contain the full untruncated text
|
||||
const cardTitleElement = document.querySelector(".card-title");
|
||||
expect(cardTitleElement).toBeDefined();
|
||||
expect(cardTitleElement?.getAttribute("title")).toBe(longTitle);
|
||||
});
|
||||
|
||||
it("includes full description in title attribute when using description fallback", () => {
|
||||
const longDescription = "E".repeat(200);
|
||||
const task = makeTask({ title: undefined, description: longDescription });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const cardTitleElement = document.querySelector(".card-title");
|
||||
expect(cardTitleElement).toBeDefined();
|
||||
expect(cardTitleElement?.getAttribute("title")).toBe(longDescription);
|
||||
});
|
||||
|
||||
it("includes task id in title attribute when falling back to id", () => {
|
||||
const task = makeTask({ title: undefined, description: "" });
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const cardTitleElement = document.querySelector(".card-title");
|
||||
expect(cardTitleElement).toBeDefined();
|
||||
expect(cardTitleElement?.getAttribute("title")).toBe("FN-001");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1341,7 +1341,7 @@ describe("TaskDetailModal", () => {
|
||||
renderWithSearch();
|
||||
fireEvent.click(screen.getByText("Add Dependency"));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "fn-020" } });
|
||||
fireEvent.change(input, { target: { value: "kb-020" } });
|
||||
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items).toHaveLength(1);
|
||||
@@ -1465,7 +1465,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(depLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error");
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to load dependency KB-001", "error");
|
||||
});
|
||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1805,7 +1805,7 @@ describe("TaskDetailModal", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Plan approved — FN-001 moved to Todo", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Plan approved — KB-001 moved to Todo", "success");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1846,7 +1846,7 @@ describe("TaskDetailModal", () => {
|
||||
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
"Plan rejected — FN-001 returned to Triage for re-specification",
|
||||
"Plan rejected — KB-001 returned to Triage for re-specification",
|
||||
"info"
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
@@ -2013,7 +2013,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Duplicate"));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
"Duplicate FN-001? This will create a new task in Triage with the same description and prompt."
|
||||
"Duplicate KB-001? This will create a new task in Triage with the same description and prompt."
|
||||
);
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
@@ -2072,7 +2072,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Duplicate"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Duplicated FN-001 → FN-002", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Duplicated KB-001 → KB-002", "success");
|
||||
});
|
||||
|
||||
window.confirm = originalConfirm;
|
||||
@@ -2393,7 +2393,7 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineTask).toHaveBeenCalledWith("FN-001", "Need to add more tests");
|
||||
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-002", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Refinement task created: KB-002", "success");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2682,7 +2682,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Updated FN-001", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Updated KB-001", "success");
|
||||
});
|
||||
|
||||
// Should exit edit mode
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("useAgentLogs", () => {
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001");
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/KB-001/logs/stream");
|
||||
});
|
||||
|
||||
it("appends live SSE entries to historical entries", async () => {
|
||||
|
||||
@@ -110,8 +110,8 @@ describe("useMultiAgentLogs", () => {
|
||||
await waitFor(() => {
|
||||
// Filter to unique URLs (Strict Mode may create duplicates)
|
||||
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/FN-001/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/FN-002/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/KB-001/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("useMultiAgentLogs", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/FN-002/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,7 +232,7 @@ describe("useMultiAgentLogs", () => {
|
||||
expect(result.current["FN-001"].entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Clear only FN-001
|
||||
// Clear only KB-001
|
||||
act(() => {
|
||||
result.current["FN-001"].clear();
|
||||
});
|
||||
|
||||
@@ -809,140 +809,4 @@ describe("useTasks", () => {
|
||||
expect(result.current.tasks[0].column).toBe("todo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibility change", () => {
|
||||
let originalVisibilityState: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original descriptor to restore later
|
||||
originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original visibilityState property
|
||||
if (originalVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", originalVisibilityState);
|
||||
} else {
|
||||
// If no original descriptor, just delete our mock
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (document as any).visibilityState;
|
||||
}
|
||||
});
|
||||
|
||||
function setVisibilityState(state: "visible" | "hidden") {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: state,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchVisibilityChange() {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
|
||||
it("refetches tasks when visibility changes from hidden to visible", async () => {
|
||||
const initialTask = createMockTask({ id: "FN-001", column: "todo" as Column });
|
||||
const refreshedTask = createMockTask({
|
||||
id: "FN-001",
|
||||
column: "in-progress" as Column,
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Reset mock to return refreshed data
|
||||
mockFetchTasks.mockResolvedValueOnce([refreshedTask]);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
setVisibilityState("hidden");
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("in-progress");
|
||||
});
|
||||
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not refetch when visibility changes to hidden", async () => {
|
||||
const initialTask = createMockTask({ id: "FN-001" });
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
setVisibilityState("visible");
|
||||
setVisibilityState("hidden");
|
||||
dispatchVisibilityChange();
|
||||
|
||||
// Should not trigger another fetch
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
|
||||
const initialTask = createMockTask({ id: "FN-001" });
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Wait for 1 second to ensure debounce window has passed from initial fetch
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
// Reset mock to track new calls
|
||||
mockFetchTasks.mockClear();
|
||||
|
||||
// First visibility change should trigger a fetch (1s has passed)
|
||||
setVisibilityState("hidden");
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Rapid visibility changes immediately after should be debounced
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setVisibilityState("hidden");
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
}
|
||||
|
||||
// Should still only be 1 call (debounced)
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans up visibility change listener on unmount", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
|
||||
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,41 +30,11 @@ export function useTasks() {
|
||||
const tasksRef = useRef(tasks);
|
||||
tasksRef.current = tasks;
|
||||
|
||||
// Ref to track last visibility fetch time for debouncing (1 second minimum)
|
||||
const lastVisibilityFetchRef = useRef<number>(0);
|
||||
const VISIBILITY_FETCH_DEBOUNCE_MS = 1000;
|
||||
|
||||
// Fetch initial tasks
|
||||
useEffect(() => {
|
||||
api.fetchTasks().then((tasks) => setTasks(tasks.map(normalizeTask))).catch(() => setTasks([]));
|
||||
}, []);
|
||||
|
||||
// Visibility change listener - refresh tasks when tab becomes visible
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastVisibilityFetchRef.current;
|
||||
|
||||
// Debounce: only fetch if at least 1 second has passed since last visibility fetch
|
||||
if (timeSinceLastFetch >= VISIBILITY_FETCH_DEBOUNCE_MS) {
|
||||
lastVisibilityFetchRef.current = now;
|
||||
api.fetchTasks()
|
||||
.then((tasks) => setTasks(tasks.map(normalizeTask)))
|
||||
.catch(() => {
|
||||
// Silently ignore fetch errors on visibility change
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// SSE live updates
|
||||
useEffect(() => {
|
||||
let closedByCleanup = false;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
try {
|
||||
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'dark';
|
||||
var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'default';
|
||||
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'solarized', 'factory', 'ayu', 'one-dark'];
|
||||
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'monochrome', 'high-contrast', 'solarized', 'factory', 'ayu', 'one-dark'];
|
||||
if (!validThemes.includes(colorTheme)) {
|
||||
colorTheme = 'default';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -4798,11 +4797,6 @@ body {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* Set width on ID column header to match data cells */
|
||||
.list-table th:nth-child(2).list-header-cell {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.list-header-cell:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -4864,7 +4858,6 @@ body {
|
||||
}
|
||||
|
||||
.list-cell-id {
|
||||
width: 70px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -4874,6 +4867,7 @@ body {
|
||||
}
|
||||
|
||||
.list-cell-title {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -5133,9 +5127,7 @@ body {
|
||||
}
|
||||
|
||||
.list-cell-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.list-cell-date {
|
||||
@@ -10562,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 */
|
||||
@@ -11662,7 +11651,7 @@ html .column.drag-over * {
|
||||
.gm-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -11699,7 +11688,7 @@ html .column.drag-over * {
|
||||
}
|
||||
|
||||
.gm-content {
|
||||
min-height: 200px;
|
||||
min-height: 300px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -11812,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user