Files
kandev/apps/web/app/office/workspace/activity/activity-row.tsx
T

200 lines
7.0 KiB
TypeScript

"use client";
import Link from "@/components/routing/app-link";
import type { ActivityEntry } from "@/lib/state/slices/office/types";
import { timeAgo } from "@/lib/utils/time";
import type { OfficeTaskStatus } from "@/lib/state/slices/office/types";
import { STATUS_LABEL_KEYS } from "../../lib/label-keys";
import { useTranslation } from "react-i18next";
// Module-level `t`, resolved at call time: `renderAction` and `actorLabel` are
// plain helpers called during the row's render, not components, so there is no
// hook to bind. The row re-renders on `languageChanged` through the
// `useTranslation()` below.
import { t } from "@/lib/i18n";
// Catalog keys, not copy — module scope freezes a `t()` at the boot locale. The
// record keys are the wire cancellation reasons.
const CANCEL_REASON_LABEL_KEYS: Record<string, string> = {
assignee_changed: "office:cancelReasonAssigneeChanged",
task_terminal: "office:cancelReasonTaskTerminal",
task_not_found: "office:cancelReasonTaskNotFound",
review_participant_changed: "office:cancelReasonReviewParticipantChanged",
};
const MAX_DESCRIPTION_LENGTH = 80;
function actorInitial(actorType: string, actorId: string, actorName?: string): string {
if (actorType === "system") return "SY";
if (actorType === "agent") {
const trimmed = (actorName || actorId).trim();
return trimmed.slice(0, 2).toUpperCase() || "AG";
}
return "U";
}
function actorLabel(entry: ActivityEntry): string {
if (entry.actorType === "system") return t("office:system");
// Historical rows may have no resolved label, so retain the identifier.
return entry.actorName || entry.actorId || entry.actorType;
}
function taskIdentifier(details: Record<string, unknown> | undefined): string | null {
const id = details?.task_identifier;
if (typeof id === "string" && id) return id;
return null;
}
function taskRefNode(
entry: ActivityEntry,
details: Record<string, unknown> | undefined,
): React.ReactNode {
const id = details?.task_id ?? entry.targetId;
const identifier = entry.targetIdentifier ?? taskIdentifier(details);
if (!id && !identifier) return null;
const fallback = identifier || (typeof id === "string" ? id : "");
let label = fallback;
if (entry.targetName) {
label = identifier ? `${identifier} ${entry.targetName}` : entry.targetName;
}
return <span className="font-bold"> {label}</span>;
}
function truncate(text: string): string {
if (text.length <= MAX_DESCRIPTION_LENGTH) return text;
return `${text.slice(0, MAX_DESCRIPTION_LENGTH)}…`;
}
/** `.replace(/_/g, " ")` keeps an unmapped wire reason visible rather than blank. */
function cancelReasonLabel(reason: string): string {
if (!reason) return "";
const key = CANCEL_REASON_LABEL_KEYS[reason];
return key ? t(key) : reason.replace(/_/g, " ");
}
/** Same fallback rule for a task status that is not in the shared label map. */
function taskStatusLabel(raw: string): string {
if (!raw) return "";
const key = STATUS_LABEL_KEYS[raw as OfficeTaskStatus];
return key ? t(key) : raw.replace(/_/g, " ");
}
function renderStaleCancellation(entry: ActivityEntry): React.ReactNode {
const d = entry.details;
const label = cancelReasonLabel(typeof d?.reason === "string" ? d.reason : "");
return (
<>
<span className="text-muted-foreground"> {t("office:staleRunCancelled")}</span>
{taskRefNode(entry, d)}
{label && <span className="text-muted-foreground"> - {truncate(label)}</span>}
</>
);
}
function renderReassignmentCancellation(entry: ActivityEntry): React.ReactNode {
return (
<>
<span className="text-muted-foreground"> {t("office:retryCancelledReassigned")}</span>
{taskRefNode(entry, entry.details)}
</>
);
}
function renderRecoveryDispatch(entry: ActivityEntry): React.ReactNode {
return (
<>
<span className="text-muted-foreground"> {t("office:unstartedTaskRecovered")}</span>
{taskRefNode(entry, entry.details)}
</>
);
}
function renderTaskStatusChange(entry: ActivityEntry): React.ReactNode {
const d = entry.details;
// One key for the whole clause. The raw status resolves through the shared
// label map without freezing the English word order.
const status = taskStatusLabel(typeof d?.new_status === "string" ? d.new_status : "");
return (
<>
<span className="text-muted-foreground">
{" "}
{status ? t("office:activityStatusChangedTo", { status }) : t("office:statusChanged")}
</span>
{taskRefNode(entry, d)}
</>
);
}
const SPECIAL_ACTION_RENDERERS: Record<string, (entry: ActivityEntry) => React.ReactNode> = {
run_stale_cancelled: renderStaleCancellation,
run_retry_cancelled: renderReassignmentCancellation,
recovery_dispatch: renderRecoveryDispatch,
task_status_changed: renderTaskStatusChange,
};
function renderFallbackAction(entry: ActivityEntry): React.ReactNode {
// NOT localized deliberately: these are open-ended backend identifiers.
const formatted = truncate(entry.action.replace(/[._]/g, " "));
return (
<>
<span className="text-muted-foreground"> {formatted} </span>
{entry.targetType && (
<span className="font-medium">
{entry.targetName || entry.targetType}
{entry.targetIdentifier || entry.targetId
? ` ${entry.targetIdentifier || entry.targetId}`
: ""}
</span>
)}
</>
);
}
function renderAction(entry: ActivityEntry): React.ReactNode {
const renderer = SPECIAL_ACTION_RENDERERS[entry.action];
if (renderer) return renderer(entry);
return renderFallbackAction(entry);
}
function runHref(entry: ActivityEntry): string | null {
if (!entry.runId) return null;
const agentID = resolveAgentId(entry);
if (!agentID) return null;
return `/office/agents/${encodeURIComponent(agentID)}/runs/${encodeURIComponent(entry.runId)}`;
}
function resolveAgentId(entry: ActivityEntry): string | null {
if (entry.actorType === "agent" && entry.actorId) return entry.actorId;
const fallback = entry.details?.agent_id;
return typeof fallback === "string" && fallback ? fallback : null;
}
type Props = {
entry: ActivityEntry;
};
export function ActivityRow({ entry }: Props) {
const { t } = useTranslation();
const href = runHref(entry);
return (
<div className="flex items-start gap-3 px-4 py-2.5 text-sm hover:bg-accent/50 transition-colors">
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-medium uppercase text-muted-foreground">
{actorInitial(entry.actorType, entry.actorId, entry.actorName)}
</div>
<div className="flex-1 min-w-0 truncate">
<span className="font-medium">{actorLabel(entry)}</span>
{renderAction(entry)}
</div>
{href && (
<Link
href={href}
className="text-xs text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
>
{t("office:run")}
</Link>
)}
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(entry.createdAt)}</span>
</div>
);
}