chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
|
||||
type CloseSessionDialogProps = {
|
||||
sessionParam: string;
|
||||
environmentId: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export function CloseSessionDialog({
|
||||
sessionParam,
|
||||
environmentId,
|
||||
redirectPath,
|
||||
}: CloseSessionDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/sessions/${encodeURIComponent(sessionParam)}/close`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="close-session">
|
||||
<DialogHeader>Close this session?</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph>
|
||||
Closing a session is permanent. The session will no longer accept new input or trigger new
|
||||
runs. Any in-flight run continues until it finishes on its own.
|
||||
</Paragraph>
|
||||
<Form action={formAction} method="post" className="flex flex-col gap-3">
|
||||
<input type="hidden" name="redirectUrl" value={redirectPath} />
|
||||
<input type="hidden" name="environmentId" value={environmentId} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="close-session-reason">Reason (optional)</Label>
|
||||
<Input
|
||||
id="close-session-reason"
|
||||
name="reason"
|
||||
placeholder="e.g. user signed out, ticket resolved"
|
||||
variant="medium"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/medium"
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Closing…" : "Close session"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant={"secondary/medium"}>Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
CpuChipIcon,
|
||||
FingerPrintIcon,
|
||||
PlusIcon,
|
||||
TagIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { StatusIcon } from "~/assets/icons/StatusIcon";
|
||||
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { MiddleTruncate } from "~/components/primitives/MiddleTruncate";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectButtonItem,
|
||||
SelectGroup,
|
||||
SelectGroupLabel,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import { appliedSummary, FilterMenuProvider, TimeFilter } from "../../runs/v3/SharedFilters";
|
||||
import {
|
||||
allSessionStatuses,
|
||||
descriptionForSessionStatus,
|
||||
SessionStatusCombo,
|
||||
sessionStatusTitle,
|
||||
} from "./SessionStatus";
|
||||
|
||||
const StringOrStringArray = z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
z.array(z.string()).optional()
|
||||
);
|
||||
|
||||
export const SessionStatus = z.enum(allSessionStatuses);
|
||||
|
||||
export const SessionListSearchFilters = z.object({
|
||||
cursor: z.string().optional(),
|
||||
direction: z.enum(["forward", "backward"]).optional(),
|
||||
statuses: z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
SessionStatus.array().optional()
|
||||
),
|
||||
types: StringOrStringArray,
|
||||
taskIdentifiers: StringOrStringArray,
|
||||
externalId: z.string().optional(),
|
||||
tags: StringOrStringArray,
|
||||
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
|
||||
from: z.coerce.number().optional(),
|
||||
to: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export type SessionListSearchFilters = z.infer<typeof SessionListSearchFilters>;
|
||||
export type SessionListSearchFilterKey = keyof SessionListSearchFilters;
|
||||
|
||||
export function getSessionFiltersFromSearchParams(
|
||||
searchParams: URLSearchParams
|
||||
): SessionListSearchFilters {
|
||||
function listOrUndefined(key: string) {
|
||||
const values = searchParams.getAll(key).filter((v) => v.length > 0);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
const params = {
|
||||
cursor: searchParams.get("cursor") ?? undefined,
|
||||
direction: searchParams.get("direction") ?? undefined,
|
||||
statuses: listOrUndefined("statuses"),
|
||||
types: listOrUndefined("types"),
|
||||
taskIdentifiers: listOrUndefined("taskIdentifiers"),
|
||||
externalId: searchParams.get("externalId") ?? undefined,
|
||||
tags: listOrUndefined("tags"),
|
||||
period: searchParams.get("period") ?? undefined,
|
||||
from: searchParams.get("from") ?? undefined,
|
||||
to: searchParams.get("to") ?? undefined,
|
||||
};
|
||||
|
||||
const parsed = SessionListSearchFilters.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return {};
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
type PossibleTask = { slug: string; isInLatestDeployment: boolean };
|
||||
|
||||
type SessionFiltersProps = {
|
||||
hasFilters: boolean;
|
||||
possibleTypes?: string[];
|
||||
possibleTasks: PossibleTask[];
|
||||
};
|
||||
|
||||
export function SessionFilters(props: SessionFiltersProps) {
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hasFilters =
|
||||
searchParams.has("statuses") ||
|
||||
searchParams.has("types") ||
|
||||
searchParams.has("taskIdentifiers") ||
|
||||
searchParams.has("externalId") ||
|
||||
searchParams.has("tags");
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center gap-1.5">
|
||||
<PermanentStatusFilter />
|
||||
<PermanentTaskIdentifierFilter possibleTasks={props.possibleTasks} />
|
||||
<TimeFilter shortcut={{ key: "d" }} />
|
||||
<AppliedFilters />
|
||||
<FilterMenu {...props} />
|
||||
{hasFilters && (
|
||||
<Form className="-ml-1 h-6">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={XMarkIcon}
|
||||
tooltip="Clear all filters"
|
||||
className="group-hover/button:bg-transparent"
|
||||
leadingIconClassName="group-hover/button:text-text-bright"
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filterTypes = [
|
||||
{ name: "types", title: "Type", icon: <CpuChipIcon className="size-4" /> },
|
||||
{
|
||||
name: "externalId",
|
||||
title: "External ID",
|
||||
icon: <FingerPrintIcon className="size-4" />,
|
||||
},
|
||||
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
|
||||
] as const;
|
||||
|
||||
type FilterType = (typeof filterTypes)[number]["name"];
|
||||
|
||||
const shortcut = { key: "f" };
|
||||
|
||||
function FilterMenu(props: SessionFiltersProps) {
|
||||
const [filterType, setFilterType] = useState<FilterType | undefined>();
|
||||
|
||||
const filterTrigger = (
|
||||
<SelectTrigger
|
||||
icon={
|
||||
<div className="flex size-4 items-center justify-center">
|
||||
<PlusIcon className="size-3.5" />
|
||||
</div>
|
||||
}
|
||||
variant={"secondary/small"}
|
||||
shortcut={shortcut}
|
||||
tooltipTitle={"More filters"}
|
||||
className="pl-1 pr-2"
|
||||
>
|
||||
More filters
|
||||
</SelectTrigger>
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
|
||||
{(search, setSearch) => (
|
||||
<Menu
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
trigger={filterTrigger}
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedFilters() {
|
||||
return (
|
||||
<>
|
||||
<AppliedTypeFilter />
|
||||
<AppliedExternalIdFilter />
|
||||
<AppliedTagsFilter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuProps = {
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
trigger: React.ReactNode;
|
||||
filterType: FilterType | undefined;
|
||||
setFilterType: (filterType: FilterType | undefined) => void;
|
||||
} & SessionFiltersProps;
|
||||
|
||||
function Menu(props: MenuProps) {
|
||||
switch (props.filterType) {
|
||||
case undefined:
|
||||
return <MainMenu {...props} />;
|
||||
case "types":
|
||||
return <TypeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "externalId":
|
||||
return <ExternalIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "tags":
|
||||
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
|
||||
const filtered = useMemo(() => {
|
||||
return filterTypes.filter((item) =>
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover>
|
||||
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((type, index) => (
|
||||
<SelectButtonItem
|
||||
key={type.name}
|
||||
onClick={() => {
|
||||
clearSearchValue();
|
||||
setFilterType(type.name);
|
||||
}}
|
||||
icon={type.icon}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<span className="text-text-bright">{type.title}</span>
|
||||
</SelectButtonItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const statusItems = allSessionStatuses.map((status) => ({
|
||||
title: sessionStatusTitle(status),
|
||||
value: status,
|
||||
}));
|
||||
|
||||
function StatusDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (next: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ statuses: next, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return statusItems.filter((item) =>
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="group flex w-full flex-col py-0">
|
||||
<SessionStatusCombo status={item.value} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={9}>
|
||||
<Paragraph variant="extra-small">
|
||||
{descriptionForSessionStatus(item.value)}
|
||||
</Paragraph>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const statusShortcut = { key: "s" };
|
||||
|
||||
function PermanentStatusFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const statuses = values("statuses");
|
||||
const hasStatuses = statuses.length > 0 && !statuses.every((v) => v === "");
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: statusShortcut,
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
triggerRef.current?.click();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<StatusDropdown
|
||||
trigger={
|
||||
<Ariakit.TooltipProvider timeout={200}>
|
||||
<Ariakit.TooltipAnchor
|
||||
render={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<Ariakit.Select
|
||||
ref={triggerRef as any}
|
||||
render={<div className="group cursor-pointer focus-custom" />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{hasStatuses ? (
|
||||
<AppliedFilter
|
||||
label="Status"
|
||||
icon={<StatusIcon className="size-4 border-text-bright" />}
|
||||
value={appliedSummary(
|
||||
statuses.map((v) =>
|
||||
sessionStatusTitle(v as (typeof allSessionStatuses)[number])
|
||||
)
|
||||
)}
|
||||
onRemove={() => del(["statuses", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
className="pl-1"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-6 items-center gap-1 rounded border border-border-bright bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-border-brighter group-hover:bg-surface-control">
|
||||
<div className="grid size-4 place-items-center">
|
||||
<div className="size-[75%] rounded-full border-2 border-text-bright" />
|
||||
</div>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
)}
|
||||
</Ariakit.TooltipAnchor>
|
||||
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Filter by status</span>
|
||||
<ShortcutKey
|
||||
className="size-4 flex-none"
|
||||
shortcut={statusShortcut}
|
||||
variant="small"
|
||||
/>
|
||||
</div>
|
||||
</Ariakit.Tooltip>
|
||||
</Ariakit.TooltipProvider>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeDropdown({
|
||||
trigger,
|
||||
searchValue,
|
||||
clearSearchValue,
|
||||
possibleTypes,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
possibleTypes?: string[];
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (next: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ types: next, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const items = useMemo(() => {
|
||||
const all = possibleTypes && possibleTypes.length > 0 ? possibleTypes : ["chat"];
|
||||
const seen = new Set(all);
|
||||
for (const v of values("types")) {
|
||||
if (!seen.has(v)) {
|
||||
all.push(v);
|
||||
seen.add(v);
|
||||
}
|
||||
}
|
||||
return all.filter((t) => t.toLowerCase().includes(searchValue.toLowerCase()));
|
||||
}, [possibleTypes, searchValue, values]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("types")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by type..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{items.map((value, index) => (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<span className="font-mono text-xs">{value}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedTypeFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const types = values("types");
|
||||
if (types.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TypeDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Type"
|
||||
icon={<CpuChipIcon className="size-4" />}
|
||||
value={appliedSummary(types)}
|
||||
onRemove={() => del(["types", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskIdentifierDropdown({
|
||||
trigger,
|
||||
searchValue,
|
||||
clearSearchValue,
|
||||
onClose,
|
||||
possibleTasks,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
onClose?: () => void;
|
||||
possibleTasks: PossibleTask[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (newValues: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
taskIdentifiers: newValues.length > 0 ? newValues : undefined,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const selected = values("taskIdentifiers");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
// Surface any selected identifiers that aren't in the registry (deleted
|
||||
// agents) so the dropdown can still show + un-check them.
|
||||
const seen = new Set(possibleTasks.map((t) => t.slug));
|
||||
const extras: PossibleTask[] = selected
|
||||
.filter((slug) => slug && !seen.has(slug))
|
||||
.map((slug) => ({ slug, isInLatestDeployment: false }));
|
||||
return [...possibleTasks, ...extras].filter((task) =>
|
||||
task.slug.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [possibleTasks, searchValue, selected]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={selected} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by Agent ID..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered
|
||||
.filter((item) => item.isInLatestDeployment)
|
||||
.map((item) => (
|
||||
<SelectItem
|
||||
key={item.slug}
|
||||
value={item.slug}
|
||||
icon={<CubeSparkleIcon className="size-4 flex-none text-agents" />}
|
||||
className="text-text-bright"
|
||||
>
|
||||
<MiddleTruncate text={item.slug} />
|
||||
</SelectItem>
|
||||
))}
|
||||
{filtered.some((item) => !item.isInLatestDeployment) && (
|
||||
<SelectGroup>
|
||||
<SelectGroupLabel>Archived</SelectGroupLabel>
|
||||
{filtered
|
||||
.filter((item) => !item.isInLatestDeployment)
|
||||
.map((item) => (
|
||||
<SelectItem
|
||||
key={item.slug}
|
||||
value={item.slug}
|
||||
icon={
|
||||
<span className="opacity-50">
|
||||
<CubeSparkleIcon className="size-4 flex-none text-agents" />
|
||||
</span>
|
||||
}
|
||||
className="text-text-bright"
|
||||
>
|
||||
<MiddleTruncate text={item.slug} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{filtered.length === 0 && <SelectItem disabled>No agents found</SelectItem>}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const taskShortcut = { key: "t" };
|
||||
|
||||
function PermanentTaskIdentifierFilter({ possibleTasks }: { possibleTasks: PossibleTask[] }) {
|
||||
const { values, del } = useSearchParams();
|
||||
const taskIdentifiers = values("taskIdentifiers");
|
||||
const hasTasks = taskIdentifiers.length > 0 && !taskIdentifiers.every((v) => v === "");
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: taskShortcut,
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
triggerRef.current?.click();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TaskIdentifierDropdown
|
||||
trigger={
|
||||
<Ariakit.TooltipProvider timeout={200}>
|
||||
<Ariakit.TooltipAnchor
|
||||
render={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<Ariakit.Select
|
||||
ref={triggerRef as any}
|
||||
render={<div className="group cursor-pointer focus-custom" />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{hasTasks ? (
|
||||
<AppliedFilter
|
||||
label="Agent ID"
|
||||
icon={<CubeSparkleIcon className="size-4 text-text-bright" />}
|
||||
value={appliedSummary(taskIdentifiers)}
|
||||
onRemove={() => del(["taskIdentifiers", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
className="pl-1"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-6 items-center gap-1.5 rounded border border-border-bright bg-secondary pl-1 pr-2 text-xs text-text-bright transition group-hover:border-border-brighter group-hover:bg-surface-control">
|
||||
<CubeSparkleIcon className="size-4 text-text-bright" />
|
||||
<span>Agent ID</span>
|
||||
</div>
|
||||
)}
|
||||
</Ariakit.TooltipAnchor>
|
||||
<Ariakit.Tooltip className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Filter by Agent ID</span>
|
||||
<ShortcutKey
|
||||
className="size-4 flex-none"
|
||||
shortcut={taskShortcut}
|
||||
variant="small"
|
||||
/>
|
||||
</div>
|
||||
</Ariakit.Tooltip>
|
||||
</Ariakit.TooltipProvider>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalIdDropdown({
|
||||
trigger,
|
||||
searchValue,
|
||||
clearSearchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const current = value("externalId");
|
||||
const [draft, setDraft] = useState(current ?? "");
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
externalId: draft.trim() === "" ? undefined : draft.trim(),
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
}, [clearSearchValue, draft, replace]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(36ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>External ID</Label>
|
||||
<Input
|
||||
placeholder="user-supplied id"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[33ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["mod"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={apply}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedExternalIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
const externalId = value("externalId");
|
||||
if (!externalId) return null;
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<ExternalIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="External ID"
|
||||
icon={<FingerPrintIcon className="size-4" />}
|
||||
value={externalId}
|
||||
onRemove={() => del(["externalId", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsDropdown({
|
||||
trigger,
|
||||
searchValue,
|
||||
clearSearchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { values, replace } = useSearchParams();
|
||||
const current = values("tags");
|
||||
const [draft, setDraft] = useState(current.join(", "));
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
const next = draft
|
||||
.split(/[,\n]/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
replace({
|
||||
tags: next.length === 0 ? undefined : next,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
}, [clearSearchValue, draft, replace]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(40ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Tags</Label>
|
||||
<Input
|
||||
placeholder="tag1, tag2"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[37ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Paragraph variant="extra-small/dimmed">
|
||||
Comma-separated. Matches sessions with any of these tags.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="secondary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["mod"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={apply}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedTagsFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const tags = values("tags");
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TagsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Tags"
|
||||
icon={<TagIcon className="size-4" />}
|
||||
value={appliedSummary(tags)}
|
||||
onRemove={() => del(["tags", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/20/solid";
|
||||
import assertNever from "assert-never";
|
||||
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allSessionStatuses = ["ACTIVE", "CLOSED", "EXPIRED"] as const satisfies Readonly<
|
||||
Array<SessionStatus>
|
||||
>;
|
||||
|
||||
const descriptions: Record<SessionStatus, string> = {
|
||||
ACTIVE: "The session is open and can receive input or schedule new runs.",
|
||||
CLOSED: "The session was closed; no further input or runs can be triggered against it.",
|
||||
EXPIRED: "The session passed its expiry time without being closed explicitly.",
|
||||
};
|
||||
|
||||
export function descriptionForSessionStatus(status: SessionStatus): string {
|
||||
return descriptions[status];
|
||||
}
|
||||
|
||||
export function sessionStatusTitle(status: SessionStatus): string {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "Active";
|
||||
case "CLOSED":
|
||||
return "Closed";
|
||||
case "EXPIRED":
|
||||
return "Expired";
|
||||
default:
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionStatusColor(status: SessionStatus): string {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "text-pending";
|
||||
case "CLOSED":
|
||||
return "text-success";
|
||||
case "EXPIRED":
|
||||
return "text-text-dimmed";
|
||||
default:
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionStatusIcon({
|
||||
status,
|
||||
className,
|
||||
pulse = true,
|
||||
}: {
|
||||
status: SessionStatus;
|
||||
className: string;
|
||||
pulse?: boolean;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return (
|
||||
<span className={cn("inline-flex items-center justify-center", className)}>
|
||||
<span className="relative flex size-2">
|
||||
{pulse && (
|
||||
<span className="absolute h-full w-full animate-ping rounded-full border border-pending opacity-100 duration-1000" />
|
||||
)}
|
||||
<span className="size-2 rounded-full bg-pending" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
case "CLOSED":
|
||||
return <CheckCircleIcon className={cn(sessionStatusColor(status), className)} />;
|
||||
case "EXPIRED":
|
||||
return <ClockIcon className={cn(sessionStatusColor(status), className)} />;
|
||||
default:
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionStatusLabel({ status }: { status: SessionStatus }) {
|
||||
return <span className={sessionStatusColor(status)}>{sessionStatusTitle(status)}</span>;
|
||||
}
|
||||
|
||||
export function SessionStatusCombo({
|
||||
status,
|
||||
className,
|
||||
iconClassName,
|
||||
pulse = true,
|
||||
}: {
|
||||
status: SessionStatus;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
pulse?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1", className)}>
|
||||
<SessionStatusIcon status={status} className={cn("size-4", iconClassName)} pulse={pulse} />
|
||||
<SessionStatusLabel status={status} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { useLocation, useNavigation } from "@remix-run/react";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon";
|
||||
import { MiddleTruncate } from "~/components/primitives/MiddleTruncate";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { RunTag } from "~/components/runs/v3/RunTag";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import {
|
||||
type SessionListItem,
|
||||
type SessionList,
|
||||
} from "~/presenters/v3/SessionListPresenter.server";
|
||||
import { v3RunPath, v3RunsPath, v3SessionPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
descriptionForSessionStatus,
|
||||
SessionStatusCombo,
|
||||
allSessionStatuses,
|
||||
} from "./SessionStatus";
|
||||
|
||||
type SessionsTableProps = Pick<SessionList, "sessions" | "filters" | "hasFilters"> & {
|
||||
showTopBorder?: boolean;
|
||||
stickyHeader?: boolean;
|
||||
};
|
||||
|
||||
export function SessionsTable({
|
||||
sessions,
|
||||
hasFilters,
|
||||
showTopBorder = true,
|
||||
stickyHeader = false,
|
||||
}: SessionsTableProps) {
|
||||
const navigation = useNavigation();
|
||||
const location = useLocation();
|
||||
const isLoading =
|
||||
navigation.state !== "idle" && navigation.location?.pathname === location.pathname;
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="max-h-full overflow-y-auto"
|
||||
showTopBorder={showTopBorder}
|
||||
stickyHeader={stickyHeader}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
{allSessionStatuses.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="grid grid-cols-[4rem_1fr] gap-x-3 py-2 first:pt-1 last:pb-1"
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<SessionStatusCombo status={status} />
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
|
||||
{descriptionForSessionStatus(status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
disableTooltipHoverableContent
|
||||
>
|
||||
Status
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
<TableHeaderCell>Agent ID</TableHeaderCell>
|
||||
<TableHeaderCell>Test</TableHeaderCell>
|
||||
<TableHeaderCell>Tags</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Duration</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.length === 0 ? (
|
||||
<TableBlankRow colSpan={9}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">
|
||||
{hasFilters
|
||||
? "No sessions match these filters"
|
||||
: "No sessions in this environment yet"}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const runPath = session.currentRunFriendlyId
|
||||
? v3RunPath(organization, project, environment, {
|
||||
friendlyId: session.currentRunFriendlyId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const displayId = session.externalId ?? session.friendlyId;
|
||||
const sessionPath = v3SessionPath(organization, project, environment, {
|
||||
friendlyId: session.friendlyId,
|
||||
});
|
||||
const allRunsPath = v3RunsPath(organization, project, environment, {
|
||||
tags: [`chat:${displayId}`],
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow key={session.id}>
|
||||
<TableCell to={sessionPath} isTabbableCell>
|
||||
<div className="w-[28ch]">
|
||||
<MiddleTruncate text={displayId} className="font-mono text-xs" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
<SimpleTooltip
|
||||
content={descriptionForSessionStatus(session.status)}
|
||||
disableHoverableContent
|
||||
button={<SessionStatusCombo status={session.status} />}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
<span className="font-mono text-xs">{session.type}</span>
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
<div className="w-[24ch]">
|
||||
<MiddleTruncate text={session.taskIdentifier} className="font-mono text-xs" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
<span className="sr-only">{session.isTest ? "Yes" : "No"}</span>
|
||||
{session.isTest ? (
|
||||
<CheckIcon
|
||||
aria-hidden
|
||||
className="size-4 text-text-dimmed group-hover/table-row:text-text-bright"
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="text-text-dimmed">
|
||||
–
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
{session.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{session.tags.map((tag) => (
|
||||
<RunTag key={tag} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
<DateTime date={session.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath} className="w-[1%]" actionClassName="pr-0 tabular-nums">
|
||||
<SessionDuration session={session} />
|
||||
</TableCell>
|
||||
<SessionActionsCell runPath={runPath} allRunsPath={allRunsPath} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={9}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-background-deep/90"
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading…</span>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionDuration({ session }: { session: SessionListItem }) {
|
||||
// Active sessions tick live; closed/expired sessions freeze at the
|
||||
// moment they ended (closedAt for explicit closes, expiresAt when the
|
||||
// TTL ran out without a close call).
|
||||
const endedAt =
|
||||
session.status === "CLOSED"
|
||||
? session.closedAt
|
||||
: session.status === "EXPIRED"
|
||||
? session.expiresAt
|
||||
: undefined;
|
||||
|
||||
if (endedAt) {
|
||||
return (
|
||||
<>{formatDuration(new Date(session.createdAt), new Date(endedAt), { style: "short" })}</>
|
||||
);
|
||||
}
|
||||
|
||||
return <LiveTimer startTime={new Date(session.createdAt)} />;
|
||||
}
|
||||
|
||||
function SessionActionsCell({ runPath, allRunsPath }: { runPath?: string; allRunsPath: string }) {
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
{runPath && (
|
||||
<PopoverMenuItem
|
||||
to={runPath}
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-runs"
|
||||
title="View current run"
|
||||
/>
|
||||
)}
|
||||
<PopoverMenuItem
|
||||
to={allRunsPath}
|
||||
icon={RunsIconExtraSmall}
|
||||
leadingIconClassName="text-runs"
|
||||
title="View all runs"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user