mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task indicators tell an operator whether work is active, waiting, or
complete.
> - An agent can yield until a saved monitor checks CI or another
external result.
> - The task currently keeps the In Progress icon between those runs.
> - Changing the stored status would affect monitor admission and
recovery.
> - This pull request adds a read-only Monitoring display state with a
blue clock.
> - Operators can see and filter scheduled waits while execution keeps
its existing rules.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Task status indicators, task collection filters, board columns, and
search facets.
**Current behavior**
A task that waits for a scheduled monitor shows the same In Progress
icon as active work.
**Proposed behavior**
Show Monitoring with a static blue clock when an agent-owned task has a
saved ordinary monitor and no active run, execution lock, review
participant, or active recovery action. Return to the normal task
indicator when the check starts. Preserve review, blocked, terminal, and
quota-recovery states.
**Reason and benefit**
Operators can distinguish scheduled waits from current agent work
without reading the task history.
**Breaking changes**
None to stored task statuses or execution rules. Task reads gain
optional displayStatus. Collections and search gain a separate read
filter. Existing status filters keep their lifecycle meaning.
Related historical work: #4396 covered a monitoring label exemption.
This change uses the saved monitor and a read projection.
## What Changed
- Add one SQL projection for task detail, compact lists, summaries,
relations, search results, and display-status counts.
- Show the blue clock in shared task icons and chips, task detail and
breadcrumbs, properties, references, list rows, recent navigation, and
agent task cards.
- Add Monitoring to task filtering, grouping, sorting, board columns,
and search facets. Prevent a drag from creating this derived state.
- Keep stored lifecycle statuses, monitor scheduling, checkout,
recovery, permissions, and agent status filters unchanged.
- Add database and UI regression coverage, full-page Storybook examples,
and product and access-map documentation.
## Verification
- Passed `pnpm -r typecheck`.
- Passed `pnpm build`.
- Passed `pnpm check:token-gates` and the map/catalog structural checks.
- Ran the new database suite on a disposable PostgreSQL 17 database. It
covers read surfaces, filter pagination/counts, queued/running checks,
rescheduling, completion, retained monitors, review, and recovery holds.
- Ran existing search, list, board, sidebar, task status, and filter
tests. Corrected board queries use displayStatus and eight columns.
- The full local `pnpm test:run` stopped in a chat integration shard: 12
failures (four timeouts and eight FIFO publication assertions). That run
spanned a rebase. A focused rerun on the current source passed all 21
selected cases without assertion or timeout changes. The original cause
is not established; this does not claim the full local suite passed.
Final-head required CI passed (ci / verify and ci / e2e, run
41488267209).
- Passed 82 tests for task API summaries, recovery precedence, search,
and optimistic updates; 54 tests for status cards, database reads, and
board moves; and 27 tests for blocker relations and recent-task cache
updates. One intermediate database setup hit its 20-second hook timeout;
the serial rerun passed.
- Greptile is 5/5 on d003ea672d. All
review threads are resolved. The separate optional Paperclip Review
ended early and returned neutral; GitHub rejected a direct rerun request
with HTTP 404. It is not counted as a completed review.
- Review fixes cover escalated recovery, is:open, optimistic status
changes, blocker-summary projection, and recent-task dedupe.
- Reviewed 15 screenshots from production pages and components with
isolated Storybook API fixtures: task detail/properties/breadcrumb,
list, groups, status filter, board, search/results/filter counts,
reference pills, sibling navigation, agent cards, picker, dashboard,
inbox, profile, watched status-card tasks, light theme, and mobile. This
does not claim deployed staging verification.
## Risks
- Read projections must stay consistent across full, compact, summary,
and search responses. Tests cover those paths.
- Monitoring is not a writable lifecycle status. The board does not
accept drops into its Monitoring lane or onto a monitored card. This
prevents a drag from implying a scheduled check. Move work into In
Progress through that lane instead.
- The read projection adds run/recovery existence checks. Existing
heartbeat_runs_company_issue_created_idx and
issue_recovery_actions_company_source_status_idx indexes cover the
company/task lookup. It adds no worker, timer, recurring query,
migration, or provider call. No production load benchmark is claimed.
- Older clients can ignore displayStatus. Existing lifecycle status
filters keep their behavior.
## Model Used
OpenAI Codex, GPT-6, with reasoning, repository tools, code execution,
and browser tools. The exact backend model identifier and context-window
size are not exposed in this session.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
522 lines
18 KiB
TypeScript
522 lines
18 KiB
TypeScript
import { AgentIdentity } from "@/components/AgentIdentity";
|
|
import type { AvatarAgent } from "./AgentAvatar";
|
|
import type { ReactNode } from "react";
|
|
import { deriveOriginatingActor, type Issue } from "@paperclipai/shared";
|
|
import { Columns3 } from "lucide-react";
|
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
|
import type { InboxIssueColumn } from "../lib/inbox";
|
|
import { cn } from "../lib/utils";
|
|
import { timeAgo } from "../lib/timeAgo";
|
|
import { Identity } from "./Identity";
|
|
import { StatusIcon } from "./StatusIcon";
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
export const issueTrailingColumns: InboxIssueColumn[] = ["assignee", "kickedOffBy", "project", "workspace", "parent", "labels", "updated"];
|
|
|
|
const issueColumnLabels: Record<InboxIssueColumn, string> = {
|
|
status: "Status",
|
|
id: "ID",
|
|
assignee: "Responsible",
|
|
kickedOffBy: "Kicked off by",
|
|
project: "Project",
|
|
workspace: "Workspace",
|
|
parent: "Parent task",
|
|
labels: "Tags",
|
|
updated: "Last updated",
|
|
};
|
|
|
|
const issueColumnDescriptions: Record<InboxIssueColumn, string> = {
|
|
status: "Task state chip on the left edge.",
|
|
id: "Ticket identifier like PAP-1009.",
|
|
assignee: "Responsible agent or board user.",
|
|
kickedOffBy: "Board user or agent who created the task.",
|
|
project: "Linked project pill with its color.",
|
|
workspace: "Execution or project workspace used for the task.",
|
|
parent: "Parent task identifier and title.",
|
|
labels: "Task labels and tags.",
|
|
updated: "Latest visible activity time.",
|
|
};
|
|
|
|
export function issueColumnDescription(
|
|
column: InboxIssueColumn,
|
|
presentation: "legacy" | "task" = "legacy",
|
|
): string {
|
|
if (column === "id" && presentation === "task") {
|
|
return "Task identifier like PAP-1009 on the trailing edge.";
|
|
}
|
|
if (column === "status" && presentation === "task") {
|
|
return "Task state icon on the leading edge.";
|
|
}
|
|
return issueColumnDescriptions[column];
|
|
}
|
|
|
|
export function issueActivityTimestamp(issue: Issue): string {
|
|
return timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
|
}
|
|
|
|
export function issueActivityText(issue: Issue): string {
|
|
return `Updated ${issueActivityTimestamp(issue)}`;
|
|
}
|
|
|
|
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
|
return columns
|
|
.map((column) => {
|
|
if (column === "assignee") return "minmax(6rem, 8rem)";
|
|
if (column === "kickedOffBy") return "minmax(6rem, 8rem)";
|
|
if (column === "project") return "minmax(4.5rem, 7rem)";
|
|
if (column === "workspace") return "minmax(6rem, 9rem)";
|
|
if (column === "parent") return "minmax(3.5rem, 5.5rem)";
|
|
if (column === "labels") return "minmax(3rem, 6rem)";
|
|
return "minmax(3.5rem, 4.5rem)";
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
export function IssueColumnPicker({
|
|
availableColumns,
|
|
visibleColumnSet,
|
|
onToggleColumn,
|
|
showDateGroupSeparators,
|
|
onToggleDateGroupSeparators,
|
|
onResetColumns,
|
|
title,
|
|
iconOnly = false,
|
|
rowPresentation = "legacy",
|
|
}: {
|
|
availableColumns: InboxIssueColumn[];
|
|
visibleColumnSet: ReadonlySet<InboxIssueColumn>;
|
|
onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void;
|
|
showDateGroupSeparators?: boolean;
|
|
onToggleDateGroupSeparators?: (enabled: boolean) => void;
|
|
onResetColumns: () => void;
|
|
title: string;
|
|
iconOnly?: boolean;
|
|
rowPresentation?: "legacy" | "task";
|
|
}) {
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant={iconOnly ? "outline" : "ghost"}
|
|
size={iconOnly ? "icon" : "sm"}
|
|
className={iconOnly ? "h-8 w-8 shrink-0" : "hidden h-8 shrink-0 px-2 text-xs sm:inline-flex"}
|
|
title="Columns"
|
|
>
|
|
<Columns3 className={iconOnly ? "h-3.5 w-3.5" : "mr-1 h-3.5 w-3.5"} />
|
|
{!iconOnly && "Columns"}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-(--sz-300px) rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
|
|
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
|
|
<div className="space-y-1">
|
|
<div className="text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
|
|
Desktop task rows
|
|
</div>
|
|
<div className="text-sm font-medium text-foreground">
|
|
{title}
|
|
</div>
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
{availableColumns.map((column) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={column}
|
|
checked={visibleColumnSet.has(column)}
|
|
onSelect={(event) => event.preventDefault()}
|
|
onCheckedChange={(checked) => onToggleColumn(column, checked === true)}
|
|
className="items-start rounded-lg px-3 py-2.5 pl-8"
|
|
>
|
|
<span className="flex flex-col gap-0.5">
|
|
<span className="text-sm font-medium text-foreground">
|
|
{issueColumnLabels[column]}
|
|
</span>
|
|
<span className="text-xs leading-relaxed text-muted-foreground">
|
|
{issueColumnDescription(column, rowPresentation)}
|
|
</span>
|
|
</span>
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
{showDateGroupSeparators !== undefined && onToggleDateGroupSeparators ? (
|
|
<DropdownMenuCheckboxItem
|
|
checked={showDateGroupSeparators}
|
|
onSelect={(event) => event.preventDefault()}
|
|
onCheckedChange={(checked) => onToggleDateGroupSeparators(checked === true)}
|
|
className="items-start rounded-lg px-3 py-2.5 pl-8"
|
|
>
|
|
<span className="flex flex-col gap-0.5">
|
|
<span className="text-sm font-medium text-foreground">
|
|
Date group separators
|
|
</span>
|
|
<span className="text-xs leading-relaxed text-muted-foreground">
|
|
Show Today, Yesterday, and Earlier rules on newest-first task lists.
|
|
</span>
|
|
</span>
|
|
</DropdownMenuCheckboxItem>
|
|
) : null}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onSelect={onResetColumns}
|
|
className="rounded-lg px-3 py-2 text-sm"
|
|
>
|
|
Reset defaults
|
|
<span className="ml-auto text-xs text-muted-foreground">status, id, updated</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
export function InboxIssueMetaLeading({
|
|
issue,
|
|
isLive,
|
|
subtreeLiveCount = 0,
|
|
showSubtreeLiveChip = true,
|
|
showStatus = true,
|
|
showIdentifier = true,
|
|
statusSlot,
|
|
checklistStepNumber = null,
|
|
}: {
|
|
issue: Issue;
|
|
isLive: boolean;
|
|
subtreeLiveCount?: number;
|
|
showSubtreeLiveChip?: boolean;
|
|
showStatus?: boolean;
|
|
showIdentifier?: boolean;
|
|
statusSlot?: ReactNode;
|
|
checklistStepNumber?: number | string | null;
|
|
}) {
|
|
return (
|
|
<>
|
|
{showStatus ? (
|
|
<span className="hidden shrink-0 items-center sm:inline-flex">
|
|
{statusSlot ?? <StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />}
|
|
</span>
|
|
) : null}
|
|
{checklistStepNumber !== null ? (
|
|
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
|
|
{checklistStepNumber}.
|
|
</span>
|
|
) : null}
|
|
{showIdentifier ? (
|
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
</span>
|
|
) : null}
|
|
{isLive && (
|
|
<Badge variant="ghost"
|
|
className={cn(
|
|
"px-1.5 sm:gap-1.5 sm:px-2",
|
|
"bg-blue-500/10",
|
|
)}
|
|
>
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
|
<span
|
|
className={cn(
|
|
"relative inline-flex h-2 w-2 rounded-full",
|
|
"bg-blue-500",
|
|
)}
|
|
/>
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"hidden text-(length:--text-micro) font-medium sm:inline",
|
|
"text-blue-600 dark:text-blue-400",
|
|
)}
|
|
>
|
|
Live
|
|
</span>
|
|
</Badge>
|
|
)}
|
|
{showSubtreeLiveChip && !isLive && subtreeLiveCount > 0 && (
|
|
<Badge variant="outline"
|
|
className={cn(
|
|
"px-1.5 sm:gap-1.5 sm:px-2",
|
|
"border-border bg-transparent",
|
|
)}
|
|
title={`${subtreeLiveCount} sub-task${subtreeLiveCount === 1 ? "" : "s"} running below`}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"h-2 w-2 shrink-0 rounded-full border",
|
|
"border-muted-foreground/60 bg-transparent",
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
<span className="hidden text-(length:--text-micro) font-medium text-muted-foreground sm:inline">
|
|
{subtreeLiveCount} live below
|
|
</span>
|
|
</Badge>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function InboxIssueTrailingColumns({
|
|
issue,
|
|
columns,
|
|
projectName,
|
|
projectColor,
|
|
workspaceId,
|
|
workspaceName,
|
|
assigneeName,
|
|
assigneeAgent,
|
|
creatorAgent,
|
|
assigneeUserName,
|
|
assigneeUserAvatarUrl,
|
|
creatorAgentName,
|
|
creatorUserName,
|
|
creatorUserAvatarUrl,
|
|
viaAgentName,
|
|
currentUserId,
|
|
parentIdentifier,
|
|
parentTitle,
|
|
assigneeContent,
|
|
onFilterWorkspace,
|
|
}: {
|
|
issue: Issue;
|
|
columns: InboxIssueColumn[];
|
|
projectName: string | null;
|
|
projectColor: string | null;
|
|
workspaceId?: string | null;
|
|
workspaceName: string | null;
|
|
assigneeName: string | null;
|
|
assigneeAgent?: AvatarAgent;
|
|
creatorAgent?: AvatarAgent;
|
|
assigneeUserName?: string | null;
|
|
assigneeUserAvatarUrl?: string | null;
|
|
creatorAgentName?: string | null;
|
|
creatorUserName?: string | null;
|
|
creatorUserAvatarUrl?: string | null;
|
|
viaAgentName?: string | null;
|
|
currentUserId: string | null;
|
|
parentIdentifier: string | null;
|
|
parentTitle: string | null;
|
|
assigneeContent?: ReactNode;
|
|
onFilterWorkspace?: (workspaceId: string) => void;
|
|
}) {
|
|
const activityText = issueActivityTimestamp(issue);
|
|
const userLabel = assigneeUserName ?? formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
|
const originatingActor = deriveOriginatingActor(issue);
|
|
const originatingUserId = originatingActor?.kind === "user" ? originatingActor.id : null;
|
|
const creatorUserLabel = creatorUserName ?? formatAssigneeUserLabel(originatingUserId, currentUserId) ?? "User";
|
|
|
|
return (
|
|
<span
|
|
className="grid items-center gap-2"
|
|
style={{ gridTemplateColumns: issueTrailingGridTemplate(columns) }}
|
|
>
|
|
{columns.map((column) => {
|
|
if (column === "assignee") {
|
|
if (assigneeContent) {
|
|
return <span key={column} className="min-w-0">{assigneeContent}</span>;
|
|
}
|
|
|
|
if (issue.assigneeAgentId) {
|
|
return (
|
|
<span key={column} className="min-w-0 text-xs text-foreground">
|
|
<AgentIdentity
|
|
agent={assigneeAgent ?? { id: issue.assigneeAgentId, name: assigneeName ?? issue.assigneeAgentId.slice(0, 8) }}
|
|
size="sm"
|
|
className="min-w-0"
|
|
/>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (issue.assigneeUserId) {
|
|
return (
|
|
<span key={column} className="min-w-0 text-xs text-foreground">
|
|
<Identity
|
|
name={userLabel}
|
|
avatarUrl={assigneeUserAvatarUrl}
|
|
size="sm"
|
|
className="min-w-0"
|
|
/>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
|
Unassigned
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (column === "kickedOffBy") {
|
|
if (originatingActor?.kind === "agent") {
|
|
const name = creatorAgentName ?? originatingActor.id.slice(0, 8);
|
|
return (
|
|
<Tooltip key={column}>
|
|
<TooltipTrigger asChild>
|
|
<span className="min-w-0 text-xs text-foreground">
|
|
<AgentIdentity
|
|
agent={creatorAgent ?? { id: originatingActor.id, name }}
|
|
size="sm"
|
|
className="min-w-0"
|
|
/>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={6}>{name}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
if (originatingActor?.kind === "user") {
|
|
const tooltipText = viaAgentName ? `${creatorUserLabel} · via ${viaAgentName}` : creatorUserLabel;
|
|
return (
|
|
<Tooltip key={column}>
|
|
<TooltipTrigger asChild>
|
|
<span className="min-w-0 text-xs text-foreground">
|
|
<Identity
|
|
name={creatorUserLabel}
|
|
avatarUrl={creatorUserAvatarUrl}
|
|
size="sm"
|
|
className="min-w-0"
|
|
/>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={6}>{tooltipText}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
|
Unknown
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (column === "project") {
|
|
if (projectName) {
|
|
// token-extraction: allowlisted — accentColor also feeds pickTextColorForPillBg() contrast math; a var() string can't be parsed as a hex color there.
|
|
const accentColor = projectColor ?? "#64748b";
|
|
return (
|
|
<span
|
|
key={column}
|
|
className="inline-flex min-w-0 items-center gap-2 text-xs font-medium"
|
|
style={{ color: pickTextColorForPillBg(accentColor, 0.12) }}
|
|
>
|
|
<span
|
|
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
|
style={{ backgroundColor: accentColor }}
|
|
/>
|
|
<span className="truncate">{projectName}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
|
No project
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (column === "labels") {
|
|
if ((issue.labels ?? []).length > 0) {
|
|
return (
|
|
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden">
|
|
{(issue.labels ?? []).slice(0, 2).map((label) => (
|
|
<Badge variant="outline"
|
|
key={label.id}
|
|
className="min-w-0 max-w-full px-1.5 py-0 text-(length:--text-nano)"
|
|
style={{
|
|
borderColor: label.color,
|
|
color: pickTextColorForPillBg(label.color, 0.12),
|
|
backgroundColor: `${label.color}1f`,
|
|
}}
|
|
>
|
|
<span className="truncate">{label.name}</span>
|
|
</Badge>
|
|
))}
|
|
{(issue.labels ?? []).length > 2 ? (
|
|
<span className="shrink-0 text-(length:--text-nano) font-medium text-muted-foreground">
|
|
+{(issue.labels ?? []).length - 2}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
|
}
|
|
|
|
if (column === "workspace") {
|
|
if (!workspaceName) {
|
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
|
}
|
|
|
|
return (
|
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
|
{workspaceId && onFilterWorkspace ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="truncate rounded-sm text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onFilterWorkspace(workspaceId);
|
|
}}
|
|
>
|
|
{workspaceName}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={6}>
|
|
Filter by workspace
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
workspaceName
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (column === "parent") {
|
|
if (!issue.parentId) {
|
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
|
}
|
|
|
|
return (
|
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground" title={parentTitle ?? undefined}>
|
|
{parentIdentifier ? (
|
|
<span className="font-mono">{parentIdentifier}</span>
|
|
) : (
|
|
<span className="italic">Sub-task</span>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (column === "updated") {
|
|
return (
|
|
<span key={column} className="min-w-0 truncate text-right text-(length:--text-micro) font-medium text-muted-foreground">
|
|
{activityText}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
})}
|
|
</span>
|
|
);
|
|
}
|