feat(KB-086): complete Step 4 — add activity log API endpoints
This commit is contained in:
@@ -11,7 +11,6 @@ import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { SteeringTab } from "./SteeringTab";
|
||||
import { ModelSelectorTab } from "./ModelSelectorTab";
|
||||
import { PrSection } from "./PrSection";
|
||||
import { SpecEditor } from "./SpecEditor";
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
@@ -104,7 +103,7 @@ export function TaskDetailModal({
|
||||
addToast,
|
||||
githubTokenConfigured,
|
||||
}: TaskDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "model" | "spec">("definition");
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "model">("definition");
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
@@ -112,6 +111,9 @@ export function TaskDetailModal({
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
const [isSavingSpec, setIsSavingSpec] = useState(false);
|
||||
const [isRequestingRevision, setIsRequestingRevision] = useState(false);
|
||||
const [isEditingSpec, setIsEditingSpec] = useState(false);
|
||||
const [specEditContent, setSpecEditContent] = useState(task.prompt || "");
|
||||
const [specFeedback, setSpecFeedback] = useState("");
|
||||
const [showRefineModal, setShowRefineModal] = useState(false);
|
||||
const [refineFeedback, setRefineFeedback] = useState("");
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
@@ -137,6 +139,13 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [showDepDropdown]);
|
||||
|
||||
// Reset spec edit state when task changes
|
||||
useEffect(() => {
|
||||
setIsEditingSpec(false);
|
||||
setSpecEditContent(task.prompt || "");
|
||||
setSpecFeedback("");
|
||||
}, [task.id, task.prompt]);
|
||||
|
||||
// Auto-focus title when entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
@@ -463,6 +472,7 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [onOpenDetail, addToast]);
|
||||
|
||||
// Spec save handlers (must be declared before functions that use them)
|
||||
const handleSaveSpec = useCallback(async (newContent: string) => {
|
||||
setIsSavingSpec(true);
|
||||
try {
|
||||
@@ -496,6 +506,44 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [task.id, addToast, onClose]);
|
||||
|
||||
// Spec editing handlers (depend on handleSaveSpec and handleRequestSpecRevision)
|
||||
const enterSpecEditMode = useCallback(() => {
|
||||
setIsEditingSpec(true);
|
||||
setSpecEditContent(task.prompt || "");
|
||||
setSpecFeedback("");
|
||||
}, [task.prompt]);
|
||||
|
||||
const exitSpecEditMode = useCallback(() => {
|
||||
setIsEditingSpec(false);
|
||||
setSpecEditContent(task.prompt || "");
|
||||
setSpecFeedback("");
|
||||
}, [task.prompt]);
|
||||
|
||||
const handleSaveSpecFromEdit = useCallback(async () => {
|
||||
if (specEditContent === (task.prompt || "")) {
|
||||
exitSpecEditMode();
|
||||
return;
|
||||
}
|
||||
await handleSaveSpec(specEditContent);
|
||||
setIsEditingSpec(false);
|
||||
}, [specEditContent, task.prompt, handleSaveSpec, exitSpecEditMode]);
|
||||
|
||||
const handleRequestRevisionFromEdit = useCallback(async () => {
|
||||
if (!specFeedback.trim()) return;
|
||||
await handleRequestSpecRevision(specFeedback.trim());
|
||||
}, [specFeedback, handleRequestSpecRevision]);
|
||||
|
||||
// Keyboard shortcuts for spec edit mode
|
||||
const handleSpecTextareaKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
exitSpecEditMode();
|
||||
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
void handleSaveSpecFromEdit();
|
||||
}
|
||||
}, [exitSpecEditMode, handleSaveSpecFromEdit]);
|
||||
|
||||
const availableTasks = tasks
|
||||
.filter((t) => t.id !== task.id && !dependencies.includes(t.id))
|
||||
.sort((a, b) => {
|
||||
@@ -633,24 +681,8 @@ export function TaskDetailModal({
|
||||
>
|
||||
Model
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "spec" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("spec")}
|
||||
>
|
||||
Spec
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === "spec" ? (
|
||||
<div className="detail-section detail-section--spec">
|
||||
<SpecEditor
|
||||
content={task.prompt || ""}
|
||||
onSave={handleSaveSpec}
|
||||
onRequestRevision={handleRequestSpecRevision}
|
||||
isSaving={isSavingSpec}
|
||||
isRequesting={isRequestingRevision}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "model" ? (
|
||||
{activeTab === "model" ? (
|
||||
<div className="detail-section">
|
||||
<ModelSelectorTab task={task} addToast={addToast} />
|
||||
</div>
|
||||
@@ -713,7 +745,66 @@ export function TaskDetailModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
{task.prompt ? (
|
||||
{isEditingSpec ? (
|
||||
<div className="spec-editor-edit-mode">
|
||||
<textarea
|
||||
className="spec-editor-textarea"
|
||||
value={specEditContent}
|
||||
onChange={(e) => setSpecEditContent(e.target.value)}
|
||||
onKeyDown={handleSpecTextareaKeyDown}
|
||||
disabled={isSavingSpec}
|
||||
placeholder="Enter task specification in Markdown..."
|
||||
rows={12}
|
||||
/>
|
||||
<div className="spec-editor-actions-row">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={exitSpecEditMode}
|
||||
disabled={isSavingSpec}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleSaveSpecFromEdit()}
|
||||
disabled={specEditContent === (task.prompt || "") || isSavingSpec}
|
||||
>
|
||||
{isSavingSpec ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="spec-editor-hint">
|
||||
<kbd>Ctrl</kbd>+<kbd>Enter</kbd> to save · <kbd>Escape</kbd> to cancel
|
||||
</div>
|
||||
{/* AI Revision Section */}
|
||||
<div className="spec-editor-revision">
|
||||
<h4>Ask AI to Revise</h4>
|
||||
<p className="spec-editor-revision-help">
|
||||
Provide feedback for the AI to improve this specification. The task will move to triage for re-specification.
|
||||
</p>
|
||||
<textarea
|
||||
className="spec-editor-feedback"
|
||||
value={specFeedback}
|
||||
onChange={(e) => setSpecFeedback(e.target.value)}
|
||||
placeholder="e.g., 'Add more details about error handling', 'Split this into smaller steps', 'Include tests for the API endpoints'..."
|
||||
disabled={isRequestingRevision}
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<div className="spec-editor-revision-actions">
|
||||
<span className="spec-editor-char-count">
|
||||
{specFeedback.length}/2000
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleRequestRevisionFromEdit()}
|
||||
disabled={!specFeedback.trim() || isRequestingRevision}
|
||||
>
|
||||
{isRequestingRevision ? "Requesting…" : "Request AI Revision"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : task.prompt ? (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{task.prompt.replace(/^#\s+[^\n]*\n+/, "")}
|
||||
@@ -722,6 +813,13 @@ export function TaskDetailModal({
|
||||
) : (
|
||||
<div className="detail-prompt">(no prompt)</div>
|
||||
)}
|
||||
{!isEditingSpec && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<button className="btn btn-sm" onClick={enterSpecEditMode}>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<h4>Attachments</h4>
|
||||
|
||||
@@ -7542,6 +7542,22 @@ html .column.drag-over * {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Actions row for Save/Cancel in Definition tab edit mode */
|
||||
.spec-editor-actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Edit mode container in Definition tab */
|
||||
.spec-editor-edit-mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Content area - flexible and scrollable */
|
||||
.spec-editor-content {
|
||||
flex: 1;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response, type NextFunction } from "express"
|
||||
import multer from "multer";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType } from "@kb/core";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType } from "@kb/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
||||
@@ -3334,6 +3334,65 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Activity Log Routes ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/activity
|
||||
* Get activity log entries.
|
||||
* Query params: limit (default 100, max 1000), since (ISO timestamp), type (event type filter)
|
||||
* Returns: ActivityLogEntry[] sorted newest first
|
||||
*/
|
||||
router.get("/activity", async (req, res) => {
|
||||
try {
|
||||
const limitParam = req.query.limit;
|
||||
const sinceParam = req.query.since;
|
||||
const typeParam = req.query.type;
|
||||
|
||||
// Parse and validate limit
|
||||
let limit: number | undefined;
|
||||
if (limitParam !== undefined) {
|
||||
const parsed = Number.parseInt(limitParam as string, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
res.status(400).json({ error: "limit must be a non-negative integer" });
|
||||
return;
|
||||
}
|
||||
limit = Math.min(parsed, 1000); // Max 1000
|
||||
}
|
||||
|
||||
// Validate type if provided
|
||||
const validTypes = ["task:created", "task:moved", "task:updated", "task:deleted", "task:merged", "task:failed", "settings:updated"];
|
||||
if (typeParam !== undefined && !validTypes.includes(typeParam as string)) {
|
||||
res.status(400).json({ error: `Invalid type. Must be one of: ${validTypes.join(", ")}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const options: { limit?: number; since?: string; type?: ActivityEventType } = {
|
||||
limit,
|
||||
since: sinceParam as string | undefined,
|
||||
type: typeParam as ActivityEventType | undefined,
|
||||
};
|
||||
|
||||
const entries = await store.getActivityLog(options);
|
||||
res.json(entries);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/activity
|
||||
* Clear all activity log entries (maintenance endpoint).
|
||||
* Returns: { success: true }
|
||||
*/
|
||||
router.delete("/activity", async (_req, res) => {
|
||||
try {
|
||||
await store.clearActivityLog();
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user