Files
PaperClipAI/ui/src/components/issue-properties/external-object-rows.tsx
T
Devin FoleyandPaperclip 185515c97b fix(external-objects): refresh PR status labels (#10704)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The issue properties panel can show external objects such as GitHub
pull requests.
> - Those objects are resolved by external-object providers and then
displayed as compact status labels.
> - A GitHub pull request could remain in the fallback `unknown` state
and appear as `Not yet resolved`.
> - That label is confusing when the object is known but has not been
refreshed yet.
> - This pull request refreshes due external objects from the heartbeat
scheduler and improves the unknown-status copy.
> - The benefit is a properties panel that moves from pending refresh to
the real pull request state without a manual refresh.

## Linked Issues or Issue Description

No public GitHub issue exists for this bug. I searched for related
public issues and pull requests using the terms `Not yet refreshed`,
`external objects refresh`, and `external PR status`, and did not find a
duplicate implementation.

**What happened?**

The issue properties panel could show a GitHub pull request as `Not yet
resolved` even when the referenced pull request was valid. The object
stayed stale unless a manual refresh path ran.

**Expected behavior**

A known external object should show pending-refresh copy while it waits
for provider data. When the scheduler refreshes it, the properties panel
should show the provider status such as open, merged, or closed.

**Steps to reproduce**

1. Create or view an issue that references a GitHub pull request.
2. Open the issue properties panel.
3. Observe the external object row before a manual refresh has run.

**Paperclip version or commit**

Current `master` before this pull request.

**Deployment mode**

Local dev and self-hosted server.

**Installation method**

Built from source.

**Agent adapter(s) involved**

Not adapter-specific.

**Database mode**

Not database-related.

**Access context**

Board view.

**Privacy checklist**

I reviewed this description and did not include logs, credentials,
private URLs, internal issue IDs, or PII.

## What Changed

- Added a heartbeat scheduler tick that refreshes due external objects
for active companies.
- Kept manual external-object refresh behavior on the same service path.
- Changed display copy so known provider objects use liveness labels
such as `Not yet refreshed`, while fresh unknown provider statuses show
`Status unavailable`.
- Added server and UI tests for scheduled refresh and label behavior.

## Verification

- `corepack pnpm install --frozen-lockfile`
- `pnpm check:token-gates`
- `pnpm exec vitest run
server/src/__tests__/external-objects-service.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts
ui/src/components/ExternalObjectPill.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/lib/external-objects.test.ts`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server build`
- `pnpm --filter @paperclipai/ui build`

- `pnpm run typecheck:build-gaps`
- GitHub PR checks passed on head `0e7fcd30`
- Greptile reported 5/5 on head `0e7fcd30` with no unresolved review
threads

Notes:

- I ran recursive typecheck and build first. Both hit container resource
limits with exit 137 during concurrent package work, so I reran the
affected server and UI targets separately.
- An unrelated workspace-runtime auto-port test fails in this container
with a PID ownership mismatch. It is outside the files changed here.

## Risks

Low to medium risk.

The scheduler does more periodic external-object work, so the main risk
is extra provider refresh load. The implementation bounds the work to
active companies, due non-terminal objects, and 50 objects per company
per tick. The path also stays behind the external-objects experimental
setting.

## Model Used

OpenAI GPT-5 Codex in the Codex execution environment, with shell and
GitHub CLI tool use. The runtime did not expose a more specific internal
model ID or context window.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-02 20:24:52 -07:00

240 lines
8.1 KiB
TypeScript

import { useEffect, useState, type ReactNode } from "react";
import type { IssueExternalObjectGroup } from "../../hooks/useIssueExternalObjects";
import {
externalObjectDisplayStatusLabel,
externalObjectDisplayLabel,
externalObjectIconForKey,
externalObjectProviderLabel,
externalObjectToneSeverity,
externalObjectTypeLabel,
} from "../../lib/external-objects";
import {
externalObjectStatusIcon,
externalObjectStatusIconDefault,
} from "../../lib/status-colors";
import { cn } from "../../lib/utils";
import { ExternalObjectStatusIcon } from "../ExternalObjectStatusIcon";
import { PropertyRow } from "./primitives";
import { ExpandRelationListButton } from "./relation-controls";
const EXTERNAL_OBJECT_PROPERTY_PREVIEW_COUNT = 5;
function sortExternalObjectGroups(groups: IssueExternalObjectGroup[]) {
return [...groups].sort((a, b) => {
const aTone = externalObjectToneSeverity(a.group.object?.statusTone);
const bTone = externalObjectToneSeverity(b.group.object?.statusTone);
return bTone - aTone;
});
}
function externalObjectRowDisplayKey(group: IssueExternalObjectGroup): string {
const { pill } = group;
const displayKey = pill.displayKey?.trim();
if (displayKey) return displayKey;
if (pill.providerKey === "github") {
if (pill.objectType === "pull_request") return "Github PR";
if (pill.objectType === "issue") return "Github Issue";
}
return externalObjectDisplayLabel(pill.providerKey, pill.objectType);
}
function externalObjectRowLabel(group: IssueExternalObjectGroup): ReactNode {
const { pill } = group;
const displayKey = externalObjectRowDisplayKey(group);
const Icon = externalObjectIconForKey(pill.iconKey);
return (
<span className="inline-flex min-w-0 items-start gap-1" title={displayKey}>
{Icon ? <Icon aria-hidden="true" className="h-3.5 w-3.5 shrink-0 mt-0.5" /> : null}
<span className="truncate">{displayKey}</span>
</span>
);
}
function githubObjectPropertyValue(url: string | null | undefined): string | null {
if (!url) return null;
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") return null;
const [, owner, repo, kind, number] = parsed.pathname.split("/");
if (!owner || !repo || !number) return null;
if (kind === "pull") return `PR ${number}`;
if (kind === "issues") return `Issue ${number}`;
return null;
} catch {
return null;
}
}
function externalObjectPropertyStatusLabel(group: IssueExternalObjectGroup): string {
return externalObjectDisplayStatusLabel(group.pill);
}
function externalObjectPropertyValue(group: IssueExternalObjectGroup): string {
const { pill } = group;
const statusLabel = externalObjectPropertyStatusLabel(group);
const githubLabel = pill.providerKey === "github" ? githubObjectPropertyValue(pill.url) : null;
const base = githubLabel ?? pill.displayTitle?.trim() ?? externalObjectRowDisplayKey(group);
return statusLabel ? `${base} - ${statusLabel}` : base;
}
function isMergedExternalObject(group: IssueExternalObjectGroup): boolean {
const statusLabel = externalObjectPropertyStatusLabel(group);
return group.pill.statusIconKey === "git-merge" || statusLabel.toLowerCase() === "merged";
}
function externalObjectPropertyTone(group: IssueExternalObjectGroup): string {
const tone = isMergedExternalObject(group)
? externalObjectStatusIcon.merged
: externalObjectStatusIcon[group.pill.statusCategory] ?? externalObjectStatusIconDefault;
return tone.split(" ").filter((c) => c.startsWith("text-")).join(" ");
}
function externalObjectPropertyStatusIconKey(group: IssueExternalObjectGroup): string | null | undefined {
if (isMergedExternalObject(group)) return group.pill.statusIconKey ?? "git-merge";
return group.pill.statusIconKey;
}
function externalObjectPropertyTitle(group: IssueExternalObjectGroup): string {
const { pill, sourceLabels } = group;
const base = pill.displayTitle ?? externalObjectPropertyValue(group);
return sourceLabels.length > 0 ? `${base} - ${sourceLabels.join(", ")}` : base;
}
function ExternalObjectPropertyValue({ group }: { group: IssueExternalObjectGroup }) {
const { pill } = group;
const statusLabel = externalObjectPropertyStatusLabel(group);
const providerLabel = externalObjectProviderLabel(pill.providerKey);
const typeLabel = externalObjectTypeLabel(pill.objectType);
const value = externalObjectPropertyValue(group);
const content = (
<>
<ExternalObjectStatusIcon
category={pill.statusCategory}
liveness={pill.liveness}
statusIconKey={externalObjectPropertyStatusIconKey(group)}
sizeClassName="h-3.5 w-3.5"
label={`${providerLabel}: ${statusLabel}`}
/>
<span className="min-w-0 truncate">{value}</span>
</>
);
const className = cn(
"inline-flex min-w-0 max-w-full items-center gap-1.5 text-sm no-underline",
externalObjectPropertyTone(group),
pill.url ? "hover:underline focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring" : "",
);
if (pill.url) {
return (
<a
href={pill.url}
target="_blank"
rel="noopener noreferrer"
data-mention-kind="external-object"
data-external-status={pill.statusCategory}
data-external-liveness={pill.liveness}
className={className}
title={externalObjectPropertyTitle(group)}
aria-label={`${providerLabel} ${typeLabel} - ${statusLabel}: ${pill.displayTitle ?? value}`}
>
{content}
</a>
);
}
return (
<span
data-mention-kind="external-object"
data-external-status={pill.statusCategory}
data-external-liveness={pill.liveness}
className={className}
title={externalObjectPropertyTitle(group)}
aria-label={`${providerLabel} ${typeLabel} - ${statusLabel}: ${pill.displayTitle ?? value}`}
>
{content}
</span>
);
}
export function ExternalObjectRows({
externalObjects,
externalObjectsLoading,
externalObjectsError,
onRetryExternalObjects,
}: {
externalObjects?: IssueExternalObjectGroup[];
externalObjectsLoading?: boolean;
externalObjectsError?: boolean;
onRetryExternalObjects?: () => void;
}) {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
setExpanded(false);
}, [externalObjects]);
if (externalObjectsError) {
return (
<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>
);
}
if (externalObjectsLoading) {
return (
<PropertyRow label="External objects">
<span className="h-4 w-24 animate-pulse rounded bg-muted/40" />
</PropertyRow>
);
}
if (!externalObjects || externalObjects.length === 0) return null;
const sortedExternalObjects = sortExternalObjectGroups(externalObjects);
const visibleExternalObjects = expanded
? sortedExternalObjects
: sortedExternalObjects.slice(0, EXTERNAL_OBJECT_PROPERTY_PREVIEW_COUNT);
const hiddenExternalObjectCount = sortedExternalObjects.length - visibleExternalObjects.length;
return (
<>
{visibleExternalObjects
.map((externalObject) => {
const { pill, group } = externalObject;
return (
<PropertyRow
key={group.object?.id ?? `${pill.providerKey}:${pill.objectType}:${pill.url ?? "anon"}`}
label={externalObjectRowLabel(externalObject)}
>
<ExternalObjectPropertyValue group={externalObject} />
</PropertyRow>
);
})}
{expanded || hiddenExternalObjectCount > 0 ? (
<PropertyRow label="References">
<ExpandRelationListButton
hiddenCount={hiddenExternalObjectCount}
expanded={expanded}
onClick={() => setExpanded((next) => !next)}
/>
</PropertyRow>
) : null}
</>
);
}