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:
parent
e4e5609132
commit
20176d9d60
4 changed files with 74 additions and 1 deletions
|
|
@ -108,7 +108,7 @@ export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, cl
|
||||||
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<p className="flex-1 min-w-0">
|
<p className="flex-1 min-w-0 truncate">
|
||||||
<Identity
|
<Identity
|
||||||
name={actorName}
|
name={actorName}
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const shouldPreventCloseAutoFocusRef = useRef(false);
|
||||||
|
|
||||||
const allOptions = useMemo<InlineEntityOption[]>(
|
const allOptions = useMemo<InlineEntityOption[]>(
|
||||||
() => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options],
|
() => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options],
|
||||||
|
|
@ -70,6 +71,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||||
const commitSelection = (index: number, moveNext: boolean) => {
|
const commitSelection = (index: number, moveNext: boolean) => {
|
||||||
const option = filteredOptions[index] ?? filteredOptions[0];
|
const option = filteredOptions[index] ?? filteredOptions[0];
|
||||||
if (option) onChange(option.id);
|
if (option) onChange(option.id);
|
||||||
|
shouldPreventCloseAutoFocusRef.current = moveNext;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
if (moveNext && onConfirm) {
|
if (moveNext && onConfirm) {
|
||||||
|
|
@ -109,6 +111,11 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!shouldPreventCloseAutoFocusRef.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
shouldPreventCloseAutoFocusRef.current = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,12 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
return value as Record<string, unknown>;
|
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() {
|
export function AgentDetail() {
|
||||||
const { agentId, tab: urlTab, runId: urlRunId } = useParams<{ agentId: string; tab?: string; runId?: string }>();
|
const { agentId, tab: urlTab, runId: urlRunId } = useParams<{ agentId: string; tab?: string; runId?: string }>();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
|
|
@ -1509,6 +1515,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
|
||||||
|
|
||||||
function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
|
function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
const metrics = runMetrics(run);
|
const metrics = runMetrics(run);
|
||||||
const [sessionOpen, setSessionOpen] = useState(false);
|
const [sessionOpen, setSessionOpen] = useState(false);
|
||||||
const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
|
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) });
|
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({
|
const { data: touchedIssues } = useQuery({
|
||||||
queryKey: queryKeys.runIssues(run.id),
|
queryKey: queryKeys.runIssues(run.id),
|
||||||
|
|
@ -1602,7 +1644,24 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
||||||
{cancelRun.isPending ? "Cancelling..." : "Cancel"}
|
{cancelRun.isPending ? "Cancelling..." : "Cancel"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
{resumeRun.isError && (
|
||||||
|
<div className="text-xs text-destructive">
|
||||||
|
{resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{startTime && (
|
{startTime && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="text-sm font-mono">
|
<div className="text-sm font-mono">
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,12 @@ export function Dashboard() {
|
||||||
return map;
|
return map;
|
||||||
}, [issues, agents, projects]);
|
}, [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) => {
|
const agentName = (id: string | null) => {
|
||||||
if (!id || !agents) return null;
|
if (!id || !agents) return null;
|
||||||
return agents.find((a) => a.id === id)?.name ?? null;
|
return agents.find((a) => a.id === id)?.name ?? null;
|
||||||
|
|
@ -240,6 +246,7 @@ export function Dashboard() {
|
||||||
event={event}
|
event={event}
|
||||||
agentMap={agentMap}
|
agentMap={agentMap}
|
||||||
entityNameMap={entityNameMap}
|
entityNameMap={entityNameMap}
|
||||||
|
entityTitleMap={entityTitleMap}
|
||||||
className={animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined}
|
className={animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue