Compare commits

...

6 Commits

Author SHA1 Message Date
Naiyuan Qing
3301a9c9a1 fix(issues): preserve execution log row accessibility 2026-06-01 16:03:49 +08:00
Naiyuan Qing
00a01284a8 fix(issues): simplify execution log row layout 2026-06-01 15:47:41 +08:00
Naiyuan Qing
04e524a4bd fix(issues): keep execution log status off trigger text 2026-06-01 15:42:28 +08:00
Naiyuan Qing
4c3be06ae8 fix(issues): isolate execution log row hover 2026-06-01 15:40:21 +08:00
Naiyuan Qing
17bf8c1787 fix(issues): use fixed execution log trailing slot 2026-06-01 15:33:58 +08:00
Naiyuan Qing
3a04c87724 fix(issues): clean execution log active rows 2026-06-01 15:24:03 +08:00

View File

@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Loader2, RotateCcw, Square } from "lucide-react";
import { Ban, CheckCircle2, ChevronRight, Loader2, RotateCcw, Square, XCircle } from "lucide-react";
import { toast } from "sonner";
import { api } from "@multica/core/api";
import { issueKeys } from "@multica/core/issues/queries";
@@ -19,18 +19,6 @@ import { failureReasonLabel } from "../../agents/components/tabs/task-failure";
import { useT } from "../../i18n";
import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog";
// Mask gradient that fades the trigger-summary text into transparency at
// the right edge. Mirrors the pattern used by the desktop tab bar
// (apps/desktop/.../tab-bar.tsx) and the sidebar pin item
// (packages/views/layout/app-sidebar.tsx) — gives the row a smooth
// visual ramp toward the trailing actions instead of a hard truncate +
// ellipsis cut.
const TRIGGER_MASK_STYLE: React.CSSProperties = {
maskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
WebkitMaskImage:
"linear-gradient(to right, black calc(100% - 12px), transparent)",
};
// Right-panel section that lists every agent run for this issue. Active
// runs sit at the top (always visible when present); past runs (terminal
// statuses) collapse behind a "Show past runs (N)" toggle.
@@ -40,13 +28,12 @@ const TRIGGER_MASK_STYLE: React.CSSProperties = {
// (sticky card stays as a header-only banner)
// - the standalone <TaskRunHistory> below the main content
//
// Row layout — three columns, left to right:
// Row layout — simple left/right flex:
// 1. Agent avatar (no status dot — agent availability is not the
// story here; the row's right column carries the task status)
// 2. Trigger description (e.g. "From comment", "Autopilot", "Retry"),
// truncated with ellipsis when narrow
// 3. Status + relative time, swapped to hover actions (cancel /
// transcript) on hover
// 2. Trigger description flexes and truncates
// 3. Status is a normal shrink-0 right column; hover actions overlay that
// same right edge. Do not use masks/padding gymnastics here.
//
// One query (`listTasksByIssue`) drives both buckets — the back-end
// returns every status, the front-end filters into active vs past on the
@@ -214,22 +201,6 @@ const STATUS_TONE: Record<AgentTask["status"], string> = {
cancelled: "text-muted-foreground",
};
// Time anchor depends on status. Active rows want "Started 2m ago" /
// "Queued 30s ago" — what's happening now. Past rows want "5m ago" — when
// the verdict landed.
function activeTimeText(task: AgentTask, timeAgo: (dateStr: string) => string): string {
if (task.status === "running" && task.started_at) {
return timeAgo(task.started_at);
}
if (
(task.status === "dispatched" || task.status === "waiting_local_directory") &&
task.dispatched_at
) {
return timeAgo(task.dispatched_at);
}
return timeAgo(task.created_at);
}
// ─── Active row ────────────────────────────────────────────────────────────
import { stripMentionMarkdown } from "../utils/strip-mention-markdown";
@@ -270,13 +241,11 @@ function useStatusLabel(status: AgentTask["status"]): string {
function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
const { t } = useT("issues");
const timeAgo = useTimeAgo();
const [cancelling, setCancelling] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const tone = STATUS_TONE[task.status];
const label = useStatusLabel(task.status);
const trigger = useTriggerText(task);
const time = activeTimeText(task, timeAgo);
// Transcript only meaningful once messages exist — pure-queued and
// waiting_local_directory tasks haven't streamed any agent output yet.
@@ -302,12 +271,10 @@ function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
return (
<RowShell task={task}>
<TriggerText text={trigger} />
{/* Status + time always visible — actions append on hover, never
replace. Same pattern as desktop tab bar / sidebar pins. */}
<span className="shrink-0 whitespace-nowrap text-xs">
<span className={tone}>{label}</span>
<span className="text-muted-foreground"> · {time}</span>
</span>
<RowStatus title={label}>
{task.status === "running" && <Loader2 className="h-3 w-3 animate-spin text-info" />}
<span className={`${tone} min-w-0 truncate`}>{label}</span>
</RowStatus>
<RowActions>
{showTranscript && (
<TranscriptButton
@@ -358,7 +325,6 @@ function PastRow({ task, issueId }: { task: AgentTask; issueId: string }) {
const { t } = useT("issues");
const timeAgo = useTimeAgo();
const [retrying, setRetrying] = useState(false);
const tone = STATUS_TONE[task.status];
const label = useStatusLabel(task.status);
const trigger = useTriggerText(task);
const time = task.completed_at ? timeAgo(task.completed_at) : "—";
@@ -392,10 +358,11 @@ function PastRow({ task, issueId }: { task: AgentTask; issueId: string }) {
return (
<RowShell task={task}>
<TriggerText text={trigger} />
<span className="shrink-0 whitespace-nowrap text-xs">
<span className={tone}>{failureLabel ?? label}</span>
<span className="text-muted-foreground"> · {time}</span>
</span>
<RowStatus title={failureLabel ?? label}>
<TaskStatusIcon status={task.status} />
<span className="sr-only">{failureLabel ?? label}</span>
<span className="text-muted-foreground">{time}</span>
</RowStatus>
<RowActions>
<TranscriptButton task={task} agentName="" title={t(($) => $.execution_log.transcript_tooltip)} />
{canRetry && (
@@ -437,7 +404,7 @@ function RowShell({
// `relative` so the absolute-positioned RowActions slot anchors to this
// row instead of an outer container.
return (
<div className="group relative flex items-center gap-2 rounded px-1 py-1.5 transition-colors hover:bg-accent/40">
<div className="group/execution-log-row relative flex items-center gap-2 overflow-hidden rounded px-1 py-1.5 transition-colors hover:bg-accent/40">
{task.agent_id ? (
<ActorAvatar
actorType="agent"
@@ -453,38 +420,53 @@ function RowShell({
);
}
// Trigger description with a mask-gradient right edge — text fades into
// transparency in the trailing 12px for the same reason desktop tab /
// sidebar pin do it: avoids a hard truncate cut against neighbouring
// content.
function TriggerText({ text }: { text: string }) {
return <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{text}</span>;
}
function RowStatus({
children,
title,
}: {
children: React.ReactNode;
title?: string;
}) {
return (
<span
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-xs text-muted-foreground"
style={TRIGGER_MASK_STYLE}
<div
title={title}
className="flex h-7 w-20 shrink-0 items-center justify-end gap-1 overflow-hidden whitespace-nowrap text-xs transition-opacity group-hover/execution-log-row:opacity-0 group-focus-within/execution-log-row:opacity-0"
>
{text}
</span>
{children}
</div>
);
}
function TaskStatusIcon({ status }: { status: AgentTask["status"] }) {
switch (status) {
case "completed":
return <CheckCircle2 aria-hidden="true" className="h-3.5 w-3.5 text-success" />;
case "failed":
return <XCircle aria-hidden="true" className="h-3.5 w-3.5 text-destructive" />;
case "cancelled":
return <Ban aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />;
default:
return null;
}
}
// Hover-only action slot — absolute-positioned over the row's right edge.
// Status + time stay anchored in the layout; on hover the action buttons
// fade in on top of them with a left-fading gradient backdrop, so the
// status copy is gracefully covered (not hard-clipped) and the row
// content never reflows. Mirrors the "actions sticky over content" idiom
// used by GitHub PR rows, Linear issue rows, etc.
// It covers the normal right status column only on hover.
function RowActions({ children }: { children: React.ReactNode }) {
return (
<div
className={[
"pointer-events-none absolute inset-y-0 right-1 flex items-center gap-0.5 pl-6 opacity-0 transition-opacity",
"pointer-events-none absolute inset-y-0 right-1 flex w-20 items-center justify-end gap-0.5 opacity-0 transition-opacity",
// The gradient backdrop blends the row's hover background (accent/40)
// from the right and fades to transparent on the left, so the
// status text underneath is dimmed gracefully rather than cut.
"bg-gradient-to-l from-accent/95 via-accent/80 to-transparent",
"group-hover:pointer-events-auto group-hover:opacity-100",
"group-focus-within:pointer-events-auto group-focus-within:opacity-100",
"group-hover/execution-log-row:pointer-events-auto group-hover/execution-log-row:opacity-100",
"group-focus-within/execution-log-row:pointer-events-auto group-focus-within/execution-log-row:opacity-100",
].join(" ")}
>
{children}