= [remarkGfm];
if (enableWikiLinks) {
remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
@@ -706,6 +792,17 @@ export function MarkdownBody({
);
}
+ const externalReference = href && externalReferenceLookup
+ ? externalReferenceLookup[normalizeExternalObjectHref(href) ?? ""] ?? null
+ : null;
+ if (externalReference && href) {
+ return (
+
+ {linkChildren}
+
+ );
+ }
+
const isGitHubLink = isGitHubUrl(href);
const isExternal = isExternalHttpUrl(href);
const leadingIcon = isGitHubLink ? (
diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx
index 968e05d8d89..43244dc2ba4 100644
--- a/ui/src/components/SidebarProjects.tsx
+++ b/ui/src/components/SidebarProjects.tsx
@@ -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 = (
{project.name}
+ {!rail ? : null}
{!rail && project.pauseReason === "budget" ? : null}
);
@@ -230,6 +234,7 @@ function SortableProjectItem(props: ProjectItemProps) {
transition,
isDragging,
} = useSortable({ id: props.project.id });
+ } = useSortable({ id: props.project.id });
return (
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(() => {
+ 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(() => {
+ 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,
+ };
+}
diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts
index d18bc7c89e5..0c83f8d9f54 100644
--- a/ui/src/lib/inbox.test.ts
+++ b/ui/src/lib/inbox.test.ts
@@ -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,
},
});
diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts
index 63ce019378d..d966ceb66b3 100644
--- a/ui/src/lib/issue-filters.ts
+++ b/ui/src/lib/issue-filters.ts
@@ -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 = {
+ 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;
}
diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts
index d5429df38aa..3cb53b8a335 100644
--- a/ui/src/lib/queryKeys.ts
+++ b/ui/src/lib/queryKeys.ts
@@ -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,
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 342313bc0f9..8817f5334e5 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -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() {
-
+
{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}
/>