diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 804e8d48..7aac9dfa 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -161,6 +161,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -349,6 +351,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> }> diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts new file mode 100644 index 00000000..d0b3fab0 --- /dev/null +++ b/ui/src/api/issues.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("./client", () => ({ + api: mockApi, +})); + +import { issuesApi } from "./issues"; + +describe("issuesApi.list", () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockApi.get.mockResolvedValue([]); + }); + + it("passes parentId through to the company issues endpoint", async () => { + await issuesApi.list("company-1", { parentId: "issue-parent-1", limit: 25 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/issues?parentId=issue-parent-1&limit=25", + ); + }); +}); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 30bacbb9..bd604af9 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -24,6 +24,7 @@ export const issuesApi = { filters?: { status?: string; projectId?: string; + parentId?: string; assigneeAgentId?: string; participantAgentId?: string; assigneeUserId?: string; @@ -42,6 +43,7 @@ export const issuesApi = { const params = new URLSearchParams(); if (filters?.status) params.set("status", filters.status); if (filters?.projectId) params.set("projectId", filters.projectId); + if (filters?.parentId) params.set("parentId", filters.parentId); if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId); if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId); if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId); @@ -80,7 +82,21 @@ export const issuesApi = { expectedStatuses: ["todo", "backlog", "blocked", "in_review"], }), release: (id: string) => api.post(`/issues/${id}/release`, {}), - listComments: (id: string) => api.get(`/issues/${id}/comments`), + listComments: ( + id: string, + filters?: { + after?: string; + order?: "asc" | "desc"; + limit?: number; + }, + ) => { + const params = new URLSearchParams(); + if (filters?.after) params.set("after", filters.after); + if (filters?.order) params.set("order", filters.order); + if (filters?.limit) params.set("limit", String(filters.limit)); + const qs = params.toString(); + return api.get(`/issues/${id}/comments${qs ? `?${qs}` : ""}`); + }, listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), listFeedbackTraces: (id: string, filters?: Record) => { const params = new URLSearchParams(); diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 41c23bed..0fa73210 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -61,12 +61,26 @@ vi.mock("@/plugins/slots", () => ({ describe("CommentThread", () => { let container: HTMLDivElement; + let writeTextMock: ReturnType; + let execCommandMock: ReturnType; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z")); + writeTextMock = vi.fn(async () => {}); + execCommandMock = vi.fn(() => true); + Object.assign(navigator, { + clipboard: { + writeText: writeTextMock, + }, + }); + Object.defineProperty(window, "isSecureContext", { + value: true, + configurable: true, + }); + document.execCommand = execCommandMock; }); afterEach(() => { @@ -234,4 +248,59 @@ describe("CommentThread", () => { root.unmount(); }); }); + + it("uses a larger copy control with feedback and a clipboard fallback", async () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + /> + , + ); + }); + + const copyButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.getAttribute("aria-label") === "Copy comment as markdown", + ) as HTMLButtonElement | undefined; + + expect(copyButton).toBeDefined(); + expect(copyButton?.className).toContain("min-h-8"); + expect(copyButton?.textContent).toContain("Copy"); + + Object.defineProperty(window, "isSecureContext", { + value: false, + configurable: true, + }); + + await act(async () => { + copyButton?.click(); + }); + + expect(writeTextMock).not.toHaveBeenCalled(); + expect(execCommandMock).toHaveBeenCalledWith("copy"); + expect(copyButton?.textContent).toContain("Copied"); + + act(() => { + vi.advanceTimersByTime(1500); + }); + + expect(copyButton?.textContent).toContain("Copy"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index b2125b25..062cbcf7 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -210,21 +210,71 @@ function runStatusClass(status: string) { } } +async function copyTextWithFallback(text: string) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + + try { + textarea.select(); + const success = document.execCommand("copy"); + if (!success) throw new Error("execCommand copy failed"); + } finally { + document.body.removeChild(textarea); + } +} + function CopyMarkdownButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); + const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle"); + const timeoutRef = useRef | null>(null); + + useEffect(() => () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, []); + + const label = status === "copied" ? "Copied" : status === "failed" ? "Copy failed" : "Copy"; + return ( ); } diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index f292646a..5ba66f14 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -1,12 +1,20 @@ // @vitest-environment jsdom -import { act } from "react"; +import { act, createRef, forwardRef, useImperativeHandle } from "react"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread"; +const { markdownEditorFocusMock } = vi.hoisted(() => ({ + markdownEditorFocusMock: vi.fn(), +})); + +const { threadMessagesMock } = vi.hoisted(() => ({ + threadMessagesMock: vi.fn(() =>
), +})); + vi.mock("@assistant-ui/react", () => ({ AssistantRuntimeProvider: ({ children }: { children: ReactNode }) =>
{children}
, ThreadPrimitive: { @@ -17,7 +25,7 @@ vi.mock("@assistant-ui/react", () => ({
{children}
), Empty: ({ children }: { children: ReactNode }) =>
{children}
, - Messages: () =>
, + Messages: () => threadMessagesMock(), }, MessagePrimitive: { Root: ({ children }: { children: ReactNode }) =>
{children}
, @@ -48,22 +56,34 @@ vi.mock("./MarkdownBody", () => ({ })); vi.mock("./MarkdownEditor", () => ({ - MarkdownEditor: ({ + MarkdownEditor: forwardRef(({ value = "", onChange, placeholder, + className, + contentClassName, }: { value?: string; onChange?: (value: string) => void; placeholder?: string; - }) => ( -