fix(ui): resume lost runs, activity feed fixes, and selector focus

Add resume button for process_lost runs on agent detail page. Fix
activity row text overflow with truncation. Pass entityTitleMap to
Dashboard activity feed. Fix InlineEntitySelector stealing focus on
close when advancing to next field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-26 10:32:51 -06:00
parent e4e5609132
commit 20176d9d60
4 changed files with 74 additions and 1 deletions

View File

@@ -108,7 +108,7 @@ export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, cl
const inner = (
<div className="flex gap-3">
<p className="flex-1 min-w-0">
<p className="flex-1 min-w-0 truncate">
<Identity
name={actorName}
size="xs"

View File

@@ -44,6 +44,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const shouldPreventCloseAutoFocusRef = useRef(false);
const allOptions = useMemo<InlineEntityOption[]>(
() => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options],
@@ -70,6 +71,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
const commitSelection = (index: number, moveNext: boolean) => {
const option = filteredOptions[index] ?? filteredOptions[0];
if (option) onChange(option.id);
shouldPreventCloseAutoFocusRef.current = moveNext;
setOpen(false);
setQuery("");
if (moveNext && onConfirm) {
@@ -109,6 +111,11 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
event.preventDefault();
inputRef.current?.focus();
}}
onCloseAutoFocus={(event) => {
if (!shouldPreventCloseAutoFocusRef.current) return;
event.preventDefault();
shouldPreventCloseAutoFocusRef.current = false;
}}
>
<input
ref={inputRef}

View File

@@ -215,6 +215,12 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function asNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function AgentDetail() {
const { agentId, tab: urlTab, runId: urlRunId } = useParams<{ agentId: string; tab?: string; runId?: string }>();
const { selectedCompanyId } = useCompany();
@@ -1509,6 +1515,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const metrics = runMetrics(run);
const [sessionOpen, setSessionOpen] = useState(false);
const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
@@ -1523,6 +1530,41 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
},
});
const canResumeLostRun = run.errorCode === "process_lost" && run.status === "failed";
const resumePayload = useMemo(() => {
const payload: Record<string, unknown> = {
resumeFromRunId: run.id,
};
const context = asRecord(run.contextSnapshot);
if (!context) return payload;
const issueId = asNonEmptyString(context.issueId);
const taskId = asNonEmptyString(context.taskId);
const taskKey = asNonEmptyString(context.taskKey);
const commentId = asNonEmptyString(context.wakeCommentId) ?? asNonEmptyString(context.commentId);
if (issueId) payload.issueId = issueId;
if (taskId) payload.taskId = taskId;
if (taskKey) payload.taskKey = taskKey;
if (commentId) payload.commentId = commentId;
return payload;
}, [run.contextSnapshot, run.id]);
const resumeRun = useMutation({
mutationFn: async () => {
const result = await agentsApi.wakeup(run.agentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "resume_process_lost_run",
payload: resumePayload,
});
if (!("id" in result)) {
throw new Error("Resume request was skipped because the agent is not currently invokable.");
}
return result;
},
onSuccess: (resumedRun) => {
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
navigate(`/agents/${run.agentId}/runs/${resumedRun.id}`);
},
});
const { data: touchedIssues } = useQuery({
queryKey: queryKeys.runIssues(run.id),
@@ -1602,7 +1644,24 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{cancelRun.isPending ? "Cancelling..." : "Cancel"}
</Button>
)}
{canResumeLostRun && (
<Button
variant="ghost"
size="sm"
className="text-xs h-6 px-2"
onClick={() => resumeRun.mutate()}
disabled={resumeRun.isPending}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
{resumeRun.isPending ? "Resuming..." : "Resume"}
</Button>
)}
</div>
{resumeRun.isError && (
<div className="text-xs text-destructive">
{resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"}
</div>
)}
{startTime && (
<div className="space-y-0.5">
<div className="text-sm font-mono">

View File

@@ -142,6 +142,12 @@ export function Dashboard() {
return map;
}, [issues, agents, projects]);
const entityTitleMap = useMemo(() => {
const map = new Map<string, string>();
for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title);
return map;
}, [issues]);
const agentName = (id: string | null) => {
if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null;
@@ -240,6 +246,7 @@ export function Dashboard() {
event={event}
agentMap={agentMap}
entityNameMap={entityNameMap}
entityTitleMap={entityTitleMap}
className={animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined}
/>
))}