mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Wire external object surfaces across markdown, properties, related work, sidebar, list, filters
- MarkdownBody/InlineEditor accept an externalReferences map and render resolved URLs with a host-rendered status icon prefix (no inline plugin React, per Phase 1B security review) - IssueProperties shows a read-only "External objects" row above Parent, with skeleton/error fallbacks - IssueRelatedWorkPanel adds a third "External objects" section between References and Referenced by, severity-sorted with mention-source chips - SidebarProjects renders a per-project ExternalObjectStatusSummary marker to the right of the project name - IssueRow accepts an externalObjectSummary in the desktop trailing slot - IssueFiltersPopover gains an "External object status" section with failed/waiting/running/auth_required/unreachable/stale/none options - Add useIssueExternalObjects, useIssueExternalObjectSummary, useProjectExternalObjectSummary hooks plus queryKeys.externalObjects - IssueDetail loads external objects once and forwards to description, properties, mobile properties drawer, and the related-work tab Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
fe3d3550a0
commit
58d5adc1c7
12 files changed
+554
-7
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
import { FoldCurtain } from "./FoldCurtain";
|
||||
@@ -19,6 +19,11 @@ interface InlineEditorProps {
|
||||
nullable?: boolean;
|
||||
/** When true, long display-mode markdown is clipped with a fade curtain that expands on click. */
|
||||
foldable?: boolean;
|
||||
/**
|
||||
* Optional host-resolved external object metadata. Forwarded to the read-mode
|
||||
* `MarkdownBody` so resolved URLs render with the inline status icon prefix.
|
||||
*/
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}
|
||||
|
||||
/** Shared padding so display and edit modes occupy the exact same box. */
|
||||
@@ -55,6 +60,7 @@ export function InlineEditor({
|
||||
onDropFile,
|
||||
mentions,
|
||||
foldable = false,
|
||||
externalReferences,
|
||||
}: InlineEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [multilineEditing, setMultilineEditing] = useState(false);
|
||||
@@ -288,12 +294,18 @@ export function InlineEditor({
|
||||
>
|
||||
{foldable ? (
|
||||
<FoldCurtain>
|
||||
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
|
||||
<MarkdownBody
|
||||
className={cn("paperclip-edit-in-place-content", className)}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
{previewValue}
|
||||
</MarkdownBody>
|
||||
</FoldCurtain>
|
||||
) : (
|
||||
<MarkdownBody className={cn("paperclip-edit-in-place-content", className)}>
|
||||
<MarkdownBody
|
||||
className={cn("paperclip-edit-in-place-content", className)}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
{previewValue}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { PriorityIcon } from "./PriorityIcon";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import {
|
||||
defaultIssueFilterState,
|
||||
externalObjectFilterLabel,
|
||||
externalObjectFilterOrder,
|
||||
issueFilterArraysEqual,
|
||||
issueFilterLabel,
|
||||
issuePriorityOrder,
|
||||
@@ -17,6 +19,8 @@ import {
|
||||
toggleIssueFilterValue,
|
||||
type IssueFilterState,
|
||||
} from "../lib/issue-filters";
|
||||
import { externalObjectIconForCategory } from "../lib/external-objects";
|
||||
import { externalObjectStatusIcon } from "../lib/status-colors";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
|
||||
type AgentOption = {
|
||||
@@ -344,6 +348,37 @@ export function IssueFiltersPopover({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">External object status</span>
|
||||
<div className="space-y-0.5">
|
||||
{externalObjectFilterOrder.map((value) => {
|
||||
const iconCategory = value === "failed" ? "failed"
|
||||
: value === "waiting" ? "waiting"
|
||||
: value === "running" ? "running"
|
||||
: value === "auth_required" ? "auth_required"
|
||||
: value === "unreachable" ? "unreachable"
|
||||
: value === "stale" ? "unknown"
|
||||
: "closed";
|
||||
const Icon = externalObjectIconForCategory(iconCategory);
|
||||
const tone = externalObjectStatusIcon[iconCategory] ?? "";
|
||||
const textTone = tone.split(" ").filter((c) => c.startsWith("text-")).join(" ");
|
||||
return (
|
||||
<label
|
||||
key={value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={state.externalObjectStatuses.includes(value)}
|
||||
onCheckedChange={() => onChange({ externalObjectStatuses: toggleIssueFilterValue(state.externalObjectStatuses, value) })}
|
||||
/>
|
||||
<Icon className={`h-3.5 w-3.5 shrink-0 ${textTone}`} aria-hidden="true" />
|
||||
<span className="text-sm">{externalObjectFilterLabel(value)}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Visibility</span>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
|
||||
@@ -35,6 +35,9 @@ import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
import { IssueReferencePill } from "./IssueReferencePill";
|
||||
import { formatDate, formatDateTime, cn, projectUrl } from "../lib/utils";
|
||||
import { ExternalObjectPill } from "./ExternalObjectPill";
|
||||
import type { IssueExternalObjectGroup } from "../hooks/useIssueExternalObjects";
|
||||
import { externalObjectToneSeverity } from "../lib/external-objects";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
@@ -153,6 +156,10 @@ interface IssuePropertiesProps {
|
||||
/** Whether an agent run is currently in flight on this issue, so the assignee
|
||||
* picker can warn that reassigning will interrupt it. */
|
||||
hasActiveRun?: boolean;
|
||||
externalObjects?: IssueExternalObjectGroup[];
|
||||
externalObjectsLoading?: boolean;
|
||||
externalObjectsError?: boolean;
|
||||
onRetryExternalObjects?: () => void;
|
||||
}
|
||||
|
||||
const ISSUE_BLOCKER_SEARCH_LIMIT = 50;
|
||||
@@ -414,6 +421,10 @@ export function IssueProperties({
|
||||
onUpdate,
|
||||
inline,
|
||||
hasActiveRun = false,
|
||||
externalObjects,
|
||||
externalObjectsLoading,
|
||||
externalObjectsError,
|
||||
onRetryExternalObjects,
|
||||
}: IssuePropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -2168,6 +2179,49 @@ export function IssueProperties({
|
||||
{projectContent}
|
||||
</PropertyPicker>
|
||||
|
||||
{externalObjectsError ? (
|
||||
<PropertyRow label="External objects">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Couldn't load external objects.
|
||||
{onRetryExternalObjects ? (
|
||||
<>
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
onClick={onRetryExternalObjects}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</PropertyRow>
|
||||
) : externalObjectsLoading ? (
|
||||
<PropertyRow label="External objects">
|
||||
<span className="h-4 w-24 animate-pulse rounded bg-muted/40" />
|
||||
</PropertyRow>
|
||||
) : externalObjects && externalObjects.length > 0 ? (
|
||||
<PropertyRow label="External objects">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{[...externalObjects]
|
||||
.sort((a, b) => {
|
||||
const aTone = externalObjectToneSeverity(a.group.object?.statusTone);
|
||||
const bTone = externalObjectToneSeverity(b.group.object?.statusTone);
|
||||
return bTone - aTone;
|
||||
})
|
||||
.map(({ pill, mentionCount, sourceLabels, group }) => (
|
||||
<ExternalObjectPill
|
||||
key={group.object?.id ?? `${pill.providerKey}:${pill.objectType}:${pill.url ?? "anon"}`}
|
||||
object={pill}
|
||||
sourceCount={mentionCount}
|
||||
sourceSummary={sourceLabels.join(", ")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PropertyRow>
|
||||
) : null}
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Parent"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { IssueRelatedWorkItem, IssueRelatedWorkSummary } from "@paperclipai/shared";
|
||||
import { IssueReferencePill } from "./IssueReferencePill";
|
||||
import { ExternalObjectPill } from "./ExternalObjectPill";
|
||||
import type { IssueExternalObjectGroup } from "../hooks/useIssueExternalObjects";
|
||||
import { externalObjectToneSeverity } from "../lib/external-objects";
|
||||
|
||||
type GroupedSource = {
|
||||
label: string;
|
||||
@@ -83,10 +86,101 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalObjectsSection({
|
||||
groups,
|
||||
isLoading,
|
||||
isError,
|
||||
onRetry,
|
||||
}: {
|
||||
groups: IssueExternalObjectGroup[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
// Severity-first sort with most-recently-changed as the secondary sort.
|
||||
const sorted = [...groups].sort((a, b) => {
|
||||
const aTone = externalObjectToneSeverity(a.pill.statusCategory ? a.group.object?.statusTone ?? null : null);
|
||||
const bTone = externalObjectToneSeverity(b.pill.statusCategory ? b.group.object?.statusTone ?? null : null);
|
||||
if (aTone !== bTone) return bTone - aTone;
|
||||
const aChanged = a.group.object?.lastChangedAt ?? a.group.object?.lastResolvedAt ?? "";
|
||||
const bChanged = b.group.object?.lastChangedAt ?? b.group.object?.lastResolvedAt ?? "";
|
||||
return aChanged < bChanged ? 1 : aChanged > bChanged ? -1 : 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border border-border p-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">External objects</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remote work referenced from this issue — pull requests, deployments, tickets in other systems, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Couldn't load external objects.{" "}
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Loading external objects…</p>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This issue does not reference any external objects yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="-mx-1 flex flex-col">
|
||||
{sorted.map(({ pill, mentionCount, sourceLabels, group }) => {
|
||||
const object = group.object;
|
||||
return (
|
||||
<li
|
||||
key={object?.id ?? `${pill.providerKey}:${pill.objectType}:${pill.url ?? "anon"}`}
|
||||
className="flex flex-wrap items-center gap-x-2 gap-y-1.5 rounded-md px-1 py-1.5 hover:bg-accent/40"
|
||||
>
|
||||
<ExternalObjectPill object={pill} sourceCount={mentionCount} sourceSummary={sourceLabels.join(", ")} />
|
||||
{pill.displayTitle ? (
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||
{pill.displayTitle}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{sourceLabels.map((label) => (
|
||||
<span
|
||||
key={`${object?.id ?? pill.url ?? label}:${label}`}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueRelatedWorkPanel({
|
||||
relatedWork,
|
||||
externalObjects,
|
||||
externalObjectsLoading,
|
||||
externalObjectsError,
|
||||
onRetryExternalObjects,
|
||||
}: {
|
||||
relatedWork?: IssueRelatedWorkSummary | null;
|
||||
externalObjects?: IssueExternalObjectGroup[];
|
||||
externalObjectsLoading?: boolean;
|
||||
externalObjectsError?: boolean;
|
||||
onRetryExternalObjects?: () => void;
|
||||
}) {
|
||||
const outbound = relatedWork?.outbound ?? [];
|
||||
const inbound = relatedWork?.inbound ?? [];
|
||||
@@ -99,6 +193,12 @@ export function IssueRelatedWorkPanel({
|
||||
items={outbound}
|
||||
emptyLabel="This task does not reference any other tasks yet."
|
||||
/>
|
||||
<ExternalObjectsSection
|
||||
groups={externalObjects ?? []}
|
||||
isLoading={Boolean(externalObjectsLoading)}
|
||||
isError={Boolean(externalObjectsError)}
|
||||
onRetry={onRetryExternalObjects}
|
||||
/>
|
||||
<Section
|
||||
title="Referenced by"
|
||||
description="Other tasks that currently point at this task."
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Issue, IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import type { ExternalObjectSummary, Issue, IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Eye, Flag, X } from "lucide-react";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
|
||||
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
|
||||
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
|
||||
|
||||
type UnreadState = "hidden" | "visible" | "fading";
|
||||
|
||||
@@ -28,6 +29,11 @@ interface IssueRowProps {
|
||||
desktopLeadingSpacer?: boolean;
|
||||
mobileMeta?: ReactNode;
|
||||
desktopTrailing?: ReactNode;
|
||||
/**
|
||||
* Optional pre-fetched external-object summary. Renders a compact severity
|
||||
* marker before the rest of `desktopTrailing` on desktop only.
|
||||
*/
|
||||
externalObjectSummary?: ExternalObjectSummary | null;
|
||||
trailingMeta?: ReactNode;
|
||||
titleSuffix?: ReactNode;
|
||||
titleClassName?: string;
|
||||
@@ -51,6 +57,7 @@ export function IssueRow({
|
||||
desktopLeadingSpacer = false,
|
||||
mobileMeta,
|
||||
desktopTrailing,
|
||||
externalObjectSummary,
|
||||
trailingMeta,
|
||||
titleSuffix,
|
||||
titleClassName,
|
||||
@@ -162,8 +169,11 @@ export function IssueRow({
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
{(desktopTrailing || trailingMeta) ? (
|
||||
{(desktopTrailing || trailingMeta || externalObjectSummary) ? (
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
|
||||
{externalObjectSummary ? (
|
||||
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
|
||||
) : null}
|
||||
{desktopTrailing}
|
||||
{trailingMeta ? (
|
||||
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { isValidElement, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react";
|
||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||
@@ -15,6 +15,33 @@ import { parseWorkspaceFileHref, remarkWorkspaceFileRefs, WORKSPACE_FILE_HREF_PR
|
||||
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { WorkspaceFileLink } from "./WorkspaceFileLink";
|
||||
import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon";
|
||||
import {
|
||||
externalObjectCategoryLabel,
|
||||
externalObjectLivenessLabel,
|
||||
externalObjectProviderLabel,
|
||||
} from "../lib/external-objects";
|
||||
import { normalizeExternalObjectHref } from "../lib/external-object-href";
|
||||
import type {
|
||||
ExternalObjectLivenessState,
|
||||
ExternalObjectStatusCategory,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Host-resolved external-object metadata for inline markdown decoration.
|
||||
* The renderer only consumes the host normalized fields here — plugin React
|
||||
* is never mounted inline (Phase 1B security review).
|
||||
*/
|
||||
export interface MarkdownExternalReference {
|
||||
providerKey: string | null;
|
||||
objectType: string | null;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
liveness: ExternalObjectLivenessState;
|
||||
statusLabel?: string | null;
|
||||
displayTitle?: string | null;
|
||||
}
|
||||
|
||||
export type MarkdownExternalReferenceMap = Record<string, MarkdownExternalReference>;
|
||||
|
||||
interface MarkdownBodyProps {
|
||||
children: string;
|
||||
@@ -28,6 +55,12 @@ interface MarkdownBodyProps {
|
||||
wikiLinkRoot?: string;
|
||||
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
|
||||
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||
/**
|
||||
* Optional map of `normalizeExternalObjectHref(href)` → host-resolved metadata.
|
||||
* Hrefs in the markdown that resolve to one of these keys get the inline
|
||||
* status icon prefix used by §2 of the UX spec.
|
||||
*/
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||
resolveImageSrc?: (src: string) => string | null;
|
||||
/** Called when a user clicks an inline image */
|
||||
@@ -72,6 +105,49 @@ function MarkdownIssueLink({
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownExternalLink({
|
||||
href,
|
||||
reference,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
reference: MarkdownExternalReference;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const provider = externalObjectProviderLabel(reference.providerKey);
|
||||
const statusLabel = reference.statusLabel ?? externalObjectCategoryLabel(reference.statusCategory);
|
||||
const livenessLabel = externalObjectLivenessLabel(reference.liveness);
|
||||
const livenessSuffix = reference.liveness === "fresh" || reference.liveness === "unknown"
|
||||
? ""
|
||||
: ` (${livenessLabel})`;
|
||||
const titleParts = [
|
||||
reference.displayTitle ?? `${provider} ${statusLabel}`,
|
||||
`${provider} — ${statusLabel}${livenessSuffix}`,
|
||||
];
|
||||
const title = titleParts.filter(Boolean).join(" · ");
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-external-link="resolved"
|
||||
data-external-status={reference.statusCategory}
|
||||
data-external-liveness={reference.liveness}
|
||||
title={title}
|
||||
aria-label={`${provider} ${statusLabel}${livenessSuffix}: ${reference.displayTitle ?? href}`}
|
||||
className="paperclip-markdown-external-ref"
|
||||
>
|
||||
<ExternalObjectStatusIcon
|
||||
category={reference.statusCategory}
|
||||
liveness={reference.liveness}
|
||||
label={`${provider}: ${statusLabel}`}
|
||||
inline
|
||||
/>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function loadMermaid() {
|
||||
if (!mermaidLoaderPromise) {
|
||||
mermaidLoaderPromise = import("mermaid").then((module) => module.default);
|
||||
@@ -572,6 +648,7 @@ export function MarkdownBody({
|
||||
enableWikiLinks = false,
|
||||
wikiLinkRoot,
|
||||
resolveWikiLinkHref,
|
||||
externalReferences,
|
||||
resolveImageSrc,
|
||||
onImageClick,
|
||||
linkWorkspaceFileRefs = false,
|
||||
@@ -584,6 +661,15 @@ export function MarkdownBody({
|
||||
const knownPrefixes = company?.companies.length
|
||||
? company.companies.map((c) => c.issuePrefix)
|
||||
: undefined;
|
||||
const externalReferenceLookup = useMemo<MarkdownExternalReferenceMap | null>(() => {
|
||||
if (!externalReferences) return null;
|
||||
const lookup: MarkdownExternalReferenceMap = {};
|
||||
for (const [key, value] of Object.entries(externalReferences)) {
|
||||
const normalized = normalizeExternalObjectHref(key) ?? key;
|
||||
lookup[normalized] = value;
|
||||
}
|
||||
return lookup;
|
||||
}, [externalReferences]);
|
||||
const remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
||||
if (enableWikiLinks) {
|
||||
remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
||||
@@ -706,6 +792,17 @@ export function MarkdownBody({
|
||||
</a>
|
||||
);
|
||||
}
|
||||
const externalReference = href && externalReferenceLookup
|
||||
? externalReferenceLookup[normalizeExternalObjectHref(href) ?? ""] ?? null
|
||||
: null;
|
||||
if (externalReference && href) {
|
||||
return (
|
||||
<MarkdownExternalLink href={href} reference={externalReference}>
|
||||
{linkChildren}
|
||||
</MarkdownExternalLink>
|
||||
);
|
||||
}
|
||||
|
||||
const isGitHubLink = isGitHubUrl(href);
|
||||
const isExternal = isExternalHttpUrl(href);
|
||||
const leadingIcon = isGitHubLink ? (
|
||||
|
||||
@@ -22,7 +22,9 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||
import { useProjectExternalObjectSummary } from "../hooks/useIssueExternalObjects";
|
||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -122,6 +124,7 @@ function ProjectItem({
|
||||
isDragging = false,
|
||||
}: ProjectItemProps) {
|
||||
const routeRef = projectRouteRef(project);
|
||||
const { summary: externalObjectsSummary } = useProjectExternalObjectSummary(project.id);
|
||||
|
||||
const link = (
|
||||
<NavLink
|
||||
@@ -143,6 +146,7 @@ function ProjectItem({
|
||||
>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
|
||||
{!rail ? <ExternalObjectStatusSummary summary={externalObjectsSummary} compact /> : null}
|
||||
{!rail && project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||
</NavLink>
|
||||
);
|
||||
@@ -230,6 +234,7 @@ function SortableProjectItem(props: ProjectItemProps) {
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: props.project.id });
|
||||
} = useSortable({ id: props.project.id });
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
ExternalObjectMentionGroup,
|
||||
ExternalObjectSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { externalObjectsApi } from "../api/externalObjects";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { normalizeExternalObjectHref } from "../lib/external-object-href";
|
||||
import {
|
||||
formatExternalObjectMentionSourceLabel,
|
||||
} from "@paperclipai/shared";
|
||||
import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody";
|
||||
import type { ExternalObjectPillData } from "../components/ExternalObjectPill";
|
||||
|
||||
export interface IssueExternalObjectGroup {
|
||||
pill: ExternalObjectPillData;
|
||||
mentionCount: number;
|
||||
sourceLabels: string[];
|
||||
group: ExternalObjectMentionGroup;
|
||||
}
|
||||
|
||||
export interface IssueExternalObjectsResult {
|
||||
groups: IssueExternalObjectGroup[];
|
||||
/** Lookup map for `MarkdownBody`'s `externalReferences` prop. */
|
||||
markdownReferences: MarkdownExternalReferenceMap;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads `external_objects` for an issue and produces both the per-group rows
|
||||
* (used by the property panel and related-work section) and the markdown URL
|
||||
* lookup map (used by inline decoration). Single source of truth so every
|
||||
* surface reads from the same query result.
|
||||
*/
|
||||
export function useIssueExternalObjects(issueId: string | null | undefined): IssueExternalObjectsResult {
|
||||
const enabled = Boolean(issueId);
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.externalObjects.byIssue(issueId ?? "__none__"),
|
||||
queryFn: () => externalObjectsApi.listForIssue(issueId!),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const groups = useMemo<IssueExternalObjectGroup[]>(() => {
|
||||
const data = query.data ?? [];
|
||||
return data
|
||||
.filter((entry): entry is ExternalObjectMentionGroup => Boolean(entry.object))
|
||||
.map((entry) => {
|
||||
const object = entry.object!;
|
||||
const sourceLabels = entry.sourceLabels && entry.sourceLabels.length > 0
|
||||
? entry.sourceLabels
|
||||
: Array.from(new Set(entry.mentions.map((mention) =>
|
||||
formatExternalObjectMentionSourceLabel({
|
||||
sourceKind: mention.sourceKind,
|
||||
documentKey: mention.documentKey,
|
||||
propertyKey: mention.propertyKey,
|
||||
}),
|
||||
)));
|
||||
return {
|
||||
group: entry,
|
||||
mentionCount: entry.mentionCount ?? entry.mentions.length,
|
||||
sourceLabels,
|
||||
pill: {
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
statusCategory: object.statusCategory,
|
||||
liveness: object.liveness,
|
||||
displayTitle: object.displayTitle,
|
||||
statusLabel: object.statusLabel,
|
||||
url: object.sanitizedCanonicalUrl,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [query.data]);
|
||||
|
||||
const markdownReferences = useMemo<MarkdownExternalReferenceMap>(() => {
|
||||
const result: MarkdownExternalReferenceMap = {};
|
||||
for (const { group } of groups) {
|
||||
const object = group.object;
|
||||
if (!object) continue;
|
||||
// Index by the object's canonical URL.
|
||||
const canonical = normalizeExternalObjectHref(object.sanitizedCanonicalUrl ?? null);
|
||||
if (canonical) {
|
||||
result[canonical] = {
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
statusCategory: object.statusCategory,
|
||||
liveness: object.liveness,
|
||||
statusLabel: object.statusLabel,
|
||||
displayTitle: object.displayTitle,
|
||||
};
|
||||
}
|
||||
// Also index by every mention's sanitized display URL so user-pasted
|
||||
// hrefs that differ only in case/punctuation still resolve.
|
||||
for (const mention of group.mentions) {
|
||||
const normalizedMention = normalizeExternalObjectHref(
|
||||
mention.sanitizedDisplayUrl ?? null,
|
||||
);
|
||||
if (normalizedMention && !result[normalizedMention]) {
|
||||
result[normalizedMention] = {
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
statusCategory: object.statusCategory,
|
||||
liveness: object.liveness,
|
||||
statusLabel: object.statusLabel,
|
||||
displayTitle: object.displayTitle,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [groups]);
|
||||
|
||||
return {
|
||||
groups,
|
||||
markdownReferences,
|
||||
isLoading: enabled && query.isLoading,
|
||||
isError: query.isError,
|
||||
refetch: () => { void query.refetch(); },
|
||||
};
|
||||
}
|
||||
|
||||
export function useIssueExternalObjectSummary(issueId: string | null | undefined): {
|
||||
summary: ExternalObjectSummary | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const enabled = Boolean(issueId);
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.externalObjects.issueSummary(issueId ?? "__none__"),
|
||||
queryFn: () => externalObjectsApi.getIssueSummary(issueId!),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return {
|
||||
summary: query.data ?? null,
|
||||
isLoading: enabled && query.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useProjectExternalObjectSummary(projectId: string | null | undefined): {
|
||||
summary: ExternalObjectSummary | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const enabled = Boolean(projectId);
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.externalObjects.projectSummary(projectId ?? "__none__"),
|
||||
queryFn: () => externalObjectsApi.getProjectSummary(projectId!),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return {
|
||||
summary: query.data ?? null,
|
||||
isLoading: enabled && query.isLoading,
|
||||
};
|
||||
}
|
||||
@@ -909,6 +909,7 @@ describe("inbox helpers", () => {
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
}).map((issue) => issue.id),
|
||||
@@ -929,6 +930,7 @@ describe("inbox helpers", () => {
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
}),
|
||||
@@ -949,6 +951,7 @@ describe("inbox helpers", () => {
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
}),
|
||||
@@ -1021,6 +1024,7 @@ describe("inbox helpers", () => {
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
liveOnly: true,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
@@ -1036,6 +1040,7 @@ describe("inbox helpers", () => {
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
@@ -1052,6 +1057,7 @@ describe("inbox helpers", () => {
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
liveOnly: true,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
@@ -1067,6 +1073,7 @@ describe("inbox helpers", () => {
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
@@ -1101,6 +1108,7 @@ describe("inbox helpers", () => {
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,6 +19,21 @@ export type IssueFilterState = {
|
||||
projects: string[];
|
||||
workspaces: string[];
|
||||
liveOnly?: boolean;
|
||||
/**
|
||||
* External object status filter. Values are special tokens that map to
|
||||
* properties of the issue's external-object summary (rather than to a
|
||||
* single category) so the filter UI can describe intent rather than every
|
||||
* possible permutation.
|
||||
*
|
||||
* - `failed` — any external object with `statusCategory in (failed, blocked)`
|
||||
* - `waiting` — any external object with `statusCategory in (waiting)`
|
||||
* - `running` — any external object with `statusCategory in (running)`
|
||||
* - `auth_required` — any external object with `liveness == auth_required`
|
||||
* - `unreachable` — any external object with `liveness == unreachable`
|
||||
* - `stale` — any external object with `liveness == stale`
|
||||
* - `none` — issues with zero external objects
|
||||
*/
|
||||
externalObjectStatuses: string[];
|
||||
hideRoutineExecutions: boolean;
|
||||
};
|
||||
|
||||
@@ -31,9 +46,34 @@ export const defaultIssueFilterState: IssueFilterState = {
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
liveOnly: false,
|
||||
externalObjectStatuses: [],
|
||||
hideRoutineExecutions: false,
|
||||
};
|
||||
|
||||
export const externalObjectFilterOrder = [
|
||||
"failed",
|
||||
"waiting",
|
||||
"running",
|
||||
"auth_required",
|
||||
"unreachable",
|
||||
"stale",
|
||||
"none",
|
||||
];
|
||||
|
||||
const EXTERNAL_OBJECT_FILTER_LABELS: Record<string, string> = {
|
||||
failed: "Any failed",
|
||||
waiting: "Any waiting",
|
||||
running: "Any running",
|
||||
auth_required: "Auth required",
|
||||
unreachable: "Unreachable",
|
||||
stale: "Stale",
|
||||
none: "No external objects",
|
||||
};
|
||||
|
||||
export function externalObjectFilterLabel(value: string): string {
|
||||
return EXTERNAL_OBJECT_FILTER_LABELS[value] ?? issueFilterLabel(value);
|
||||
}
|
||||
|
||||
export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||
export const issuePriorityOrder = ["critical", "high", "medium", "low"];
|
||||
|
||||
@@ -72,6 +112,7 @@ export function normalizeIssueFilterState(value: unknown): IssueFilterState {
|
||||
projects: normalizeIssueFilterValueArray(candidate.projects),
|
||||
workspaces: normalizeIssueFilterValueArray(candidate.workspaces),
|
||||
liveOnly: candidate.liveOnly === true,
|
||||
externalObjectStatuses: normalizeIssueFilterValueArray(candidate.externalObjectStatuses),
|
||||
hideRoutineExecutions: candidate.hideRoutineExecutions === true,
|
||||
};
|
||||
}
|
||||
@@ -182,6 +223,7 @@ export function countActiveIssueFilters(
|
||||
if (state.projects.length > 0) count += 1;
|
||||
if (state.workspaces.length > 0) count += 1;
|
||||
if (state.liveOnly) count += 1;
|
||||
if (state.externalObjectStatuses.length > 0) count += 1;
|
||||
if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) count += 1;
|
||||
return count;
|
||||
}
|
||||
@@ -139,6 +139,11 @@ export const queryKeys = {
|
||||
list: (companyId: string) => ["projects", companyId] as const,
|
||||
detail: (id: string) => ["projects", "detail", id] as const,
|
||||
},
|
||||
externalObjects: {
|
||||
byIssue: (issueId: string) => ["external-objects", "by-issue", issueId] as const,
|
||||
issueSummary: (issueId: string) => ["external-objects", "issue-summary", issueId] as const,
|
||||
projectSummary: (projectId: string) => ["external-objects", "project-summary", projectId] as const,
|
||||
},
|
||||
goals: {
|
||||
list: (companyId: string) => ["goals", companyId] as const,
|
||||
detail: (id: string) => ["goals", "detail", id] as const,
|
||||
|
||||
@@ -88,6 +88,7 @@ import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews";
|
||||
import { computePauseAffectsSummary } from "../lib/interrupt-handoff";
|
||||
import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects";
|
||||
import { IssueRunLedger } from "../components/IssueRunLedger";
|
||||
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
@@ -1355,6 +1356,7 @@ export function IssueDetail() {
|
||||
enabled: !!issueId,
|
||||
});
|
||||
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
|
||||
const externalObjectsState = useIssueExternalObjects(issue?.id ?? null);
|
||||
const commentComposerDisabledReason = useMemo(() => {
|
||||
if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) {
|
||||
return null;
|
||||
@@ -2859,6 +2861,10 @@ export function IssueDetail() {
|
||||
onAddSubIssue={openNewSubIssue}
|
||||
onUpdate={handleIssuePropertiesUpdate}
|
||||
hasActiveRun={resolvedHasActiveRun}
|
||||
externalObjects={externalObjectsState.groups}
|
||||
externalObjectsLoading={externalObjectsState.isLoading}
|
||||
externalObjectsError={externalObjectsState.isError}
|
||||
onRetryExternalObjects={externalObjectsState.refetch}
|
||||
/>
|
||||
);
|
||||
return () => closePanel();
|
||||
@@ -2871,6 +2877,10 @@ export function IssueDetail() {
|
||||
panelChildIssues,
|
||||
panelIssue,
|
||||
resolvedHasActiveRun,
|
||||
externalObjectsState.groups,
|
||||
externalObjectsState.isLoading,
|
||||
externalObjectsState.isError,
|
||||
externalObjectsState.refetch,
|
||||
]);
|
||||
|
||||
const goToInboxShortcutArmedRef = useRef(false);
|
||||
@@ -3947,6 +3957,7 @@ export function IssueDetail() {
|
||||
multiline
|
||||
foldable
|
||||
mentions={mentionOptions}
|
||||
externalReferences={externalObjectsState.markdownReferences}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
@@ -4268,7 +4279,13 @@ export function IssueDetail() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="related-work">
|
||||
<IssueRelatedWorkPanel relatedWork={issue.relatedWork} />
|
||||
<IssueRelatedWorkPanel
|
||||
relatedWork={issue.relatedWork}
|
||||
externalObjects={externalObjectsState.groups}
|
||||
externalObjectsLoading={externalObjectsState.isLoading}
|
||||
externalObjectsError={externalObjectsState.isError}
|
||||
onRetryExternalObjects={externalObjectsState.refetch}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{activePluginTab && (
|
||||
@@ -4456,6 +4473,10 @@ export function IssueDetail() {
|
||||
onUpdate={(data) => updateIssue.mutate(data)}
|
||||
inline
|
||||
hasActiveRun={resolvedHasActiveRun}
|
||||
externalObjects={externalObjectsState.groups}
|
||||
externalObjectsLoading={externalObjectsState.isLoading}
|
||||
externalObjectsError={externalObjectsState.isError}
|
||||
onRetryExternalObjects={externalObjectsState.refetch}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
Reference in new issue
Block a user