diff --git a/ui/src/adapters/transcript.test.ts b/ui/src/adapters/transcript.test.ts index c33c900836e..ddd164a54d2 100644 --- a/ui/src/adapters/transcript.test.ts +++ b/ui/src/adapters/transcript.test.ts @@ -70,4 +70,116 @@ describe("buildTranscript", () => { expect(first).toEqual([]); expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]); }); + + it("converts parser failures into transcript error entries and keeps going", () => { + const entries = buildTranscript( + [ + { ts, stream: "stdout", chunk: "ok\nexplode\nlater\n" }, + ], + (line, entryTs) => { + if (line === "explode") { + throw new Error("boom"); + } + return [{ kind: "stdout", ts: entryTs, text: line }]; + }, + ); + + expect(entries).toEqual([ + { kind: "stdout", ts, text: "ok" }, + { + kind: "result", + ts, + text: "Chat transcript error: boom. Falling back for line: explode", + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "transcript_parse_error", + isError: true, + errors: [], + }, + { kind: "stdout", ts, text: "later" }, + ]); + }); + + it("resets stateful parsers after a failure before parsing later lines", () => { + const statefulAdapter: UIAdapterModule = { + type: "stateful_test", + label: "Stateful Test", + parseStdoutLine: (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }], + createStdoutParser: () => { + let pending: string | null = null; + return { + parseLine: (line, entryTs) => { + if (line.startsWith("begin:")) { + pending = line.slice("begin:".length); + return []; + } + if (line === "explode") { + throw new Error(`bad state:${pending ?? "none"}`); + } + if (line === "finish" && pending) { + const text = `completed:${pending}`; + pending = null; + return [{ kind: "stdout", ts: entryTs, text }]; + } + return [{ kind: "stdout", ts: entryTs, text: `literal:${line}` }]; + }, + reset: () => { + pending = null; + }, + }; + }, + ConfigFields: () => null, + buildAdapterConfig: () => ({}), + }; + + const entries = buildTranscript( + [{ ts, stream: "stdout", chunk: "begin:task-a\nexplode\nfinish\n" }], + statefulAdapter, + ); + + expect(entries).toEqual([ + { + kind: "result", + ts, + text: "Chat transcript error: bad state:task-a. Falling back for line: explode", + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "transcript_parse_error", + isError: true, + errors: [], + }, + { kind: "stdout", ts, text: "literal:finish" }, + ]); + }); + + it("handles trailing buffered parser failures without throwing", () => { + const entries = buildTranscript( + [{ ts, stream: "stdout", chunk: "explode" }], + (line, entryTs) => { + if (line === "explode") { + throw new Error("trailing boom"); + } + return [{ kind: "stdout", ts: entryTs, text: line }]; + }, + ); + + expect(entries).toEqual([ + { + kind: "result", + ts, + text: "Chat transcript error: trailing boom. Falling back for line: explode", + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "transcript_parse_error", + isError: true, + errors: [], + }, + ]); + }); }); diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 307aa5ae44b..95a81707dde 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -3,6 +3,7 @@ import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from " export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; type TranscriptBuildOptions = { censorUsernameInLogs?: boolean }; +type RedactionOptions = { enabled: boolean }; function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) { if (typeof source === "function") { @@ -33,6 +34,66 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr } } +function truncateTranscriptLine(line: string, maxLength = 160) { + if (line.length <= maxLength) return line; + return `${line.slice(0, maxLength - 3)}...`; +} + +function formatTranscriptParserError(error: unknown) { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "string" && error) return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function createTranscriptParseErrorEntry( + line: string, + ts: string, + error: unknown, + redactionOptions: RedactionOptions, +): TranscriptEntry { + const errorText = formatTranscriptParserError(error) || "unknown parser error"; + const preview = truncateTranscriptLine(line); + return { + kind: "result", + ts, + text: redactHomePathUserSegments( + `Chat transcript error: ${errorText}. Falling back for line: ${preview}`, + redactionOptions, + ), + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "transcript_parse_error", + isError: true, + errors: [], + }; +} + +function appendParsedTranscriptLine(args: { + entries: TranscriptEntry[]; + line: string; + ts: string; + parseLine: (line: string, ts: string) => TranscriptEntry[]; + reset: (() => void) | null; + redactionOptions: RedactionOptions; +}) { + const { entries, line, ts, parseLine, reset, redactionOptions } = args; + try { + appendTranscriptEntries( + entries, + parseLine(line, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)), + ); + } catch (error) { + reset?.(); + appendTranscriptEntry(entries, createTranscriptParseErrorEntry(line, ts, error, redactionOptions)); + } +} + export function buildTranscript( chunks: RunLogChunk[], parserSource: StdoutLineParser | TranscriptParserSource, @@ -59,14 +120,28 @@ export function buildTranscript( for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - appendTranscriptEntries(entries, parseLine(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); + appendParsedTranscriptLine({ + entries, + line: trimmed, + ts: chunk.ts, + parseLine, + reset, + redactionOptions, + }); } } const trailing = stdoutBuffer.trim(); if (trailing) { const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString(); - appendTranscriptEntries(entries, parseLine(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); + appendParsedTranscriptLine({ + entries, + line: trailing, + ts, + parseLine, + reset, + redactionOptions, + }); } reset?.(); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 02e0d4b40ee..f4ad17b5233 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -369,7 +369,7 @@ function CommentCard({ - {comment.body} + {comment.body} {companyId && !isPending ? (
+ {text} ); diff --git a/ui/src/components/IssueColumns.tsx b/ui/src/components/IssueColumns.tsx index a1489a7e02b..98ebc68cdfc 100644 --- a/ui/src/components/IssueColumns.tsx +++ b/ui/src/components/IssueColumns.tsx @@ -50,12 +50,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 +66,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 ( @@ -276,20 +279,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} diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index c3f0a9d783f..d2dade973c6 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -69,7 +69,7 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) { } function renderBody(body: string, className?: string) { - return {body}; + return {body}; } function isPlanKey(key: string) { diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index ac35a308353..02f63acf9c8 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 00000000000..4bb0048f98d --- /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 a1fe7e1c341..4ccb2484e80 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,92 @@ describe("IssueProperties", () => { 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 c6d33ff2dff..f076282cf83 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -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({ @@ -685,6 +687,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) => ( @@ -838,6 +934,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 4ba0cee2e6a..2b52a042b8c 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 1348848909b..2ba4e92ea10 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 96ea92e825e..56f627ad847 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -297,7 +297,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).toBeDefined(); expect(container.textContent).toContain("PAP-9"); expect(container.textContent).toContain("Agent One"); expect(container.textContent).not.toContain("Updated"); @@ -341,7 +344,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 +373,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 576a9edeb69..c03b71f8d61 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 { @@ -170,9 +174,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" />
); @@ -531,6 +553,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 ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace" && w.status === "active").map((w) => ({ id: w.id, name: w.name })) : undefined} /> {/* Sort (list view only) */} {viewState.viewMode === "list" && ( - @@ -592,9 +616,8 @@ export function IssuesList({ {viewState.viewMode === "list" && ( - diff --git a/ui/src/components/IssuesQuicklook.tsx b/ui/src/components/IssuesQuicklook.tsx index ba89d1cdf31..f8a12ce67d7 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 8b2bad2cec0..3e1ef1fb6bf 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 937292adccd..45d6858ded3 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 461d429ce3e..cd6972180c9 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/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index fa8d46de5bd..8e632cea731 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -1,11 +1,16 @@ // @vitest-environment node -import { describe, expect, it } from "vitest"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared"; import { ThemeProvider } from "../context/ThemeContext"; import { MarkdownBody } from "./MarkdownBody"; +vi.mock("@/lib/router", () => ({ + Link: ({ children, to }: { children: ReactNode; to: string }) => {children}, +})); + describe("MarkdownBody", () => { it("renders markdown images without a resolver", () => { const html = renderToStaticMarkup( @@ -48,4 +53,66 @@ describe("MarkdownBody", () => { expect(html).toContain('href="/skills/skill-789"'); expect(html).toContain('data-mention-kind="skill"'); }); + + it("uses soft-break styling by default", () => { + const html = renderToStaticMarkup( + + + {"First line\nSecond line"} + + , + ); + + expect(html).toContain("First line
"); + expect(html).toContain("Second line"); + }); + + it("can opt out of soft-break styling", () => { + const html = renderToStaticMarkup( + + + {"First line\nSecond line"} + + , + ); + + expect(html).not.toContain("
"); + }); + + it("does not inject extra line-break nodes into nested lists", () => { + const html = renderToStaticMarkup( + + + {"1. Parent item\n - child a\n - child b\n\n2. Second item"} + + , + ); + + expect(html).not.toContain("[&_p]:whitespace-pre-line"); + expect(html).not.toContain("Parent item
"); + expect(html).toContain("
    "); + expect(html).toContain("
      "); + }); + + it("linkifies bare issue identifiers in markdown text", () => { + const html = renderToStaticMarkup( + + {"Depends on PAP-1271 for the hover state."} + , + ); + + expect(html).toContain('href="/issues/PAP-1271"'); + expect(html).toContain(">PAP-1271<"); + }); + + it("rewrites full issue URLs to internal issue links", () => { + const html = renderToStaticMarkup( + + {"See http://localhost:3100/PAP/issues/PAP-1179."} + , + ); + + expect(html).toContain('href="/issues/PAP-1179"'); + expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<"); + }); }); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index a454260787f..3453170ebac 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -4,11 +4,15 @@ import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; import { useTheme } from "../context/ThemeContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; +import { Link } from "@/lib/router"; +import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference"; +import { remarkSoftBreaks } from "../lib/remark-soft-breaks"; interface MarkdownBodyProps { children: string; className?: string; style?: React.CSSProperties; + softBreaks?: boolean; /** Optional resolver for relative image paths (e.g. within export packages) */ resolveImageSrc?: (src: string) => string | null; /** Called when a user clicks an inline image */ @@ -94,8 +98,11 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b ); } -export function MarkdownBody({ children, className, style, resolveImageSrc, onImageClick }: MarkdownBodyProps) { +export function MarkdownBody({ children, className, style, softBreaks = true, resolveImageSrc, onImageClick }: MarkdownBodyProps) { const { theme } = useTheme(); + const remarkPlugins = softBreaks + ? [remarkGfm, remarkLinkIssueReferences, remarkSoftBreaks] + : [remarkGfm, remarkLinkIssueReferences]; const components: Components = { pre: ({ node: _node, children: preChildren, ...preProps }) => { const mermaidSource = extractMermaidSource(preChildren); @@ -105,6 +112,15 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm return
      {preChildren}
      ; }, a: ({ href, children: linkChildren }) => { + const issueRef = parseIssueReferenceFromHref(href); + if (issueRef) { + return ( + + {linkChildren} + + ); + } + const parsed = href ? parseMentionChipHref(href) : null; if (parsed) { const targetHref = parsed.kind === "project" @@ -159,7 +175,7 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm )} style={style} > - url}> + url}> {children}
diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index b1c29f7081a..b81b30e4327 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 618428d763a..7798c2348aa 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1056,9 +1056,10 @@ export function NewIssueDialog() {
- {/* Title */} -
-