mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 14:09:22 +02:00
* refactor(views): migrate agent/runtime/skill lists to TanStack DataTable
Replace the per-page CSS Grid + minmax(min, fr) + sticky-first-col + truncate
implementation with a TanStack Table backend rendered through a Dice UI-style
DataTable shell. Column widths are now px-based via column.size, so cells
no longer shrink or auto-truncate as the viewport narrows; when the sum of
columns exceeds the viewport, the container scrolls horizontally instead.
- Add @tanstack/react-table to the catalog (8.21.3) and wire it into
packages/ui (dep) and packages/views (peerDep).
- packages/ui: new DataTable + DataTableColumnHeader + lib/data-table.ts
(getColumnPinningStyle), adapted from Dice UI's registry. The shell
renders <table> directly (skipping shadcn's <Table> wrapper) so its own
outer overflow controls both axes — no nested overflow conflicts.
- packages/views: each list now declares ColumnDef[] with explicit
cell renderers. Row click navigates to detail via onRowClick (instead of
wrapping <tr> in <a>, which is invalid HTML); kebab dropdowns
stopPropagation so they don't trigger the row navigation.
- Drop the previous AGENT_LIST_GRID / GRID_WITH_OWNER / ROW_GRID
templates and the sticky-first-col / subgrid mechanics that came with
them. agent-list-item.tsx is removed; runtime-list.tsx and
skills-page.tsx are trimmed to thin wrappers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent): cap description at 255 chars (db + api + ui)
Symmetric enforcement across DB, server, and UI:
- Migration 060: pre-flight truncate of any oversize rows, then ADD
CONSTRAINT NOT VALID + VALIDATE CONSTRAINT so the new check doesn't
block writes during validation.
- Server handler validates utf8.RuneCountInString on Create/Update and
rejects over-limit input with 400.
- Front-end gets AGENT_DESCRIPTION_MAX_LENGTH in core/agents/constants
(single source of truth shared by the create dialog + edit modal +
test suite) and a CharCounter component that warns at 90% and errors
past the cap.
- Description editor moves from a 288px popover to a roomy modal.
Editor body is mounted only while the dialog is open, so the local
draft state is locked in at mount time and never reset by an external
WS update — the React-recommended replacement for the
useEffect(reset, [value]) anti-pattern.
Counted in code points everywhere (rune count / spread length /
char_length) so multibyte input agrees across all three layers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(views): data-table polish across runtime + skill lists
Builds on the DataTable migration in 2be0f287:
- Add ColumnMeta.grow flag — declared via TanStack module augmentation
in ui/lib/data-table.ts. Columns marked meta.grow skip their inline
width so fixed table-layout assigns them the leftover container space
(no spacer column). The Title-grows / others-fixed pattern from
Linear / GitHub PR rows.
- Authoritative table min-width = sum of column.size, applied to the
<table> itself (fixed-layout ignores cell-level min-width per spec,
so the floor has to live on the table).
- Header tightens to h-8 + uppercase + tracking-wider; pinned cells
switch to opaque bg + group-hover so they cover content scrolling
beneath them and follow row hover state.
- Toolbar slot removed from DataTable (callers wrap the toolbar
themselves now — keeps DataTable single-purpose).
Also: hover-card popup stops contextmenu / auxclick / dblclick from
bubbling out (in addition to click). Stops the popup from triggering
ancestor handlers (e.g. issue list rows) on right-click / middle-click
without breaking Base UI's outside-click dismiss, which listens to
pointerdown — pointerdown is deliberately NOT stopped.
Runtime + skill list pages updated to use the new sizing model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent): drop LastTaskState, introduce 3-state Workload
Continues the presence-model rework started in #1794 / #1798.
The previous LastTaskState union (running / completed / failed /
cancelled / idle) carried historical outcome at the list level — a
runtime-healthy agent whose last task failed showed a sticky red dot
indistinguishable from a daemon-dead agent.
New model: presence is two orthogonal "right-now" dimensions:
AgentAvailability — runtime reachability only (online / unstable /
offline). Drives the dot colour everywhere.
Workload — current load (working / queued / idle). Three
states, never historical. Failure / completion /
cancellation are surfaced via Recent Work + Inbox,
not list-level state.
`queued` (= nothing running, ≥1 queued) is an honest "stuck on offline
runtime" signal. To avoid amber flashes during the brief enqueue→claim
race on healthy runtimes, the queued chip composes with availability:
muted on online, warning amber otherwise.
Activity tab cleanup that follows from the new model:
- failureReasonLabel relocated from agents/presence.ts to
tabs/task-failure.ts (presence no longer owns historical state).
- Recent Work paginates (5 initial, +20 per "Show more"); chat-session
tasks are filtered out of every Agent-scoped surface to keep
"team work" separate from private chat.
- Agents page drops the lastTaskFilter chip group; users find broken
agents via Inbox / Recent Work, not a list-level filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(task): trigger summary snapshot + task:queued lifecycle event
Two task-lifecycle improvements that ship together because they share
the same enqueue/retry hot paths and changes interleave inside task.go:
1. trigger_summary snapshot (migration 061)
New nullable column on agent_task_queue. Comment-triggered tasks
snapshot the comment content; autopilot tasks snapshot the run title.
Truncated to 200 runes via strings.Builder so multibyte input counts
correctly without O(N²) concatenation. Snapshot survives source
edits/deletes — every task row self-describes across surfaces (issue
detail Execution log, agent activity tooltip, inbox) without joining
back to the originating row.
Retry rows inherit the parent's snapshot (CreateRetryTask SELECT) so
the description stays meaningful across attempts. The UI is
responsible for stacking "Retry #N" context on top.
2. task:queued WS event
New protocol event covering the ∅ → queued transition. Front-end
types/events.ts registers it; use-realtime-sync's task: prefix path
already invalidates task caches via onAny, so old clients without
this exact-match subscription still refresh correctly. Specific
subscribers (sticky banner) get sub-second updates instead of
waiting for daemon claim.
Retry path now broadcasts task:queued (not task:dispatch) — same
status transition shape as enqueue, so all "new task created" paths
agree on one event type.
Ordering: broadcastTaskEvent runs *before* notifyTaskAvailable so
the queued event is published into the WS bus before the daemon is
poked. Without this, a fast daemon could claim and emit task:dispatch
over the wire before the in-process queued broadcast fan-out reached
clients — race window is tiny but unsafe-by-construction.
Per-agent task list (agentTasksKeys.all) and per-issue task list
(["issues","tasks"]) added to the task: invalidation set so Activity
tab Recent Work and the Execution log section stay fresh.
Type contracts: AgentTask gains parent_task_id / attempt /
trigger_comment_id (already returned by the API, just missing from TS)
plus the new trigger_summary field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(issue): ExecutionLogSection — unified active+past runs panel
Replaces two pieces:
- the click-to-expand timeline that lived inside AgentLiveCard
- the standalone TaskRunHistory below the main content
with a single right-panel section that lists every agent run for the
issue. Active runs sit at the top (always visible when present); past
runs collapse behind a "Show past runs (N)" toggle, sorted failed →
cancelled → completed within group.
Active rows show the trigger summary, status + relative time, and
Cancel / Transcript actions on hover (gradient backdrop fades the
status text rather than hard-clipping). Past rows show the same
shape minus Cancel.
Retry tasks prepend "Retry #N · " to the inherited summary so they're
distinguishable from their parent (which would otherwise share the
exact same trigger text).
Cache key registered as issueKeys.tasks(issueId); the global
useRealtimeSync task: prefix path already invalidates ["issues","tasks"]
on every task lifecycle event, so the section stays fresh without
local WS subscriptions.
AgentLiveCard slims down to a header-only "agent is working" sticky
banner — keeps the at-a-glance "is anyone working on this right now"
signal and the Stop / Transcript actions, drops the inline timeline
that ExecutionLogSection now owns. Subscribes to both task:queued and
task:dispatch so retries (which only emit queued) land in the banner
without waiting for daemon claim.
issue-detail mounts ExecutionLogSection in the right panel and removes
the now-defunct TaskRunHistory call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { ChevronRight, Loader2, Square } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { api } from "@multica/core/api";
|
|
import { issueKeys } from "@multica/core/issues/queries";
|
|
import type { AgentTask, TaskFailureReason } from "@multica/core/types";
|
|
import { timeAgo } from "@multica/core/utils";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@multica/ui/components/ui/tooltip";
|
|
import { ActorAvatar } from "../../common/actor-avatar";
|
|
import { TranscriptButton } from "../../common/task-transcript";
|
|
import { failureReasonLabel } from "../../agents/components/tabs/task-failure";
|
|
|
|
// 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.
|
|
//
|
|
// Replaces:
|
|
// - the click-to-expand timeline that used to live inside AgentLiveCard
|
|
// (sticky card stays as a header-only banner)
|
|
// - the standalone <TaskRunHistory> below the main content
|
|
//
|
|
// Row layout — three columns, left to right:
|
|
// 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
|
|
//
|
|
// One query (`listTasksByIssue`) drives both buckets — the back-end
|
|
// returns every status, the front-end filters into active vs past on the
|
|
// client. WS task:* events for this issue trigger an invalidate so the
|
|
// list updates without polling.
|
|
|
|
interface ExecutionLogSectionProps {
|
|
issueId: string;
|
|
}
|
|
|
|
// Past-runs sort priority: failed first (needs attention), then
|
|
// cancelled (procedural noise), then completed (the boring 'done'
|
|
// case sinks to the bottom). Within each group, newest first.
|
|
const PAST_STATUS_RANK: Record<string, number> = {
|
|
failed: 0,
|
|
cancelled: 1,
|
|
completed: 2,
|
|
};
|
|
|
|
export function ExecutionLogSection({ issueId }: ExecutionLogSectionProps) {
|
|
const [open, setOpen] = useState(true);
|
|
const [showPast, setShowPast] = useState(false);
|
|
|
|
// Cache key registered in `issueKeys.tasks` (packages/core/issues/queries.ts)
|
|
// so the global useRealtimeSync `task:` prefix path invalidates it via
|
|
// a `["issues", "tasks"]` prefix-match — no local WS subscriptions
|
|
// needed, and the cache stays fresh even when this component isn't
|
|
// mounted (e.g. user cancels from agent-side, then navigates here).
|
|
const { data: tasks = [] } = useQuery({
|
|
queryKey: issueKeys.tasks(issueId),
|
|
queryFn: () => api.listTasksByIssue(issueId),
|
|
staleTime: 30_000,
|
|
refetchOnWindowFocus: true,
|
|
});
|
|
|
|
const activeTasks = useMemo(
|
|
() =>
|
|
tasks.filter(
|
|
(t) =>
|
|
t.status === "queued" ||
|
|
t.status === "dispatched" ||
|
|
t.status === "running",
|
|
),
|
|
[tasks],
|
|
);
|
|
|
|
const pastTasks = useMemo(() => {
|
|
const past = tasks.filter(
|
|
(t) =>
|
|
t.status === "completed" ||
|
|
t.status === "failed" ||
|
|
t.status === "cancelled",
|
|
);
|
|
// Stable sort: failed first, cancelled second, completed last.
|
|
// Within group: newest completed_at first (fall back to created_at
|
|
// for malformed rows missing completed_at).
|
|
return [...past].sort((a, b) => {
|
|
const rankDiff =
|
|
(PAST_STATUS_RANK[a.status] ?? 99) -
|
|
(PAST_STATUS_RANK[b.status] ?? 99);
|
|
if (rankDiff !== 0) return rankDiff;
|
|
const at = a.completed_at ?? a.created_at;
|
|
const bt = b.completed_at ?? b.created_at;
|
|
return new Date(bt).getTime() - new Date(at).getTime();
|
|
});
|
|
}, [tasks]);
|
|
|
|
if (activeTasks.length === 0 && pastTasks.length === 0) return null;
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
className={`flex w-full items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors mb-2 hover:bg-accent/70 ${
|
|
open ? "" : "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
onClick={() => setOpen(!open)}
|
|
>
|
|
Execution log
|
|
<ChevronRight
|
|
className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${
|
|
open ? "rotate-90" : ""
|
|
}`}
|
|
/>
|
|
{activeTasks.length > 0 && (
|
|
<span className="ml-auto inline-flex items-center gap-1 text-info">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-info animate-pulse" />
|
|
<span className="font-mono tabular-nums">{activeTasks.length}</span>
|
|
</span>
|
|
)}
|
|
</button>
|
|
{open && (
|
|
<div className="space-y-0.5 pl-2">
|
|
{activeTasks.map((task) => (
|
|
<ActiveRow key={task.id} task={task} issueId={issueId} />
|
|
))}
|
|
|
|
{pastTasks.length > 0 && (
|
|
<>
|
|
{activeTasks.length > 0 && (
|
|
<div className="my-1.5 border-t border-border/60" />
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPast(!showPast)}
|
|
className="flex w-full items-center gap-1 rounded px-1 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
|
|
>
|
|
<ChevronRight
|
|
className={`!size-3 shrink-0 stroke-[2.5] transition-transform ${
|
|
showPast ? "rotate-90" : ""
|
|
}`}
|
|
/>
|
|
{showPast ? "Hide" : "Show"} past runs ({pastTasks.length})
|
|
</button>
|
|
{showPast && (
|
|
<div className="mt-0.5 space-y-0.5">
|
|
{pastTasks.map((task) => (
|
|
<PastRow key={task.id} task={task} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Trigger description ────────────────────────────────────────────────────
|
|
|
|
// Primary source: the canonical snapshot taken at task creation time
|
|
// (comment text / autopilot title). Survives source edits/deletes and
|
|
// is information-dense — far better than a structural label.
|
|
//
|
|
// Retry tasks inherit the parent's trigger_summary on the DB side (so the
|
|
// snapshot survives across attempts), but a row that just shows the
|
|
// inherited summary is indistinguishable from its parent. We prepend
|
|
// "Retry #N" when parent_task_id is set so retries are scannable as
|
|
// retries even when their summary is inherited.
|
|
//
|
|
// Fallback chain for legacy tasks created before the snapshot field
|
|
// shipped, OR for sources we don't snapshot (direct assignment / chat):
|
|
// degrade to a short structural label by trigger source. New tasks
|
|
// (post-061 migration) almost always hit the snapshot path.
|
|
function buildTriggerText(task: AgentTask): string {
|
|
const isRetry = !!task.parent_task_id;
|
|
const retryPrefix = isRetry
|
|
? task.attempt && task.attempt > 1
|
|
? `Retry #${task.attempt} · `
|
|
: "Retry · "
|
|
: "";
|
|
|
|
if (task.trigger_summary) return retryPrefix + task.trigger_summary;
|
|
if (isRetry) {
|
|
return task.attempt && task.attempt > 1 ? `Retry #${task.attempt}` : "Retry";
|
|
}
|
|
if (task.autopilot_run_id) return "Autopilot run";
|
|
if (task.trigger_comment_id) return "Comment trigger";
|
|
return "Initial run";
|
|
}
|
|
|
|
// ─── Row visual config ─────────────────────────────────────────────────────
|
|
|
|
const STATUS_VISUAL: Record<
|
|
AgentTask["status"],
|
|
{ label: string; tone: string }
|
|
> = {
|
|
queued: { label: "Queued", tone: "text-warning" },
|
|
dispatched: { label: "Starting", tone: "text-warning" },
|
|
running: { label: "Working", tone: "text-info" },
|
|
completed: { label: "Completed", tone: "text-success" },
|
|
failed: { label: "Failed", tone: "text-destructive" },
|
|
cancelled: { label: "Cancelled", tone: "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): string {
|
|
if (task.status === "running" && task.started_at) {
|
|
return timeAgo(task.started_at);
|
|
}
|
|
if (task.status === "dispatched" && task.dispatched_at) {
|
|
return timeAgo(task.dispatched_at);
|
|
}
|
|
return timeAgo(task.created_at);
|
|
}
|
|
|
|
// ─── Active row ────────────────────────────────────────────────────────────
|
|
|
|
function ActiveRow({ task, issueId }: { task: AgentTask; issueId: string }) {
|
|
const [cancelling, setCancelling] = useState(false);
|
|
const cfg = STATUS_VISUAL[task.status];
|
|
const trigger = buildTriggerText(task);
|
|
const time = activeTimeText(task);
|
|
|
|
// Transcript only meaningful once messages exist — pure-queued tasks
|
|
// have nothing to show yet.
|
|
const showTranscript = task.status !== "queued";
|
|
|
|
const handleCancel = async () => {
|
|
if (cancelling) return;
|
|
setCancelling(true);
|
|
try {
|
|
await api.cancelTask(issueId, task.id);
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
|
|
setCancelling(false);
|
|
}
|
|
};
|
|
|
|
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={cfg.tone}>{cfg.label}</span>
|
|
<span className="text-muted-foreground"> · {time}</span>
|
|
</span>
|
|
<RowActions>
|
|
{showTranscript && (
|
|
<TranscriptButton
|
|
task={task}
|
|
agentName=""
|
|
isLive
|
|
title="View transcript"
|
|
/>
|
|
)}
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<button
|
|
type="button"
|
|
onClick={handleCancel}
|
|
disabled={cancelling}
|
|
aria-label="Cancel task"
|
|
/>
|
|
}
|
|
className="flex items-center justify-center rounded p-1 text-destructive transition-colors hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{cancelling ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Square className="h-3.5 w-3.5" />
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent>Cancel task</TooltipContent>
|
|
</Tooltip>
|
|
</RowActions>
|
|
</RowShell>
|
|
);
|
|
}
|
|
|
|
// ─── Past row ──────────────────────────────────────────────────────────────
|
|
|
|
function PastRow({ task }: { task: AgentTask }) {
|
|
const cfg = STATUS_VISUAL[task.status];
|
|
const trigger = buildTriggerText(task);
|
|
const time = task.completed_at ? timeAgo(task.completed_at) : "—";
|
|
const failureLabel =
|
|
task.status === "failed" && task.failure_reason
|
|
? failureReasonLabel[task.failure_reason as TaskFailureReason]
|
|
: null;
|
|
|
|
return (
|
|
<RowShell task={task}>
|
|
<TriggerText text={trigger} />
|
|
<span className="shrink-0 whitespace-nowrap text-xs">
|
|
<span className={cfg.tone}>{failureLabel ?? cfg.label}</span>
|
|
<span className="text-muted-foreground"> · {time}</span>
|
|
</span>
|
|
<RowActions>
|
|
<TranscriptButton task={task} agentName="" title="View transcript" />
|
|
</RowActions>
|
|
</RowShell>
|
|
);
|
|
}
|
|
|
|
// ─── Shared row chrome ─────────────────────────────────────────────────────
|
|
|
|
function RowShell({
|
|
task,
|
|
children,
|
|
}: {
|
|
task: AgentTask;
|
|
children: React.ReactNode;
|
|
}) {
|
|
// `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">
|
|
{task.agent_id ? (
|
|
<ActorAvatar
|
|
actorType="agent"
|
|
actorId={task.agent_id}
|
|
size={20}
|
|
enableHoverCard
|
|
/>
|
|
) : (
|
|
<span className="inline-block h-5 w-5 shrink-0 rounded-full bg-muted" />
|
|
)}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 overflow-hidden whitespace-nowrap text-xs text-muted-foreground"
|
|
style={TRIGGER_MASK_STYLE}
|
|
>
|
|
{text}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// 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.
|
|
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",
|
|
// 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",
|
|
].join(" ")}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|