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:
DottaandPaperclip committed 2026-06-23 00:35:04 +00:00
1 parent fe3d3550a0
commit 58d5adc1c7
12 files changed
+554 -7

No files matched your search

+15 -3
View File
@@ -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>
)}
+35
View File
@@ -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">
+54
View File
@@ -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"
+100
View File
@@ -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."
+12 -2
View File
@@ -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>
+98 -1
View File
@@ -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 ? (
+5
View File
@@ -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
+158
View File
@@ -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,
};
}
+8
View File
@@ -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,
},
});
+42
View File
@@ -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;
}
+5
View File
@@ -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,
+22 -1
View File
@@ -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>