diff --git a/packages/shared/src/external-objects-server.ts b/packages/shared/src/external-objects-server.ts new file mode 100644 index 00000000000..f323ba3e5e0 --- /dev/null +++ b/packages/shared/src/external-objects-server.ts @@ -0,0 +1,217 @@ +import { createHash } from "node:crypto"; +import { parseIssueReferenceHref } from "./issue-references.js"; +import type { + ExternalObjectCanonicalIdentity, + ExternalObjectCanonicalUrl, + ExternalObjectMentionSource, + ExternalObjectUrlCanonicalizationOptions, + ExternalObjectUrlMatch, +} from "./external-objects.js"; + +const EXTERNAL_URL_TOKEN_RE = /https?:\/\/[^\s<>()]+/gi; + +function preserveNewlinesAsWhitespace(value: string) { + return value.replace(/[^\n]/g, " "); +} + +function stripMarkdownCode(markdown: string): string { + if (!markdown) return ""; + + let output = ""; + let index = 0; + + while (index < markdown.length) { + const remaining = markdown.slice(index); + const fenceMatch = /^(?:```+|~~~+)/.exec(remaining); + const atLineStart = index === 0 || markdown[index - 1] === "\n"; + + if (atLineStart && fenceMatch) { + const fence = fenceMatch[0]!; + const blockStart = index; + index += fence.length; + while (index < markdown.length && markdown[index] !== "\n") index += 1; + if (index < markdown.length) index += 1; + + while (index < markdown.length) { + const lineStart = index === 0 || markdown[index - 1] === "\n"; + if (lineStart && markdown.startsWith(fence, index)) { + index += fence.length; + while (index < markdown.length && markdown[index] !== "\n") index += 1; + if (index < markdown.length) index += 1; + break; + } + index += 1; + } + + output += preserveNewlinesAsWhitespace(markdown.slice(blockStart, index)); + continue; + } + + if (markdown[index] === "`") { + let tickCount = 1; + while (index + tickCount < markdown.length && markdown[index + tickCount] === "`") { + tickCount += 1; + } + const fence = "`".repeat(tickCount); + const inlineStart = index; + index += tickCount; + const closeIndex = markdown.indexOf(fence, index); + if (closeIndex === -1) { + output += markdown.slice(inlineStart, inlineStart + tickCount); + index = inlineStart + tickCount; + continue; + } + index = closeIndex + tickCount; + output += preserveNewlinesAsWhitespace(markdown.slice(inlineStart, index)); + continue; + } + + output += markdown[index]!; + index += 1; + } + + return output; +} + +function trimTrailingPunctuation(token: string): string { + let trimmed = token; + while (trimmed.length > 0) { + const last = trimmed[trimmed.length - 1]!; + if (!".,!?;:".includes(last) && last !== ")" && last !== "]") break; + + if ( + (last === ")" && (trimmed.match(/\(/g)?.length ?? 0) >= (trimmed.match(/\)/g)?.length ?? 0)) + || (last === "]" && (trimmed.match(/\[/g)?.length ?? 0) >= (trimmed.match(/\]/g)?.length ?? 0)) + ) { + break; + } + trimmed = trimmed.slice(0, -1); + } + return trimmed; +} + +function sha256Hex(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function normalizePathname(pathname: string): string { + return pathname || "/"; +} + +export function findExternalObjectUrlMatches(markdown: string): ExternalObjectUrlMatch[] { + if (!markdown) return []; + + const scrubbed = stripMarkdownCode(markdown); + const matches: ExternalObjectUrlMatch[] = []; + let match: RegExpExecArray | null; + const re = new RegExp(EXTERNAL_URL_TOKEN_RE); + + while ((match = re.exec(scrubbed)) !== null) { + const matchedText = trimTrailingPunctuation(match[0]); + if (!matchedText || parseIssueReferenceHref(matchedText)) continue; + + matches.push({ + index: match.index, + length: matchedText.length, + matchedText, + }); + } + + return matches; +} + +export function canonicalizeExternalObjectUrl( + value: string, + options: ExternalObjectUrlCanonicalizationOptions = {}, +): ExternalObjectCanonicalUrl | null { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return null; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (url.username || url.password) return null; + + const scheme = url.protocol === "https:" ? "https" : "http"; + const path = normalizePathname(url.pathname); + const sanitizedCanonicalUrl = `${scheme}://${url.host.toLowerCase()}${path}`; + const identityQueryParams = new Set(options.identityQueryParams ?? []); + const queryParamHashes: Record = {}; + + for (const key of [...identityQueryParams].sort()) { + const values = url.searchParams.getAll(key); + if (values.length === 0) continue; + queryParamHashes[key] = sha256Hex(values.join("\u0000")); + } + + const canonicalIdentity: ExternalObjectCanonicalIdentity = { + scheme, + host: url.host.toLowerCase(), + path, + ...(Object.keys(queryParamHashes).length > 0 ? { queryParamHashes } : {}), + }; + + return { + sanitizedCanonicalUrl, + sanitizedDisplayUrl: sanitizedCanonicalUrl, + canonicalIdentity, + canonicalIdentityHash: sha256Hex(stableStringify(canonicalIdentity)), + redactedMatchedText: sanitizedCanonicalUrl, + }; +} + +export function extractExternalObjectCanonicalUrls( + markdown: string, + options: ExternalObjectUrlCanonicalizationOptions = {}, +): ExternalObjectCanonicalUrl[] { + const seen = new Set(); + const ordered: ExternalObjectCanonicalUrl[] = []; + + for (const match of findExternalObjectUrlMatches(markdown)) { + const canonical = canonicalizeExternalObjectUrl(match.matchedText, options); + if (!canonical || seen.has(canonical.canonicalIdentityHash)) continue; + seen.add(canonical.canonicalIdentityHash); + ordered.push(canonical); + } + + return ordered; +} + +export function buildExternalObjectScopedIdentityKey(args: { + companyId: string; + providerKey: string; + objectType: string; + canonicalIdentityHash: string; +}): string { + return [args.companyId, args.providerKey, args.objectType, args.canonicalIdentityHash].join(":"); +} + +export function buildExternalObjectMentionSourceKey(source: Required> & ExternalObjectMentionSource): string { + return [ + source.companyId, + source.sourceIssueId, + source.sourceKind, + source.sourceRecordId ?? "", + source.documentKey ?? "", + source.propertyKey ?? "", + ].join(":"); +} diff --git a/packages/shared/src/external-objects.test.ts b/packages/shared/src/external-objects.test.ts index 0c0d5457087..aa55bd97d91 100644 --- a/packages/shared/src/external-objects.test.ts +++ b/packages/shared/src/external-objects.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; +import { formatExternalObjectMentionSourceLabel } from "./external-objects.js"; import { buildExternalObjectMentionSourceKey, buildExternalObjectScopedIdentityKey, canonicalizeExternalObjectUrl, extractExternalObjectCanonicalUrls, findExternalObjectUrlMatches, - formatExternalObjectMentionSourceLabel, -} from "./external-objects.js"; +} from "./external-objects-server.js"; import { externalObjectProviderKeySchema, externalObjectTypeSchema } from "./validators/external-object.js"; describe("external object references", () => { diff --git a/packages/shared/src/external-objects.ts b/packages/shared/src/external-objects.ts index 443ab43033d..a9953c4e3f7 100644 --- a/packages/shared/src/external-objects.ts +++ b/packages/shared/src/external-objects.ts @@ -1,6 +1,4 @@ -import { createHash } from "node:crypto"; import type { ExternalObjectMentionSourceKind } from "./constants.js"; -import { parseIssueReferenceHref } from "./issue-references.js"; export interface ExternalObjectUrlMatch { index: number; @@ -36,214 +34,6 @@ export interface ExternalObjectMentionSource { propertyKey?: string | null; } -const EXTERNAL_URL_TOKEN_RE = /https?:\/\/[^\s<>()]+/gi; - -function preserveNewlinesAsWhitespace(value: string) { - return value.replace(/[^\n]/g, " "); -} - -function stripMarkdownCode(markdown: string): string { - if (!markdown) return ""; - - let output = ""; - let index = 0; - - while (index < markdown.length) { - const remaining = markdown.slice(index); - const fenceMatch = /^(?:```+|~~~+)/.exec(remaining); - const atLineStart = index === 0 || markdown[index - 1] === "\n"; - - if (atLineStart && fenceMatch) { - const fence = fenceMatch[0]!; - const blockStart = index; - index += fence.length; - while (index < markdown.length && markdown[index] !== "\n") index += 1; - if (index < markdown.length) index += 1; - - while (index < markdown.length) { - const lineStart = index === 0 || markdown[index - 1] === "\n"; - if (lineStart && markdown.startsWith(fence, index)) { - index += fence.length; - while (index < markdown.length && markdown[index] !== "\n") index += 1; - if (index < markdown.length) index += 1; - break; - } - index += 1; - } - - output += preserveNewlinesAsWhitespace(markdown.slice(blockStart, index)); - continue; - } - - if (markdown[index] === "`") { - let tickCount = 1; - while (index + tickCount < markdown.length && markdown[index + tickCount] === "`") { - tickCount += 1; - } - const fence = "`".repeat(tickCount); - const inlineStart = index; - index += tickCount; - const closeIndex = markdown.indexOf(fence, index); - if (closeIndex === -1) { - output += markdown.slice(inlineStart, inlineStart + tickCount); - index = inlineStart + tickCount; - continue; - } - index = closeIndex + tickCount; - output += preserveNewlinesAsWhitespace(markdown.slice(inlineStart, index)); - continue; - } - - output += markdown[index]!; - index += 1; - } - - return output; -} - -function trimTrailingPunctuation(token: string): string { - let trimmed = token; - while (trimmed.length > 0) { - const last = trimmed[trimmed.length - 1]!; - if (!".,!?;:".includes(last) && last !== ")" && last !== "]") break; - - if ( - (last === ")" && (trimmed.match(/\(/g)?.length ?? 0) >= (trimmed.match(/\)/g)?.length ?? 0)) - || (last === "]" && (trimmed.match(/\[/g)?.length ?? 0) >= (trimmed.match(/\]/g)?.length ?? 0)) - ) { - break; - } - trimmed = trimmed.slice(0, -1); - } - return trimmed; -} - -function sha256Hex(value: string): string { - return createHash("sha256").update(value).digest("hex"); -} - -function stableStringify(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; - } - if (value && typeof value === "object") { - const record = value as Record; - return `{${Object.keys(record) - .sort() - .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) - .join(",")}}`; - } - return JSON.stringify(value); -} - -function normalizePathname(pathname: string): string { - return pathname || "/"; -} - -export function findExternalObjectUrlMatches(markdown: string): ExternalObjectUrlMatch[] { - if (!markdown) return []; - - const scrubbed = stripMarkdownCode(markdown); - const matches: ExternalObjectUrlMatch[] = []; - let match: RegExpExecArray | null; - const re = new RegExp(EXTERNAL_URL_TOKEN_RE); - - while ((match = re.exec(scrubbed)) !== null) { - const matchedText = trimTrailingPunctuation(match[0]); - if (!matchedText || parseIssueReferenceHref(matchedText)) continue; - - matches.push({ - index: match.index, - length: matchedText.length, - matchedText, - }); - } - - return matches; -} - -export function canonicalizeExternalObjectUrl( - value: string, - options: ExternalObjectUrlCanonicalizationOptions = {}, -): ExternalObjectCanonicalUrl | null { - let url: URL; - try { - url = new URL(value.trim()); - } catch { - return null; - } - - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - if (url.username || url.password) return null; - - const scheme = url.protocol === "https:" ? "https" : "http"; - const path = normalizePathname(url.pathname); - const sanitizedCanonicalUrl = `${scheme}://${url.host.toLowerCase()}${path}`; - const identityQueryParams = new Set(options.identityQueryParams ?? []); - const queryParamHashes: Record = {}; - - for (const key of [...identityQueryParams].sort()) { - const values = url.searchParams.getAll(key); - if (values.length === 0) continue; - queryParamHashes[key] = sha256Hex(values.join("\u0000")); - } - - const canonicalIdentity: ExternalObjectCanonicalIdentity = { - scheme, - host: url.host.toLowerCase(), - path, - ...(Object.keys(queryParamHashes).length > 0 ? { queryParamHashes } : {}), - }; - - return { - sanitizedCanonicalUrl, - sanitizedDisplayUrl: sanitizedCanonicalUrl, - canonicalIdentity, - canonicalIdentityHash: sha256Hex(stableStringify(canonicalIdentity)), - redactedMatchedText: sanitizedCanonicalUrl, - }; -} - -export function extractExternalObjectCanonicalUrls( - markdown: string, - options: ExternalObjectUrlCanonicalizationOptions = {}, -): ExternalObjectCanonicalUrl[] { - const seen = new Set(); - const ordered: ExternalObjectCanonicalUrl[] = []; - - for (const match of findExternalObjectUrlMatches(markdown)) { - const canonical = canonicalizeExternalObjectUrl(match.matchedText, options); - if (!canonical || seen.has(canonical.canonicalIdentityHash)) continue; - seen.add(canonical.canonicalIdentityHash); - ordered.push(canonical); - } - - return ordered; -} - -export function buildExternalObjectScopedIdentityKey(args: { - companyId: string; - providerKey: string; - objectType: string; - canonicalIdentityHash: string; -}): string { - return [args.companyId, args.providerKey, args.objectType, args.canonicalIdentityHash].join(":"); -} - -export function buildExternalObjectMentionSourceKey(source: Required> & ExternalObjectMentionSource): string { - return [ - source.companyId, - source.sourceIssueId, - source.sourceKind, - source.sourceRecordId ?? "", - source.documentKey ?? "", - source.propertyKey ?? "", - ].join(":"); -} - export function formatExternalObjectMentionSourceLabel(source: ExternalObjectMentionSource): string { switch (source.sourceKind) { case "title": diff --git a/screenshots/pap-2277/external-filter-popover-open-1440.png b/screenshots/pap-2277/external-filter-popover-open-1440.png new file mode 100644 index 00000000000..de591642d87 Binary files /dev/null and b/screenshots/pap-2277/external-filter-popover-open-1440.png differ diff --git a/screenshots/pap-2277/external-integration-surfaces-dark-1440.png b/screenshots/pap-2277/external-integration-surfaces-dark-1440.png new file mode 100644 index 00000000000..5379076ef2e Binary files /dev/null and b/screenshots/pap-2277/external-integration-surfaces-dark-1440.png differ diff --git a/screenshots/pap-2277/external-issue-list-row-1440.png b/screenshots/pap-2277/external-issue-list-row-1440.png new file mode 100644 index 00000000000..63bfc4cf326 Binary files /dev/null and b/screenshots/pap-2277/external-issue-list-row-1440.png differ diff --git a/screenshots/pap-2277/external-objects-full-surface-dark-desktop.png b/screenshots/pap-2277/external-objects-full-surface-dark-desktop.png index 14cb4027b98..920ab120d96 100644 Binary files a/screenshots/pap-2277/external-objects-full-surface-dark-desktop.png and b/screenshots/pap-2277/external-objects-full-surface-dark-desktop.png differ diff --git a/screenshots/pap-2277/external-objects-full-surface-light-mobile.png b/screenshots/pap-2277/external-objects-full-surface-light-mobile.png index 64d0182f53d..f96aba3d8e7 100644 Binary files a/screenshots/pap-2277/external-objects-full-surface-light-mobile.png and b/screenshots/pap-2277/external-objects-full-surface-light-mobile.png differ diff --git a/screenshots/pap-2277/external-properties-row-desktop-1440.png b/screenshots/pap-2277/external-properties-row-desktop-1440.png new file mode 100644 index 00000000000..d31fa8624ec Binary files /dev/null and b/screenshots/pap-2277/external-properties-row-desktop-1440.png differ diff --git a/screenshots/pap-2277/external-properties-row-mobile-390.png b/screenshots/pap-2277/external-properties-row-mobile-390.png new file mode 100644 index 00000000000..58be09fcd90 Binary files /dev/null and b/screenshots/pap-2277/external-properties-row-mobile-390.png differ diff --git a/screenshots/pap-2277/external-related-work-empty-1440.png b/screenshots/pap-2277/external-related-work-empty-1440.png new file mode 100644 index 00000000000..19983f05197 Binary files /dev/null and b/screenshots/pap-2277/external-related-work-empty-1440.png differ diff --git a/screenshots/pap-2277/external-sidebar-mobile-390.png b/screenshots/pap-2277/external-sidebar-mobile-390.png new file mode 100644 index 00000000000..2cb8f18c91f Binary files /dev/null and b/screenshots/pap-2277/external-sidebar-mobile-390.png differ diff --git a/screenshots/pap-2277/issue-chat-thread-external-dark-desktop.png b/screenshots/pap-2277/issue-chat-thread-external-dark-desktop.png index 82ddea24db2..da4d7a01388 100644 Binary files a/screenshots/pap-2277/issue-chat-thread-external-dark-desktop.png and b/screenshots/pap-2277/issue-chat-thread-external-dark-desktop.png differ diff --git a/screenshots/pap-2277/issue-chat-thread-external-light-desktop.png b/screenshots/pap-2277/issue-chat-thread-external-light-desktop.png index 3968303a980..0e2ddb15971 100644 Binary files a/screenshots/pap-2277/issue-chat-thread-external-light-desktop.png and b/screenshots/pap-2277/issue-chat-thread-external-light-desktop.png differ diff --git a/screenshots/pap-2277/issue-chat-thread-external-light-mobile.png b/screenshots/pap-2277/issue-chat-thread-external-light-mobile.png index 1bda24a078c..0b74f78f627 100644 Binary files a/screenshots/pap-2277/issue-chat-thread-external-light-mobile.png and b/screenshots/pap-2277/issue-chat-thread-external-light-mobile.png differ diff --git a/screenshots/pap-2279/03-properties-desktop-3-pills-tooltip.png b/screenshots/pap-2279/03-properties-desktop-3-pills-tooltip.png new file mode 100644 index 00000000000..5aa35fe19ab Binary files /dev/null and b/screenshots/pap-2279/03-properties-desktop-3-pills-tooltip.png differ diff --git a/screenshots/pap-2279/04-properties-mobile.png b/screenshots/pap-2279/04-properties-mobile.png new file mode 100644 index 00000000000..61ec609e312 Binary files /dev/null and b/screenshots/pap-2279/04-properties-mobile.png differ diff --git a/screenshots/pap-2279/07-related-work-empty-desktop.png b/screenshots/pap-2279/07-related-work-empty-desktop.png new file mode 100644 index 00000000000..145b3f92043 Binary files /dev/null and b/screenshots/pap-2279/07-related-work-empty-desktop.png differ diff --git a/screenshots/pap-2279/09-sidebar-mobile-drawer.png b/screenshots/pap-2279/09-sidebar-mobile-drawer.png new file mode 100644 index 00000000000..1b0e926ddf8 Binary files /dev/null and b/screenshots/pap-2279/09-sidebar-mobile-drawer.png differ diff --git a/screenshots/pap-2279/10-issue-list-rows-desktop.png b/screenshots/pap-2279/10-issue-list-rows-desktop.png new file mode 100644 index 00000000000..ec1f8ebacb7 Binary files /dev/null and b/screenshots/pap-2279/10-issue-list-rows-desktop.png differ diff --git a/screenshots/pap-2279/11-filter-popover-desktop.png b/screenshots/pap-2279/11-filter-popover-desktop.png new file mode 100644 index 00000000000..d0dc99afd98 Binary files /dev/null and b/screenshots/pap-2279/11-filter-popover-desktop.png differ diff --git a/screenshots/pap-2289/08-issues-list-1920.png b/screenshots/pap-2289/08-issues-list-1920.png new file mode 100644 index 00000000000..b17f32252d8 Binary files /dev/null and b/screenshots/pap-2289/08-issues-list-1920.png differ diff --git a/screenshots/pap-2289/09-issues-list-zoom.png b/screenshots/pap-2289/09-issues-list-zoom.png new file mode 100644 index 00000000000..8cae6d5e0b6 Binary files /dev/null and b/screenshots/pap-2289/09-issues-list-zoom.png differ diff --git a/screenshots/pap-2289/11-inbox-badge-scrolled-1920.png b/screenshots/pap-2289/11-inbox-badge-scrolled-1920.png new file mode 100644 index 00000000000..95703ec4b0a Binary files /dev/null and b/screenshots/pap-2289/11-inbox-badge-scrolled-1920.png differ diff --git a/screenshots/pap-2289/12-inbox-zoom.png b/screenshots/pap-2289/12-inbox-zoom.png new file mode 100644 index 00000000000..62291df7f8d Binary files /dev/null and b/screenshots/pap-2289/12-inbox-zoom.png differ diff --git a/screenshots/pap-2289/13-inbox-mobile-no-badge.png b/screenshots/pap-2289/13-inbox-mobile-no-badge.png new file mode 100644 index 00000000000..f28e9dae42e Binary files /dev/null and b/screenshots/pap-2289/13-inbox-mobile-no-badge.png differ diff --git a/scripts/capture-pap-2277-screenshots.mjs b/scripts/capture-pap-2277-screenshots.mjs index 7dce40ac4c3..5fbcb59825a 100644 --- a/scripts/capture-pap-2277-screenshots.mjs +++ b/scripts/capture-pap-2277-screenshots.mjs @@ -134,6 +134,49 @@ const SHOTS = [ viewport: { width: 1280, height: 900 }, theme: "light", }, + // PAP-2279 §9 acceptance shots — integration surfaces missing from the prior set. + { + storyId: "foundations-external-objects--properties-row-desktop", + label: "external-properties-row-desktop-1440", + viewport: { width: 1440, height: 900 }, + theme: "light", + }, + { + storyId: "foundations-external-objects--properties-row-mobile-sheet", + label: "external-properties-row-mobile-390", + viewport: { width: 390, height: 844 }, + theme: "light", + }, + { + storyId: "foundations-external-objects--related-work-empty", + label: "external-related-work-empty-1440", + viewport: { width: 1440, height: 900 }, + theme: "light", + }, + { + storyId: "foundations-external-objects--sidebar-mobile", + label: "external-sidebar-mobile-390", + viewport: { width: 390, height: 844 }, + theme: "light", + }, + { + storyId: "foundations-external-objects--issue-list-row", + label: "external-issue-list-row-1440", + viewport: { width: 1440, height: 600 }, + theme: "light", + }, + { + storyId: "foundations-external-objects--filter-popover-open", + label: "external-filter-popover-open-1440", + viewport: { width: 1440, height: 900 }, + theme: "light", + }, + { + storyId: "foundations-external-objects--integration-surfaces", + label: "external-integration-surfaces-dark-1440", + viewport: { width: 1440, height: 1600 }, + theme: "dark", + }, ]; async function applyTheme(page, theme) { diff --git a/scripts/capture-pap-2279-screenshots.mjs b/scripts/capture-pap-2279-screenshots.mjs new file mode 100644 index 00000000000..48b76bbd465 --- /dev/null +++ b/scripts/capture-pap-2279-screenshots.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node +// Captures the §9 acceptance screenshots for PAP-2279 from a logged-in +// dev server (local_trusted mode auto-authenticates as local-board). +// Seed: PAP-2232 has 3 external objects (failed/auth_required/succeeded); +// PAP-2179 has one running object; PAP-2210 has none (related-work empty state). + +import { createRequire } from "node:module"; +const localRequire = createRequire(import.meta.url); +const { chromium } = localRequire("playwright"); +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const outDir = path.join(repoRoot, "screenshots", "pap-2279"); + +const baseUrl = process.env.PAP_DEV_URL ?? "http://127.0.0.1:3104"; +const DESKTOP = { width: 1440, height: 900 }; +const MOBILE = { width: 390, height: 844 }; + +async function shoot(page, dest) { + await fs.mkdir(path.dirname(dest), { recursive: true }); + await page.screenshot({ path: dest, fullPage: false }); + console.log("captured", path.relative(repoRoot, dest)); +} + +async function main() { + await fs.mkdir(outDir, { recursive: true }); + const browser = await chromium.launch(); + + // ====== Desktop ====== + const desktop = await browser.newContext({ viewport: DESKTOP, deviceScaleFactor: 2 }); + const page = await desktop.newPage(); + + // ---- #03 Properties panel @ 1440x900 with 3 pills + tooltip ---- + await page.goto(`${baseUrl}/PAP/issues/PAP-2232`, { waitUntil: "networkidle" }); + await page.locator('text=External objects').first().waitFor({ state: "visible", timeout: 15000 }); + await page.waitForTimeout(800); + // Hover the failed-status pill to open its Radix tooltip. We aim for the + // pill in the properties rail; the test scope is the Properties aside. + const propertiesRail = page.locator('aside, [data-slot="sidebar"], [class*="properties" i]').first(); + const railScope = (await propertiesRail.count()) > 0 ? propertiesRail : page; + const firstPill = railScope.locator('a, button').filter({ hasText: /pull request|lead/i }).first(); + if ((await firstPill.count()) > 0) { + try { + await firstPill.scrollIntoViewIfNeeded(); + const box = await firstPill.boundingBox(); + if (box) { + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + // Move from far away first, then to the pill center, to ensure pointermove fires + await page.mouse.move(0, 0); + await page.waitForTimeout(50); + await page.mouse.move(cx, cy, { steps: 10 }); + } + await firstPill.focus().catch(() => {}); + await page.waitForTimeout(1500); + } catch (e) { console.warn("pill hover failed:", e.message); } + } else { + console.warn("no pill in properties rail found"); + } + await shoot(page, path.join(outDir, "03-properties-desktop-3-pills-tooltip.png")); + + // ---- #07 Related-work empty state @ 1440x900 (PAP-2210) ---- + await page.goto(`${baseUrl}/PAP/issues/PAP-2210`, { waitUntil: "networkidle" }); + await page.waitForTimeout(800); + // Click the "Related work" tab + const relatedTab = page.locator('[role="tab"]', { hasText: /Related work/i }).first(); + if ((await relatedTab.count()) > 0) { + try { await relatedTab.click({ timeout: 5000 }); } catch (e) { console.warn("related tab click failed:", e.message); } + await page.waitForTimeout(800); + } else { + console.warn("Related work tab not found"); + } + // Scroll down so the empty External objects copy is in viewport + const emptyCopy = page.locator('text=does not reference any external objects').first(); + if ((await emptyCopy.count()) > 0) { + try { await emptyCopy.scrollIntoViewIfNeeded(); } catch {} + } + await page.waitForTimeout(400); + await shoot(page, path.join(outDir, "07-related-work-empty-desktop.png")); + + // ---- #10 Issue-list row @ 1440x900 (badge + non-badge) ---- + // Note: the Phase 6 plumbing ships the IssueRow `externalObjectSummary` prop + // but no IssuesList caller fetches/forwards summaries today. So the live-app + // list cannot render the badge yet — we still capture the list view as the + // truthful current state and flag the wiring gap in the issue comment. + await page.goto(`${baseUrl}/PAP/issues`, { waitUntil: "networkidle" }); + await page.waitForTimeout(1500); + // Search for PAP-2232 so the seeded "with-badge" issue shows up at the top + // alongside any other matches (control rows without badge). + const search = page.locator('input[placeholder*="Search issues" i], input[type="search"]').first(); + if ((await search.count()) > 0) { + try { + await search.click(); + await search.fill("Follow up PR"); + await page.waitForTimeout(800); + } catch {} + } else { + console.warn("Search input not found"); + } + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(500); + await shoot(page, path.join(outDir, "10-issue-list-rows-desktop.png")); + + // ---- #11 Filter popover @ 1440x900, External object status section, "Failed" checked ---- + // Click the icon-only Filter button (title="Filter") + const filterButton = page.locator('button[title="Filter"], button[title*="Filters" i]').first(); + if ((await filterButton.count()) > 0) { + try { + await filterButton.click({ timeout: 5000 }); + await page.waitForTimeout(600); + } catch (e) { console.warn("filter click failed:", e.message); } + } else { + console.warn("Filter button not found"); + } + // Scroll the popover so the "External object status" section is visible + const externalSection = page.locator('text=External object status').first(); + if ((await externalSection.count()) > 0) { + try { await externalSection.scrollIntoViewIfNeeded({ timeout: 3000 }); } catch {} + // Click the Failed checkbox label + const failedLabel = page.locator('label', { hasText: /^\s*Failed\s*$/ }).first(); + const failedFallback = page.locator('label:has-text("Failed")').first(); + const target = (await failedLabel.count()) > 0 ? failedLabel : failedFallback; + if ((await target.count()) > 0) { + try { await target.click({ timeout: 3000 }); } catch (e) { console.warn("failed-check click failed:", e.message); } + await page.waitForTimeout(400); + } + try { await externalSection.scrollIntoViewIfNeeded(); } catch {} + } else { + console.warn("External object status section not found in popover"); + } + await page.waitForTimeout(400); + await shoot(page, path.join(outDir, "11-filter-popover-desktop.png")); + + await desktop.close(); + + // ====== Mobile ====== + const mobile = await browser.newContext({ + viewport: MOBILE, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", + }); + const m = await mobile.newPage(); + + // ---- #04 Properties mobile sheet expanded ---- + await m.goto(`${baseUrl}/PAP/issues/PAP-2232`, { waitUntil: "networkidle" }); + await m.waitForTimeout(1200); + // Click button[title="Properties"] (SlidersHorizontal icon in mobile toolbar) + const propsTrigger = m.locator('button[title="Properties"]').first(); + if ((await propsTrigger.count()) > 0) { + try { await propsTrigger.click({ timeout: 5000 }); } catch (e) { console.warn("props trigger click failed:", e.message); } + await m.waitForTimeout(800); + } else { + console.warn("mobile Properties trigger not found"); + } + // Scroll sheet so external objects row visible + const extLabel = m.locator('text=External objects').first(); + if ((await extLabel.count()) > 0) { + try { await extLabel.scrollIntoViewIfNeeded(); } catch {} + } + await m.waitForTimeout(400); + await shoot(m, path.join(outDir, "04-properties-mobile.png")); + + // ---- #09 Sidebar mobile drawer ---- + await m.goto(`${baseUrl}/PAP/issues`, { waitUntil: "networkidle" }); + await m.waitForTimeout(1000); + // The hamburger to open the sidebar drawer is "Open Paperclip menu" in mobile + // BUT the drawer that contains nav (with project list) is opened via the + // dropdown trigger or a separate sidebar trigger; try both. + const menuButton = m.locator('button[aria-label="Open Paperclip menu"]').first(); + const sidebarTrigger = m.locator('[data-slot="sidebar-trigger"], button[aria-label*="sidebar" i], button[aria-label*="navigation" i]').first(); + let opened = false; + for (const cand of [sidebarTrigger, menuButton]) { + if ((await cand.count()) > 0) { + try { + await cand.scrollIntoViewIfNeeded({ timeout: 1000 }); + await cand.click({ timeout: 3000, force: true }); + opened = true; + break; + } catch (e) { console.warn("drawer trigger click failed:", e.message); } + } + } + if (!opened) console.warn("no drawer trigger clicked"); + await m.waitForTimeout(700); + await shoot(m, path.join(outDir, "09-sidebar-mobile-drawer.png")); + + await mobile.close(); + await browser.close(); +} + +main().catch((err) => { + console.error("capture failed:", err); + process.exit(1); +}); diff --git a/server/src/__tests__/external-object-routes.test.ts b/server/src/__tests__/external-object-routes.test.ts index a3e4705ffa0..6566ae7891e 100644 --- a/server/src/__tests__/external-object-routes.test.ts +++ b/server/src/__tests__/external-object-routes.test.ts @@ -23,6 +23,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockExternalObjectsService = vi.hoisted(() => ({ getIssueSummary: vi.fn(), + getIssueSummaries: vi.fn(), listForIssue: vi.fn(), refreshIssueObjects: vi.fn(), })); @@ -145,6 +146,9 @@ describe("external object routes", () => { { id: peerAgentId, companyId, reportsTo: null, permissions: { canCreateAgents: false } }, ]); mockExternalObjectsService.getIssueSummary.mockResolvedValue({ total: 1, objects: [] }); + mockExternalObjectsService.getIssueSummaries.mockResolvedValue(new Map([ + [issueId, { total: 1, objects: [] }], + ])); mockExternalObjectsService.listForIssue.mockResolvedValue([]); mockExternalObjectsService.refreshIssueObjects.mockResolvedValue([ { object: { id: "77777777-7777-4777-8777-777777777777" }, refreshed: false, reason: "no_resolver" }, @@ -170,6 +174,29 @@ describe("external object routes", () => { expect(mockExternalObjectsService.getIssueSummary).toHaveBeenCalledWith(issueId); }); + it("allows board users to fetch company-scoped external object summaries in bulk", async () => { + const app = await createApp(boardActor()); + + const res = await request(app) + .post(`/api/companies/${companyId}/issues/external-object-summaries`) + .send({ issueIds: [issueId] }); + + expect(res.status).toBe(200); + expect(res.body.summaries[issueId].total).toBe(1); + expect(mockExternalObjectsService.getIssueSummaries).toHaveBeenCalledWith(companyId, [issueId]); + }); + + it("enforces company access on bulk external object summaries", async () => { + const app = await createApp({ ...ownerActor(), companyId: "other-company" }); + + const res = await request(app) + .post(`/api/companies/${companyId}/issues/external-object-summaries`) + .send({ issueIds: [issueId] }); + + expect(res.status).toBe(403); + expect(mockExternalObjectsService.getIssueSummaries).not.toHaveBeenCalled(); + }); + it("requires active checkout ownership for agent manual refresh", async () => { const app = await createApp(peerActor()); diff --git a/server/src/__tests__/external-objects-service.test.ts b/server/src/__tests__/external-objects-service.test.ts index eb94f998cd2..c56f9c25472 100644 --- a/server/src/__tests__/external-objects-service.test.ts +++ b/server/src/__tests__/external-objects-service.test.ts @@ -20,7 +20,7 @@ import { externalObjectService, type ExternalObjectResolver, } from "../services/external-objects.js"; -import { canonicalizeExternalObjectUrl } from "@paperclipai/shared"; +import { canonicalizeExternalObjectUrl } from "@paperclipai/shared/external-objects-server"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { createGitHubExternalObjectProvider } from "../services/github-external-object-provider.js"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 29d56e29728..bbcaaf4b878 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -156,6 +156,9 @@ const updateIssueRouteSchema = updateIssueSchema.extend({ const refreshExternalObjectsSchema = z.object({ objectIds: z.array(z.string().uuid()).max(50).optional(), }).strict(); +const externalObjectSummariesSchema = z.object({ + issueIds: z.array(z.string().uuid()).max(1000), +}).strict(); const promoteLowTrustOutputSchema = z.object({ sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]), @@ -3736,6 +3739,13 @@ export function issueRoutes( res.json(summary); }); + router.post("/companies/:companyId/issues/external-object-summaries", validate(externalObjectSummariesSchema), async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const summaries = await externalObjectsSvc.getIssueSummaries(companyId, req.body.issueIds); + res.json({ summaries: Object.fromEntries(summaries) }); + }); + router.post("/issues/:id/external-objects/refresh", validate(refreshExternalObjectsSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); diff --git a/server/src/services/external-objects.ts b/server/src/services/external-objects.ts index b92d15623ba..83df3816d71 100644 --- a/server/src/services/external-objects.ts +++ b/server/src/services/external-objects.ts @@ -2,7 +2,6 @@ import { and, asc, eq, inArray, isNull, lte } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues, plugins } from "@paperclipai/db"; import { - extractExternalObjectCanonicalUrls, formatExternalObjectMentionSourceLabel, type ExternalObjectCanonicalUrl, type ExternalObjectLivenessState, @@ -12,6 +11,7 @@ import { type ExternalObjectStatusTone, type PaperclipPluginManifestV1, } from "@paperclipai/shared"; +import { extractExternalObjectCanonicalUrls } from "@paperclipai/shared/external-objects-server"; import type { PluginExternalObjectRecordSnapshot, PluginExternalObjectResolveResult } from "@paperclipai/plugin-sdk"; import { notFound } from "../errors.js"; import { logger } from "../middleware/logger.js"; @@ -642,12 +642,10 @@ export function externalObjectService( }; } - async function getIssueSummary(issueId: string) { - const groups = await listForIssue(issueId); - const objects = groups.flatMap((group) => (group.object ? [group.object] : [])); + function summarizeObjectPayloads(objects: Array>, objectLimit = objects.length) { return { ...summarizeObjects(objects), - objects: objects.map((object) => ({ + objects: objects.slice(0, objectLimit).map((object) => ({ id: object.id, providerKey: object.providerKey, objectType: object.objectType, @@ -660,6 +658,44 @@ export function externalObjectService( }; } + async function getIssueSummary(issueId: string) { + const groups = await listForIssue(issueId); + const objects = groups.flatMap((group) => (group.object ? [group.object] : [])); + return summarizeObjectPayloads(objects); + } + + async function getIssueSummaries(companyId: string, issueIds: string[]) { + const uniqueIssueIds = [...new Set(issueIds)].filter((id) => id.length > 0); + const summaries = new Map>(); + if (uniqueIssueIds.length === 0) return summaries; + + const rows = await db + .select({ + issueId: externalObjectMentions.sourceIssueId, + object: externalObjects, + }) + .from(externalObjectMentions) + .innerJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id)) + .where(and( + eq(externalObjectMentions.companyId, companyId), + inArray(externalObjectMentions.sourceIssueId, uniqueIssueIds), + )); + + const now = new Date(); + const objectsByIssueId = new Map>>(); + for (const row of rows) { + const issueObjects = objectsByIssueId.get(row.issueId) ?? new Map>(); + issueObjects.set(row.object.id, toObjectPayload(row.object, now)); + objectsByIssueId.set(row.issueId, issueObjects); + } + + for (const [issueId, issueObjects] of objectsByIssueId) { + const objects = [...issueObjects.values()]; + if (objects.length > 0) summaries.set(issueId, summarizeObjectPayloads(objects)); + } + return summaries; + } + async function getProjectSummary(projectId: string) { const projectIssues = await db .select({ id: issues.id, companyId: issues.companyId }) @@ -681,19 +717,7 @@ export function externalObjectService( const objectsById = new Map>(); for (const row of rows) objectsById.set(row.object.id, toObjectPayload(row.object, now)); const objects = [...objectsById.values()]; - return { - ...summarizeObjects(objects), - objects: objects.slice(0, 25).map((object) => ({ - id: object.id, - providerKey: object.providerKey, - objectType: object.objectType, - displayTitle: object.displayTitle, - statusCategory: object.statusCategory, - statusTone: object.statusTone, - liveness: object.liveness, - isTerminal: object.isTerminal, - })), - }; + return summarizeObjectPayloads(objects, 25); } async function refreshObject( @@ -853,6 +877,7 @@ export function externalObjectService( syncDocumentSafely, listForIssue, getIssueSummary, + getIssueSummaries, getProjectSummary, refreshObject, refreshIssueObjects, diff --git a/ui/src/api/externalObjects.ts b/ui/src/api/externalObjects.ts index fba3b0a9e8a..e728c82cd5b 100644 --- a/ui/src/api/externalObjects.ts +++ b/ui/src/api/externalObjects.ts @@ -6,6 +6,11 @@ export const externalObjectsApi = { api.get(`/issues/${issueId}/external-objects`), getIssueSummary: (issueId: string) => api.get(`/issues/${issueId}/external-object-summary`), + getIssueSummaries: (companyId: string, issueIds: string[]) => + api.post<{ summaries: Record }>( + `/companies/${companyId}/issues/external-object-summaries`, + { issueIds }, + ), refreshIssueObjects: (issueId: string, data?: { objectIds?: string[] }) => api.post<{ refreshed: unknown[] }>(`/issues/${issueId}/external-objects/refresh`, data ?? {}), getProjectSummary: (projectId: string) => diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index cb07cdfc247..df614026410 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -42,6 +42,10 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), })); +const mockExternalObjectsApi = vi.hoisted(() => ({ + getIssueSummaries: vi.fn(), +})); + vi.mock("../context/CompanyContext", () => ({ useCompany: () => companyState, })); @@ -87,6 +91,10 @@ vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); +vi.mock("../api/externalObjects", () => ({ + externalObjectsApi: mockExternalObjectsApi, +})); + vi.mock("./IssueRow", () => ({ IssueRow: ({ issue, @@ -97,6 +105,7 @@ vi.mock("./IssueRow", () => ({ checklistCurrentStep, checklistDependencyChips, checklistRowId, + externalObjectSummary, }: { issue: Issue; desktopMetaLeading?: ReactNode; @@ -106,6 +115,7 @@ vi.mock("./IssueRow", () => ({ checklistCurrentStep?: boolean; checklistDependencyChips?: ReactNode; checklistRowId?: string; + externalObjectSummary?: { total: number } | null; }) => (
({ data-title-class={titleClassName ?? undefined} > {issue.title} + {externalObjectSummary ? ( + {externalObjectSummary.total} + ) : null} {desktopMetaLeading} {desktopTrailing} {checklistDependencyChips} @@ -286,6 +299,7 @@ describe("IssuesList", () => { mockExecutionWorkspacesApi.list.mockReset(); mockExecutionWorkspacesApi.listSummaries.mockReset(); mockInstanceSettingsApi.getExperimental.mockReset(); + mockExternalObjectsApi.getIssueSummaries.mockReset(); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); mockAuthApi.getSession.mockResolvedValue({ user: null, session: null }); @@ -295,6 +309,7 @@ describe("IssuesList", () => { mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 }); + mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: {} }); localStorage.clear(); }); @@ -303,6 +318,43 @@ describe("IssuesList", () => { container.remove(); }); + it("forwards external-object summaries into issue rows", async () => { + mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ + summaries: { + "issue-1": { + total: 2, + byStatusCategory: { failed: 1, succeeded: 1 }, + byLiveness: { fresh: 2 }, + highestSeverity: "danger", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + }, + }, + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith("company-1", ["issue-1"]); + expect(container.querySelector("[data-testid='external-object-summary']")?.textContent).toBe("2"); + }); + + act(() => { + root.unmount(); + }); + }); + it("renders server search results instead of filtering the full issue list locally", async () => { const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" }); const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 833845afaf7..d61b43cc2a5 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -9,6 +9,7 @@ import { issuesApi } from "../api/issues"; import { authApi } from "../api/auth"; import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; +import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects"; import { shouldBlurPageSearchOnEnter, shouldBlurPageSearchOnEscape, @@ -995,6 +996,15 @@ export function IssuesList({ issueFilterWorkspaceContext, ]); + const issueIdsForExternalObjectSummaries = useMemo( + () => (viewState.viewMode === "list" ? filtered.map((issue) => issue.id) : []), + [filtered, viewState.viewMode], + ); + const { summaries: externalObjectSummaryByIssueId } = useIssueExternalObjectSummaries( + selectedCompanyId, + issueIdsForExternalObjectSummaries, + ); + const progressSummary = useMemo( () => shouldRenderSubIssueProgressSummary(showProgressSummary, issues.length) ? buildSubIssueProgressSummary(issues) @@ -1749,6 +1759,7 @@ export function IssuesList({ checklistDependencyChips={checklistDependencyChips} checklistRowId={checklistRowId} titleClassName={doneRowTitleClass} + externalObjectSummary={externalObjectSummaryByIssueId.get(issue.id) ?? null} titleSuffix={( <> {hasChildren && !isExpanded ? ( diff --git a/ui/src/hooks/useIssueExternalObjects.ts b/ui/src/hooks/useIssueExternalObjects.ts index 51b0c95eb05..f8e8671bcc1 100644 --- a/ui/src/hooks/useIssueExternalObjects.ts +++ b/ui/src/hooks/useIssueExternalObjects.ts @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import type { ExternalObjectMention, @@ -12,9 +12,8 @@ import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody"; import type { ExternalObjectPillData } from "../components/ExternalObjectPill"; /** - * Browser-safe mention-source label. Mirrors the shared/server helper but - * avoids importing `@paperclipai/shared/external-objects.ts` (which pulls in - * `node:crypto`). Keep in sync with the shared formatter. + * Browser-side mention-source label. Keep in sync with the shared formatter + * without coupling this hook to the server-only URL canonicalization helpers. */ function formatMentionSourceLabel(mention: ExternalObjectMention): string { switch (mention.sourceKind) { @@ -130,12 +129,16 @@ export function useIssueExternalObjects(issueId: string | null | undefined): Iss return result; }, [groups]); + const refetch = useCallback(() => { + void query.refetch(); + }, [query.refetch]); + return { groups, markdownReferences, isLoading: enabled && query.isLoading, isError: query.isError, - refetch: () => { void query.refetch(); }, + refetch, }; } @@ -156,6 +159,34 @@ export function useIssueExternalObjectSummary(issueId: string | null | undefined }; } +export function useIssueExternalObjectSummaries( + companyId: string | null | undefined, + issueIds: readonly string[], +): { + summaries: Map; + isLoading: boolean; +} { + const normalizedIssueIds = useMemo( + () => [...new Set(issueIds.filter((issueId) => issueId.length > 0))].sort(), + [issueIds], + ); + const enabled = Boolean(companyId) && normalizedIssueIds.length > 0; + const query = useQuery({ + queryKey: queryKeys.externalObjects.issueSummaries(companyId ?? "__none__", normalizedIssueIds), + queryFn: () => externalObjectsApi.getIssueSummaries(companyId!, normalizedIssueIds), + enabled, + staleTime: 60_000, + }); + const summaries = useMemo( + () => new Map(Object.entries(query.data?.summaries ?? {})), + [query.data?.summaries], + ); + return { + summaries, + isLoading: enabled && query.isLoading, + }; +} + export function useProjectExternalObjectSummary(projectId: string | null | undefined): { summary: ExternalObjectSummary | null; isLoading: boolean; diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 3cb53b8a335..8583974684c 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -142,6 +142,8 @@ export const queryKeys = { externalObjects: { byIssue: (issueId: string) => ["external-objects", "by-issue", issueId] as const, issueSummary: (issueId: string) => ["external-objects", "issue-summary", issueId] as const, + issueSummaries: (companyId: string, issueIds: readonly string[]) => + ["external-objects", "issue-summaries", companyId, issueIds] as const, projectSummary: (projectId: string) => ["external-objects", "project-summary", projectId] as const, }, goals: { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index b63218dc601..865c2238756 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -25,6 +25,7 @@ import { useGeneralSettings } from "../context/GeneralSettingsContext"; import { useSidebar } from "../context/SidebarContext"; import { queryKeys } from "../lib/queryKeys"; import { useDialogActions } from "../context/DialogContext"; +import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects"; import { applyIssueFilters, countActiveIssueFilters, @@ -1276,6 +1277,20 @@ export function Inbox() { if (!defaults) return; openNewIssue(defaults); }, [groupBy, inboxWorkspaceGrouping, openNewIssue]); + + const inboxIssueIdsForExternalObjectSummaries = useMemo(() => { + const issueIds = new Set(); + for (const section of groupedSections) { + for (const item of section.displayItems) { + if (item.kind === "issue") issueIds.add(item.issue.id); + } + } + return [...issueIds]; + }, [groupedSections]); + const { summaries: externalObjectSummaryByIssueId } = useIssueExternalObjectSummaries( + selectedCompanyId, + inboxIssueIdsForExternalObjectSummaries, + ); const totalVisibleWorkItems = useMemo( () => groupedSections.reduce((count, group) => count + group.displayItems.length, 0), [groupedSections], @@ -2334,6 +2349,7 @@ export function Inbox() { key={`issue:${issue.id}`} issue={issue} issueLinkState={issueLinkState} + externalObjectSummary={externalObjectSummaryByIssueId.get(issue.id) ?? null} selected={selected} className={ isArchiving diff --git a/ui/storybook/stories/external-objects.stories.tsx b/ui/storybook/stories/external-objects.stories.tsx index 77f6a43aa06..d70e211c3ed 100644 --- a/ui/storybook/stories/external-objects.stories.tsx +++ b/ui/storybook/stories/external-objects.stories.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { EXTERNAL_OBJECT_LIVENESS_STATES, @@ -11,8 +12,16 @@ import { import { ExternalObjectPill } from "@/components/ExternalObjectPill"; import { ExternalObjectStatusIcon } from "@/components/ExternalObjectStatusIcon"; import { ExternalObjectStatusSummary } from "@/components/ExternalObjectStatusSummary"; +import { IssueFiltersPopover } from "@/components/IssueFiltersPopover"; +import { IssueProperties } from "@/components/IssueProperties"; import { IssueRelatedWorkPanel } from "@/components/IssueRelatedWorkPanel"; +import { IssueRow } from "@/components/IssueRow"; import { MarkdownBody, type MarkdownExternalReferenceMap } from "@/components/MarkdownBody"; +import { + countActiveIssueFilters, + defaultIssueFilterState, + type IssueFilterState, +} from "@/lib/issue-filters"; import { externalObjectCategoryLabel, externalObjectFallbackTone, @@ -21,6 +30,13 @@ import { externalObjectTypeLabel, } from "@/lib/external-objects"; import type { IssueExternalObjectGroup } from "@/hooks/useIssueExternalObjects"; +import { + storybookAgents, + storybookExecutionWorkspaces, + storybookIssueLabels, + storybookIssues, + storybookProjects, +} from "../fixtures/paperclipData"; function makeObject(args: { id: string; @@ -423,6 +439,239 @@ function StateMatrixStory() { ); } +function makeIntegrationGroups(): IssueExternalObjectGroup[] { + return [ + makeGroup({ + object: makeObject({ + id: "obj-int-failed", + providerKey: "github", + objectType: "pull_request", + statusCategory: "failed", + liveness: "fresh", + displayTitle: "CI broken on main", + url: "https://github.com/acme/web/pull/241", + statusLabel: "CI failed", + }), + mentionCount: 4, + sourceLabels: ["Description", "3 comments"], + }), + makeGroup({ + object: makeObject({ + id: "obj-int-auth", + providerKey: "hubspot", + objectType: "lead", + statusCategory: "auth_required", + liveness: "auth_required", + displayTitle: "Acme deal — needs reconnect", + url: "https://app.hubspot.com/leads/99", + statusLabel: "Reconnect", + }), + mentionCount: 1, + sourceLabels: ["External links property"], + }), + makeGroup({ + object: makeObject({ + id: "obj-int-running", + providerKey: "ci", + objectType: "deployment", + statusCategory: "running", + liveness: "fresh", + displayTitle: "deploy prod-0412", + url: "https://ci.example.com/runs/88421", + statusLabel: "Running", + }), + mentionCount: 2, + sourceLabels: ["2 comments"], + }), + ]; +} + +function makeIntegrationSummary(): ExternalObjectSummary { + const objects = makeIntegrationGroups() + .map((entry) => entry.group.object) + .filter((object): object is ExternalObject => Boolean(object)) + .map((object) => ({ + id: object.id, + providerKey: object.providerKey, + objectType: object.objectType, + displayTitle: object.displayTitle, + statusCategory: object.statusCategory, + statusTone: object.statusTone, + liveness: object.liveness, + isTerminal: object.isTerminal, + })); + const tones = objects.map((o) => o.statusTone); + const dominant: ExternalObjectSummary["highestSeverity"] = + tones.includes("danger") ? "danger" + : tones.includes("warning") ? "warning" + : tones.includes("info") ? "info" + : tones.includes("success") ? "success" + : "muted"; + const byStatusCategory: Record = {}; + for (const o of objects) { + byStatusCategory[o.statusCategory] = (byStatusCategory[o.statusCategory] ?? 0) + 1; + } + return { + total: objects.length, + byStatusCategory, + byLiveness: { fresh: objects.length - 1, stale: 0, auth_required: 1, unreachable: 0, unknown: 0 }, + highestSeverity: dominant, + staleCount: 0, + authRequiredCount: 1, + unreachableCount: 0, + objects, + }; +} + +function PropertiesPanelDesktop() { + const issue = storybookIssues[0]!; + return ( +
+
+ Issue properties — desktop @ 1440×900 +
+ undefined} + onUpdate={() => undefined} + /> +
+ ); +} + +function PropertiesPanelMobile() { + const issue = storybookIssues[0]!; + return ( +
+
+ Issue properties — mobile sheet @ 390×844 +
+ undefined} + onUpdate={() => undefined} + inline + /> +
+ ); +} + +function RelatedWorkEmptyDesktop() { + return ( +
+
+ Related work — empty external objects (zero refs, empty copy visible) +
+ +
+ ); +} + +function SidebarMobileDrawer() { + const summary = makeIntegrationSummary(); + return ( +
+
Projects (mobile drawer)
+
    + {[ + { name: "Paperclip App", color: "#6366f1", summary }, + { name: "Marketing site", color: "#22c55e", summary: { ...summary, highestSeverity: "warning", byStatusCategory: { waiting: 2 }, total: 2, objects: [] } }, + { name: "Experimental", color: "#a855f7", summary: { ...summary, highestSeverity: "muted", byStatusCategory: {}, total: 0, objects: [] } }, + ].map((project) => ( +
  • + + {project.name} + +
  • + ))} +
+
+ ); +} + +function IssueListWithBadge() { + const summary = makeIntegrationSummary(); + return ( +
+
+ Issue list — desktop @ 1440×900 (badge + control row) +
+
+ {storybookIssues.slice(0, 2).map((issue, index) => ( + {issue.priority} + } + /> + ))} +
+
+ ); +} + +function FilterPopoverWithExternalChecked() { + const [state, setState] = useState({ + ...defaultIssueFilterState, + externalObjectStatuses: ["failed", "auth_required"], + }); + const triggerRef = useRef(null); + + useEffect(() => { + const timer = window.setTimeout(() => { + triggerRef.current?.querySelector("button")?.click(); + }, 150); + return () => window.clearTimeout(timer); + }, []); + + return ( +
+
+ setState((current) => ({ ...current, ...patch }))} + activeFilterCount={countActiveIssueFilters(state, true)} + agents={storybookAgents.map((agent) => ({ id: agent.id, name: agent.name }))} + projects={storybookProjects.map((project) => ({ id: project.id, name: project.name }))} + labels={storybookIssueLabels.map((label) => ({ id: label.id, name: label.name, color: label.color }))} + currentUserId="user-board" + enableRoutineVisibilityFilter + buttonVariant="outline" + workspaces={storybookExecutionWorkspaces.map((workspace) => ({ id: workspace.id, name: workspace.name }))} + creators={[ + { id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" }, + ]} + /> +
+
+ ); +} + +function IntegrationSurfacesStory() { + return ( +
+ + + + + + +
+ ); +} + const meta = { title: "Foundations/External Objects", component: StateMatrixStory, @@ -441,3 +690,31 @@ export default meta; type Story = StoryObj; export const FullSurface: Story = {}; + +export const PropertiesRowDesktop: StoryObj = { + render: () => , +}; + +export const PropertiesRowMobileSheet: StoryObj = { + render: () => , +}; + +export const RelatedWorkEmpty: StoryObj = { + render: () => , +}; + +export const SidebarMobile: StoryObj = { + render: () => , +}; + +export const IssueListRow: StoryObj = { + render: () => , +}; + +export const FilterPopoverOpen: StoryObj = { + render: () => , +}; + +export const IntegrationSurfaces: StoryObj = { + render: () => , +};