feat(HAI-013): add markdown rendering styles and TaskDetailModal tests

- Add markdown-body base styles in dashboard styles.css
- Remove backdrop and enable scroll on task detail prompt rendering
- Add TaskDetailModal unit tests with vitest jsdom environment
- Update vitest config with globals and jsdom support
- Clean up unused test files and legacy concurrency module
This commit is contained in:
Dustin Byrne
2026-03-25 21:44:48 -04:00
6 changed files with 125 additions and 23 deletions

View File

@@ -117,7 +117,7 @@ export function TaskDetailModal({
<div className="detail-section">
<h4>PROMPT.md</h4>
{task.prompt ? (
<div className="detail-prompt markdown-body">
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{task.prompt}
</ReactMarkdown>

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TaskDetailModal } from "../TaskDetailModal";
import type { TaskDetail, Column, MergeResult, Task } from "@hai/core";
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "HAI-099",
description: "Test task",
column: "in-progress" as Column,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
const noop = vi.fn();
const noopMove = vi.fn(async () => ({}) as Task);
const noopDelete = vi.fn(async () => ({}) as Task);
const noopMerge = vi.fn(async () => ({ merged: false }) as MergeResult);
describe("TaskDetailModal", () => {
it("renders markdown-body without detail-prompt class when prompt exists", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nSome **bold** text" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
const markdownDiv = container.querySelector(".markdown-body");
expect(markdownDiv).toBeTruthy();
expect(markdownDiv!.classList.contains("detail-prompt")).toBe(false);
});
it("renders ReactMarkdown output (heading and bold text)", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nSome **bold** text" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(container.querySelector("h1")?.textContent).toBe("Hello");
expect(container.querySelector("strong")?.textContent).toBe("bold");
});
it("renders (no prompt) with detail-prompt class when prompt is absent", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
const fallback = screen.getByText("(no prompt)");
expect(fallback).toBeTruthy();
expect(fallback.classList.contains("detail-prompt")).toBe(true);
expect(fallback.classList.contains("markdown-body")).toBe(false);
});
});

View File

@@ -432,9 +432,6 @@ html, body {
}
.detail-prompt {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
font-size: 13px;
line-height: 1.6;
@@ -442,17 +439,16 @@ html, body {
white-space: pre-wrap;
word-break: break-word;
color: var(--text-muted);
max-height: 320px;
overflow-y: auto;
}
.detail-prompt.markdown-body {
font-family: inherit;
white-space: normal;
color: var(--text);
}
/* === Markdown Prose === */
.markdown-body {
font-size: 13px;
line-height: 1.6;
color: var(--text);
word-break: break-word;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,

View File

@@ -340,9 +340,6 @@ html, body {
}
.detail-prompt {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
font-size: 13px;
line-height: 1.6;
@@ -350,8 +347,6 @@ html, body {
white-space: pre-wrap;
word-break: break-word;
color: var(--text-muted);
max-height: 320px;
overflow-y: auto;
}
.detail-deps { margin-top: 16px; }

View File

@@ -11,6 +11,7 @@ export default defineConfig({
},
test: {
environment: "jsdom",
globals: true,
include: ["app/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
},
});

View File

@@ -147,14 +147,17 @@ export class TaskExecutor {
return;
}
// Create worktree
// Create or reuse worktree
const branchName = `hai/${task.id.toLowerCase()}`;
const worktreePath = join(this.rootDir, ".worktrees", task.id);
const worktreePath = task.worktree || join(this.rootDir, ".worktrees", task.id);
const isResume = existsSync(worktreePath);
this.createWorktree(branchName, worktreePath);
this.activeWorktrees.set(task.id, worktreePath);
await this.store.updateTask(task.id, { worktree: worktreePath });
await this.store.logEntry(task.id, `Worktree created at ${worktreePath}`);
if (!isResume) {
await this.store.updateTask(task.id, { worktree: worktreePath });
await this.store.logEntry(task.id, `Worktree created at ${worktreePath}`);
}
this.options.onStart?.(task, worktreePath);
@@ -422,6 +425,34 @@ function buildExecutionPrompt(task: TaskDetail): string {
const reviewMatch = task.prompt.match(/##\s*Review Level[:\s]*(\d)/);
const reviewLevel = reviewMatch ? parseInt(reviewMatch[1], 10) : 0;
// Build step progress for resume
const hasProgress = task.steps.length > 0 && task.steps.some((s) => s.status !== "pending");
let progressSection = "";
if (hasProgress) {
const doneSteps = task.steps
.map((s, i) => ({ ...s, index: i }))
.filter((s) => s.status === "done");
const currentStep = task.currentStep;
const currentStepInfo = task.steps[currentStep];
progressSection = `
## ⚠️ RESUMING — Previous progress exists
This task was already partially executed. DO NOT redo completed steps.
### Step status:
${task.steps.map((s, i) => `- Step ${i} (${s.name}): **${s.status}**`).join("\n")}
### Resume from: Step ${currentStep}${currentStepInfo ? ` (${currentStepInfo.name})` : ""}
${doneSteps.length > 0 ? `Steps ${doneSteps.map((s) => s.index).join(", ")} are already complete — skip them entirely.` : ""}
Check the git log to understand what was already implemented:
\`\`\`bash
git log --oneline
\`\`\`
`;
}
return `Execute this task.
## Task: ${task.id}
@@ -431,7 +462,7 @@ ${task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}`
## PROMPT.md
${task.prompt}
${progressSection}
## Review level: ${reviewLevel}
${reviewLevel === 0 ? "No reviews required. Implement directly." : ""}
@@ -443,7 +474,9 @@ ${reviewLevel >= 3 ? `After tests, also call review_step with type="code" for te
## Begin
Start with Step 0 (Preflight). Work through each step in order.
${hasProgress
? `Resume from Step ${task.currentStep}. Do NOT redo completed steps.`
: "Start with Step 0 (Preflight). Work through each step in order."}
Use \`task_update\` to report progress on every step transition.
Use \`task_log\` for important actions and decisions.
Use \`task_create\` if you find out-of-scope work that needs doing.