From dab95740be90dc133d345dc7e7ed6626bddccc7a Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 10 Apr 2026 22:26:21 -0500 Subject: [PATCH] feat: polish inbox and issue list workflows --- .../issue-document-restore-routes.test.ts | 16 +- server/src/__tests__/routines-e2e.test.ts | 99 ++++--- server/src/__tests__/routines-routes.test.ts | 52 ++-- ui/src/components/AgentProperties.tsx | 8 +- ui/src/components/GoalProperties.tsx | 6 +- ui/src/components/IssueColumns.tsx | 61 +++- ui/src/components/IssueFiltersPopover.tsx | 40 ++- ui/src/components/IssueLinkQuicklook.tsx | 135 +++++++++ ui/src/components/IssueProperties.test.tsx | 115 ++++++++ ui/src/components/IssueProperties.tsx | 200 +++++++++---- ui/src/components/IssueRow.test.tsx | 26 +- ui/src/components/IssueRow.tsx | 1 + ui/src/components/IssuesList.test.tsx | 154 +++++++++- ui/src/components/IssuesList.tsx | 54 +++- ui/src/components/IssuesQuicklook.tsx | 30 +- ui/src/components/KanbanBoard.tsx | 1 + .../KeyboardShortcutsCheatsheet.tsx | 1 + ui/src/components/Layout.tsx | 9 + ui/src/components/NewIssueDialog.test.tsx | 33 +-- ui/src/components/NewIssueDialog.tsx | 138 ++++----- ui/src/components/ProjectProperties.tsx | 14 +- ui/src/components/PropertiesPanel.tsx | 4 +- ui/src/components/Sidebar.tsx | 2 +- ui/src/components/SidebarAgents.tsx | 2 + ui/src/components/SidebarNavItem.tsx | 2 + ui/src/components/SidebarProjects.tsx | 2 + ui/src/hooks/useKeyboardShortcuts.test.tsx | 51 ++++ ui/src/hooks/useKeyboardShortcuts.ts | 23 +- ui/src/lib/inbox.test.ts | 150 ++++++++++ ui/src/lib/inbox.ts | 198 ++++++++++++- ui/src/lib/issue-filters.ts | 15 + ui/src/lib/keyboardShortcuts.test.ts | 72 ++++- ui/src/lib/keyboardShortcuts.ts | 51 ++++ ui/src/lib/optimistic-issue-runs.test.ts | 24 -- ui/src/pages/Inbox.test.tsx | 1 + ui/src/pages/Inbox.tsx | 270 +++++++++++++----- ui/src/pages/Routines.tsx | 25 +- 37 files changed, 1674 insertions(+), 411 deletions(-) create mode 100644 ui/src/components/IssueLinkQuicklook.tsx diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index d576d3ac..386da1e2 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; const issueId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -50,11 +52,7 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); -async function createApp() { - const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/issues.js"), - import("../middleware/index.js"), - ]); +function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -74,7 +72,6 @@ async function createApp() { describe("issue document revision routes", () => { beforeEach(() => { - vi.resetModules(); vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue({ id: issueId, @@ -125,10 +122,9 @@ describe("issue document revision routes", () => { }); it("returns revision snapshots including title and format", async () => { - const res = await request(await createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`); + const res = await request(createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`); expect(res.status).toBe(200); - expect(mockDocumentsService.listIssueDocumentRevisions).toHaveBeenCalledWith(issueId, "plan"); expect(res.body).toEqual([ expect.objectContaining({ revisionNumber: 2, @@ -140,7 +136,7 @@ describe("issue document revision routes", () => { }); it("restores a revision through the append-only route and logs the action", async () => { - const res = await request(await createApp()) + const res = await request(createApp()) .post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`) .send({}); @@ -172,7 +168,7 @@ describe("issue document revision routes", () => { }); it("rejects invalid document keys before attempting restore", async () => { - const res = await request(await createApp()) + const res = await request(createApp()) .post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`) .send({}); diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index 12a3d837..deb3e110 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -26,56 +26,53 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { errorHandler } from "../middleware/index.js"; import { accessService } from "../services/access.js"; -function registerServiceMocks() { - vi.doMock("../services/index.js", async () => { - const actual = await vi.importActual("../services/index.js"); +vi.mock("../services/index.js", async () => { + const actual = await vi.importActual("../services/index.js"); - return { - ...actual, - routineService: (db: any) => - actual.routineService(db, { - heartbeat: { - wakeup: async (agentId: string, wakeupOpts: any) => { - const issueId = - (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || - (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || - null; - if (!issueId) return null; + return { + ...actual, + routineService: (db: any) => + actual.routineService(db, { + heartbeat: { + wakeup: async (agentId: string, wakeupOpts: any) => { + const issueId = + (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || + (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || + null; + if (!issueId) return null; - const issue = await db - .select({ companyId: issues.companyId }) - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); - if (!issue) return null; + const issue = await db + .select({ companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); + if (!issue) return null; - const queuedRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: queuedRunId, - companyId: issue.companyId, - agentId, - invocationSource: wakeupOpts?.source ?? "assignment", - triggerDetail: wakeupOpts?.triggerDetail ?? null, - status: "queued", - contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId }, - }); - await db - .update(issues) - .set({ - executionRunId: queuedRunId, - executionLockedAt: new Date(), - }) - .where(eq(issues.id, issueId)); - return { id: queuedRunId }; - }, + const queuedRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: queuedRunId, + companyId: issue.companyId, + agentId, + invocationSource: wakeupOpts?.source ?? "assignment", + triggerDetail: wakeupOpts?.triggerDetail ?? null, + status: "queued", + contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId }, + }); + await db + .update(issues) + .set({ + executionRunId: queuedRunId, + executionLockedAt: new Date(), + }) + .where(eq(issues.id, issueId)); + return { id: queuedRunId }; }, - }), - }; - }); -} + }, + }), + }; +}); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -95,11 +92,6 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { db = createDb(tempDb.connectionString); }, 20_000); - beforeEach(() => { - vi.resetModules(); - registerServiceMocks(); - }); - afterEach(async () => { await db.delete(activityLog); await db.delete(routineRuns); @@ -123,8 +115,15 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { await tempDb?.cleanup(); }); + beforeEach(() => { + vi.resetModules(); + }); + async function createApp(actor: Record) { - const { routineRoutes } = await import("../routes/routines.js"); + const [{ routineRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/routines.js"), + import("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index ce1db549..d3a9edea 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { routineRoutes } from "../routes/routines.js"; const companyId = "22222222-2222-4222-8222-222222222222"; const agentId = "11111111-1111-4111-8111-111111111111"; @@ -83,28 +85,22 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackRoutineCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -function registerRouteMocks() { - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackRoutineCreated: mockTrackRoutineCreated, - trackErrorHandlerCrash: vi.fn(), - })); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackRoutineCreated: mockTrackRoutineCreated, + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); - vi.doMock("../services/index.js", () => ({ - accessService: () => mockAccessService, - logActivity: mockLogActivity, - routineService: () => mockRoutineService, - })); -} +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + logActivity: mockLogActivity, + routineService: () => mockRoutineService, +})); -async function createApp(actor: Record) { - const [{ routineRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/routines.js"), - import("../middleware/index.js"), - ]); +function createApp(actor: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -118,9 +114,7 @@ async function createApp(actor: Record) { describe("routine routes", () => { beforeEach(() => { - vi.resetModules(); - registerRouteMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockRoutineService.create.mockResolvedValue(routine); mockRoutineService.get.mockResolvedValue(routine); @@ -136,7 +130,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission for non-admin board routine creation", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -158,7 +152,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to retarget a routine assignee", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -179,7 +173,7 @@ describe("routine routes", () => { it("requires tasks:assign permission to reactivate a routine", async () => { mockRoutineService.get.mockResolvedValue(pausedRoutine); - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -199,7 +193,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to create a trigger", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -221,7 +215,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to update a trigger", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -241,7 +235,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to manually run a routine", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -260,7 +254,7 @@ describe("routine routes", () => { it("allows routine creation when the board user has tasks:assign", async () => { mockAccessService.canUser.mockResolvedValue(true); - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 911a109a..b10f69e6 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -19,9 +19,9 @@ const roleLabels = AGENT_ROLE_LABELS as Record; function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } @@ -68,7 +68,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { )} {runtimeState?.lastError && ( - {runtimeState.lastError} + {runtimeState.lastError} )} {agent.lastHeartbeatAt && ( diff --git a/ui/src/components/GoalProperties.tsx b/ui/src/components/GoalProperties.tsx index fdc4da2a..27700a0e 100644 --- a/ui/src/components/GoalProperties.tsx +++ b/ui/src/components/GoalProperties.tsx @@ -20,9 +20,9 @@ interface GoalPropertiesProps { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } diff --git a/ui/src/components/IssueColumns.tsx b/ui/src/components/IssueColumns.tsx index a1489a7e..516a8c45 100644 --- a/ui/src/components/IssueColumns.tsx +++ b/ui/src/components/IssueColumns.tsx @@ -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; onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void; onResetColumns: () => void; title: string; + iconOnly?: boolean; }) { return ( @@ -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 ( - + {(issue.labels ?? []).slice(0, 2).map((label) => ( {label.name} ))} {(issue.labels ?? []).length > 2 ? ( - + +{(issue.labels ?? []).length - 2} ) : null} @@ -307,7 +317,28 @@ export function InboxIssueTrailingColumns({ return ( - {workspaceName} + {workspaceId && onFilterWorkspace ? ( + + + + + + Filter by workspace + + + ) : ( + workspaceName + )} ); } diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index ac35a308..02f63acf 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -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) => void; @@ -51,15 +58,18 @@ export function IssueFiltersPopover({ currentUserId?: string | null; enableRoutineVisibilityFilter?: boolean; buttonVariant?: "ghost" | "outline"; + iconOnly?: boolean; + workspaces?: WorkspaceOption[]; }) { return ( -
) : null} + {workspaces && workspaces.length > 0 ? ( +
+ Workspace +
+ {workspaces.map((workspace) => ( + + ))} +
+
+ ) : null} + {enableRoutineVisibilityFilter ? (
Visibility diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx new file mode 100644 index 00000000..4bb0048f --- /dev/null +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -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 ( +
+
+ + + {issue.title} + +
+
+ {issue.identifier ?? issue.id.slice(0, 8)} + · + {issue.status.replace(/_/g, " ")} + · + {timeAgo(new Date(issue.updatedAt))} +
+ {description ? ( +

+ {description} +

+ ) : null} +
+ ); +} + +export const IssueLinkQuicklook = React.forwardRef< + HTMLAnchorElement, + React.ComponentProps & { 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 ( + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + { + setOpen(false); + onClick?.(event); + }} + {...props} + > + {children} + + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + onOpenAutoFocus={(event) => event.preventDefault()} + > + {data ? ( + + ) : ( +
+
+
+
+ {!isLoading ? ( +

Unable to load issue preview.

+ ) : null} +
+ )} + + + ); +}); diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index a1fe7e1c..3c92683a 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -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, { diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index c6d33ff2..ed7a5c31 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -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 ( -
- {label} -
{children}
+
+ {label} +
{children}
); } @@ -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 - ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} + ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} : None; const approverTrigger = approverValues.length > 0 - ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} + ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} : None; const nextRunnableExecutionStage = (() => { if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) { @@ -369,6 +362,17 @@ export function IssueProperties({ No labels ); + const labelsExtra = (issue.labelIds ?? []).length > 0 ? ( + + ) : undefined; const labelsContent = ( <> @@ -388,26 +392,17 @@ export function IssueProperties({ .map((label) => { const selected = (issue.labelIds ?? []).includes(label.id); return ( -
- - -
+ ); })}
@@ -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" }} /> - {projectName(issue.projectId)} + {projectName(issue.projectId)} ) : ( <> @@ -685,6 +680,100 @@ export function IssueProperties({ ); const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? []; + const descendantIssueIds = useMemo(() => { + if (!allIssues?.length) return new Set(); + const childrenByParentId = new Map(); + 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(); + 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 ? ( + + {issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier + ? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} ` + : ""} + {issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)} + + ) : ( + No parent + ); + 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 = ( + <> + setParentSearch(e.target.value)} + autoFocus={!inline} + /> +
+ + {parentOptions.map((candidate) => ( + + ))} +
+ + ); const blockedByTrigger = blockedByIds.length > 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} @@ -838,6 +928,30 @@ export function IssueProperties({ {projectContent} + { + setParentOpen(open); + if (!open) setParentSearch(""); + }} + triggerContent={parentTrigger} + triggerClassName="min-w-0 max-w-full" + popoverClassName="w-72" + extra={issue.parentId ? ( + e.stopPropagation()} + > + + + ) : undefined} + > + {parentContent} + + )} - {issue.parentId && ( - - - {issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)} - - - )} {issue.requestDepth > 0 && ( {issue.requestDepth} diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 4ba0cee2..2b52a042 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -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">) => ( - {children} + Link: ({ children, className, disableIssueQuicklook: _disableIssueQuicklook, ...props }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean }) => ( + + {children} + ), })); @@ -135,6 +141,22 @@ describe("IssueRow", () => { }); }); + it("opts issue quicklook out for dense inbox rows", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + 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" }); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 13488489..2ba4e92e 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -58,6 +58,7 @@ export function IssueRow({ rememberIssueDetailLocationState(issuePathId, detailState)} className={cn( diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 96ea92e8..e87fa41c 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -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( - {node} + + {node} + , ); }); @@ -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( + 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( + 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( + 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(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 576a9ede..9dde9e08 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -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" />
); @@ -346,6 +369,16 @@ export function IssuesList({ return map; }, [executionWorkspaceById, projectWorkspaceById]); + const workspaceOptions = useMemo(() => { + const options = new Map(); + 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 /> ({ 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" && ( - @@ -592,9 +631,8 @@ export function IssuesList({ {viewState.viewMode === "list" && ( - @@ -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} diff --git a/ui/src/components/IssuesQuicklook.tsx b/ui/src/components/IssuesQuicklook.tsx index ba89d1cd..f8a12ce6 100644 --- a/ui/src/components/IssuesQuicklook.tsx +++ b/ui/src/components/IssuesQuicklook.tsx @@ -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} setOpen(true)} onMouseLeave={() => setOpen(false)} onOpenAutoFocus={(e) => e.preventDefault()} > -
-
- - - {issue.title} - -
-
- {issue.identifier ?? issue.id.slice(0, 8)} - · - {issue.status.replace(/_/g, " ")} - · - {timeAgo(new Date(issue.updatedAt))} -
-
+
); diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 8b2bad2c..3e1ef1fb 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -148,6 +148,7 @@ function KanbanCard({ > { // Prevent navigation during drag diff --git a/ui/src/components/KeyboardShortcutsCheatsheet.tsx b/ui/src/components/KeyboardShortcutsCheatsheet.tsx index 937292ad..45d6858d 100644 --- a/ui/src/components/KeyboardShortcutsCheatsheet.tsx +++ b/ui/src/components/KeyboardShortcutsCheatsheet.tsx @@ -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" }, diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 461d429c..cd697218 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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), diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index b1c29f70..b81b30e4 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -222,18 +222,6 @@ async function flush() { }); } -async function waitForValue(getValue: () => T | null | undefined, attempts = 10): Promise { - 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(); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 618428d7..7798c234 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1056,9 +1056,10 @@ export function NewIssueDialog() {
- {/* Title */} -
-