diff --git a/cli/src/__tests__/common.test.ts b/cli/src/__tests__/common.test.ts index 9562e47f1e3..f436bee0b49 100644 --- a/cli/src/__tests__/common.test.ts +++ b/cli/src/__tests__/common.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { writeContext } from "../client/context.js"; import { setStoredBoardCredential } from "../client/board-auth.js"; -import { resolveApiBase, resolveCommandContext } from "../commands/client/common.js"; +import { inferContentTypeFromPath, resolveApiBase, resolveCommandContext } from "../commands/client/common.js"; const ORIGINAL_ENV = { ...process.env }; @@ -158,3 +158,30 @@ describe("resolveCommandContext", () => { expect(explicitResolved.authSource).toBe("explicit"); }); }); + +describe("inferContentTypeFromPath", () => { + it("maps the issue-attachment file types the server allows", () => { + // Must match server/src/attachment-types.ts DEFAULT_ALLOWED_TYPES exactly. + expect(inferContentTypeFromPath("newsletter.html")).toBe("text/html"); + expect(inferContentTypeFromPath("page.htm")).toBe("text/html"); + expect(inferContentTypeFromPath("data.csv")).toBe("text/csv"); + expect(inferContentTypeFromPath("bundle.zip")).toBe("application/zip"); + expect(inferContentTypeFromPath("demo.mp4")).toBe("video/mp4"); + expect(inferContentTypeFromPath("clip.webm")).toBe("video/webm"); + expect(inferContentTypeFromPath("teaser.m4v")).toBe("video/x-m4v"); + expect(inferContentTypeFromPath("walkthrough.mov")).toBe("video/quicktime"); + expect(inferContentTypeFromPath("report.pdf")).toBe("application/pdf"); + expect(inferContentTypeFromPath("chart.png")).toBe("image/png"); + }); + + it("emits text types with no charset parameter so they match the exact allowlist", () => { + expect(inferContentTypeFromPath("notes.md")).toBe("text/markdown"); + expect(inferContentTypeFromPath("log.txt")).toBe("text/plain"); + }); + + it("is case-insensitive and returns undefined for unknown extensions", () => { + expect(inferContentTypeFromPath("/abs/Path/IMAGE.PNG")).toBe("image/png"); + expect(inferContentTypeFromPath("archive.unknownext")).toBeUndefined(); + expect(inferContentTypeFromPath("noextension")).toBeUndefined(); + }); +}); diff --git a/cli/src/__tests__/issue-subresources.test.ts b/cli/src/__tests__/issue-subresources.test.ts index fafd5de661f..ce1c60b9918 100644 --- a/cli/src/__tests__/issue-subresources.test.ts +++ b/cli/src/__tests__/issue-subresources.test.ts @@ -221,6 +221,40 @@ describe("issue subresource commands", () => { selectedOptionIds: ["file-a", "file-b"], }); }); + + it("forwards the agent run-id header and inferred content-type on attachment:upload", async () => { + // Regression: the multipart upload uses a hand-rolled fetch (not the JSON + // client), so it must forward X-Paperclip-Run-Id itself — otherwise an + // agent-authenticated upload is rejected with "401 Agent run id required". + const RUN_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + const tmp = await mkdtemp(join(tmpdir(), "paperclip-cli-test-")); + const filePath = join(tmp, "deliverable.html"); + await writeFile(filePath, "hi", "utf8"); + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + try { + await run([ + "issue", "attachment:upload", ISSUE_ID, + "--company-id", COMPANY_ID, + "--file", filePath, + "--run-id", RUN_ID, + ]); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/issues/${ISSUE_ID}/attachments`); + expect(init.method).toBe("POST"); + const headers = init.headers as Record; + expect(headers["x-paperclip-run-id"]).toBe(RUN_ID); + expect(headers.authorization).toBe("Bearer board-token"); + const file = (init.body as FormData).get("file") as File; + expect(file.type).toBe("text/html"); + }); }); function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { diff --git a/cli/src/commands/client/common.ts b/cli/src/commands/client/common.ts index 6a48b4ab150..ad5095e6971 100644 --- a/cli/src/commands/client/common.ts +++ b/cli/src/commands/client/common.ts @@ -13,6 +13,7 @@ export interface BaseClientOptions { profile?: string; apiBase?: string; apiKey?: string; + runId?: string; companyId?: string; json?: boolean; } @@ -34,6 +35,7 @@ export function addCommonClientOptions(command: Command, opts?: { includeCompany .option("--profile ", "CLI context profile name") .option("--api-base ", "Base URL for the Paperclip API") .option("--api-key ", "Bearer token for agent-authenticated calls") + .option("--run-id ", "Heartbeat run id for agent-authenticated mutations (checkout/release/interactions/in-progress update); falls back to $PAPERCLIP_RUN_ID") .option("--json", "Output raw JSON"); if (opts?.includeCompany) { @@ -68,9 +70,16 @@ export function resolveCommandContext( ); } + // Agent-authenticated mutations (checkout, release, interactions, PATCH of an + // in-progress issue) require the X-Paperclip-Run-Id header (the server returns + // "401 Agent run id required" without it). Source it from --run-id, else the + // PAPERCLIP_RUN_ID env the adapter/embodiment context already exports. + const runId = options.runId?.trim() || process.env.PAPERCLIP_RUN_ID?.trim() || undefined; + const api = new PaperclipApiClient({ apiBase, apiKey, + runId, recoverAuth: explicitApiKey || !canAttemptInteractiveBoardAuth() ? undefined : async ({ error }) => { @@ -126,18 +135,32 @@ export function apiPath(strings: TemplateStringsArray, ...values: Array { const bytes = await readFile(input.filePath); const form = new FormData(); form.set("file", new Blob([bytes], { type: inferContentTypeFromPath(input.filePath) }), input.filePath.split(/[\\/]/).pop() ?? "attachment"); if (input.commentId) form.set("issueCommentId", input.commentId); + // This multipart upload uses a hand-rolled fetch rather than PaperclipApiClient, + // so it must forward the agent run-id header itself — otherwise an + // agent-authenticated upload is rejected with "401 Agent run id required" + // (the client injects x-paperclip-run-id automatically for JSON requests). + const headers: Record = {}; + if (apiKey) headers.authorization = `Bearer ${apiKey}`; + if (input.runId) headers["x-paperclip-run-id"] = input.runId; const response = await fetch(buildApiUrl(apiBase, apiPath`/api/companies/${input.companyId}/issues/${input.issueId}/attachments`), { method: "POST", - headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined, + headers, body: form, }); return parseFetchResponse(response); diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 1fed2d477a6..31bf37ffe15 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -608,6 +608,27 @@ describe("agent issue mutation checkout ownership", () => { expect(mockStorageService.deleteObject).not.toHaveBeenCalled(); }); + it("rejects the checked-out owner without a run id on attachment upload (401)", async () => { + // Regression: an agent-authenticated client (e.g. the CLI's attachment:upload) + // that fails to send X-Paperclip-Run-Id must be rejected — mutating your own + // in-progress checkout requires proving run ownership. + const app = await createApp({ + type: "agent", + agentId: ownerAgentId, + companyId, + source: "agent_key", + // intentionally no runId + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/issues/${issueId}/attachments`) + .attach("file", Buffer.from("report"), { filename: "report.html", contentType: "text/html" }); + + expect(res.status, JSON.stringify(res.body)).toBe(401); + expect(res.body.error).toBe("Agent run id required"); + expect(mockStorageService.putFile).not.toHaveBeenCalled(); + }); + it("allows the checked-out owner with the matching run id to patch and update documents", async () => { const app = await createApp(ownerActor()); diff --git a/ui/src/components/InlineEntitySelector.test.tsx b/ui/src/components/InlineEntitySelector.test.tsx index f127f41c330..270adc54d8f 100644 --- a/ui/src/components/InlineEntitySelector.test.tsx +++ b/ui/src/components/InlineEntitySelector.test.tsx @@ -1,22 +1,33 @@ // @vitest-environment jsdom -import { act } from "react"; import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { InlineEntitySelector } from "./InlineEntitySelector"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + describe("InlineEntitySelector", () => { let container: HTMLDivElement; + let originalMatchMedia: typeof window.matchMedia; beforeEach(() => { + originalMatchMedia = window.matchMedia; container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { + window.matchMedia = originalMatchMedia; container.remove(); document.body.innerHTML = ""; }); @@ -77,4 +88,51 @@ describe("InlineEntitySelector", () => { root.unmount(); }); }); + + it("focuses the search input when opened on coarse pointers", async () => { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(pointer: coarse)", + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const trigger = container.querySelector("button") as HTMLButtonElement | null; + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const searchInput = document.querySelector('input[placeholder="Search assignees..."]') as HTMLInputElement | null; + expect(searchInput).not.toBeNull(); + expect(document.activeElement).toBe(searchInput); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx index 0ac378b5fee..58fea66970b 100644 --- a/ui/src/components/InlineEntitySelector.tsx +++ b/ui/src/components/InlineEntitySelector.tsx @@ -135,15 +135,7 @@ export const InlineEntitySelector = forwardRef { event.preventDefault(); - // On touch devices, don't auto-focus the search input to avoid - // opening the virtual keyboard which reshapes the viewport and - // pushes the popover off-screen. - const isTouch = typeof window.matchMedia === "function" - ? window.matchMedia("(pointer: coarse)").matches - : false; - if (!isTouch) { - inputRef.current?.focus(); - } + inputRef.current?.focus(); }} onCloseAutoFocus={(event) => { if (!shouldPreventCloseAutoFocusRef.current) return; diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index f4791a1ae9d..f36ff7816fd 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { act } from "react"; import type { ComponentProps, ReactNode } from "react"; +import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; @@ -34,6 +34,10 @@ vi.mock("@/lib/router", () => ({ ), })); +function act(callback: () => void) { + flushSync(callback); +} + function renderCard( props: Partial> = {}, ) { @@ -460,6 +464,48 @@ describe("IssueThreadInteractionCard", () => { expect(host.textContent).toContain("+34 more"); }); + it("expands the hidden accepted selections when the +N more chip is clicked", () => { + const host = renderCard({ + interaction: acceptedManyRequestCheckboxConfirmationInteraction, + }); + + const countSelectedChips = () => + Array.from(host.querySelectorAll("*")).filter( + (node) => node.children.length === 0 && node.textContent?.trim().startsWith("Selected:"), + ).length; + + expect(countSelectedChips()).toBe(8); + + const moreButton = Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes("+34 more"), + ); + expect(moreButton).toBeTruthy(); + moreButton?.focus(); + expect(document.activeElement).toBe(moreButton); + + act(() => { + moreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(host.textContent).not.toContain("+34 more"); + expect(countSelectedChips()).toBe(42); + expect(document.activeElement).toBe(moreButton); + + const showLessButton = Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes("Show less"), + ); + expect(showLessButton).toBeTruthy(); + expect(showLessButton).toBe(moreButton); + + act(() => { + showLessButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(countSelectedChips()).toBe(8); + expect(host.textContent).toContain("+34 more"); + expect(document.activeElement).toBe(moreButton); + }); + it("stays compact and scrollable with around 100 options", () => { const host = renderCard({ interaction: manyOptionsRequestCheckboxConfirmationInteraction, diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index a29ae2a45a9..1117cf06088 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -1335,6 +1335,7 @@ function RequestCheckboxConfirmationResolution({ interaction: RequestCheckboxConfirmationInteraction; }) { const target = interaction.payload.target ?? null; + const [expanded, setExpanded] = useState(false); if (interaction.status === "accepted") { const totalOptions = interaction.payload.options.length; @@ -1343,8 +1344,13 @@ function RequestCheckboxConfirmationResolution({ result: interaction.result, }); const selectedCount = interaction.result?.selectedOptionIds?.length ?? selectedLabels.length; - const visibleLabels = selectedLabels.slice(0, CHECKBOX_SUMMARY_LABEL_LIMIT); - const hiddenCount = selectedLabels.length - visibleLabels.length; + const visibleLabels = expanded + ? selectedLabels + : selectedLabels.slice(0, CHECKBOX_SUMMARY_LABEL_LIMIT); + const hiddenCount = selectedLabels.length - CHECKBOX_SUMMARY_LABEL_LIMIT; + const hasHiddenLabels = hiddenCount > 0; + const chipClassName = + "inline-flex items-center rounded-sm border border-border/60 bg-transparent px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground"; return (
@@ -1361,10 +1367,18 @@ function RequestCheckboxConfirmationResolution({ {visibleLabels.map((label, index) => ( ))} - {hiddenCount > 0 ? ( - - +{hiddenCount} more - + {hasHiddenLabels ? ( + ) : null}
) : null} diff --git a/ui/src/components/artifacts/ArtifactCard.test.tsx b/ui/src/components/artifacts/ArtifactCard.test.tsx index 2c7503ffd53..9ea18a61894 100644 --- a/ui/src/components/artifacts/ArtifactCard.test.tsx +++ b/ui/src/components/artifacts/ArtifactCard.test.tsx @@ -1,4 +1,8 @@ +// @vitest-environment jsdom + import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; @@ -88,6 +92,82 @@ describe("ArtifactCard", () => { expect(markup).toContain('preload="metadata"'); }); + it("seeks video previews after metadata loads so a thumbnail frame is painted", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + flushSync(() => { + root.render( + , + ); + }); + + const video = container.querySelector("video") as HTMLVideoElement; + expect(video).not.toBeNull(); + expect(video.dataset.frameReady).toBe("false"); + + Object.defineProperty(video, "readyState", { + configurable: true, + value: HTMLMediaElement.HAVE_METADATA, + }); + + flushSync(() => { + video.dispatchEvent(new Event("loadedmetadata", { bubbles: true })); + }); + + expect(video.currentTime).toBe(0.05); + + flushSync(() => { + video.dispatchEvent(new Event("seeked", { bubbles: true })); + }); + + expect(video.dataset.frameReady).toBe("true"); + + flushSync(() => root.unmount()); + container.remove(); + }); + + it("reveals video previews if the browser does not report seek completion", () => { + vi.useFakeTimers(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + flushSync(() => { + root.render( + , + ); + }); + + const video = container.querySelector("video") as HTMLVideoElement; + expect(video).not.toBeNull(); + expect(video.dataset.frameReady).toBe("false"); + + flushSync(() => { + video.dispatchEvent(new Event("loadedmetadata", { bubbles: true })); + }); + + expect(video.currentTime).toBe(0.05); + expect(video.dataset.frameReady).toBe("false"); + + flushSync(() => { + vi.advanceTimersByTime(3000); + }); + + expect(video.dataset.frameReady).toBe("true"); + } finally { + flushSync(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + } + }); + it("renders a falling-back video placeholder when no content path exists", () => { const markup = renderToStaticMarkup( , diff --git a/ui/src/components/artifacts/ArtifactCard.tsx b/ui/src/components/artifacts/ArtifactCard.tsx index f5f6d0e70ef..bc4a4819093 100644 --- a/ui/src/components/artifacts/ArtifactCard.tsx +++ b/ui/src/components/artifacts/ArtifactCard.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { type SyntheticEvent, useEffect, useRef, useState } from "react"; import { Download, ExternalLink, Paperclip, Play } from "lucide-react"; import type { CompanyArtifact } from "@/api/artifacts"; import { Link } from "@/lib/router"; @@ -52,6 +52,16 @@ function ImagePreview({ artifact }: { artifact: CompanyArtifact }) { function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { const [errored, setErrored] = useState(false); + const [frameReady, setFrameReady] = useState(false); + const thumbnailSeekRequested = useRef(false); + const frameReadyFallbackTimer = useRef(null); + useEffect(() => { + return () => { + if (frameReadyFallbackTimer.current !== null) { + window.clearTimeout(frameReadyFallbackTimer.current); + } + }; + }, []); if (errored || !artifact.contentPath) { return ( @@ -61,6 +71,43 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { ); } + + const markFrameReady = () => { + if (frameReadyFallbackTimer.current !== null) { + window.clearTimeout(frameReadyFallbackTimer.current); + frameReadyFallbackTimer.current = null; + } + setFrameReady(true); + }; + const scheduleFrameReadyFallback = () => { + if (frameReadyFallbackTimer.current !== null) { + window.clearTimeout(frameReadyFallbackTimer.current); + } + frameReadyFallbackTimer.current = window.setTimeout(markFrameReady, 3000); + }; + const loadThumbnailFrame = (event: SyntheticEvent) => { + if (thumbnailSeekRequested.current) return; + thumbnailSeekRequested.current = true; + const video = event.currentTarget; + const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0; + const seekTarget = duration > 0 ? Math.min(0.12, duration / 2) : 0.05; + try { + if (Math.abs(video.currentTime - seekTarget) > 0.001) { + video.currentTime = seekTarget; + scheduleFrameReadyFallback(); + } else { + markFrameReady(); + } + } catch { + markFrameReady(); + } + }; + const handleLoadedData = (event: SyntheticEvent) => { + if (thumbnailSeekRequested.current || event.currentTarget.currentTime > 0) { + markFrameReady(); + } + }; + return (