Prepare external object references for PR

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-23 00:35:05 +00:00
1 parent e48a1a4f31
commit c7b393a74e
39 files changed
+941 -236

No files matched your search

@@ -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<string, unknown>;
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<string, string> = {};
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<string>();
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<Pick<
ExternalObjectMentionSource,
"companyId" | "sourceIssueId" | "sourceKind"
>> & ExternalObjectMentionSource): string {
return [
source.companyId,
source.sourceIssueId,
source.sourceKind,
source.sourceRecordId ?? "",
source.documentKey ?? "",
source.propertyKey ?? "",
].join(":");
}
+2 -2
View File
@@ -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", () => {
-210
View File
@@ -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<string, unknown>;
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<string, string> = {};
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<string>();
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<Pick<
ExternalObjectMentionSource,
"companyId" | "sourceIssueId" | "sourceKind"
>> & 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":
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+43
View File
@@ -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) {
+199
View File
@@ -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);
});
@@ -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());
@@ -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";
+10
View File
@@ -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);
+43 -18
View File
@@ -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<ReturnType<typeof toObjectPayload>>, 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<string, ReturnType<typeof summarizeObjectPayloads>>();
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<string, Map<string, ReturnType<typeof toObjectPayload>>>();
for (const row of rows) {
const issueObjects = objectsByIssueId.get(row.issueId) ?? new Map<string, ReturnType<typeof toObjectPayload>>();
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<string, ReturnType<typeof toObjectPayload>>();
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,
+5
View File
@@ -6,6 +6,11 @@ export const externalObjectsApi = {
api.get<ExternalObjectMentionGroup[]>(`/issues/${issueId}/external-objects`),
getIssueSummary: (issueId: string) =>
api.get<ExternalObjectSummary>(`/issues/${issueId}/external-object-summary`),
getIssueSummaries: (companyId: string, issueIds: string[]) =>
api.post<{ summaries: Record<string, ExternalObjectSummary> }>(
`/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) =>
+52
View File
@@ -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;
}) => (
<div
data-testid="issue-row"
@@ -115,6 +125,9 @@ vi.mock("./IssueRow", () => ({
data-title-class={titleClassName ?? undefined}
>
<span>{issue.title}</span>
{externalObjectSummary ? (
<span data-testid="external-object-summary">{externalObjectSummary.total}</span>
) : 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(
<IssuesList
issues={[createIssue()]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => 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" });
+11
View File
@@ -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 ? (
+36 -5
View File
@@ -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<string, ExternalObjectSummary>;
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;
+2
View File
@@ -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: {
+16
View File
@@ -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<string>();
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
@@ -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<string, number> = {};
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 (
<div className="paperclip-story w-[420px] rounded-lg border border-border bg-background/70 p-4">
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
Issue properties — desktop @ 1440×900
</div>
<IssueProperties
issue={issue}
childIssues={[]}
externalObjects={makeIntegrationGroups()}
onAddSubIssue={() => undefined}
onUpdate={() => undefined}
/>
</div>
);
}
function PropertiesPanelMobile() {
const issue = storybookIssues[0]!;
return (
<div className="paperclip-story mx-auto w-[358px] rounded-lg border border-border bg-background/70 p-3">
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
Issue properties — mobile sheet @ 390×844
</div>
<IssueProperties
issue={issue}
childIssues={[]}
externalObjects={makeIntegrationGroups()}
onAddSubIssue={() => undefined}
onUpdate={() => undefined}
inline
/>
</div>
);
}
function RelatedWorkEmptyDesktop() {
return (
<div className="paperclip-story space-y-3 p-6">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Related work — empty external objects (zero refs, empty copy visible)
</div>
<IssueRelatedWorkPanel
relatedWork={{ outbound: [], inbound: [] }}
externalObjects={[]}
/>
</div>
);
}
function SidebarMobileDrawer() {
const summary = makeIntegrationSummary();
return (
<div className="paperclip-story mx-auto w-[320px] rounded border border-border bg-background p-2">
<div className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">Projects (mobile drawer)</div>
<ul className="flex flex-col">
{[
{ 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) => (
<li
key={project.name}
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium hover:bg-accent/50"
>
<span className="h-3.5 w-3.5 shrink-0 rounded-sm" style={{ backgroundColor: project.color }} />
<span className="flex-1 truncate">{project.name}</span>
<ExternalObjectStatusSummary summary={project.summary as ExternalObjectSummary} compact />
</li>
))}
</ul>
</div>
);
}
function IssueListWithBadge() {
const summary = makeIntegrationSummary();
return (
<div className="paperclip-story p-6">
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
Issue list — desktop @ 1440×900 (badge + control row)
</div>
<div className="overflow-hidden rounded-xl border border-border bg-background/70">
{storybookIssues.slice(0, 2).map((issue, index) => (
<IssueRow
key={issue.id}
issue={issue}
externalObjectSummary={index === 0 ? summary : null}
desktopTrailing={
<span className="text-xs text-muted-foreground">{issue.priority}</span>
}
/>
))}
</div>
</div>
);
}
function FilterPopoverWithExternalChecked() {
const [state, setState] = useState<IssueFilterState>({
...defaultIssueFilterState,
externalObjectStatuses: ["failed", "auth_required"],
});
const triggerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const timer = window.setTimeout(() => {
triggerRef.current?.querySelector("button")?.click();
}, 150);
return () => window.clearTimeout(timer);
}, []);
return (
<div className="paperclip-story flex min-h-[640px] items-start justify-end p-6">
<div ref={triggerRef}>
<IssueFiltersPopover
state={state}
onChange={(patch) => 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" },
]}
/>
</div>
</div>
);
}
function IntegrationSurfacesStory() {
return (
<div className="paperclip-story space-y-10 p-6">
<PropertiesPanelDesktop />
<PropertiesPanelMobile />
<RelatedWorkEmptyDesktop />
<SidebarMobileDrawer />
<IssueListWithBadge />
<FilterPopoverWithExternalChecked />
</div>
);
}
const meta = {
title: "Foundations/External Objects",
component: StateMatrixStory,
@@ -441,3 +690,31 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const FullSurface: Story = {};
export const PropertiesRowDesktop: StoryObj = {
render: () => <PropertiesPanelDesktop />,
};
export const PropertiesRowMobileSheet: StoryObj = {
render: () => <PropertiesPanelMobile />,
};
export const RelatedWorkEmpty: StoryObj = {
render: () => <RelatedWorkEmptyDesktop />,
};
export const SidebarMobile: StoryObj = {
render: () => <SidebarMobileDrawer />,
};
export const IssueListRow: StoryObj = {
render: () => <IssueListWithBadge />,
};
export const FilterPopoverOpen: StoryObj = {
render: () => <FilterPopoverWithExternalChecked />,
};
export const IntegrationSurfaces: StoryObj = {
render: () => <IntegrationSurfacesStory />,
};