mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
Merge pull request #3356 from cryppadotta/pap-1331-inbox-ux
feat: polish inbox and issue list workflows
This commit is contained in:
commit
45ebecab5a
36 changed files with 1695 additions and 390 deletions
|
|
@ -19,9 +19,9 @@ const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
|||
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0">{children}</div>
|
||||
<div className="flex items-start gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20 mt-0.5">{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1 flex-wrap">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
|||
)}
|
||||
{runtimeState?.lastError && (
|
||||
<PropertyRow label="Last error">
|
||||
<span className="text-xs text-red-600 dark:text-red-400 truncate max-w-[160px]">{runtimeState.lastError}</span>
|
||||
<span className="text-xs text-red-600 dark:text-red-400 break-words min-w-0">{runtimeState.lastError}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{agent.lastHeartbeatAt && (
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ interface GoalPropertiesProps {
|
|||
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0">{children}</div>
|
||||
<div className="flex items-start gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20 mt-0.5">{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1 flex-wrap">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
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";
|
||||
|
|
@ -50,12 +51,12 @@ export function issueActivityText(issue: Issue): string {
|
|||
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
||||
return columns
|
||||
.map((column) => {
|
||||
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
|
||||
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
||||
if (column === "workspace") return "minmax(9rem, 12rem)";
|
||||
if (column === "parent") return "minmax(5rem, 7rem)";
|
||||
if (column === "labels") return "minmax(8rem, 10rem)";
|
||||
return "minmax(4rem, 5.5rem)";
|
||||
if (column === "assignee") 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(" ");
|
||||
}
|
||||
|
|
@ -66,24 +67,27 @@ export function IssueColumnPicker({
|
|||
onToggleColumn,
|
||||
onResetColumns,
|
||||
title,
|
||||
iconOnly = false,
|
||||
}: {
|
||||
availableColumns: InboxIssueColumn[];
|
||||
visibleColumnSet: ReadonlySet<InboxIssueColumn>;
|
||||
onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void;
|
||||
onResetColumns: () => void;
|
||||
title: string;
|
||||
iconOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden h-8 shrink-0 px-2 text-xs sm:inline-flex"
|
||||
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="mr-1 h-3.5 w-3.5" />
|
||||
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-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
|
||||
|
|
@ -189,23 +193,27 @@ export function InboxIssueTrailingColumns({
|
|||
columns,
|
||||
projectName,
|
||||
projectColor,
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
assigneeName,
|
||||
currentUserId,
|
||||
parentIdentifier,
|
||||
parentTitle,
|
||||
assigneeContent,
|
||||
onFilterWorkspace,
|
||||
}: {
|
||||
issue: Issue;
|
||||
columns: InboxIssueColumn[];
|
||||
projectName: string | null;
|
||||
projectColor: string | null;
|
||||
workspaceId?: string | null;
|
||||
workspaceName: string | null;
|
||||
assigneeName: string | null;
|
||||
currentUserId: string | null;
|
||||
parentIdentifier: string | null;
|
||||
parentTitle: string | null;
|
||||
assigneeContent?: ReactNode;
|
||||
onFilterWorkspace?: (workspaceId: string) => void;
|
||||
}) {
|
||||
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||
|
|
@ -276,20 +284,22 @@ export function InboxIssueTrailingColumns({
|
|||
if (column === "labels") {
|
||||
if ((issue.labels ?? []).length > 0) {
|
||||
return (
|
||||
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
|
||||
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
{(issue.labels ?? []).slice(0, 2).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="inline-flex min-w-0 max-w-full items-center font-medium"
|
||||
className="inline-flex min-w-0 max-w-full shrink-0 items-center rounded-full border px-1.5 py-0 text-[10px] font-medium"
|
||||
style={{
|
||||
borderColor: label.color,
|
||||
color: pickTextColorForPillBg(label.color, 0.12),
|
||||
backgroundColor: `${label.color}1f`,
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{label.name}</span>
|
||||
</span>
|
||||
))}
|
||||
{(issue.labels ?? []).length > 2 ? (
|
||||
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
|
||||
+{(issue.labels ?? []).length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
|
|
@ -307,7 +317,28 @@ export function InboxIssueTrailingColumns({
|
|||
|
||||
return (
|
||||
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
{workspaceName}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Filter, X, User } from "lucide-react";
|
||||
import { Filter, X, User, HardDrive } from "lucide-react";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import {
|
||||
|
|
@ -31,6 +31,11 @@ type LabelOption = {
|
|||
color: string;
|
||||
};
|
||||
|
||||
type WorkspaceOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function IssueFiltersPopover({
|
||||
state,
|
||||
onChange,
|
||||
|
|
@ -41,6 +46,8 @@ export function IssueFiltersPopover({
|
|||
currentUserId,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
buttonVariant = "ghost",
|
||||
iconOnly = false,
|
||||
workspaces,
|
||||
}: {
|
||||
state: IssueFilterState;
|
||||
onChange: (patch: Partial<IssueFilterState>) => void;
|
||||
|
|
@ -51,15 +58,18 @@ export function IssueFiltersPopover({
|
|||
currentUserId?: string | null;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
buttonVariant?: "ghost" | "outline";
|
||||
iconOnly?: boolean;
|
||||
workspaces?: WorkspaceOption[];
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={buttonVariant} size="sm" className={`text-xs ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`}>
|
||||
<Filter className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span>
|
||||
{activeFilterCount > 0 ? <span className="ml-0.5 text-[10px] font-medium sm:hidden">{activeFilterCount}</span> : null}
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button variant={buttonVariant} size={iconOnly ? "icon" : "sm"} className={`text-xs ${iconOnly ? "relative h-8 w-8 shrink-0" : ""} ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`} title={iconOnly ? (activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter") : undefined}>
|
||||
<Filter className={iconOnly ? "h-3.5 w-3.5" : "h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1"} />
|
||||
{!iconOnly && <span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span>}
|
||||
{!iconOnly && activeFilterCount > 0 ? <span className="ml-0.5 text-[10px] font-medium sm:hidden">{activeFilterCount}</span> : null}
|
||||
{iconOnly && activeFilterCount > 0 ? <span className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-blue-600 text-[9px] font-bold text-white">{activeFilterCount}</span> : null}
|
||||
{!iconOnly && activeFilterCount > 0 ? (
|
||||
<X
|
||||
className="ml-1 hidden h-3 w-3 sm:block"
|
||||
onClick={(event) => {
|
||||
|
|
@ -211,6 +221,24 @@ export function IssueFiltersPopover({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{workspaces && workspaces.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Workspace</span>
|
||||
<div className="max-h-32 space-y-0.5 overflow-y-auto">
|
||||
{workspaces.map((workspace) => (
|
||||
<label key={workspace.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.workspaces.includes(workspace.id)}
|
||||
onCheckedChange={() => onChange({ workspaces: toggleIssueFilterValue(state.workspaces, workspace.id) })}
|
||||
/>
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm">{workspace.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{enableRoutineVisibilityFilter ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Visibility</span>
|
||||
|
|
|
|||
135
ui/src/components/IssueLinkQuicklook.tsx
Normal file
135
ui/src/components/IssueLinkQuicklook.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import * as RouterDom from "react-router-dom";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
|
||||
function summarizeIssueDescription(description: string | null | undefined) {
|
||||
if (!description) return null;
|
||||
const summary = description
|
||||
.replace(/!\[[^\]]*]\([^)]+\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/[#>*_`~-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (!summary) return null;
|
||||
return summary.length > 180 ? `${summary.slice(0, 177).trimEnd()}...` : summary;
|
||||
}
|
||||
|
||||
export function IssueQuicklookCard({
|
||||
issue,
|
||||
linkTo,
|
||||
linkState,
|
||||
compact = false,
|
||||
}: {
|
||||
issue: Issue;
|
||||
linkTo: RouterDom.To;
|
||||
linkState?: unknown;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const description = useMemo(() => summarizeIssueDescription(issue.description), [issue.description]);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", compact && "space-y-1.5")}>
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
|
||||
<RouterDom.Link
|
||||
to={linkTo}
|
||||
state={linkState ?? withIssueDetailHeaderSeed(null, issue)}
|
||||
className="text-sm font-medium leading-snug hover:underline line-clamp-2"
|
||||
>
|
||||
{issue.title}
|
||||
</RouterDom.Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
<span>·</span>
|
||||
<span>{issue.status.replace(/_/g, " ")}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(new Date(issue.updatedAt))}</span>
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="text-xs leading-5 text-muted-foreground [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:4] overflow-hidden">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const IssueLinkQuicklook = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<typeof RouterDom.Link> & { issuePathId: string }
|
||||
>(function IssueLinkQuicklookImpl(
|
||||
{
|
||||
issuePathId,
|
||||
to,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.detail(issuePathId),
|
||||
queryFn: () => issuesApi.get(issuePathId),
|
||||
enabled: open,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const detailPath = createIssueDetailPath(issuePathId);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<RouterDom.Link
|
||||
ref={ref}
|
||||
to={to}
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
setOpen(false);
|
||||
onClick?.(event);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RouterDom.Link>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-72 p-3"
|
||||
side="top"
|
||||
align="start"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{data ? (
|
||||
<IssueQuicklookCard issue={data} linkTo={detailPath} compact />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-24 rounded bg-accent/50" />
|
||||
<div className="h-4 w-full rounded bg-accent/40" />
|
||||
<div className="h-4 w-3/4 rounded bg-accent/30" />
|
||||
{!isLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Unable to load issue preview.</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
|
@ -18,6 +18,7 @@ const mockProjectsApi = vi.hoisted(() => ({
|
|||
}));
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listLabels: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -193,6 +194,7 @@ describe("IssueProperties", () => {
|
|||
document.body.appendChild(container);
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
||||
});
|
||||
|
|
@ -227,6 +229,119 @@ describe("IssueProperties", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows an add-label button when labels already exist and opens the picker", async () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
labels: [{ id: "label-1", companyId: "company-1", name: "Bug", color: "#ef4444", createdAt: new Date("2026-04-06T12:00:00.000Z"), updatedAt: new Date("2026-04-06T12:00:00.000Z") }],
|
||||
labelIds: ["label-1"],
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const addLabelButton = container.querySelector('button[aria-label="Add label"]');
|
||||
expect(addLabelButton).not.toBeNull();
|
||||
expect(container.querySelector('input[placeholder="Search labels..."]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('input[placeholder="Search labels..."]')).not.toBeNull();
|
||||
expect(container.querySelector('button[title="Delete Bug"]')).toBeNull();
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("allows setting and clearing a parent issue from the properties pane", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
mockIssuesApi.list.mockResolvedValue([
|
||||
createIssue({ id: "issue-2", identifier: "PAP-2", title: "Candidate parent", status: "in_progress" }),
|
||||
]);
|
||||
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue(),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const parentTrigger = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("No parent"));
|
||||
expect(parentTrigger).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const candidateButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
|
||||
expect(candidateButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-2" });
|
||||
|
||||
onUpdate.mockClear();
|
||||
const rerenderedIssue = createIssue({
|
||||
parentId: "issue-2",
|
||||
ancestors: [
|
||||
{
|
||||
id: "issue-2",
|
||||
identifier: "PAP-2",
|
||||
title: "Candidate parent",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
project: null,
|
||||
goal: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
act(() => root.unmount());
|
||||
|
||||
const rerenderedRoot = renderProperties(container, {
|
||||
issue: rerenderedIssue,
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const selectedParentTrigger = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
|
||||
expect(selectedParentTrigger).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const clearParentButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("No parent"));
|
||||
expect(clearParentButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
clearParentButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ parentId: null });
|
||||
|
||||
act(() => rerenderedRoot.unmount());
|
||||
});
|
||||
|
||||
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const root = renderProperties(container, {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
|
|||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, GitBranch, FolderOpen, Copy, Check } from "lucide-react";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Copy, Check } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
|
||||
|
|
@ -82,9 +82,9 @@ interface IssuePropertiesProps {
|
|||
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">{children}</div>
|
||||
<div className="flex items-start gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20 mt-0.5">{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1 flex-wrap">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ function PropertyPicker({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
const btnCn = cn(
|
||||
"inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors",
|
||||
"inline-flex items-start gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors min-w-0 max-w-full text-left",
|
||||
triggerClassName,
|
||||
);
|
||||
|
||||
|
|
@ -167,6 +167,8 @@ export function IssueProperties({
|
|||
const [projectSearch, setProjectSearch] = useState("");
|
||||
const [blockedByOpen, setBlockedByOpen] = useState(false);
|
||||
const [blockedBySearch, setBlockedBySearch] = useState("");
|
||||
const [parentOpen, setParentOpen] = useState(false);
|
||||
const [parentSearch, setParentSearch] = useState("");
|
||||
const [reviewersOpen, setReviewersOpen] = useState(false);
|
||||
const [reviewerSearch, setReviewerSearch] = useState("");
|
||||
const [approversOpen, setApproversOpen] = useState(false);
|
||||
|
|
@ -212,7 +214,7 @@ export function IssueProperties({
|
|||
const { data: allIssues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(companyId!),
|
||||
queryFn: () => issuesApi.list(companyId!),
|
||||
enabled: !!companyId && blockedByOpen,
|
||||
enabled: !!companyId && (blockedByOpen || parentOpen),
|
||||
});
|
||||
|
||||
const createLabel = useMutation({
|
||||
|
|
@ -224,15 +226,6 @@ export function IssueProperties({
|
|||
},
|
||||
});
|
||||
|
||||
const deleteLabel = useMutation({
|
||||
mutationFn: (labelId: string) => issuesApi.deleteLabel(labelId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleLabel = (labelId: string) => {
|
||||
const ids = issue.labelIds ?? [];
|
||||
const next = ids.includes(labelId)
|
||||
|
|
@ -304,10 +297,10 @@ export function IssueProperties({
|
|||
return value;
|
||||
};
|
||||
const reviewerTrigger = reviewerValues.length > 0
|
||||
? <span className="text-sm truncate">{reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
||||
? <span className="text-sm break-words min-w-0">{reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
||||
: <span className="text-sm text-muted-foreground">None</span>;
|
||||
const approverTrigger = approverValues.length > 0
|
||||
? <span className="text-sm truncate">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
||||
? <span className="text-sm break-words min-w-0">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
||||
: <span className="text-sm text-muted-foreground">None</span>;
|
||||
const nextRunnableExecutionStage = (() => {
|
||||
if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) {
|
||||
|
|
@ -369,6 +362,17 @@ export function IssueProperties({
|
|||
<span className="text-sm text-muted-foreground">No labels</span>
|
||||
</>
|
||||
);
|
||||
const labelsExtra = (issue.labelIds ?? []).length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setLabelsOpen(true)}
|
||||
aria-label="Add label"
|
||||
title="Add label"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
) : undefined;
|
||||
|
||||
const labelsContent = (
|
||||
<>
|
||||
|
|
@ -388,26 +392,17 @@ export function IssueProperties({
|
|||
.map((label) => {
|
||||
const selected = (issue.labelIds ?? []).includes(label.id);
|
||||
return (
|
||||
<div key={label.id} className="flex items-center gap-1">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 flex-1 px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
||||
selected && "bg-accent"
|
||||
)}
|
||||
onClick={() => toggleLabel(label.id)}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: label.color }} />
|
||||
<span className="truncate">{label.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-muted-foreground hover:text-destructive rounded"
|
||||
onClick={() => deleteLabel.mutate(label.id)}
|
||||
title={`Delete ${label.name}`}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
key={label.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
||||
selected && "bg-accent"
|
||||
)}
|
||||
onClick={() => toggleLabel(label.id)}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: label.color }} />
|
||||
<span className="truncate">{label.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -609,7 +604,7 @@ export function IssueProperties({
|
|||
className="shrink-0 h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
|
||||
/>
|
||||
<span className="text-sm truncate">{projectName(issue.projectId)}</span>
|
||||
<span className="text-sm break-words min-w-0">{projectName(issue.projectId)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -685,6 +680,100 @@ export function IssueProperties({
|
|||
);
|
||||
|
||||
const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? [];
|
||||
const descendantIssueIds = useMemo(() => {
|
||||
if (!allIssues?.length) return new Set<string>();
|
||||
const childrenByParentId = new Map<string, string[]>();
|
||||
for (const candidate of allIssues) {
|
||||
if (!candidate.parentId) continue;
|
||||
const children = childrenByParentId.get(candidate.parentId) ?? [];
|
||||
children.push(candidate.id);
|
||||
childrenByParentId.set(candidate.parentId, children);
|
||||
}
|
||||
|
||||
const descendants = new Set<string>();
|
||||
const stack = [...(childrenByParentId.get(issue.id) ?? [])];
|
||||
while (stack.length > 0) {
|
||||
const candidateId = stack.pop();
|
||||
if (!candidateId || descendants.has(candidateId)) continue;
|
||||
descendants.add(candidateId);
|
||||
stack.push(...(childrenByParentId.get(candidateId) ?? []));
|
||||
}
|
||||
return descendants;
|
||||
}, [allIssues, issue.id]);
|
||||
const currentParentIssue = useMemo(() => {
|
||||
if (!issue.parentId) return null;
|
||||
return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null;
|
||||
}, [allIssues, issue.parentId]);
|
||||
const parentTrigger = issue.parentId ? (
|
||||
<span className="text-sm break-words min-w-0">
|
||||
{issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier
|
||||
? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} `
|
||||
: ""}
|
||||
{issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">No parent</span>
|
||||
);
|
||||
const parentOptions = (allIssues ?? [])
|
||||
.filter((candidate) => candidate.id !== issue.id)
|
||||
.filter((candidate) => !descendantIssueIds.has(candidate.id))
|
||||
.filter((candidate) => {
|
||||
if (!parentSearch.trim()) return true;
|
||||
const query = parentSearch.toLowerCase();
|
||||
return (
|
||||
(candidate.identifier ?? "").toLowerCase().includes(query) ||
|
||||
candidate.title.toLowerCase().includes(query)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aLabel = `${a.identifier ?? ""} ${a.title}`.trim();
|
||||
const bLabel = `${b.identifier ?? ""} ${b.title}`.trim();
|
||||
return aLabel.localeCompare(bLabel);
|
||||
});
|
||||
const parentContent = (
|
||||
<>
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder="Search issues..."
|
||||
value={parentSearch}
|
||||
onChange={(e) => setParentSearch(e.target.value)}
|
||||
autoFocus={!inline}
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
!issue.parentId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({ parentId: null });
|
||||
setParentOpen(false);
|
||||
}}
|
||||
>
|
||||
No parent
|
||||
</button>
|
||||
{parentOptions.map((candidate) => (
|
||||
<button
|
||||
key={candidate.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs rounded hover:bg-accent/50",
|
||||
candidate.id === issue.parentId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({ parentId: candidate.id });
|
||||
setParentOpen(false);
|
||||
}}
|
||||
>
|
||||
<StatusIcon status={candidate.status} />
|
||||
<span className="truncate">
|
||||
{candidate.identifier ? `${candidate.identifier} ` : ""}
|
||||
{candidate.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const blockedByTrigger = blockedByIds.length > 0 ? (
|
||||
<div className="flex items-center gap-1 flex-wrap min-w-0">
|
||||
{(issue.blockedBy ?? []).slice(0, 2).map((relation) => (
|
||||
|
|
@ -793,6 +882,7 @@ export function IssueProperties({
|
|||
triggerContent={labelsTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName="w-64"
|
||||
extra={labelsExtra}
|
||||
>
|
||||
{labelsContent}
|
||||
</PropertyPicker>
|
||||
|
|
@ -838,6 +928,30 @@ export function IssueProperties({
|
|||
{projectContent}
|
||||
</PropertyPicker>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Parent"
|
||||
open={parentOpen}
|
||||
onOpenChange={(open) => {
|
||||
setParentOpen(open);
|
||||
if (!open) setParentSearch("");
|
||||
}}
|
||||
triggerContent={parentTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName="w-72"
|
||||
extra={issue.parentId ? (
|
||||
<Link
|
||||
to={`/issues/${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier ?? issue.parentId}`}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : undefined}
|
||||
>
|
||||
{parentContent}
|
||||
</PropertyPicker>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Blocked by"
|
||||
|
|
@ -939,16 +1053,6 @@ export function IssueProperties({
|
|||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{issue.parentId && (
|
||||
<PropertyRow label="Parent">
|
||||
<Link
|
||||
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
|
||||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.requestDepth > 0 && (
|
||||
<PropertyRow label="Depth">
|
||||
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { IssueRow } from "./IssueRow";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, className, ...props }: React.ComponentProps<"a">) => (
|
||||
<a className={className} {...props}>{children}</a>
|
||||
Link: ({ children, className, disableIssueQuicklook: _disableIssueQuicklook, ...props }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean }) => (
|
||||
<a
|
||||
className={className}
|
||||
data-disable-issue-quicklook={_disableIssueQuicklook ? "true" : undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
|
|
@ -135,6 +141,22 @@ describe("IssueRow", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("opts issue quicklook out for dense inbox rows", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<IssueRow issue={createIssue()} />);
|
||||
});
|
||||
|
||||
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.getAttribute("data-disable-issue-quicklook")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders titleSuffix inline after the issue title", () => {
|
||||
const root = createRoot(container);
|
||||
const issue = createIssue({ title: "Parent task" });
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export function IssueRow({
|
|||
<Link
|
||||
to={createIssueDetailPath(issuePathId)}
|
||||
state={detailState}
|
||||
disableIssueQuicklook
|
||||
data-inbox-issue-link
|
||||
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import type { Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssuesList } from "./IssuesList";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const companyState = vi.hoisted(() => ({
|
||||
selectedCompanyId: "company-1",
|
||||
|
|
@ -161,7 +162,9 @@ function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
|
|||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{node}
|
||||
<TooltipProvider>
|
||||
{node}
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
|
@ -297,7 +300,10 @@ describe("IssuesList", () => {
|
|||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Columns");
|
||||
const columnsButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.getAttribute("title") === "Columns",
|
||||
);
|
||||
expect(columnsButton).not.toBeUndefined();
|
||||
expect(container.textContent).toContain("PAP-9");
|
||||
expect(container.textContent).toContain("Agent One");
|
||||
expect(container.textContent).not.toContain("Updated");
|
||||
|
|
@ -308,6 +314,77 @@ describe("IssuesList", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("filters the list to a single workspace when a workspace name is clicked", async () => {
|
||||
localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "workspace"]));
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "workspace-alpha",
|
||||
name: "Alpha",
|
||||
mode: "isolated_workspace",
|
||||
status: "active",
|
||||
projectWorkspaceId: null,
|
||||
},
|
||||
{
|
||||
id: "workspace-beta",
|
||||
name: "Beta",
|
||||
mode: "isolated_workspace",
|
||||
status: "active",
|
||||
projectWorkspaceId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const alphaIssue = createIssue({
|
||||
id: "issue-alpha",
|
||||
identifier: "PAP-20",
|
||||
title: "Alpha issue",
|
||||
executionWorkspaceId: "workspace-alpha",
|
||||
});
|
||||
const betaIssue = createIssue({
|
||||
id: "issue-beta",
|
||||
identifier: "PAP-21",
|
||||
title: "Beta issue",
|
||||
executionWorkspaceId: "workspace-beta",
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[alphaIssue, betaIssue]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Alpha issue");
|
||||
expect(container.textContent).toContain("Beta issue");
|
||||
const workspaceButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Alpha",
|
||||
);
|
||||
expect(workspaceButton).not.toBeUndefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const workspaceButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Alpha",
|
||||
);
|
||||
workspaceButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Alpha issue");
|
||||
expect(container.textContent).not.toContain("Beta issue");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => {
|
||||
const manualIssue = createIssue({
|
||||
id: "issue-manual",
|
||||
|
|
@ -341,7 +418,7 @@ describe("IssuesList", () => {
|
|||
|
||||
await act(async () => {
|
||||
const filterButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Filter"),
|
||||
(button) => button.getAttribute("title") === "Filter",
|
||||
);
|
||||
filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
|
|
@ -370,4 +447,75 @@ describe("IssuesList", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("blurs the search input on Enter without clearing the query", async () => {
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[createIssue()]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
initialSearch="bug"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
input?.focus();
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement;
|
||||
act(() => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
}));
|
||||
});
|
||||
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
expect(input.value).toBe("bug");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("blurs the search input on Escape once the field is empty", async () => {
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[createIssue()]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
initialSearch=""
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
input?.focus();
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement;
|
||||
act(() => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "Escape",
|
||||
bubbles: true,
|
||||
}));
|
||||
});
|
||||
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import { issuesApi } from "../api/issues";
|
|||
import { authApi } from "../api/auth";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
shouldBlurPageSearchOnEnter,
|
||||
shouldBlurPageSearchOnEscape,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import {
|
||||
|
|
@ -15,6 +19,7 @@ import {
|
|||
defaultIssueFilterState,
|
||||
issueFilterLabel,
|
||||
issuePriorityOrder,
|
||||
resolveIssueFilterWorkspaceId,
|
||||
issueStatusOrder,
|
||||
type IssueFilterState,
|
||||
} from "../lib/issue-filters";
|
||||
|
|
@ -170,9 +175,27 @@ function IssueSearchInput({
|
|||
onChange={(e) => {
|
||||
setDraftValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (shouldBlurPageSearchOnEnter({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBlurPageSearchOnEscape({
|
||||
key: e.key,
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
currentValue: e.currentTarget.value,
|
||||
})) {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Search issues..."
|
||||
className="pl-7 text-xs sm:text-sm"
|
||||
aria-label="Search issues"
|
||||
data-page-search-target="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -346,6 +369,16 @@ export function IssuesList({
|
|||
return map;
|
||||
}, [executionWorkspaceById, projectWorkspaceById]);
|
||||
|
||||
const workspaceOptions = useMemo(() => {
|
||||
const options = new Map<string, string>();
|
||||
for (const [workspaceId, workspaceName] of workspaceNameMap) {
|
||||
options.set(workspaceId, workspaceName);
|
||||
}
|
||||
return [...options.entries()]
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([id, name]) => ({ id, name }));
|
||||
}, [workspaceNameMap]);
|
||||
|
||||
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
|
||||
const availableIssueColumns = useMemo(
|
||||
() => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled),
|
||||
|
|
@ -404,7 +437,7 @@ export function IssuesList({
|
|||
.map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! }));
|
||||
}
|
||||
if (viewState.groupBy === "workspace") {
|
||||
const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace");
|
||||
const groups = groupBy(filtered, (issue) => resolveIssueFilterWorkspaceId(issue) ?? "__no_workspace");
|
||||
return Object.keys(groups)
|
||||
.sort((a, b) => {
|
||||
// Groups with items first, "no workspace" last
|
||||
|
|
@ -467,6 +500,10 @@ export function IssuesList({
|
|||
return defaults;
|
||||
}, [projectId, viewState.groupBy]);
|
||||
|
||||
const filterToWorkspace = useCallback((workspaceId: string) => {
|
||||
updateView({ workspaces: [workspaceId] });
|
||||
}, [updateView]);
|
||||
|
||||
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
|
||||
const normalized = normalizeInboxIssueColumns(next);
|
||||
setVisibleIssueColumns(normalized);
|
||||
|
|
@ -531,6 +568,7 @@ export function IssuesList({
|
|||
onToggleColumn={toggleIssueColumn}
|
||||
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||
title="Choose which issue columns stay visible"
|
||||
iconOnly
|
||||
/>
|
||||
|
||||
<IssueFiltersPopover
|
||||
|
|
@ -542,15 +580,16 @@ export function IssuesList({
|
|||
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId={currentUserId}
|
||||
enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
|
||||
iconOnly
|
||||
workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined}
|
||||
/>
|
||||
|
||||
{/* Sort (list view only) */}
|
||||
{viewState.viewMode === "list" && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs">
|
||||
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Sort</span>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Sort">
|
||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-48 p-0">
|
||||
|
|
@ -592,9 +631,8 @@ export function IssuesList({
|
|||
{viewState.viewMode === "list" && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs">
|
||||
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Group</span>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Group">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
|
|
@ -751,11 +789,13 @@ export function IssuesList({
|
|||
columns={visibleTrailingIssueColumns}
|
||||
projectName={issueProject?.name ?? null}
|
||||
projectColor={issueProject?.color ?? null}
|
||||
workspaceId={resolveIssueFilterWorkspaceId(issue)}
|
||||
workspaceName={resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
})}
|
||||
onFilterWorkspace={filterToWorkspace}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
currentUserId={currentUserId}
|
||||
parentIdentifier={parentIssue?.identifier ?? null}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { useState } from "react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { IssueQuicklookCard } from "./IssueLinkQuicklook";
|
||||
|
||||
interface IssuesQuicklookProps {
|
||||
issue: Issue;
|
||||
|
|
@ -24,32 +22,18 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) {
|
|||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-64 p-3"
|
||||
className="w-72 p-3"
|
||||
side="top"
|
||||
align="start"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
|
||||
<Link
|
||||
to={createIssueDetailPath(issue.identifier ?? issue.id)}
|
||||
state={withIssueDetailHeaderSeed(null, issue)}
|
||||
className="text-sm font-medium leading-snug hover:underline line-clamp-2"
|
||||
>
|
||||
{issue.title}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
<span>·</span>
|
||||
<span>{issue.status.replace(/_/g, " ")}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(new Date(issue.updatedAt))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<IssueQuicklookCard
|
||||
issue={issue}
|
||||
linkTo={createIssueDetailPath(issue.identifier ?? issue.id)}
|
||||
linkState={withIssueDetailHeaderSeed(null, issue)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ function KanbanCard({
|
|||
>
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
disableIssueQuicklook
|
||||
className="block no-underline text-inherit"
|
||||
onClick={(e) => {
|
||||
// Prevent navigation during drag
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const sections: ShortcutSection[] = [
|
|||
{
|
||||
title: "Global",
|
||||
shortcuts: [
|
||||
{ keys: ["/"], label: "Search current page or quick search" },
|
||||
{ keys: ["c"], label: "New issue" },
|
||||
{ keys: ["["], label: "Toggle sidebar" },
|
||||
{ keys: ["]"], label: "Toggle panel" },
|
||||
|
|
|
|||
|
|
@ -154,12 +154,21 @@ export function Layout() {
|
|||
]);
|
||||
|
||||
const togglePanel = togglePanelVisible;
|
||||
const openSearch = useCallback(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "k",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useCompanyPageMemory();
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: keyboardShortcutsEnabled,
|
||||
onNewIssue: () => openNewIssue(),
|
||||
onSearch: openSearch,
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onTogglePanel: togglePanel,
|
||||
onShowShortcuts: () => setShortcutsOpen(true),
|
||||
|
|
|
|||
|
|
@ -222,18 +222,6 @@ async function flush() {
|
|||
});
|
||||
}
|
||||
|
||||
async function waitForValue<T>(getValue: () => T | null | undefined, attempts = 10): Promise<T> {
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const value = getValue();
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
await flush();
|
||||
}
|
||||
|
||||
throw new Error("Timed out waiting for value");
|
||||
}
|
||||
|
||||
function renderDialog(container: HTMLDivElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -394,10 +382,15 @@ describe("NewIssueDialog", () => {
|
|||
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
|
||||
expect(dialogContent?.className).toContain("overflow-hidden");
|
||||
|
||||
const titleInput = container.querySelector('textarea[placeholder="Issue title"]');
|
||||
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]');
|
||||
const descriptionScrollRegion = descriptionInput?.parentElement?.parentElement;
|
||||
expect(descriptionScrollRegion?.className).toContain("flex-1");
|
||||
expect(descriptionScrollRegion?.className).toContain("overflow-y-auto");
|
||||
const bodyScrollRegion = Array.from(container.querySelectorAll("div")).find((element) =>
|
||||
typeof element.className === "string" && element.className.includes("overscroll-contain"),
|
||||
);
|
||||
expect(bodyScrollRegion?.className).toContain("flex-1");
|
||||
expect(bodyScrollRegion?.className).toContain("overflow-y-auto");
|
||||
expect(bodyScrollRegion?.contains(titleInput ?? null)).toBe(true);
|
||||
expect(bodyScrollRegion?.contains(descriptionInput ?? null)).toBe(true);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
|
@ -452,13 +445,13 @@ describe("NewIssueDialog", () => {
|
|||
|
||||
expect(container.textContent).not.toContain("will no longer use the parent issue workspace");
|
||||
|
||||
const modeSelect = await waitForValue(
|
||||
() => container.querySelector("select") as HTMLSelectElement | null,
|
||||
);
|
||||
const selects = Array.from(container.querySelectorAll("select"));
|
||||
const modeSelect = selects[0] as HTMLSelectElement | undefined;
|
||||
expect(modeSelect).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
modeSelect.value = "shared_workspace";
|
||||
modeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
modeSelect!.value = "shared_workspace";
|
||||
modeSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
|
|
|
|||
|
|
@ -1056,9 +1056,10 @@ export function NewIssueDialog() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||
<textarea
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{/* Title */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<textarea
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
|
||||
placeholder="Issue title"
|
||||
rows={1}
|
||||
|
|
@ -1094,12 +1095,12 @@ export function NewIssueDialog() {
|
|||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground flex-wrap sm:flex-nowrap sm:min-w-max">
|
||||
<div className="px-4 pb-2">
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground flex-wrap sm:flex-nowrap sm:min-w-max">
|
||||
<span className="w-6 shrink-0 text-center">For</span>
|
||||
<InlineEntitySelector
|
||||
ref={assigneeSelectorRef}
|
||||
|
|
@ -1235,14 +1236,14 @@ export function NewIssueDialog() {
|
|||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reviewer row */}
|
||||
{showReviewerRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><Eye className="h-3.5 w-3.5" /></span>
|
||||
<InlineEntitySelector
|
||||
{/* Reviewer row */}
|
||||
{showReviewerRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><Eye className="h-3.5 w-3.5" /></span>
|
||||
<InlineEntitySelector
|
||||
value={reviewerValue}
|
||||
options={assigneeOptions}
|
||||
placeholder="Reviewer"
|
||||
|
|
@ -1278,15 +1279,15 @@ export function NewIssueDialog() {
|
|||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approver row */}
|
||||
{showApproverRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><ShieldCheck className="h-3.5 w-3.5" /></span>
|
||||
<InlineEntitySelector
|
||||
{/* Approver row */}
|
||||
{showApproverRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><ShieldCheck className="h-3.5 w-3.5" /></span>
|
||||
<InlineEntitySelector
|
||||
value={approverValue}
|
||||
options={assigneeOptions}
|
||||
placeholder="Approver"
|
||||
|
|
@ -1322,13 +1323,13 @@ export function NewIssueDialog() {
|
|||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSubIssueMode ? (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
{isSubIssueMode ? (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="max-w-full rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ListTree className="h-3.5 w-3.5 shrink-0" />
|
||||
|
|
@ -1341,11 +1342,11 @@ export function NewIssueDialog() {
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentProject && currentProjectSupportsExecutionWorkspace && (
|
||||
<div className="px-4 py-3 shrink-0 space-y-2">
|
||||
{currentProject && currentProjectSupportsExecutionWorkspace && (
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium">Execution workspace</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
|
|
@ -1392,11 +1393,11 @@ export function NewIssueDialog() {
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supportsAssigneeOverrides && (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
{supportsAssigneeOverrides && (
|
||||
<div className="px-4 pb-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setAssigneeOptionsOpen((open) => !open)}
|
||||
|
|
@ -1447,39 +1448,39 @@ export function NewIssueDialog() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto border-t border-border/60 px-4 pb-2 pt-3"
|
||||
onDragEnter={handleFileDragEnter}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
>
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md transition-colors",
|
||||
isFileDragOver && "bg-accent/20",
|
||||
)}
|
||||
className="border-t border-border/60 px-4 pb-2 pt-3"
|
||||
onDragEnter={handleFileDragEnter}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
mentions={mentionOptions}
|
||||
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{stagedFiles.length > 0 ? (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-border/70 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md transition-colors",
|
||||
isFileDragOver && "bg-accent/20",
|
||||
)}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
mentions={mentionOptions}
|
||||
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{stagedFiles.length > 0 ? (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-border/70 p-3">
|
||||
{stagedDocuments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Documents</div>
|
||||
|
|
@ -1546,8 +1547,9 @@ export function NewIssueDialog() {
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Property chips bar */}
|
||||
|
|
|
|||
|
|
@ -109,9 +109,9 @@ function PropertyRow({
|
|||
valueClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex gap-3 py-1.5", alignStart ? "items-start" : "items-center")}>
|
||||
<div className="shrink-0 w-20">{label}</div>
|
||||
<div className={cn("min-w-0 flex-1", alignStart ? "pt-0.5" : "flex items-center gap-1.5", valueClassName)}>
|
||||
<div className={cn("flex gap-3 py-1.5 items-start")}>
|
||||
<div className="shrink-0 w-20 mt-0.5">{label}</div>
|
||||
<div className={cn("min-w-0 flex-1", alignStart ? "pt-0.5" : "flex items-center gap-1.5 flex-wrap", valueClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -551,7 +551,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||
key={goal.id}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
|
||||
>
|
||||
<Link to={`/goals/${goal.id}`} className="hover:underline max-w-[220px] truncate">
|
||||
<Link to={`/goals/${goal.id}`} className="hover:underline break-words min-w-0">
|
||||
{goal.title}
|
||||
</Link>
|
||||
{(onUpdate || onFieldUpdate) && (
|
||||
|
|
@ -668,13 +668,13 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||
className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
<Github className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{formatRepoUrl(codebase.repoUrl)}</span>
|
||||
<span className="break-all min-w-0">{formatRepoUrl(codebase.repoUrl)}</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0" />
|
||||
</a>
|
||||
) : (
|
||||
<div className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Github className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{codebase.repoUrl}</span>
|
||||
<span className="break-all min-w-0">{codebase.repoUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -723,7 +723,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">Local folder</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="min-w-0 truncate font-mono text-xs text-muted-foreground">
|
||||
<div className="min-w-0 break-all font-mono text-xs text-muted-foreground">
|
||||
{codebase.effectiveLocalFolder}
|
||||
</div>
|
||||
{codebase.origin === "managed_checkout" && (
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ export function PropertiesPanel() {
|
|||
|
||||
return (
|
||||
<aside
|
||||
className="hidden md:flex border-l border-border bg-card flex-col shrink-0 overflow-hidden transition-[width,opacity] duration-200 ease-in-out"
|
||||
className="hidden md:flex border-l border-border bg-card flex-col shrink-0 overflow-hidden transition-[width,opacity] duration-200 ease-in-out h-full"
|
||||
style={{ width: panelVisible ? 320 : 0, opacity: panelVisible ? 1 : 0 }}
|
||||
>
|
||||
<div className="w-80 flex-1 flex flex-col min-w-[320px]">
|
||||
<div className="w-80 flex-1 flex flex-col min-w-[320px] min-h-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
||||
<span className="text-sm font-medium">Properties</span>
|
||||
<Button variant="ghost" size="icon-xs" onClick={() => setPanelVisible(false)}>
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export function Sidebar() {
|
|||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} textBadge="Beta" textBadgeTone="amber" />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
</SidebarSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useSidebar } from "../context/SidebarContext";
|
|||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { useAgentOrder } from "../hooks/useAgentOrder";
|
||||
|
|
@ -105,6 +106,7 @@ export function SidebarAgents() {
|
|||
<NavLink
|
||||
key={agent.id}
|
||||
to={activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent)}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { NavLink } from "@/lib/router";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
|
@ -35,6 +36,7 @@ export function SidebarNavItem({
|
|||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
end={end}
|
||||
onClick={() => { if (isMobile) setSidebarOpen(false); }}
|
||||
className={({ isActive }) =>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { useDialog } from "../context/DialogContext";
|
|||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, projectRouteRef } from "../lib/utils";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
|
|
@ -74,6 +75,7 @@ function SortableProjectItem({
|
|||
<div className="flex flex-col gap-0.5">
|
||||
<NavLink
|
||||
to={`/projects/${routeRef}/issues`}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue