Files
fusion/packages/dashboard/app/components/TaskComments.tsx
gsxdsm 3fbb7c47cf refactor: eliminate ~400 no-explicit-any warnings across the workspace
Parallel subagent pass: four typescript-pro agents on non-overlapping scopes.

Patterns applied:
- catch (err: any) { ... err.message ... } → catch (err) { ... getErrorMessage(err) ... }
  using the new @fusion/core helper. Bare catch {} where the error was unused.
- SQLite row types: defined typed XxxRow interfaces per table and cast
  .all()/.get() results via `as unknown as XxxRow[]` (the double cast is
  required because better-sqlite3 returns Record<string, SQLOutputValue>).
- rowToX(row: any) converters: typed argument with the matching row interface.
- Dynamic settings key writes: (settings as Record<string, unknown>)[key].
- React event handlers and setState callbacks: inferred types or concrete
  React.{Mouse,Change,Form}Event<...> where needed.
- pi-claude-cli: local PiMessage / PiContext duck types to avoid re-typing
  pi-ai concrete shapes; typed Claude stream event message fields.

72 files changed, ~400 anys eliminated. Typecheck passes across the workspace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:02:36 -07:00

204 lines
7.2 KiB
TypeScript

import { useMemo, useState } from "react";
import type { Task, TaskComment } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import { addSteeringComment, updateTaskComment, deleteTaskComment } from "../api";
import type { ToastType } from "../hooks/useToast";
const MAX_COMMENT_LENGTH = 2000;
interface TaskCommentsProps {
task: Task;
onTaskUpdated?: (task: Task) => void;
addToast: (message: string, type?: ToastType) => void;
currentAuthor?: string;
projectId?: string;
}
function formatCommentTimestamp(comment: TaskComment): string {
const timestamp = comment.updatedAt || comment.createdAt;
const label = new Date(timestamp).toLocaleString();
return comment.updatedAt ? `${label} (edited)` : label;
}
function isAIGuidanceComment(author: string): boolean {
return author === "agent" || author === "system";
}
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user", projectId }: TaskCommentsProps) {
const [draft, setDraft] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Sort comments by createdAt descending (newest first)
const comments = useMemo(() => {
return [...(task.comments || [])].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}, [task.comments]);
const isOverLimit = draft.length > MAX_COMMENT_LENGTH;
async function handleAddComment() {
const text = draft.trim();
if (!text) return;
setSubmitting(true);
try {
const updated = await addSteeringComment(task.id, text, projectId);
setDraft("");
onTaskUpdated?.(updated);
addToast("Comment added", "success");
} catch (error) {
addToast(getErrorMessage(error) || "Failed to add comment", "error");
} finally {
setSubmitting(false);
}
}
async function handleSaveEdit(commentId: string) {
const text = editingText.trim();
if (!text) return;
setSubmitting(true);
try {
const updated = await updateTaskComment(task.id, commentId, text, projectId);
setEditingId(null);
setEditingText("");
onTaskUpdated?.(updated);
addToast("Comment updated", "success");
} catch (error) {
addToast(getErrorMessage(error) || "Failed to update comment", "error");
} finally {
setSubmitting(false);
}
}
async function handleDelete(commentId: string) {
setDeletingId(commentId);
try {
const updated = await deleteTaskComment(task.id, commentId, projectId);
onTaskUpdated?.(updated);
addToast("Comment deleted", "success");
} catch (error) {
addToast(getErrorMessage(error) || "Failed to delete comment", "error");
} finally {
setDeletingId(null);
}
}
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
void handleAddComment();
}
}
const placeholder = "Add a comment";
const buttonLabel = "Add Comment";
return (
<div className="detail-section">
<h4>Comments</h4>
{comments.length === 0 ? (
<div className="detail-log-empty">No comments yet.</div>
) : (
<div className="detail-activity-list">
{comments.map((comment) => {
const canEdit = comment.author === currentAuthor;
const isEditing = editingId === comment.id;
const isAIGuidance = isAIGuidanceComment(comment.author);
return (
<div key={comment.id} className="detail-log-entry">
<div className="detail-log-header comments-header-row">
<div className="comments-author-row">
{isAIGuidance ? (
<span className="ai-guidance-badge" data-testid="ai-guidance-badge">AI Guidance</span>
) : (
<strong>{comment.author}</strong>
)}
<span className="detail-log-timestamp">
{formatCommentTimestamp(comment)}
</span>
</div>
{canEdit && !isEditing ? (
<div className="comments-actions-row">
<button className="btn btn-sm" onClick={() => {
setEditingId(comment.id);
setEditingText(comment.text);
}}>
Edit
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => void handleDelete(comment.id)}
disabled={deletingId === comment.id}
>
{deletingId === comment.id ? "Deleting…" : "Delete"}
</button>
</div>
) : null}
</div>
{isEditing ? (
<div className="comments-edit-form">
<textarea
value={editingText}
onChange={(event) => setEditingText(event.target.value)}
rows={3}
className="spec-editor-feedback"
/>
<div className="comments-edit-actions">
<button
className="btn btn-sm"
onClick={() => {
setEditingId(null);
setEditingText("");
}}
disabled={submitting}
>
Cancel
</button>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleSaveEdit(comment.id)}
disabled={submitting || !editingText.trim()}
>
Save
</button>
</div>
</div>
) : (
<div className="detail-log-outcome comments-outcome-text">
{comment.text}
</div>
)}
</div>
);
})}
</div>
)}
<div className="comments-compose-form">
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
rows={3}
placeholder={placeholder}
className="spec-editor-feedback"
/>
<div className="comments-footer-row">
<span className={`comments-char-count${isOverLimit ? " comments-char-count--over" : ""}`}>
{draft.length} / {MAX_COMMENT_LENGTH}
</span>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleAddComment()}
disabled={submitting || !draft.trim() || isOverLimit}
>
{submitting ? "Posting…" : buttonLabel}
</button>
</div>
</div>
</div>
);
}