- 51 fix agents covered every dirty batch from the round-1 verifiers: helper-function labels (roles, statuses, relative time), constant label maps (SETTINGS_SECTIONS, PROVIDER_INFO, EVENT_TYPE_LABELS), TUI help overlay + tab labels, toasts, placeholders, aria-labels - Inline markup flattened by round 1 restored with <Trans> (DbCorruptionBanner storage-docs link, UpdateAvailableBanner code chip) - Catalogs merged: +527 en keys across 5 locales; CLI bundles + app locale tree regenerated (6 locales) - All 23 sweep-caused test regressions fixed: delta vs the clean-main baseline is now zero (remaining 4 local failures reproduce identically on origin/main; CI-green upstream) - typecheck/lint clean; TUI 82/82, core locale 9/9 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
208 lines
7.7 KiB
TypeScript
208 lines
7.7 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { TFunction } from "i18next";
|
|
import type { Task, TaskComment } from "@fusion/core";
|
|
import { getErrorMessage } from "@fusion/core";
|
|
import "./TaskComments.css";
|
|
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, t: TFunction<"app">): string {
|
|
const timestamp = comment.updatedAt || comment.createdAt;
|
|
const label = new Date(timestamp).toLocaleString();
|
|
return comment.updatedAt ? `${label} ${t("comments.editedSuffix", "(edited)")}` : label;
|
|
}
|
|
|
|
function isAIGuidanceComment(author: string): boolean {
|
|
return author === "agent" || author === "system";
|
|
}
|
|
|
|
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user", projectId }: TaskCommentsProps) {
|
|
const { t } = useTranslation("app");
|
|
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(t("comments.addedSuccess", "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(t("comments.updatedSuccess", "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(t("comments.deletedSuccess", "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 = t("comments.placeholder", "Add a comment");
|
|
const buttonLabel = t("comments.addButton", "Add Comment");
|
|
|
|
return (
|
|
<div className="detail-section">
|
|
<h4>{t("comments.heading", "Comments")}</h4>
|
|
{comments.length === 0 ? (
|
|
<div className="detail-log-empty">{t("comments.emptyState", "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, t)}
|
|
</span>
|
|
</div>
|
|
{canEdit && !isEditing ? (
|
|
<div className="comments-actions-row">
|
|
<button className="btn btn-sm" onClick={() => {
|
|
setEditingId(comment.id);
|
|
setEditingText(comment.text);
|
|
}}>
|
|
{t("actions.edit", "Edit")}
|
|
</button>
|
|
<button
|
|
className="btn btn-danger btn-sm"
|
|
onClick={() => void handleDelete(comment.id)}
|
|
disabled={deletingId === comment.id}
|
|
>
|
|
{deletingId === comment.id ? t("comments.deletingButton", "Deleting…") : t("actions.delete", "Delete")}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{isEditing ? (
|
|
<div className="comments-edit-form">
|
|
<textarea
|
|
value={editingText}
|
|
onChange={(event) => setEditingText(event.target.value)}
|
|
rows={3}
|
|
className="comments-textarea"
|
|
/>
|
|
<div className="comments-edit-actions">
|
|
<button
|
|
className="btn btn-sm"
|
|
onClick={() => {
|
|
setEditingId(null);
|
|
setEditingText("");
|
|
}}
|
|
disabled={submitting}
|
|
>
|
|
{t("actions.cancel", "Cancel")}
|
|
</button>
|
|
<button
|
|
className="btn btn-primary btn-sm"
|
|
onClick={() => void handleSaveEdit(comment.id)}
|
|
disabled={submitting || !editingText.trim()}
|
|
>
|
|
{t("actions.save", "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="comments-textarea"
|
|
/>
|
|
<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 ? t("comments.postingButton", "Posting…") : buttonLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|