External object references across issue surfaces (#8512)

## Thinking Path

> - Paperclip is the open source control plane people use to coordinate
AI agents, issues, approvals, comments, and work products.
> - The involved subsystem is issue context: markdown links, issue
properties, related work, lists, filters, inbox/sidebar status, and
plugin-provided external context.
> - The gap is that URLs to external systems currently remain mostly
plain links, so humans and agents must manually open them to understand
status, identity, and liveness.
> - This matters because external work objects such as GitHub issues and
pull requests are part of the operational state of a Paperclip company.
> - The implementation keeps core provider-neutral: shared contracts,
storage, sync, routes, and UI surfaces live in core while providers can
contribute detection and status resolution.
> - This pull request adds the external object reference foundation,
GitHub provider support, issue-surface rendering, filters,
sidebar/list/inbox signals, and test/story coverage.
> - The benefit is that linked external work becomes inspectable
Paperclip context without hardcoding every provider directly into the
UI.

## Linked Issues or Issue Description

No public GitHub issue exists for this work.

Feature request:

- Problem: URLs in Paperclip issues, comments, documents, and related
surfaces do not expose provider status or object identity inline.
- Proposed behavior: detect supported external object URLs, persist
normalized references, refresh provider status, and render concise
status-aware links across issue surfaces.
- Users affected: board users, agents, and maintainers who triage issues
containing external work links.
- Acceptance: external object references are company-scoped,
provider-extensible, visible in key issue surfaces, filterable where
relevant, and covered by focused shared/server/UI tests.

Related PR search:

- No open duplicate PRs found for `external object references`.
- Closed related prior attempt: #4556.

## What Changed

- Added shared external-object contracts, validators, status/liveness
helpers, and plugin protocol declarations.
- Added database schema and additive migrations for external objects,
source mentions, and display metadata.
- Added server services/routes for detecting, syncing, summarizing,
refreshing, and resolving external objects across issues, documents,
comments, projects, and plugins.
- Added a GitHub external-object provider plus plugin SDK authoring
docs.
- Wired UI presentation across markdown links, comments, issue chat,
documents, properties, related work, issue rows, filters, inbox/sidebar
badges, and Storybook stories.
- Rebasing cleanup: moved the branch onto current `master`, repaired
stale worktree provision config, hardened environment-sensitive
tests/mocks, and removed committed screenshot artifacts from the PR
branch to keep the reviewable file set below tool limits.

## Verification

- `pnpm exec vitest run packages/shared/src/external-objects.test.ts
server/src/__tests__/external-object-routes.test.ts
server/src/__tests__/external-objects-service.test.ts
ui/src/components/ExternalObjectPill.test.tsx
ui/src/lib/external-objects.test.ts` passed after rebasing: 5 files, 56
tests.
- Historical branch verification before this PR creation included `pnpm
test:run`, `pnpm -r typecheck`, and `pnpm build`; this PR body does not
claim those were rerun after the final rebase.

## Risks

- Medium: this adds a new cross-surface sync path on
issue/document/comment writes. The implementation uses safe sync
wrappers so external-object failures warn instead of blocking core
mutations.
- Medium: the migrations introduce new tables and indexes. They are
additive and company-scoped.
- Medium: provider-specific URL parsing can miss or misclassify edge
cases. Shared canonicalization tests and provider tests cover current
GitHub shapes.
- Low: UI badge/filter behavior could add visual noise for object-heavy
issues; component tests and Storybook stories cover the intended
surfaces.

> Roadmap checked: `ROADMAP.md` references the plugin system as the
current extension path and does not list a duplicate core feature.
Related long-range docs discuss external references, work products,
preview URLs, and plugin extension points; this PR implements the scoped
external-object reference foundation.

## Model Used

OpenAI Codex, GPT-5 coding-agent runtime, with shell and GitHub CLI tool
use. Reasoning mode: medium. Exact deployed runtime model ID and context
window were not exposed in the environment.

## 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>
This commit is contained in:
Dotta 2026-06-23 08:27:19 -05:00 committed by GitHub
parent cd38c150b0
commit 2dbaf4a7fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 8217 additions and 84 deletions

View File

@ -874,7 +874,7 @@ describe("worktree helpers", () => {
}
});
it("reseed preserves the current worktree ports, instance id, and branding", async () => {
itEmbeddedPostgres("reseed preserves the current worktree ports, instance id, and branding", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-"));
const repoRoot = path.join(tempRoot, "repo");
const sourceRoot = path.join(tempRoot, "source");

View File

@ -0,0 +1,28 @@
# External Object Reference Backfill
## Purpose
Backfill existing issue titles, descriptions, comments, and documents into `external_object_mentions` after the Phase 3 detector registry and mention sync service exist.
## Command Shape
Add a command parallel to the internal issue-reference backfill:
```sh
pnpm external-objects:backfill
pnpm external-objects:backfill -- --company <company-id>
pnpm external-objects:backfill -- --dry-run
```
## Required Behavior
- Scan issue title and description sources, issue comments, and issue documents through the same external-object mention sync service used by write hooks.
- Preserve company boundaries: every scan query, mention upsert, object upsert, and dry-run summary must include `company_id`.
- Use shared URL extraction and canonicalization helpers so raw URLs, URL userinfo, query strings, and fragments are never persisted.
- Replace mentions per source instead of appending, using the same source key shape as live write sync.
- Report counts by company, source kind, provider key, object type, skipped userinfo URLs, and unresolved URLs.
- Default to a non-interactive safe run; `--dry-run` must avoid writes while still reporting what would change.
## Phase 3 Hook
The command should be implemented only after the detector registry can classify URLs and the service can upsert mentions/placeholders consistently. Until then, Phase 2 owns the schema and shared helpers that make the command safe.

View File

@ -12,6 +12,9 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
- Worker-side host APIs are capability-gated.
- Plugin UI is not sandboxed by manifest capabilities.
- External object reference providers are trusted-install only in the MVP.
Capabilities gate provider detection/resolution and host API calls, but they
are not a sandbox boundary for untrusted marketplace code.
- Plugin database migrations are restricted to a host-derived plugin namespace.
- Plugin-managed surfaces are first-class records (agents, projects, routines, and
skills) rather than private plugin-only state.
@ -22,6 +25,29 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
building custom versions.
- `ctx.assets` is not supported in the current runtime.
## External object reference providers
Plugins can contribute provider-neutral object reference detection and status
resolution for URLs and future explicit links. Declare `objectReferences` in the
manifest and add at least `external.objects.detect` and `external.objects.read`.
```ts
objectReferences: [
{
providerKey: "mocktracker",
displayName: "Mock Tracker",
objectTypes: ["ticket"],
urlPatterns: ["https://mock.example/tickets/:id"],
},
],
```
Implement `onDetectExternalObjects()` in the worker to recognize sanitized URL
candidates and return provider-stable identities. Implement
`onResolveExternalObject()` to return normalized board-safe status metadata.
Paperclip owns inline markdown rendering; plugins must not return React, HTML,
or `dangerouslySetInnerHTML` content for inline references.
## Scaffold a plugin
Use the CLI scaffold command:

View File

@ -0,0 +1,47 @@
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import { externalObjectMentions } from "./schema/external_object_mentions.js";
import { externalObjects } from "./schema/external_objects.js";
function indexColumns(table: Parameters<typeof getTableConfig>[0], indexName: string): string[] {
const index = getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName);
if (!index) return [];
return index.config.columns.map((column) => (column as { name: string }).name);
}
describe("external object reference schema", () => {
it("scopes external object uniqueness by company", () => {
expect(indexColumns(externalObjects, "external_objects_company_external_id_uq")).toEqual([
"company_id",
"provider_key",
"object_type",
"external_id",
]);
expect(indexColumns(externalObjects, "external_objects_company_identity_uq")).toEqual([
"company_id",
"provider_key",
"object_type",
"canonical_identity_hash",
]);
});
it("indexes status, refresh scheduling, source issue, and object lookups by company", () => {
expect(indexColumns(externalObjects, "external_objects_company_provider_status_idx")).toEqual([
"company_id",
"provider_key",
"status_category",
]);
expect(indexColumns(externalObjects, "external_objects_company_refresh_idx")).toEqual([
"company_id",
"next_refresh_at",
]);
expect(indexColumns(externalObjectMentions, "external_object_mentions_company_source_issue_idx")).toEqual([
"company_id",
"source_issue_id",
]);
expect(indexColumns(externalObjectMentions, "external_object_mentions_company_object_idx")).toEqual([
"company_id",
"object_id",
]);
});
});

View File

@ -0,0 +1,106 @@
CREATE TABLE IF NOT EXISTS "external_objects" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"provider_key" text NOT NULL,
"plugin_id" uuid,
"object_type" text NOT NULL,
"external_id" text NOT NULL,
"sanitized_canonical_url" text,
"canonical_identity_hash" text,
"display_title" text,
"status_key" text,
"status_label" text,
"status_category" text DEFAULT 'unknown' NOT NULL,
"status_tone" text DEFAULT 'neutral' NOT NULL,
"liveness" text DEFAULT 'unknown' NOT NULL,
"is_terminal" boolean DEFAULT false NOT NULL,
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
"remote_version" text,
"etag" text,
"last_resolved_at" timestamp with time zone,
"last_changed_at" timestamp with time zone,
"last_error_at" timestamp with time zone,
"next_refresh_at" timestamp with time zone,
"last_error_code" text,
"last_error_message" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "external_object_mentions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"source_issue_id" uuid NOT NULL,
"source_kind" text NOT NULL,
"source_record_id" uuid,
"document_key" text,
"property_key" text,
"matched_text_redacted" text,
"sanitized_display_url" text,
"canonical_identity_hash" text,
"canonical_identity" jsonb,
"object_id" uuid,
"provider_key" text,
"detector_key" text,
"object_type" text,
"confidence" text DEFAULT 'exact' NOT NULL,
"created_by_plugin_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "external_objects" ADD CONSTRAINT "external_objects_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "external_objects" ADD CONSTRAINT "external_objects_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_object_id_external_objects_id_fk" FOREIGN KEY ("object_id") REFERENCES "public"."external_objects"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_created_by_plugin_id_plugins_id_fk" FOREIGN KEY ("created_by_plugin_id") REFERENCES "public"."plugins"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "external_objects_company_provider_object_idx" ON "external_objects" USING btree ("company_id","provider_key","object_type");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "external_objects_company_provider_status_idx" ON "external_objects" USING btree ("company_id","provider_key","status_category");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "external_objects_company_refresh_idx" ON "external_objects" USING btree ("company_id","next_refresh_at");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "external_objects_company_external_id_uq" ON "external_objects" USING btree ("company_id","provider_key","object_type","external_id");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "external_objects_company_identity_uq" ON "external_objects" USING btree ("company_id","provider_key","object_type","canonical_identity_hash");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "external_object_mentions_company_source_issue_idx" ON "external_object_mentions" USING btree ("company_id","source_issue_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "external_object_mentions_company_object_idx" ON "external_object_mentions" USING btree ("company_id","object_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "external_object_mentions_company_provider_idx" ON "external_object_mentions" USING btree ("company_id","provider_key","object_type");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "external_object_mentions_company_source_record_uq" ON "external_object_mentions" USING btree ("company_id","source_issue_id","source_kind","source_record_id","document_key","property_key","canonical_identity_hash") WHERE "source_record_id" is not null and "canonical_identity_hash" is not null;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "external_object_mentions_company_source_null_record_uq" ON "external_object_mentions" USING btree ("company_id","source_issue_id","source_kind","document_key","property_key","canonical_identity_hash") WHERE "source_record_id" is null and "canonical_identity_hash" is not null;

View File

@ -0,0 +1,5 @@
ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "display_key" text;
--> statement-breakpoint
ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "icon_key" text;
--> statement-breakpoint
ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "status_icon_key" text;

View File

@ -743,6 +743,20 @@
"when": 1781902000000,
"tag": "0105_instance_scoped_environments",
"breakpoints": true
},
{
"idx": 106,
"version": "7",
"when": 1781902100000,
"tag": "0106_external_object_references",
"breakpoints": true
},
{
"idx": 107,
"version": "7",
"when": 1782165200000,
"tag": "0107_external_object_display_metadata",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,61 @@
import { sql } from "drizzle-orm";
import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import type { ExternalObjectMentionConfidence, ExternalObjectMentionSourceKind } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { externalObjects } from "./external_objects.js";
import { issues } from "./issues.js";
import { plugins } from "./plugins.js";
export const externalObjectMentions = pgTable(
"external_object_mentions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
sourceIssueId: uuid("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
sourceKind: text("source_kind").$type<ExternalObjectMentionSourceKind>().notNull(),
sourceRecordId: uuid("source_record_id"),
documentKey: text("document_key"),
propertyKey: text("property_key"),
matchedTextRedacted: text("matched_text_redacted"),
sanitizedDisplayUrl: text("sanitized_display_url"),
canonicalIdentityHash: text("canonical_identity_hash"),
canonicalIdentity: jsonb("canonical_identity").$type<Record<string, unknown>>(),
objectId: uuid("object_id").references(() => externalObjects.id, { onDelete: "set null" }),
providerKey: text("provider_key"),
detectorKey: text("detector_key"),
objectType: text("object_type"),
confidence: text("confidence").$type<ExternalObjectMentionConfidence>().notNull().default("exact"),
createdByPluginId: uuid("created_by_plugin_id").references(() => plugins.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companySourceIssueIdx: index("external_object_mentions_company_source_issue_idx").on(
table.companyId,
table.sourceIssueId,
),
companyObjectIdx: index("external_object_mentions_company_object_idx").on(table.companyId, table.objectId),
companyProviderIdx: index("external_object_mentions_company_provider_idx").on(
table.companyId,
table.providerKey,
table.objectType,
),
companySourceMentionWithRecordUq: uniqueIndex("external_object_mentions_company_source_record_uq").on(
table.companyId,
table.sourceIssueId,
table.sourceKind,
table.sourceRecordId,
table.documentKey,
table.propertyKey,
table.canonicalIdentityHash,
).where(sql`${table.sourceRecordId} is not null and ${table.canonicalIdentityHash} is not null`),
companySourceMentionWithoutRecordUq: uniqueIndex("external_object_mentions_company_source_null_record_uq").on(
table.companyId,
table.sourceIssueId,
table.sourceKind,
table.documentKey,
table.propertyKey,
table.canonicalIdentityHash,
).where(sql`${table.sourceRecordId} is null and ${table.canonicalIdentityHash} is not null`),
}),
);

View File

@ -0,0 +1,77 @@
import {
boolean,
index,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import type {
ExternalObjectLivenessState,
ExternalObjectStatusCategory,
ExternalObjectStatusTone,
} from "@paperclipai/shared";
import { companies } from "./companies.js";
import { plugins } from "./plugins.js";
export const externalObjects = pgTable(
"external_objects",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
providerKey: text("provider_key").notNull(),
pluginId: uuid("plugin_id").references(() => plugins.id, { onDelete: "set null" }),
objectType: text("object_type").notNull(),
externalId: text("external_id").notNull(),
sanitizedCanonicalUrl: text("sanitized_canonical_url"),
canonicalIdentityHash: text("canonical_identity_hash"),
displayKey: text("display_key"),
iconKey: text("icon_key"),
displayTitle: text("display_title"),
statusKey: text("status_key"),
statusLabel: text("status_label"),
statusIconKey: text("status_icon_key"),
statusCategory: text("status_category").$type<ExternalObjectStatusCategory>().notNull().default("unknown"),
statusTone: text("status_tone").$type<ExternalObjectStatusTone>().notNull().default("neutral"),
liveness: text("liveness").$type<ExternalObjectLivenessState>().notNull().default("unknown"),
isTerminal: boolean("is_terminal").notNull().default(false),
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
remoteVersion: text("remote_version"),
etag: text("etag"),
lastResolvedAt: timestamp("last_resolved_at", { withTimezone: true }),
lastChangedAt: timestamp("last_changed_at", { withTimezone: true }),
lastErrorAt: timestamp("last_error_at", { withTimezone: true }),
nextRefreshAt: timestamp("next_refresh_at", { withTimezone: true }),
lastErrorCode: text("last_error_code"),
lastErrorMessage: text("last_error_message"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyProviderObjectIdx: index("external_objects_company_provider_object_idx").on(
table.companyId,
table.providerKey,
table.objectType,
),
companyProviderStatusIdx: index("external_objects_company_provider_status_idx").on(
table.companyId,
table.providerKey,
table.statusCategory,
),
companyRefreshIdx: index("external_objects_company_refresh_idx").on(table.companyId, table.nextRefreshAt),
companyExternalIdUq: uniqueIndex("external_objects_company_external_id_uq").on(
table.companyId,
table.providerKey,
table.objectType,
table.externalId,
),
companyCanonicalIdentityUq: uniqueIndex("external_objects_company_identity_uq").on(
table.companyId,
table.providerKey,
table.objectType,
table.canonicalIdentityHash,
),
}),
);

View File

@ -36,6 +36,8 @@ export { issueWatchdogs } from "./issue_watchdogs.js";
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";
export { issueRecoveryActions } from "./issue_recovery_actions.js";
export { issueReferenceMentions } from "./issue_reference_mentions.js";
export { externalObjects } from "./external_objects.js";
export { externalObjectMentions } from "./external_object_mentions.js";
export { issueRelations } from "./issue_relations.js";
export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js";
export { issueWorkProducts } from "./issue_work_products.js";

View File

@ -341,6 +341,10 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `telemetry.track` |
| | `database.namespace.migrate` |
| | `database.namespace.write` |
| | `external.objects.detect` |
| | `external.objects.read` |
| | `external.objects.write` |
| | `external.objects.refresh` |
| **Instance** | `instance.settings.register` |
| | `plugin.state.read` |
| | `plugin.state.write` |
@ -372,6 +376,42 @@ Declare in `manifest.capabilities`. Grouped by scope:
Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
### External Object Reference Providers
Trusted connector plugins can declare generic external object providers in the
manifest. The host owns URL scanning, sanitized canonical URLs, core storage,
normalized status rendering, and issue/comment/document write durability. The
plugin only identifies provider-owned objects and resolves board-safe status
metadata.
```ts
objectReferences: [
{
providerKey: "mocktracker",
displayName: "Mock Tracker",
objectTypes: ["ticket"],
urlPatterns: ["https://mock.example/tickets/:id"],
refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 },
},
],
capabilities: ["external.objects.detect", "external.objects.read"],
```
Implement `onDetectExternalObjects()` to map sanitized URL candidates to
`providerKey`, `objectType`, provider-stable `externalId`, and optional display
metadata such as `displayKey`/`iconKey`. Implement `onResolveExternalObject()`
to return a normalized snapshot with `statusCategory`, `statusTone`,
`statusLabel`, optional `statusIconKey`, board-safe `data`, and freshness
metadata. Slow or failing plugins are isolated: Paperclip logs the failure and
continues saving the source issue, comment, or document.
MVP security posture: provider plugins are trusted installs. Manifest
capabilities gate host APIs and provider invocation paths, but they are not a
sandbox boundary for untrusted marketplace code. Plugin UI is same-origin
JavaScript and must not be mounted inline in markdown; inline external-object
rendering uses host-owned metadata only. Treat untrusted providers as future work
that requires worker sandboxing plus isolated plugin UI.
### Restricted Database Namespace
Trusted orchestration plugins can declare a host-owned PostgreSQL namespace:

View File

@ -62,6 +62,12 @@ import type {
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
DetectExternalObjectsParams,
DetectExternalObjectsResult,
ResolveExternalObjectParams,
PluginExternalObjectResolveResult,
RefreshExternalObjectsParams,
RefreshExternalObjectsResult,
} from "./protocol.js";
// ---------------------------------------------------------------------------
@ -243,6 +249,39 @@ export interface PluginDefinition {
* access, capabilities, and checkout policy.
*/
onApiRequest?(input: PluginApiRequestInput): Promise<PluginApiResponse>;
/**
* Called when Paperclip scans issue/comment/document content and asks this
* plugin whether any sanitized URL candidates belong to its external object
* providers. The host has already stripped URL userinfo, query strings, and
* fragments unless provider-safe identity components were explicitly hashed.
*
* Requires `external.objects.detect`.
*/
onDetectExternalObjects?(
params: DetectExternalObjectsParams,
): Promise<DetectExternalObjectsResult>;
/**
* Called when Paperclip needs the current normalized status for one external
* object owned by a manifest-declared provider.
*
* Requires `external.objects.read`.
*/
onResolveExternalObject?(
params: ResolveExternalObjectParams,
): Promise<PluginExternalObjectResolveResult>;
/**
* Optional batch resolver used by providers that can refresh many objects
* more efficiently than individual `onResolveExternalObject` calls.
*
* Requires `external.objects.refresh`.
*/
onRefreshExternalObjects?(
params: RefreshExternalObjectsParams,
): Promise<RefreshExternalObjectsResult>;
/**
* Called to validate provider-specific configuration for a plugin-hosted
* environment driver.

View File

@ -155,6 +155,17 @@ export type {
PluginPerformActionActorContext,
PluginPerformActionContext,
ExecuteToolParams,
PluginExternalObjectUrlCandidate,
PluginExternalObjectSourceContext,
DetectExternalObjectsParams,
PluginExternalObjectDetection,
DetectExternalObjectsResult,
PluginExternalObjectRecordSnapshot,
ResolveExternalObjectParams,
PluginExternalObjectResolvedSnapshot,
PluginExternalObjectResolveResult,
RefreshExternalObjectsParams,
RefreshExternalObjectsResult,
PluginEnvironmentDiagnostic,
PluginEnvironmentDriverBaseParams,
PluginEnvironmentValidateConfigParams,
@ -313,6 +324,8 @@ export type {
PluginApiRouteDeclaration,
PluginLocalFolderDeclaration,
PluginCompanySettings,
PluginObjectReferenceRefreshPolicy,
PluginObjectReferenceProviderDeclaration,
PluginRecord,
PluginDatabaseNamespaceRecord,
PluginMigrationRecord,

View File

@ -40,6 +40,11 @@ import type {
Goal,
PluginLocalFolderDeclaration,
PrincipalPermissionGrant,
ExternalObjectStatusCategory,
ExternalObjectStatusTone,
ExternalObjectLivenessState,
ExternalObjectMentionConfidence,
ExternalObjectMentionSourceKind,
} from "@paperclipai/shared";
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
@ -432,6 +437,113 @@ export interface ExecuteToolParams {
runContext: ToolRunContext;
}
export interface PluginExternalObjectUrlCandidate {
sanitizedCanonicalUrl: string;
sanitizedDisplayUrl: string;
canonicalIdentityHash: string;
canonicalIdentity: Record<string, unknown>;
redactedMatchedText: string;
}
export interface PluginExternalObjectSourceContext {
companyId: string;
sourceIssueId: string;
sourceKind: ExternalObjectMentionSourceKind;
sourceRecordId: string | null;
documentKey: string | null;
propertyKey: string | null;
}
export interface DetectExternalObjectsParams {
companyId: string;
urls: PluginExternalObjectUrlCandidate[];
sourceContext: PluginExternalObjectSourceContext;
}
export interface PluginExternalObjectDetection {
urlIdentityHash: string;
providerKey: string;
objectType: string;
externalId: string;
displayKey?: string | null;
iconKey?: string | null;
displayTitle?: string | null;
confidence?: ExternalObjectMentionConfidence;
}
export interface DetectExternalObjectsResult {
detections: PluginExternalObjectDetection[];
}
export interface PluginExternalObjectRecordSnapshot {
id: string;
companyId: string;
providerKey: string;
objectType: string;
externalId: string;
sanitizedCanonicalUrl: string | null;
canonicalIdentityHash: string | null;
displayKey: string | null;
iconKey: string | null;
displayTitle: string | null;
statusKey: string | null;
statusLabel: string | null;
statusIconKey: string | null;
statusCategory: ExternalObjectStatusCategory;
statusTone: ExternalObjectStatusTone;
liveness: ExternalObjectLivenessState;
isTerminal: boolean;
data: Record<string, unknown>;
remoteVersion: string | null;
etag: string | null;
}
export interface ResolveExternalObjectParams {
companyId: string;
providerKey: string;
objectType: string;
externalId: string;
object: PluginExternalObjectRecordSnapshot;
}
export interface PluginExternalObjectResolvedSnapshot {
displayKey?: string | null;
iconKey?: string | null;
displayTitle?: string | null;
statusKey?: string | null;
statusLabel?: string | null;
statusIconKey?: string | null;
statusCategory: ExternalObjectStatusCategory;
statusTone: ExternalObjectStatusTone;
isTerminal?: boolean;
data?: Record<string, unknown>;
remoteVersion?: string | null;
etag?: string | null;
ttlSeconds?: number;
}
export type PluginExternalObjectResolveResult =
| { ok: true; snapshot: PluginExternalObjectResolvedSnapshot }
| {
ok: false;
liveness: Extract<ExternalObjectLivenessState, "auth_required" | "unreachable">;
errorCode: string;
errorMessage?: string | null;
retryAfterSeconds?: number;
};
export interface RefreshExternalObjectsParams {
companyId: string;
objects: PluginExternalObjectRecordSnapshot[];
}
export interface RefreshExternalObjectsResult {
results: Array<{
objectId: string;
result: PluginExternalObjectResolveResult;
}>;
}
export interface PluginEnvironmentDiagnostic {
severity: "info" | "warning" | "error";
message: string;
@ -604,6 +716,18 @@ export interface HostToWorkerMethods {
performAction: [params: PerformActionParams, result: unknown];
/** @see PLUGIN_SPEC.md §13.10 */
executeTool: [params: ExecuteToolParams, result: ToolResult];
detectExternalObjects: [
params: DetectExternalObjectsParams,
result: DetectExternalObjectsResult,
];
resolveExternalObject: [
params: ResolveExternalObjectParams,
result: PluginExternalObjectResolveResult,
];
refreshExternalObjects: [
params: RefreshExternalObjectsParams,
result: RefreshExternalObjectsResult,
];
environmentValidateConfig: [
params: PluginEnvironmentValidateConfigParams,
result: PluginEnvironmentValidationResult,
@ -659,6 +783,9 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
"getData",
"performAction",
"executeTool",
"detectExternalObjects",
"resolveExternalObject",
"refreshExternalObjects",
"environmentValidateConfig",
"environmentProbe",
"environmentAcquireLease",

View File

@ -84,6 +84,8 @@ export type {
PluginDatabaseDeclaration,
PluginApiRouteDeclaration,
PluginApiRouteCompanyResolution,
PluginObjectReferenceRefreshPolicy,
PluginObjectReferenceProviderDeclaration,
PluginRecord,
PluginDatabaseNamespaceRecord,
PluginMigrationRecord,

View File

@ -82,6 +82,9 @@ import type {
PluginPerformActionActorContext,
PluginPerformActionContext,
ExecuteToolParams,
DetectExternalObjectsParams,
ResolveExternalObjectParams,
RefreshExternalObjectsParams,
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentDestroyLeaseParams,
PluginEnvironmentExecuteParams,
@ -1351,6 +1354,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
case "executeTool":
return handleExecuteTool(params as ExecuteToolParams);
case "detectExternalObjects":
return handleDetectExternalObjects(params as DetectExternalObjectsParams);
case "resolveExternalObject":
return handleResolveExternalObject(params as ResolveExternalObjectParams);
case "refreshExternalObjects":
return handleRefreshExternalObjects(params as RefreshExternalObjectsParams);
case "environmentValidateConfig":
return handleEnvironmentValidateConfig(params as PluginEnvironmentValidateConfigParams);
@ -1409,6 +1418,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
if (plugin.definition.onHealth) supportedMethods.push("health");
if (plugin.definition.onShutdown) supportedMethods.push("shutdown");
if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest");
if (plugin.definition.onDetectExternalObjects) supportedMethods.push("detectExternalObjects");
if (plugin.definition.onResolveExternalObject) supportedMethods.push("resolveExternalObject");
if (plugin.definition.onRefreshExternalObjects) supportedMethods.push("refreshExternalObjects");
if (plugin.definition.onEnvironmentValidateConfig) supportedMethods.push("environmentValidateConfig");
if (plugin.definition.onEnvironmentProbe) supportedMethods.push("environmentProbe");
if (plugin.definition.onEnvironmentAcquireLease) supportedMethods.push("environmentAcquireLease");
@ -1588,6 +1600,27 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
return entry.fn(params.parameters, params.runContext);
}
async function handleDetectExternalObjects(params: DetectExternalObjectsParams) {
if (!plugin.definition.onDetectExternalObjects) {
throw methodNotImplemented("detectExternalObjects");
}
return plugin.definition.onDetectExternalObjects(params);
}
async function handleResolveExternalObject(params: ResolveExternalObjectParams) {
if (!plugin.definition.onResolveExternalObject) {
throw methodNotImplemented("resolveExternalObject");
}
return plugin.definition.onResolveExternalObject(params);
}
async function handleRefreshExternalObjects(params: RefreshExternalObjectsParams) {
if (!plugin.definition.onRefreshExternalObjects) {
throw methodNotImplemented("refreshExternalObjects");
}
return plugin.definition.onRefreshExternalObjects(params);
}
function methodNotImplemented(method: string): Error & { code: number } {
return Object.assign(
new Error(`${method} is not implemented by this plugin`),

View File

@ -358,6 +358,53 @@ export const DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES = [
export type DocumentAnnotationAnchorConfidence =
(typeof DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES)[number];
export const EXTERNAL_OBJECT_STATUS_CATEGORIES = [
"unknown",
"open",
"waiting",
"running",
"succeeded",
"failed",
"blocked",
"closed",
"archived",
"auth_required",
"unreachable",
] as const;
export type ExternalObjectStatusCategory = (typeof EXTERNAL_OBJECT_STATUS_CATEGORIES)[number];
export const EXTERNAL_OBJECT_STATUS_TONES = [
"neutral",
"info",
"success",
"warning",
"danger",
"muted",
] as const;
export type ExternalObjectStatusTone = (typeof EXTERNAL_OBJECT_STATUS_TONES)[number];
export const EXTERNAL_OBJECT_LIVENESS_STATES = [
"unknown",
"fresh",
"stale",
"auth_required",
"unreachable",
] as const;
export type ExternalObjectLivenessState = (typeof EXTERNAL_OBJECT_LIVENESS_STATES)[number];
export const EXTERNAL_OBJECT_MENTION_SOURCE_KINDS = [
"title",
"description",
"comment",
"document",
"property",
"plugin",
] as const;
export type ExternalObjectMentionSourceKind = (typeof EXTERNAL_OBJECT_MENTION_SOURCE_KINDS)[number];
export const EXTERNAL_OBJECT_MENTION_CONFIDENCES = ["exact", "likely", "possible"] as const;
export type ExternalObjectMentionConfidence = (typeof EXTERNAL_OBJECT_MENTION_CONFIDENCES)[number];
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
@ -677,6 +724,7 @@ export const LIVE_EVENT_TYPES = [
"heartbeat.run.log",
"agent.status",
"activity.logged",
"external_object.updated",
"plugin.ui.updated",
"plugin.worker.crashed",
"plugin.worker.restarted",
@ -851,6 +899,10 @@ export const PLUGIN_CAPABILITIES = [
"telemetry.track",
"database.namespace.migrate",
"database.namespace.write",
"external.objects.detect",
"external.objects.read",
"external.objects.write",
"external.objects.refresh",
// Plugin State
"plugin.state.read",
"plugin.state.write",

View File

@ -0,0 +1,217 @@
import { createHash } from "node:crypto";
import { parseIssueReferenceHref } from "./issue-references.js";
import type {
ExternalObjectCanonicalIdentity,
ExternalObjectCanonicalUrl,
ExternalObjectMentionSource,
ExternalObjectUrlCanonicalizationOptions,
ExternalObjectUrlMatch,
} from "./external-objects.js";
const EXTERNAL_URL_TOKEN_RE = /https?:\/\/[^\s<>()]+/gi;
function preserveNewlinesAsWhitespace(value: string) {
return value.replace(/[^\n]/g, " ");
}
function stripMarkdownCode(markdown: string): string {
if (!markdown) return "";
let output = "";
let index = 0;
while (index < markdown.length) {
const remaining = markdown.slice(index);
const fenceMatch = /^(?:```+|~~~+)/.exec(remaining);
const atLineStart = index === 0 || markdown[index - 1] === "\n";
if (atLineStart && fenceMatch) {
const fence = fenceMatch[0]!;
const blockStart = index;
index += fence.length;
while (index < markdown.length && markdown[index] !== "\n") index += 1;
if (index < markdown.length) index += 1;
while (index < markdown.length) {
const lineStart = index === 0 || markdown[index - 1] === "\n";
if (lineStart && markdown.startsWith(fence, index)) {
index += fence.length;
while (index < markdown.length && markdown[index] !== "\n") index += 1;
if (index < markdown.length) index += 1;
break;
}
index += 1;
}
output += preserveNewlinesAsWhitespace(markdown.slice(blockStart, index));
continue;
}
if (markdown[index] === "`") {
let tickCount = 1;
while (index + tickCount < markdown.length && markdown[index + tickCount] === "`") {
tickCount += 1;
}
const fence = "`".repeat(tickCount);
const inlineStart = index;
index += tickCount;
const closeIndex = markdown.indexOf(fence, index);
if (closeIndex === -1) {
output += markdown.slice(inlineStart, inlineStart + tickCount);
index = inlineStart + tickCount;
continue;
}
index = closeIndex + tickCount;
output += preserveNewlinesAsWhitespace(markdown.slice(inlineStart, index));
continue;
}
output += markdown[index]!;
index += 1;
}
return output;
}
function trimTrailingPunctuation(token: string): string {
let trimmed = token;
while (trimmed.length > 0) {
const last = trimmed[trimmed.length - 1]!;
if (!".,!?;:".includes(last) && last !== ")" && last !== "]") break;
if (
(last === ")" && (trimmed.match(/\(/g)?.length ?? 0) >= (trimmed.match(/\)/g)?.length ?? 0))
|| (last === "]" && (trimmed.match(/\[/g)?.length ?? 0) >= (trimmed.match(/\]/g)?.length ?? 0))
) {
break;
}
trimmed = trimmed.slice(0, -1);
}
return trimmed;
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function normalizePathname(pathname: string): string {
return pathname || "/";
}
export function findExternalObjectUrlMatches(markdown: string): ExternalObjectUrlMatch[] {
if (!markdown) return [];
const scrubbed = stripMarkdownCode(markdown);
const matches: ExternalObjectUrlMatch[] = [];
let match: RegExpExecArray | null;
const re = new RegExp(EXTERNAL_URL_TOKEN_RE);
while ((match = re.exec(scrubbed)) !== null) {
const matchedText = trimTrailingPunctuation(match[0]);
if (!matchedText || parseIssueReferenceHref(matchedText)) continue;
matches.push({
index: match.index,
length: matchedText.length,
matchedText,
});
}
return matches;
}
export function canonicalizeExternalObjectUrl(
value: string,
options: ExternalObjectUrlCanonicalizationOptions = {},
): ExternalObjectCanonicalUrl | null {
let url: URL;
try {
url = new URL(value.trim());
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.username || url.password) return null;
const scheme = url.protocol === "https:" ? "https" : "http";
const path = normalizePathname(url.pathname);
const sanitizedCanonicalUrl = `${scheme}://${url.host.toLowerCase()}${path}`;
const identityQueryParams = new Set(options.identityQueryParams ?? []);
const queryParamHashes: Record<string, string> = {};
for (const key of [...identityQueryParams].sort()) {
const values = url.searchParams.getAll(key);
if (values.length === 0) continue;
queryParamHashes[key] = sha256Hex(values.join("\u0000"));
}
const canonicalIdentity: ExternalObjectCanonicalIdentity = {
scheme,
host: url.host.toLowerCase(),
path,
...(Object.keys(queryParamHashes).length > 0 ? { queryParamHashes } : {}),
};
return {
sanitizedCanonicalUrl,
sanitizedDisplayUrl: sanitizedCanonicalUrl,
canonicalIdentity,
canonicalIdentityHash: sha256Hex(stableStringify(canonicalIdentity)),
redactedMatchedText: sanitizedCanonicalUrl,
};
}
export function extractExternalObjectCanonicalUrls(
markdown: string,
options: ExternalObjectUrlCanonicalizationOptions = {},
): ExternalObjectCanonicalUrl[] {
const seen = new Set<string>();
const ordered: ExternalObjectCanonicalUrl[] = [];
for (const match of findExternalObjectUrlMatches(markdown)) {
const canonical = canonicalizeExternalObjectUrl(match.matchedText, options);
if (!canonical || seen.has(canonical.canonicalIdentityHash)) continue;
seen.add(canonical.canonicalIdentityHash);
ordered.push(canonical);
}
return ordered;
}
export function buildExternalObjectScopedIdentityKey(args: {
companyId: string;
providerKey: string;
objectType: string;
canonicalIdentityHash: string;
}): string {
return [args.companyId, args.providerKey, args.objectType, args.canonicalIdentityHash].join(":");
}
export function buildExternalObjectMentionSourceKey(source: Required<Pick<
ExternalObjectMentionSource,
"companyId" | "sourceIssueId" | "sourceKind"
>> & ExternalObjectMentionSource): string {
return [
source.companyId,
source.sourceIssueId,
source.sourceKind,
source.sourceRecordId ?? "",
source.documentKey ?? "",
source.propertyKey ?? "",
].join(":");
}

View File

@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { formatExternalObjectMentionSourceLabel } from "./external-objects.js";
import {
buildExternalObjectMentionSourceKey,
buildExternalObjectScopedIdentityKey,
canonicalizeExternalObjectUrl,
extractExternalObjectCanonicalUrls,
findExternalObjectUrlMatches,
} from "./external-objects-server.js";
import { externalObjectProviderKeySchema, externalObjectTypeSchema } from "./validators/external-object.js";
describe("external object references", () => {
it("extracts external urls without changing internal issue reference behavior", () => {
expect(
findExternalObjectUrlMatches(
"See PAP-1, /issues/PAP-2, https://paperclip.ing/PAP/issues/PAP-3, and https://github.com/acme/app/pull/4.",
),
).toEqual([{ index: 70, length: 34, matchedText: "https://github.com/acme/app/pull/4" }]);
});
it("ignores urls inside inline and fenced code", () => {
const markdown = [
"Use https://github.com/acme/app/pull/1 here.",
"`https://github.com/acme/app/pull/2` should not count.",
"```",
"https://github.com/acme/app/pull/3",
"```",
].join("\n");
expect(findExternalObjectUrlMatches(markdown).map((match) => match.matchedText)).toEqual([
"https://github.com/acme/app/pull/1",
]);
});
it("canonicalizes urls by stripping query and fragment by default", () => {
expect(canonicalizeExternalObjectUrl("HTTPS://GitHub.com/acme/app/pull/1?token=secret#discussion")).toMatchObject({
sanitizedCanonicalUrl: "https://github.com/acme/app/pull/1",
sanitizedDisplayUrl: "https://github.com/acme/app/pull/1",
redactedMatchedText: "https://github.com/acme/app/pull/1",
canonicalIdentity: {
scheme: "https",
host: "github.com",
path: "/acme/app/pull/1",
},
});
});
it("rejects urls with userinfo", () => {
expect(canonicalizeExternalObjectUrl("https://token:secret@github.com/acme/app/pull/1")).toBeNull();
});
it("hashes provider-required query identity values without storing plaintext", () => {
const first = canonicalizeExternalObjectUrl("https://deploy.test/run?id=secret-run&token=drop", {
identityQueryParams: ["id"],
});
const second = canonicalizeExternalObjectUrl("https://deploy.test/run?id=secret-run&token=other", {
identityQueryParams: ["id"],
});
expect(first?.sanitizedCanonicalUrl).toBe("https://deploy.test/run");
expect(first?.canonicalIdentity.queryParamHashes?.id).toHaveLength(64);
expect(first?.canonicalIdentity.queryParamHashes?.id).not.toContain("secret-run");
expect(second?.canonicalIdentityHash).toBe(first?.canonicalIdentityHash);
});
it("dedupes extracted canonical urls by canonical identity", () => {
expect(
extractExternalObjectCanonicalUrls(
"https://github.com/acme/app/pull/1?token=a and https://github.com/acme/app/pull/1#discussion",
).map((entry) => entry.sanitizedCanonicalUrl),
).toEqual(["https://github.com/acme/app/pull/1"]);
});
it("includes company id in scoped object identity keys", () => {
const base = {
providerKey: "github",
objectType: "pull_request",
canonicalIdentityHash: "hash",
};
expect(buildExternalObjectScopedIdentityKey({ companyId: "company-a", ...base })).not.toBe(
buildExternalObjectScopedIdentityKey({ companyId: "company-b", ...base }),
);
});
it("builds source keys for replacing mentions from the same source", () => {
const oldMentionSource = buildExternalObjectMentionSourceKey({
companyId: "company-a",
sourceIssueId: "issue-1",
sourceKind: "comment",
sourceRecordId: "comment-1",
});
const newMentionSource = buildExternalObjectMentionSourceKey({
companyId: "company-a",
sourceIssueId: "issue-1",
sourceKind: "comment",
sourceRecordId: "comment-1",
});
const anotherCompanySource = buildExternalObjectMentionSourceKey({
companyId: "company-b",
sourceIssueId: "issue-1",
sourceKind: "comment",
sourceRecordId: "comment-1",
});
expect(newMentionSource).toBe(oldMentionSource);
expect(anotherCompanySource).not.toBe(oldMentionSource);
});
it("formats stable source labels", () => {
expect(formatExternalObjectMentionSourceLabel({ sourceKind: "title" })).toBe("Title");
expect(formatExternalObjectMentionSourceLabel({ sourceKind: "document", documentKey: "plan" })).toBe(
"Document: plan",
);
expect(formatExternalObjectMentionSourceLabel({ sourceKind: "property", propertyKey: "pr" })).toBe(
"Property: pr",
);
});
it("validates provider keys and object types", () => {
expect(externalObjectProviderKeySchema.parse("github.enterprise")).toBe("github.enterprise");
expect(externalObjectTypeSchema.parse("pull_request")).toBe("pull_request");
expect(externalObjectProviderKeySchema.safeParse("GitHub").success).toBe(false);
expect(externalObjectTypeSchema.safeParse("pull-request").success).toBe(false);
});
});

View File

@ -0,0 +1,52 @@
import type { ExternalObjectMentionSourceKind } from "./constants.js";
export interface ExternalObjectUrlMatch {
index: number;
length: number;
matchedText: string;
}
export interface ExternalObjectCanonicalIdentity {
scheme: "http" | "https";
host: string;
path: string;
queryParamHashes?: Record<string, string>;
}
export interface ExternalObjectUrlCanonicalizationOptions {
identityQueryParams?: readonly string[];
}
export interface ExternalObjectCanonicalUrl {
sanitizedCanonicalUrl: string;
sanitizedDisplayUrl: string;
canonicalIdentity: ExternalObjectCanonicalIdentity;
canonicalIdentityHash: string;
redactedMatchedText: string;
}
export interface ExternalObjectMentionSource {
companyId?: string;
sourceIssueId?: string;
sourceKind: ExternalObjectMentionSourceKind;
sourceRecordId?: string | null;
documentKey?: string | null;
propertyKey?: string | null;
}
export function formatExternalObjectMentionSourceLabel(source: ExternalObjectMentionSource): string {
switch (source.sourceKind) {
case "title":
return "Title";
case "description":
return "Description";
case "comment":
return "Comment";
case "document":
return source.documentKey ? `Document: ${source.documentKey}` : "Document";
case "property":
return source.propertyKey ? `Property: ${source.propertyKey}` : "Property";
case "plugin":
return "Plugin";
}
}

View File

@ -92,6 +92,11 @@ export {
DOCUMENT_ANNOTATION_THREAD_STATUSES,
DOCUMENT_ANNOTATION_ANCHOR_STATES,
DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES,
EXTERNAL_OBJECT_STATUS_CATEGORIES,
EXTERNAL_OBJECT_STATUS_TONES,
EXTERNAL_OBJECT_LIVENESS_STATES,
EXTERNAL_OBJECT_MENTION_SOURCE_KINDS,
EXTERNAL_OBJECT_MENTION_CONFIDENCES,
ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_MONITOR_SCHEDULED_BY,
@ -217,6 +222,11 @@ export {
type DocumentAnnotationThreadStatus,
type DocumentAnnotationAnchorState,
type DocumentAnnotationAnchorConfidence,
type ExternalObjectStatusCategory,
type ExternalObjectStatusTone,
type ExternalObjectLivenessState,
type ExternalObjectMentionSourceKind,
type ExternalObjectMentionConfidence,
type IssueExecutionPolicyMode,
type IssueExecutionStageType,
type IssueMonitorScheduledBy,
@ -522,6 +532,11 @@ export type {
DocumentTextRange,
UpdateDocumentAnnotationThreadRequest,
AttachmentArtifactWorkProductMetadata,
ExternalObject,
ExternalObjectMention,
ExternalObjectMentionGroup,
ExternalObjectSummary,
ExternalObjectSummaryItem,
Issue,
IssueAssigneeAdapterOverrides,
IssueBlockerAttention,
@ -786,6 +801,8 @@ export type {
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
PluginApiRouteDeclaration,
PluginObjectReferenceRefreshPolicy,
PluginObjectReferenceProviderDeclaration,
PaperclipPluginManifestV1,
PluginRecord,
PluginDatabaseNamespaceRecord,
@ -829,6 +846,15 @@ export {
type VerifyDocumentAnchorSelectorResult,
} from "./document-anchors.js";
export {
formatExternalObjectMentionSourceLabel,
type ExternalObjectCanonicalIdentity,
type ExternalObjectCanonicalUrl,
type ExternalObjectMentionSource,
type ExternalObjectUrlCanonicalizationOptions,
type ExternalObjectUrlMatch,
} from "./external-objects.js";
export {
sidebarOrderPreferenceSchema,
upsertSidebarOrderPreferenceSchema,
@ -924,11 +950,24 @@ export {
feedbackTargetTypeSchema,
feedbackTraceStatusSchema,
feedbackVoteValueSchema,
externalObjectStatusCategorySchema,
externalObjectStatusToneSchema,
externalObjectLivenessStateSchema,
externalObjectMentionSourceKindSchema,
externalObjectMentionConfidenceSchema,
externalObjectProviderKeySchema,
externalObjectTypeSchema,
externalObjectCanonicalIdentitySchema,
externalObjectMentionSourceSchema,
upsertIssueFeedbackVoteSchema,
type CreateCompany,
type UpdateCompany,
type UpdateCompanyBranding,
type UpsertIssueFeedbackVote,
type ExternalObjectCanonicalIdentityInput,
type ExternalObjectMentionSourceInput,
type ExternalObjectProviderKeyInput,
type ExternalObjectTypeInput,
environmentDriverSchema,
environmentStatusSchema,
environmentLeaseStatusSchema,

View File

@ -0,0 +1,93 @@
import type {
ExternalObjectLivenessState,
ExternalObjectMentionConfidence,
ExternalObjectMentionSourceKind,
ExternalObjectStatusCategory,
ExternalObjectStatusTone,
} from "../constants.js";
export interface ExternalObject {
id: string;
companyId: string;
providerKey: string;
pluginId: string | null;
objectType: string;
externalId: string;
sanitizedCanonicalUrl: string | null;
canonicalIdentityHash: string | null;
displayKey?: string | null;
iconKey?: string | null;
displayTitle: string | null;
statusKey: string | null;
statusLabel: string | null;
statusIconKey?: string | null;
statusCategory: ExternalObjectStatusCategory;
statusTone: ExternalObjectStatusTone;
liveness: ExternalObjectLivenessState;
isTerminal: boolean;
data: Record<string, unknown>;
remoteVersion: string | null;
etag: string | null;
lastResolvedAt: string | null;
lastChangedAt: string | null;
lastErrorAt: string | null;
nextRefreshAt: string | null;
lastErrorCode: string | null;
lastErrorMessage: string | null;
createdAt: string;
updatedAt: string;
}
export interface ExternalObjectMention {
id: string;
companyId: string;
sourceIssueId: string;
sourceKind: ExternalObjectMentionSourceKind;
sourceRecordId: string | null;
documentKey: string | null;
propertyKey: string | null;
matchedTextRedacted: string | null;
sanitizedDisplayUrl: string | null;
canonicalIdentityHash: string | null;
canonicalIdentity: Record<string, unknown> | null;
objectId: string | null;
providerKey: string | null;
detectorKey: string | null;
objectType: string | null;
confidence: ExternalObjectMentionConfidence;
createdByPluginId: string | null;
createdAt: string;
updatedAt: string;
}
export interface ExternalObjectMentionGroup {
object: ExternalObject | null;
mentions: ExternalObjectMention[];
mentionCount: number;
sourceLabels: string[];
}
export interface ExternalObjectSummaryItem {
id: string;
providerKey: string;
objectType: string;
displayKey?: string | null;
iconKey?: string | null;
displayTitle: string | null;
statusIconKey?: string | null;
statusCategory: ExternalObjectStatusCategory;
statusTone: ExternalObjectStatusTone;
liveness: ExternalObjectLivenessState;
isTerminal: boolean;
}
export interface ExternalObjectSummary {
total: number;
byStatusCategory: Record<string, number>;
byLiveness: Record<string, number>;
highestSeverity: ExternalObjectStatusTone;
staleCount: number;
authRequiredCount: number;
unreachableCount: number;
objects: ExternalObjectSummaryItem[];
}

View File

@ -265,6 +265,14 @@ export type {
CompanyArtifactSource,
CompanyArtifactsResponse,
} from "./artifact.js";
export type {
ExternalObject,
ExternalObjectMention,
ExternalObjectMentionGroup,
ExternalObjectSummary,
ExternalObjectSummaryItem,
} from "./external-object.js";
export type {
Issue,
IssueWorkMode,
@ -551,6 +559,8 @@ export type {
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
PluginApiRouteDeclaration,
PluginObjectReferenceRefreshPolicy,
PluginObjectReferenceProviderDeclaration,
PaperclipPluginManifestV1,
PluginRecord,
PluginDatabaseNamespaceRecord,

View File

@ -53,6 +53,7 @@ export interface InstanceExperimentalSettings {
enableIssuePlanDecompositions: boolean;
enableExperimentalFileViewer: boolean;
enableCloudSync: boolean;
enableExternalObjects: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;
issueGraphLivenessAutoRecoveryLookbackHours: number;

View File

@ -504,6 +504,31 @@ export interface PluginApiRouteDeclaration {
companyResolution?: PluginApiRouteCompanyResolution;
}
export interface PluginObjectReferenceRefreshPolicy {
/** Default freshness window for resolved objects from this provider. */
defaultTtlSeconds?: number;
/** UI-visible staleness window. Core still stores liveness separately from remote status. */
staleAfterSeconds?: number;
}
export interface PluginObjectReferenceProviderDeclaration {
/** Stable provider key such as "github", "linear", or "mocktracker". */
providerKey: string;
/** Human-readable provider name shown in operator-facing surfaces. */
displayName: string;
/** Provider object types this plugin can detect and resolve. */
objectTypes: string[];
/**
* Human-readable URL patterns this provider recognizes.
* These are metadata for operators and docs; workers still perform detection.
*/
urlPatterns?: string[];
/** Optional default refresh behavior for this provider. */
refreshPolicy?: PluginObjectReferenceRefreshPolicy;
/** Optional webhook endpoint keys declared under `webhooks` that can refresh these objects. */
webhookEndpointKeys?: string[];
}
// ---------------------------------------------------------------------------
// Plugin Manifest V1
// ---------------------------------------------------------------------------
@ -570,6 +595,8 @@ export interface PaperclipPluginManifestV1 {
skills?: PluginManagedSkillDeclaration[];
/** Trusted local folders this plugin can configure and access by stable key. */
localFolders?: PluginLocalFolderDeclaration[];
/** External object reference providers this plugin contributes. */
objectReferences?: PluginObjectReferenceProviderDeclaration[];
/**
* Legacy top-level launcher declarations.
* Prefer `ui.launchers` for new manifests.

View File

@ -0,0 +1,38 @@
import { z } from "zod";
import {
EXTERNAL_OBJECT_LIVENESS_STATES,
EXTERNAL_OBJECT_MENTION_CONFIDENCES,
EXTERNAL_OBJECT_MENTION_SOURCE_KINDS,
EXTERNAL_OBJECT_STATUS_CATEGORIES,
EXTERNAL_OBJECT_STATUS_TONES,
} from "../constants.js";
export const externalObjectStatusCategorySchema = z.enum(EXTERNAL_OBJECT_STATUS_CATEGORIES);
export const externalObjectStatusToneSchema = z.enum(EXTERNAL_OBJECT_STATUS_TONES);
export const externalObjectLivenessStateSchema = z.enum(EXTERNAL_OBJECT_LIVENESS_STATES);
export const externalObjectMentionSourceKindSchema = z.enum(EXTERNAL_OBJECT_MENTION_SOURCE_KINDS);
export const externalObjectMentionConfidenceSchema = z.enum(EXTERNAL_OBJECT_MENTION_CONFIDENCES);
export const externalObjectProviderKeySchema = z.string().trim().min(1).max(80).regex(/^[a-z][a-z0-9_.-]*$/);
export const externalObjectTypeSchema = z.string().trim().min(1).max(80).regex(/^[a-z][a-z0-9_]*$/);
export const externalObjectCanonicalIdentitySchema = z
.object({
scheme: z.enum(["http", "https"]),
host: z.string().trim().min(1),
path: z.string().trim().min(1),
queryParamHashes: z.record(z.string().regex(/^[a-f0-9]{64}$/)).optional(),
})
.strict();
export const externalObjectMentionSourceSchema = z
.object({
sourceKind: externalObjectMentionSourceKindSchema,
documentKey: z.string().trim().min(1).optional().nullable(),
propertyKey: z.string().trim().min(1).optional().nullable(),
})
.strict();
export type ExternalObjectCanonicalIdentityInput = z.infer<typeof externalObjectCanonicalIdentitySchema>;
export type ExternalObjectMentionSourceInput = z.infer<typeof externalObjectMentionSourceSchema>;
export type ExternalObjectProviderKeyInput = z.infer<typeof externalObjectProviderKeySchema>;
export type ExternalObjectTypeInput = z.infer<typeof externalObjectTypeSchema>;

View File

@ -59,6 +59,22 @@ export {
updateResourceMembershipSchema,
type UpdateResourceMembership,
} from "./resource-memberships.js";
export {
externalObjectStatusCategorySchema,
externalObjectStatusToneSchema,
externalObjectLivenessStateSchema,
externalObjectMentionSourceKindSchema,
externalObjectMentionConfidenceSchema,
externalObjectProviderKeySchema,
externalObjectTypeSchema,
externalObjectCanonicalIdentitySchema,
externalObjectMentionSourceSchema,
type ExternalObjectCanonicalIdentityInput,
type ExternalObjectMentionSourceInput,
type ExternalObjectProviderKeyInput,
type ExternalObjectTypeInput,
} from "./external-object.js";
export {
companySkillSourceTypeSchema,
companySkillTrustLevelSchema,

View File

@ -47,6 +47,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableIssuePlanDecompositions: z.boolean().default(false),
enableExperimentalFileViewer: z.boolean().default(false),
enableCloudSync: z.boolean().default(false),
enableExternalObjects: z.boolean().default(false),
autoRestartDevServerWhenIdle: z.boolean().default(false),
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
issueGraphLivenessAutoRecoveryLookbackHours: z

View File

@ -25,6 +25,7 @@ import {
ISSUE_SURFACE_VISIBILITIES,
} from "../constants.js";
import { routineVariableSchema } from "./routine.js";
import { externalObjectProviderKeySchema, externalObjectTypeSchema } from "./external-object.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder a permissive validator for JSON Schema objects
@ -568,6 +569,55 @@ export const pluginApiRouteDeclarationSchema = z.object({
export type PluginApiRouteDeclarationInput = z.infer<typeof pluginApiRouteDeclarationSchema>;
export const pluginObjectReferenceRefreshPolicySchema = z.object({
defaultTtlSeconds: z.number().int().positive().max(86_400).optional(),
staleAfterSeconds: z.number().int().positive().max(604_800).optional(),
}).superRefine((value, ctx) => {
if (
value.defaultTtlSeconds != null &&
value.staleAfterSeconds != null &&
value.staleAfterSeconds < value.defaultTtlSeconds
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "staleAfterSeconds must be greater than or equal to defaultTtlSeconds",
path: ["staleAfterSeconds"],
});
}
});
export const pluginObjectReferenceProviderDeclarationSchema = z.object({
providerKey: externalObjectProviderKeySchema,
displayName: z.string().min(1).max(100),
objectTypes: z.array(externalObjectTypeSchema).min(1),
urlPatterns: z.array(z.string().trim().min(1).max(500)).optional(),
refreshPolicy: pluginObjectReferenceRefreshPolicySchema.optional(),
webhookEndpointKeys: z.array(z.string().min(1)).optional(),
}).superRefine((value, ctx) => {
const duplicateObjectTypes = value.objectTypes.filter((type, i) => value.objectTypes.indexOf(type) !== i);
if (duplicateObjectTypes.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate objectTypes: ${[...new Set(duplicateObjectTypes)].join(", ")}`,
path: ["objectTypes"],
});
}
const webhookKeys = value.webhookEndpointKeys ?? [];
const duplicateWebhookKeys = webhookKeys.filter((key, i) => webhookKeys.indexOf(key) !== i);
if (duplicateWebhookKeys.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate webhookEndpointKeys: ${[...new Set(duplicateWebhookKeys)].join(", ")}`,
path: ["webhookEndpointKeys"],
});
}
});
export type PluginObjectReferenceProviderDeclarationInput = z.infer<
typeof pluginObjectReferenceProviderDeclarationSchema
>;
// ---------------------------------------------------------------------------
// Plugin Manifest V1 schema
// ---------------------------------------------------------------------------
@ -647,6 +697,7 @@ export const pluginManifestV1Schema = z.object({
routines: z.array(pluginManagedRoutineDeclarationSchema).optional(),
skills: z.array(pluginManagedSkillDeclarationSchema).optional(),
localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(),
objectReferences: z.array(pluginObjectReferenceProviderDeclarationSchema).optional(),
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
ui: z.object({
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
@ -787,6 +838,31 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.objectReferences && manifest.objectReferences.length > 0) {
for (const capability of ["external.objects.detect", "external.objects.read"] as const) {
if (!manifest.capabilities.includes(capability)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Capability '${capability}' is required when objectReferences are declared`,
path: ["capabilities"],
});
}
}
const declaredWebhookKeys = new Set((manifest.webhooks ?? []).map((webhook) => webhook.endpointKey));
for (const [providerIndex, provider] of manifest.objectReferences.entries()) {
for (const endpointKey of provider.webhookEndpointKeys ?? []) {
if (!declaredWebhookKeys.has(endpointKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `objectReferences webhookEndpointKey "${endpointKey}" must match a declared webhook endpoint`,
path: ["objectReferences", providerIndex, "webhookEndpointKeys"],
});
}
}
}
}
if (manifest.database) {
const requiredCapabilities = [
"database.namespace.migrate",
@ -950,6 +1026,18 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.objectReferences) {
const providerKeys = manifest.objectReferences.map((provider) => provider.providerKey);
const duplicateProviders = providerKeys.filter((key, i) => providerKeys.indexOf(key) !== i);
if (duplicateProviders.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate object reference provider keys: ${[...new Set(duplicateProviders)].join(", ")}`,
path: ["objectReferences"],
});
}
}
// UI slot ids must be unique within the plugin (namespaced at runtime)
if (manifest.ui) {
if (manifest.ui.slots) {

View File

@ -80,6 +80,84 @@ paperclipai_command_available() {
return 1
}
existing_worktree_config_is_usable() {
WORKTREE_CONFIG_PATH="$worktree_config_path" \
WORKTREE_ENV_PATH="$worktree_env_path" \
node <<'EOF'
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
function expandHomePrefix(value) {
if (!value) return value;
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
function parseEnvFile(contents) {
const entries = {};
for (const rawLine of contents.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
if (!match) continue;
const [, key, rawValue] = match;
const value = rawValue.trim();
if (
(value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'"))
) {
entries[key] = value.slice(1, -1);
continue;
}
entries[key] = value.replace(/\s+#.*$/, "").trim();
}
return entries;
}
function fail(reason) {
console.error(reason);
process.exit(1);
}
const configPath = path.resolve(process.env.WORKTREE_CONFIG_PATH);
const envPath = path.resolve(process.env.WORKTREE_ENV_PATH);
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const env = parseEnvFile(fs.readFileSync(envPath, "utf8"));
const envConfigPath = expandHomePrefix(env.PAPERCLIP_CONFIG);
if (envConfigPath && path.resolve(envConfigPath) !== configPath) {
fail(`existing worktree env points at ${envConfigPath}, not ${configPath}`);
}
const homeDir = expandHomePrefix(env.PAPERCLIP_HOME);
const instanceId = env.PAPERCLIP_INSTANCE_ID;
if (!homeDir || !instanceId) {
fail("existing worktree env is missing PAPERCLIP_HOME or PAPERCLIP_INSTANCE_ID");
}
if (!fs.existsSync(homeDir)) {
fail(`existing worktree home does not exist on this host: ${homeDir}`);
}
const instanceRoot = path.resolve(homeDir, "instances", instanceId);
const runtimePaths = [
config.database?.embeddedPostgresDataDir,
config.database?.backup?.dir,
config.logging?.logDir,
config.storage?.localDisk?.baseDir,
config.secrets?.localEncrypted?.keyFilePath,
].filter((value) => typeof value === "string" && value.length > 0);
for (const rawValue of runtimePaths) {
const resolved = path.resolve(expandHomePrefix(rawValue));
const relative = path.relative(instanceRoot, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
fail(`existing worktree config path is outside ${instanceRoot}: ${resolved}`);
}
}
EOF
}
write_fallback_worktree_config() {
WORKTREE_NAME="$worktree_name" \
BASE_CWD="$base_cwd" \
@ -332,9 +410,12 @@ main().catch((error) => {
EOF
}
if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]]; then
if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]] && existing_worktree_config_is_usable; then
echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2
else
if [[ -e "$worktree_config_path" || -e "$worktree_env_path" ]]; then
echo "Existing isolated Paperclip worktree config is stale for this host; regenerating." >&2
fi
if paperclipai_command_available; then
run_isolated_worktree_init
else

View File

@ -180,5 +180,5 @@ describeLiveSsh("live SSH environment smoke", () => {
expect(result.stdout).toContain(config.remoteWorkspacePath);
expect(result.stdout).toContain("git");
expect(result.stdout).toContain("tar");
});
}, 30_000);
});

View File

@ -0,0 +1,252 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const issueId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const ownerAgentId = "33333333-3333-4333-8333-333333333333";
const peerAgentId = "44444444-4444-4444-8444-444444444444";
const ownerRunId = "55555555-5555-4555-8555-555555555555";
const mockIssueService = vi.hoisted(() => ({
assertCheckoutOwner: vi.fn(),
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
hasPermission: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockExternalObjectsService = vi.hoisted(() => ({
getIssueSummary: vi.fn(),
getIssueSummaries: vi.fn(),
listForIssue: vi.fn(),
refreshIssueObjects: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
function registerRouteMocks() {
vi.doMock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectsService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/task-watchdog-scope.js", () => ({
TASK_WATCHDOG_ORIGIN_KIND: "task_watchdog",
resolveTaskWatchdogMutationScope: vi.fn(async () => ({ kind: "none" })),
taskWatchdogScopeAllowsIssueMutation: vi.fn(async () => ({ kind: "none" })),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companyService: () => ({
getById: vi.fn(async () => null),
}),
companySearchService: () => ({}),
documentAnnotationService: () => ({}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
issueApprovalService: () => ({}),
issueRecoveryActionService: () => ({}),
issueReferenceService: () => ({
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => ({}),
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({}),
workProductService: () => ({}),
}));
}
function makeIssue(overrides: Record<string, unknown> = {}) {
return {
id: issueId,
companyId,
status: "in_progress",
priority: "medium",
projectId: null,
goalId: null,
parentId: null,
assigneeAgentId: ownerAgentId,
assigneeUserId: null,
identifier: "PAP-2265",
title: "External object routes",
executionWorkspaceId: null,
...overrides,
};
}
async function createApp(actor: Express.Request["actor"]) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, { provider: "local_disk" } as any));
app.use(errorHandler);
return app;
}
function boardActor(): Express.Request["actor"] {
return {
type: "board",
userId: "board-user",
userName: null,
userEmail: null,
companyIds: [companyId],
memberships: [],
isInstanceAdmin: false,
source: "local_implicit",
};
}
function ownerActor(): Express.Request["actor"] {
return {
type: "agent",
agentId: ownerAgentId,
companyId,
keyId: "key-1",
runId: ownerRunId,
source: "agent_key",
};
}
function peerActor(): Express.Request["actor"] {
return {
type: "agent",
agentId: peerAgentId,
companyId,
keyId: "key-2",
runId: "66666666-6666-4666-8666-666666666666",
source: "agent_key",
};
}
describe("external object routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/external-objects.js");
registerRouteMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(makeIssue());
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockAccessService.hasPermission.mockResolvedValue(false);
mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({
allowed: action === "issue:mutate",
explanation: "Denied by test mock",
}));
mockAgentService.list.mockResolvedValue([
{ id: ownerAgentId, companyId, reportsTo: null, permissions: { canCreateAgents: false } },
{ id: peerAgentId, companyId, reportsTo: null, permissions: { canCreateAgents: false } },
]);
mockExternalObjectsService.getIssueSummary.mockResolvedValue({ total: 1, objects: [] });
mockExternalObjectsService.getIssueSummaries.mockResolvedValue(new Map([
[issueId, { total: 1, objects: [] }],
]));
mockExternalObjectsService.listForIssue.mockResolvedValue([]);
mockExternalObjectsService.refreshIssueObjects.mockResolvedValue([
{ object: { id: "77777777-7777-4777-8777-777777777777" }, refreshed: false, reason: "no_resolver" },
]);
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableExternalObjects: true,
});
});
it("enforces company access on read routes", async () => {
const app = await createApp({ ...ownerActor(), companyId: "other-company" });
const res = await request(app).get(`/api/issues/${issueId}/external-object-summary`);
expect(res.status).toBe(403);
expect(mockExternalObjectsService.getIssueSummary).not.toHaveBeenCalled();
});
it("allows board users to read issue external object summaries", async () => {
const app = await createApp(boardActor());
const res = await request(app).get(`/api/issues/${issueId}/external-object-summary`);
expect(res.status).toBe(200);
expect(res.body.total).toBe(1);
expect(mockExternalObjectsService.getIssueSummary).toHaveBeenCalledWith(issueId);
});
it("allows board users to fetch company-scoped external object summaries in bulk", async () => {
const app = await createApp(boardActor());
const res = await request(app)
.post(`/api/companies/${companyId}/issues/external-object-summaries`)
.send({ issueIds: [issueId] });
expect(res.status).toBe(200);
expect(res.body.summaries[issueId].total).toBe(1);
expect(mockExternalObjectsService.getIssueSummaries).toHaveBeenCalledWith(companyId, [issueId]);
});
it("enforces company access on bulk external object summaries", async () => {
const app = await createApp({ ...ownerActor(), companyId: "other-company" });
const res = await request(app)
.post(`/api/companies/${companyId}/issues/external-object-summaries`)
.send({ issueIds: [issueId] });
expect(res.status).toBe(403);
expect(mockExternalObjectsService.getIssueSummaries).not.toHaveBeenCalled();
});
it("requires active checkout ownership for agent manual refresh", async () => {
const app = await createApp(peerActor());
const res = await request(app)
.post(`/api/issues/${issueId}/external-objects/refresh`)
.send({});
expect(res.status).toBe(409);
expect(res.body.error).toBe("Issue is checked out by another agent");
expect(mockExternalObjectsService.refreshIssueObjects).not.toHaveBeenCalled();
});
it("allows the checked-out agent to request manual refresh", async () => {
const app = await createApp(ownerActor());
const res = await request(app)
.post(`/api/issues/${issueId}/external-objects/refresh`)
.send({});
expect(res.status).toBe(200);
expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId);
expect(mockExternalObjectsService.refreshIssueObjects).toHaveBeenCalledWith(issueId, expect.objectContaining({
companyId,
actor: expect.objectContaining({ actorType: "agent", actorId: ownerAgentId }),
}));
});
});

View File

@ -0,0 +1,705 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import {
companies,
createDb,
activityLog,
externalObjectMentions,
externalObjects,
issueComments,
issues,
plugins,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
createExternalObjectDetectorRegistry,
createExternalObjectResolverRegistry,
externalObjectService,
type ExternalObjectResolver,
} from "../services/external-objects.js";
import { canonicalizeExternalObjectUrl } from "@paperclipai/shared/external-objects-server";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { createGitHubExternalObjectProvider } from "../services/github-external-object-provider.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres external object tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("external object registries", () => {
it("lets provider detectors claim urls before the generic fallback", async () => {
const canonical = canonicalizeExternalObjectUrl("https://github.com/acme/app/pull/42");
if (!canonical) throw new Error("expected canonical url");
const registry = createExternalObjectDetectorRegistry([
{
key: "github",
detect: ({ urls }) =>
urls.map((url) => ({
canonical: url,
detectorKey: "github",
providerKey: "github",
objectType: "pull_request",
externalId: "acme/app#42",
confidence: "exact",
})),
},
]);
const detections = await registry.detect({
companyId: "company-1",
urls: [canonical],
sourceContext: {
companyId: "company-1",
sourceIssueId: "issue-1",
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
},
});
expect(detections).toHaveLength(1);
expect(detections[0]).toMatchObject({
providerKey: "github",
objectType: "pull_request",
externalId: "acme/app#42",
});
});
it("falls back to generic url objects when no provider detector claims a url", async () => {
const canonical = canonicalizeExternalObjectUrl("https://example.com/path?token=secret#frag");
if (!canonical) throw new Error("expected canonical url");
const registry = createExternalObjectDetectorRegistry([]);
const detections = await registry.detect({
companyId: "company-1",
urls: [canonical],
sourceContext: {
companyId: "company-1",
sourceIssueId: "issue-1",
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
},
});
expect(detections[0]).toMatchObject({
providerKey: "url",
objectType: "link",
externalId: canonical.canonicalIdentityHash,
displayTitle: "https://example.com/path",
});
});
it("matches resolvers by provider and optional object type", () => {
const fallbackResolver: ExternalObjectResolver = {
providerKey: "github",
resolve: async () => ({
ok: true,
snapshot: { statusCategory: "unknown", statusTone: "neutral" },
}),
};
const pullRequestResolver: ExternalObjectResolver = {
providerKey: "github",
objectType: "pull_request",
resolve: async () => ({
ok: true,
snapshot: { statusCategory: "open", statusTone: "info" },
}),
};
const registry = createExternalObjectResolverRegistry([pullRequestResolver, fallbackResolver]);
expect(registry.find({ providerKey: "github", objectType: "pull_request" })).toBe(pullRequestResolver);
expect(registry.find({ providerKey: "github", objectType: "issue" })).toBe(fallbackResolver);
expect(registry.find({ providerKey: "linear", objectType: "issue" })).toBeNull();
});
});
describe("GitHub external object provider", () => {
function githubObject(path: string, objectType: "pull_request" | "issue") {
const canonical = canonicalizeExternalObjectUrl(`https://github.com/acme/app/${path}`);
if (!canonical) throw new Error("expected canonical url");
return {
id: randomUUID(),
companyId: "company-1",
providerKey: "github",
objectType,
externalId: `acme/app#${path}`,
sanitizedCanonicalUrl: canonical.sanitizedCanonicalUrl,
canonicalIdentityHash: canonical.canonicalIdentityHash,
displayTitle: "acme/app#42",
statusKey: null,
statusLabel: null,
statusCategory: "unknown",
statusTone: "neutral",
liveness: "unknown",
isTerminal: false,
data: {},
remoteVersion: null,
etag: null,
} as any;
}
function response(body: Record<string, unknown>, init: ResponseInit = {}) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json", etag: '"etag-1"', ...(init.headers ?? {}) },
...init,
});
}
it("detects GitHub pull request and issue URLs before the generic fallback", async () => {
const provider = createGitHubExternalObjectProvider({} as any, { tokenProvider: null });
const pr = canonicalizeExternalObjectUrl("https://github.com/Acme/App/pull/42?token=secret#discussion");
const issue = canonicalizeExternalObjectUrl("https://github.com/Acme/App/issues/7");
const other = canonicalizeExternalObjectUrl("https://example.com/Acme/App/pull/42");
if (!pr || !issue || !other) throw new Error("expected canonical urls");
const detections = await provider.detector.detect({
companyId: "company-1",
urls: [pr, issue, other],
sourceContext: {
companyId: "company-1",
sourceIssueId: "issue-1",
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
},
});
expect(detections).toEqual([
expect.objectContaining({
providerKey: "github",
objectType: "pull_request",
externalId: "acme/app#pull/42",
displayKey: "GitHub Pull Request",
iconKey: "github",
displayTitle: "Acme/App#42",
}),
expect.objectContaining({
providerKey: "github",
objectType: "issue",
externalId: "acme/app#issues/7",
displayKey: "GitHub Issue",
iconKey: "github",
displayTitle: "Acme/App#7",
}),
]);
expect(JSON.stringify(detections)).not.toContain("secret");
});
it.each([
[
"open",
{ state: "open", draft: false, merged: false, title: "Ship it", updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "open", statusLabel: "Open", statusIconKey: "git-pull-request", statusCategory: "open", statusTone: "info", isTerminal: false },
],
[
"draft",
{ state: "open", draft: true, merged: false, title: "WIP", updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "draft", statusLabel: "Draft", statusIconKey: "clock", statusCategory: "waiting", statusTone: "warning", isTerminal: false },
],
[
"closed",
{ state: "closed", draft: false, merged: false, title: "Closed PR", updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "closed", statusLabel: "Closed", statusIconKey: "x-circle", statusCategory: "closed", statusTone: "muted", isTerminal: true },
],
[
"merged",
{ state: "closed", draft: false, merged: true, title: "Merged PR", updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "merged", statusLabel: "Merged", statusIconKey: "git-merge", statusCategory: "succeeded", statusTone: "success", isTerminal: true },
],
])("resolves a %s pull request snapshot", async (_name, body, expected) => {
const fetch = vi.fn(async () => response(body));
const provider = createGitHubExternalObjectProvider({} as any, { fetch, tokenProvider: null });
const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!;
const result = await resolver.resolve({
companyId: "company-1",
object: githubObject("pull/42", "pull_request"),
});
expect(fetch).toHaveBeenCalledWith(
"https://api.github.com/repos/acme/app/pulls/42",
expect.objectContaining({
headers: expect.not.objectContaining({ authorization: expect.any(String) }),
}),
);
expect(result).toEqual({
ok: true,
snapshot: expect.objectContaining({
...expected,
displayKey: "GitHub Pull Request",
iconKey: "github",
displayTitle: expect.stringContaining(String(body.title)),
remoteVersion: "2026-04-24T01:02:03Z",
etag: '"etag-1"',
data: expect.objectContaining({
provider: "github",
owner: "acme",
repo: "app",
number: 42,
}),
}),
});
expect(JSON.stringify(result)).not.toContain("authorization");
});
it.each([
[
"open",
{ state: "open", title: "Issue", state_reason: null, updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "open", statusLabel: "Open", statusIconKey: "circle-dot", statusCategory: "open", statusTone: "info", isTerminal: false },
],
[
"closed",
{ state: "closed", title: "Issue", state_reason: "completed", updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "closed_completed", statusLabel: "Closed: completed", statusIconKey: "circle", statusCategory: "closed", statusTone: "muted", isTerminal: true },
],
])("resolves a %s issue snapshot", async (_name, body, expected) => {
const fetch = vi.fn(async () => response(body));
const provider = createGitHubExternalObjectProvider({} as any, { fetch, tokenProvider: null });
const resolver = provider.resolvers.find((entry) => entry.objectType === "issue")!;
const result = await resolver.resolve({
companyId: "company-1",
object: githubObject("issues/42", "issue"),
});
expect(fetch).toHaveBeenCalledWith("https://api.github.com/repos/acme/app/issues/42", expect.any(Object));
expect(result).toEqual({
ok: true,
snapshot: expect.objectContaining({
...expected,
displayKey: "GitHub Issue",
iconKey: "github",
data: expect.objectContaining({
provider: "github",
owner: "acme",
repo: "app",
number: 42,
}),
}),
});
});
it("uses a configured token without storing it in the resolved snapshot", async () => {
const fetch = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.headers).toEqual(expect.objectContaining({ authorization: "Bearer ghp_secret" }));
return response({ state: "open", draft: false, merged: false, title: "Private PR" });
});
const provider = createGitHubExternalObjectProvider({} as any, {
fetch,
tokenProvider: async () => "ghp_secret",
});
const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!;
const result = await resolver.resolve({
companyId: "company-1",
object: githubObject("pull/42", "pull_request"),
});
expect(result.ok).toBe(true);
expect(JSON.stringify(result)).not.toContain("ghp_secret");
});
it.each([
[
"auth-required",
new Response("", { status: 401 }),
{ ok: false, liveness: "auth_required", errorCode: "github_auth_required" },
],
[
"rate-limit",
new Response("", {
status: 403,
headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 120) },
}),
{ ok: false, liveness: "unreachable", errorCode: "github_rate_limited" },
],
[
"not-found",
new Response("", { status: 404, headers: { etag: '"missing"' } }),
{ ok: true, snapshot: expect.objectContaining({ displayKey: "GitHub Pull Request", iconKey: "github", statusKey: "not_found", statusIconKey: "archive", statusCategory: "archived", statusTone: "muted" }) },
],
])("maps %s responses to provider-safe results", async (_name, githubResponse, expected) => {
const provider = createGitHubExternalObjectProvider({} as any, {
fetch: async () => githubResponse,
tokenProvider: null,
});
const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!;
const result = await resolver.resolve({
companyId: "company-1",
object: githubObject("pull/42", "pull_request"),
});
expect(result).toEqual(expect.objectContaining(expected));
expect(JSON.stringify(result)).not.toContain("http");
});
});
describeEmbeddedPostgres("externalObjectService", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-external-objects-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(externalObjectMentions);
await db.delete(externalObjects);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(plugins);
await db.delete(companies);
vi.restoreAllMocks();
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function createIssue(companyId = randomUUID()) {
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `E${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
identifier: `PAP-${companyId.replace(/-/g, "").slice(0, 12).toUpperCase()}`,
title: "External refs",
description: "Track https://github.com/acme/app/pull/42?token=secret#discussion twice https://github.com/acme/app/pull/42.",
status: "todo",
priority: "medium",
});
return { companyId, issueId };
}
it("syncs sanitized, deduped mentions without storing secret-bearing urls", async () => {
const { companyId, issueId } = await createIssue();
const svc = externalObjectService(db);
await svc.syncIssue(issueId);
const [objectRows, mentionRows] = await Promise.all([
db.select().from(externalObjects),
db.select().from(externalObjectMentions),
]);
expect(objectRows).toHaveLength(1);
expect(objectRows[0]).toMatchObject({
companyId,
providerKey: "github",
objectType: "pull_request",
externalId: "acme/app#pull/42",
sanitizedCanonicalUrl: "https://github.com/acme/app/pull/42",
liveness: "unknown",
statusCategory: "unknown",
});
expect(JSON.stringify(objectRows[0])).not.toContain("secret");
expect(mentionRows).toHaveLength(1);
expect(mentionRows[0]).toMatchObject({
companyId,
sourceIssueId: issueId,
sourceKind: "description",
sanitizedDisplayUrl: "https://github.com/acme/app/pull/42",
matchedTextRedacted: "https://github.com/acme/app/pull/42",
});
});
it("no-ops detection and summaries when external objects are disabled", async () => {
const { issueId } = await createIssue();
const svc = externalObjectService(db, { enabled: false });
await svc.syncIssue(issueId);
const [objectRows, mentionRows, summary] = await Promise.all([
db.select().from(externalObjects),
db.select().from(externalObjectMentions),
svc.getIssueSummary(issueId),
]);
expect(objectRows).toHaveLength(0);
expect(mentionRows).toHaveLength(0);
expect(summary).toMatchObject({
total: 0,
byStatusCategory: {},
byLiveness: {},
highestSeverity: "neutral",
objects: [],
});
});
it("preserves last-known status when resolver reports auth and unreachable failures", async () => {
const { companyId, issueId } = await createIssue();
const resolver: ExternalObjectResolver = {
providerKey: "url",
objectType: "link",
resolve: vi
.fn()
.mockResolvedValueOnce({
ok: true,
snapshot: {
statusCategory: "open",
statusTone: "info",
statusKey: "open",
statusLabel: "Open",
ttlSeconds: 1,
},
})
.mockResolvedValueOnce({
ok: false,
liveness: "auth_required",
errorCode: "auth_required",
errorMessage: "token=secret failed for https://github.com/acme/app/pull/42?token=secret",
retryAfterSeconds: 60,
})
.mockResolvedValueOnce({
ok: false,
liveness: "unreachable",
errorCode: "network",
errorMessage: "GET https://github.com/acme/app/pull/42 failed",
retryAfterSeconds: 60,
}),
};
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
await svc.syncIssue(issueId);
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
await svc.refreshObject(object.id, { companyId, force: true });
await svc.refreshObject(object.id, { companyId, force: true });
const authFailure = await db.select().from(externalObjects).then((rows) => rows[0]!);
expect(authFailure.lastErrorMessage).toContain("token=[redacted]");
expect(authFailure.lastErrorMessage).not.toContain("secret");
await svc.refreshObject(object.id, { companyId, force: true });
const updated = await db.select().from(externalObjects).then((rows) => rows[0]!);
expect(updated.statusCategory).toBe("open");
expect(updated.statusLabel).toBe("Open");
expect(updated.liveness).toBe("unreachable");
expect(updated.lastErrorMessage).toContain("[redacted-url]");
expect(updated.lastErrorMessage).not.toContain("secret");
});
it("schedules newly detected objects for automatic refresh", async () => {
const { companyId, issueId } = await createIssue();
const resolve = vi.fn(async () => ({
ok: true as const,
snapshot: {
statusCategory: "open" as const,
statusTone: "info" as const,
statusKey: "open",
statusLabel: "Open",
ttlSeconds: 300,
},
}));
const resolver: ExternalObjectResolver = {
providerKey: "url",
objectType: "link",
resolve,
};
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
await svc.syncIssue(issueId);
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
expect(object.nextRefreshAt).toBeInstanceOf(Date);
const refreshed = await svc.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000));
expect(refreshed).toHaveLength(1);
expect(resolve).toHaveBeenCalledTimes(1);
});
it("removes comment mentions when a synced comment is hard-deleted", async () => {
const { companyId, issueId } = await createIssue();
const commentId = randomUUID();
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId,
authorType: "user",
authorUserId: "local-board",
body: "See https://github.com/acme/app/issues/88",
});
const svc = externalObjectService(db, { github: false });
await svc.syncComment(commentId);
expect(await db.select().from(externalObjectMentions)).toHaveLength(1);
await db.delete(issueComments).where(eq(issueComments.id, commentId));
await svc.syncComment(commentId);
expect(await db.select().from(externalObjectMentions)).toHaveLength(0);
});
it("skips terminal objects when refreshing due objects", async () => {
const { companyId, issueId } = await createIssue();
const resolve = vi.fn(async () => ({
ok: true as const,
snapshot: {
statusCategory: "closed" as const,
statusTone: "muted" as const,
statusKey: "closed",
statusLabel: "Closed",
isTerminal: true,
ttlSeconds: 1,
},
}));
const resolver: ExternalObjectResolver = {
providerKey: "url",
objectType: "link",
resolve,
};
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
await svc.syncIssue(issueId);
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
await svc.refreshObject(object.id, { companyId, force: true });
await db
.update(externalObjects)
.set({ nextRefreshAt: new Date(0) })
.where(eq(externalObjects.id, object.id));
const refreshed = await svc.refreshDueObjects(companyId);
expect(refreshed).toEqual([]);
expect(resolve).toHaveBeenCalledTimes(1);
});
it("keeps external object identities company-scoped for duplicate urls", async () => {
const first = await createIssue();
const second = await createIssue();
const svc = externalObjectService(db);
await svc.syncIssue(first.issueId);
await svc.syncIssue(second.issueId);
const objectRows = await db.select().from(externalObjects);
expect(objectRows).toHaveLength(2);
expect(new Set(objectRows.map((row) => row.companyId))).toEqual(new Set([first.companyId, second.companyId]));
expect(new Set(objectRows.map((row) => row.canonicalIdentityHash)).size).toBe(1);
});
it("uses a mock plugin provider to detect and resolve non-GitHub objects", async () => {
const { companyId, issueId } = await createIssue();
await db
.update(issues)
.set({
description: "Track https://mock.example/tickets/123?secret=drop",
})
.where(eq(issues.id, issueId));
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.mock-object-provider",
apiVersion: 1,
version: "1.0.0",
displayName: "Mock Object Provider",
description: "Detects mock tracker tickets",
author: "Paperclip",
categories: ["connector"],
capabilities: ["external.objects.detect", "external.objects.read"],
entrypoints: { worker: "dist/worker.js" },
objectReferences: [
{
providerKey: "mocktracker",
displayName: "Mock Tracker",
objectTypes: ["ticket"],
urlPatterns: ["https://mock.example/tickets/:id"],
},
],
};
const [plugin] = await db.insert(plugins).values({
pluginKey: manifest.id,
packageName: "@paperclip/mock-object-provider",
version: manifest.version,
apiVersion: 1,
categories: manifest.categories,
manifestJson: manifest,
status: "ready",
installOrder: 1,
}).returning();
const workerManager = {
call: vi.fn(async (pluginId: string, method: string, params: any) => {
expect(pluginId).toBe(plugin!.id);
if (method === "detectExternalObjects") {
return {
detections: params.urls.map((url: any) => ({
urlIdentityHash: url.canonicalIdentityHash,
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
displayKey: "Mock Ticket",
iconKey: "circle-dot",
displayTitle: "Mock ticket 123",
confidence: "exact",
})),
};
}
if (method === "resolveExternalObject") {
return {
ok: true,
snapshot: {
displayKey: "Mock Ticket",
iconKey: "circle-dot",
displayTitle: `Resolved ${params.externalId}`,
statusKey: "ready",
statusLabel: "Ready",
statusIconKey: "check-circle",
statusCategory: "succeeded",
statusTone: "success",
ttlSeconds: 300,
},
};
}
throw new Error(`unexpected method ${method}`);
}),
} as unknown as PluginWorkerManager;
const svc = externalObjectService(db, { pluginWorkerManager: workerManager });
await svc.syncIssue(issueId);
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
expect(object).toMatchObject({
companyId,
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
displayKey: "Mock Ticket",
iconKey: "circle-dot",
pluginId: plugin!.id,
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
});
expect(JSON.stringify(object)).not.toContain("secret");
const refreshed = await svc.refreshObject(object.id, { companyId, force: true });
expect(refreshed.object).toMatchObject({
displayKey: "Mock Ticket",
iconKey: "circle-dot",
displayTitle: "Resolved MOCK-123",
statusIconKey: "check-circle",
statusCategory: "succeeded",
statusTone: "success",
liveness: "fresh",
});
});
});

View File

@ -22,13 +22,25 @@ import {
issues,
} from "@paperclipai/db";
import { feedbackService } from "../services/feedback.ts";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres feedback service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function closeDbClient(db: ReturnType<typeof createDb> | undefined) {
await db?.$client?.end?.({ timeout: 0 });
}
describe("feedbackService.saveIssueVote", () => {
describeEmbeddedPostgres("feedbackService.saveIssueVote", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof feedbackService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;

View File

@ -15,7 +15,19 @@ import {
import { runningProcesses } from "../adapters/index.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY } from "../services/recovery/index.ts";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres heartbeat comment wake batching tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs = 10_000, intervalMs = 50) {
const startedAt = Date.now();
@ -147,7 +159,7 @@ async function createControlledGatewayServer() {
};
}
describe("heartbeat comment wake batching", () => {
describeEmbeddedPostgres("heartbeat comment wake batching", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;

View File

@ -100,6 +100,7 @@ describe("instance settings routes", () => {
enableExperimentalFileViewer: false,
enableTaskWatchdogs: false,
enableCloudSync: false,
enableExternalObjects: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@ -142,6 +143,7 @@ describe("instance settings routes", () => {
enableExperimentalFileViewer: true,
enableTaskWatchdogs: true,
enableCloudSync: true,
enableExternalObjects: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@ -194,6 +196,7 @@ describe("instance settings routes", () => {
enableExperimentalFileViewer: false,
enableTaskWatchdogs: false,
enableCloudSync: false,
enableExternalObjects: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@ -271,6 +274,24 @@ describe("instance settings routes", () => {
).toBe(true);
});
it("allows local board users to update external object detection", async () => {
const app = await createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableExternalObjects: true })
.expect(200);
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
enableExternalObjects: true,
});
});
it("allows local board users to update issue graph liveness auto-recovery", async () => {
const app = await createApp({
type: "board",

View File

@ -19,6 +19,7 @@ describe("instance settings service", () => {
enableIsolatedWorkspaces: true,
enableStreamlinedLeftNavigation: true,
enableConferenceRoomChat: false,
enableExternalObjects: false,
enableIssuePlanDecompositions: true,
enableExperimentalFileViewer: true,
enableTaskWatchdogs: true,

View File

@ -104,6 +104,34 @@ const mockHeartbeatService = vi.hoisted(() => ({
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}));
const mockExternalObjectService = vi.hoisted(() => ({
getIssueSummaries: vi.fn(async () => new Map()),
getIssueSummary: vi.fn(async () => ({
authRequiredCount: 0,
byLiveness: {},
byStatusCategory: {},
highestSeverity: "muted",
objects: [],
staleCount: 0,
total: 0,
unreachableCount: 0,
})),
getProjectSummary: vi.fn(async () => ({
authRequiredCount: 0,
byLiveness: {},
byStatusCategory: {},
highestSeverity: "muted",
objects: [],
staleCount: 0,
total: 0,
unreachableCount: 0,
})),
listForIssue: vi.fn(async () => []),
refreshIssueObjects: vi.fn(async () => []),
syncCommentSafely: vi.fn(async () => undefined),
syncDocumentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}));
function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
@ -136,6 +164,10 @@ function registerRouteMocks() {
workProductService: () => mockWorkProductService,
}));
vi.doMock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: vi.fn(async () => undefined),
}));
@ -329,6 +361,7 @@ describe("agent issue mutation checkout ownership", () => {
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/documents.js");
vi.doUnmock("../services/external-objects.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/work-products.js");
@ -464,6 +497,14 @@ describe("agent issue mutation checkout ownership", () => {
mockIssueService.findMentionedAgents.mockReset();
mockDocumentService.upsertIssueDocument.mockReset();
mockWorkProductService.createForIssue.mockReset();
mockExternalObjectService.getIssueSummaries.mockClear();
mockExternalObjectService.getIssueSummary.mockClear();
mockExternalObjectService.getProjectSummary.mockClear();
mockExternalObjectService.listForIssue.mockClear();
mockExternalObjectService.refreshIssueObjects.mockClear();
mockExternalObjectService.syncCommentSafely.mockClear();
mockExternalObjectService.syncDocumentSafely.mockClear();
mockExternalObjectService.syncIssueSafely.mockClear();
mockWorkProductService.getById.mockReset();
mockWorkProductService.remove.mockReset();
mockWorkProductService.update.mockReset();

View File

@ -57,6 +57,24 @@ const mockIssueReferenceService = vi.hoisted(() => ({
syncDocument: vi.fn(async () => undefined),
syncIssue: vi.fn(async () => undefined),
}));
const mockExternalObjectService = vi.hoisted(() => ({
getIssueSummaries: vi.fn(async () => ({ summaries: {} })),
getIssueSummary: vi.fn(async () => ({
authRequiredCount: 0,
byLiveness: {},
byStatusCategory: {},
highestSeverity: "muted",
objects: [],
staleCount: 0,
total: 0,
unreachableCount: 0,
})),
listForIssue: vi.fn(async () => []),
refreshIssueObjects: vi.fn(async () => []),
syncCommentSafely: vi.fn(async () => undefined),
syncDocumentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
@ -92,6 +110,10 @@ function registerModuleMocks() {
issueService: () => mockIssueService,
}));
vi.doMock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectService,
}));
vi.doMock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
@ -181,6 +203,7 @@ describe.sequential("issue comment cancel routes", () => {
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/external-objects.js");
vi.doUnmock("../services/feedback.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
@ -240,6 +263,7 @@ describe.sequential("issue comment cancel routes", () => {
});
mockIssueReferenceService.deleteCommentSource.mockResolvedValue(undefined);
mockIssueReferenceService.syncComment.mockResolvedValue(undefined);
mockExternalObjectService.syncCommentSafely.mockResolvedValue(undefined);
});
it("cancels a queued comment from its author and restores the deleted body", async () => {
@ -352,6 +376,7 @@ describe.sequential("issue comment cancel routes", () => {
expect.objectContaining({ afterTombstone: expect.any(Function) }),
);
expect(mockIssueReferenceService.syncComment).toHaveBeenCalledWith("comment-1", "tx");
expect(mockExternalObjectService.syncCommentSafely).toHaveBeenCalledWith("comment-1", "tx");
expect(mockDocumentAnnotationService.cleanupForIssueCommentDeletion).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"comment-1",
@ -362,6 +387,7 @@ describe.sequential("issue comment cancel routes", () => {
"tx",
);
expect(mockIssueReferenceService.deleteCommentSource).toHaveBeenCalledWith("annotation-comment-1", "tx");
expect(mockExternalObjectService.syncCommentSafely).toHaveBeenCalledWith("annotation-comment-1", "tx");
const deletedActivity = mockLogActivity.mock.calls.find((call) => call[1]?.action === "issue.comment_deleted")?.[1];
expect(deletedActivity).toEqual(expect.objectContaining({
action: "issue.comment_deleted",

View File

@ -79,6 +79,10 @@ const mockIssueRecoveryActionService = vi.hoisted(() => ({
const mockIssueTreeControlService = vi.hoisted(() => ({
getActivePauseHoldGate: vi.fn(async () => null),
}));
const mockExternalObjectService = vi.hoisted(() => ({
syncCommentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
@ -158,6 +162,10 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}),
}));
vi.mock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectService,
}));
function createApp() {
const app = express();
app.use(express.json());
@ -253,6 +261,8 @@ describe.sequential("issue comment reopen routes", () => {
mockRoutineService.syncRunStatusForIssue.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockReset();
mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
mockExternalObjectService.syncCommentSafely.mockReset();
mockExternalObjectService.syncIssueSafely.mockReset();
mockTxInsertValues.mockReset();
mockTxInsert.mockReset();
mockDbSelect.mockReset();
@ -276,6 +286,8 @@ describe.sequential("issue comment reopen routes", () => {
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockExternalObjectService.syncCommentSafely.mockResolvedValue(undefined);
mockExternalObjectService.syncIssueSafely.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({

View File

@ -159,6 +159,203 @@ describe("plugin environment driver seam", () => {
});
});
const objectReferenceManifest: PaperclipPluginManifestV1 = {
id: "test.external-object-provider",
apiVersion: 1,
version: "1.0.0",
displayName: "External Object Provider",
description: "Test external object provider plugin",
author: "Paperclip",
categories: ["connector"],
capabilities: ["external.objects.detect", "external.objects.read"],
entrypoints: { worker: "dist/worker.js" },
objectReferences: [
{
providerKey: "mocktracker",
displayName: "Mock Tracker",
objectTypes: ["ticket"],
urlPatterns: ["https://mock.example/tickets/:id"],
refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 },
},
],
};
describe("plugin external object provider seam", () => {
it("validates provider manifest declarations and capabilities", () => {
expect(pluginManifestV1Schema.safeParse(objectReferenceManifest).success).toBe(true);
const missingCapability = pluginManifestV1Schema.safeParse({
...objectReferenceManifest,
capabilities: ["external.objects.detect"],
});
expect(missingCapability.success).toBe(false);
expect(JSON.stringify(missingCapability.error?.issues)).toContain("external.objects.read");
const duplicateProvider = pluginManifestV1Schema.safeParse({
...objectReferenceManifest,
objectReferences: [
objectReferenceManifest.objectReferences![0],
{ ...objectReferenceManifest.objectReferences![0], displayName: "Duplicate" },
],
});
expect(duplicateProvider.success).toBe(false);
expect(JSON.stringify(duplicateProvider.error?.issues)).toContain(
"Duplicate object reference provider keys",
);
});
it("enforces provider capability requirements", () => {
const validator = pluginCapabilityValidator();
expect(validator.getRequiredCapabilities("external.objects.detect")).toEqual([
"external.objects.detect",
]);
expect(validator.getRequiredCapabilities("external.objects.read")).toEqual([
"external.objects.read",
]);
expect(validator.checkOperation(objectReferenceManifest, "external.objects.read").allowed).toBe(true);
const withoutCapability = {
...objectReferenceManifest,
capabilities: ["external.objects.detect"],
} satisfies PaperclipPluginManifestV1;
expect(validator.checkOperation(withoutCapability, "external.objects.read")).toMatchObject({
allowed: false,
missing: ["external.objects.read"],
});
expect(validator.validateManifestCapabilities(withoutCapability)).toMatchObject({
allowed: false,
missing: ["external.objects.read"],
});
});
it("dispatches provider detection and resolution worker hooks", async () => {
const plugin = definePlugin({
async setup() {},
async onDetectExternalObjects(params) {
return {
detections: params.urls.map((url) => ({
urlIdentityHash: url.canonicalIdentityHash,
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
displayTitle: "Mock ticket",
confidence: "exact",
})),
};
},
async onResolveExternalObject(params) {
return {
ok: true,
snapshot: {
displayTitle: `Resolved ${params.externalId}`,
statusKey: "ready",
statusLabel: "Ready",
statusCategory: "succeeded",
statusTone: "success",
isTerminal: true,
ttlSeconds: 600,
},
};
},
});
const stdin = new PassThrough();
const stdout = new PassThrough();
const host = startWorkerRpcHost({ plugin, stdin, stdout });
const responses: unknown[] = [];
stdout.on("data", (chunk) => {
const lines = String(chunk).split("\n").filter(Boolean);
for (const line of lines) {
responses.push(parseMessage(line));
}
});
stdin.write(serializeMessage(createRequest("initialize", {
manifest: objectReferenceManifest,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
}, 1)));
await waitForResponses(responses, 1);
const initializeResponse = responses[0];
expect(isJsonRpcSuccessResponse(initializeResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(initializeResponse)) return;
expect(initializeResponse.result.supportedMethods).toContain("detectExternalObjects");
expect(initializeResponse.result.supportedMethods).toContain("resolveExternalObject");
stdin.write(serializeMessage(createRequest("detectExternalObjects", {
companyId: "company-1",
urls: [{
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
sanitizedDisplayUrl: "https://mock.example/tickets/123",
canonicalIdentityHash: "hash-123",
canonicalIdentity: { scheme: "https", host: "mock.example", path: "/tickets/123" },
redactedMatchedText: "https://mock.example/tickets/123",
}],
sourceContext: {
companyId: "company-1",
sourceIssueId: "issue-1",
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
},
}, 2)));
await waitForResponses(responses, 2);
const detectResponse = responses[1];
expect(isJsonRpcSuccessResponse(detectResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(detectResponse)) return;
expect(detectResponse.result.detections[0]).toMatchObject({
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
});
stdin.write(serializeMessage(createRequest("resolveExternalObject", {
companyId: "company-1",
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
object: {
id: "object-1",
companyId: "company-1",
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
canonicalIdentityHash: "hash-123",
displayTitle: "Mock ticket",
statusKey: null,
statusLabel: null,
statusCategory: "unknown",
statusTone: "neutral",
liveness: "unknown",
isTerminal: false,
data: {},
remoteVersion: null,
etag: null,
},
}, 3)));
await waitForResponses(responses, 3);
const resolveResponse = responses[2];
expect(isJsonRpcSuccessResponse(resolveResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(resolveResponse)) return;
expect(resolveResponse.result).toMatchObject({
ok: true,
snapshot: {
statusCategory: "succeeded",
statusTone: "success",
},
});
host.stop();
});
});
async function waitForResponses(responses: unknown[], count: number): Promise<void> {
const deadline = Date.now() + 1_000;
while (responses.length < count && Date.now() < deadline) {

View File

@ -1536,6 +1536,97 @@ describe("realizeExecutionWorkspace", () => {
}
});
it("regenerates stale worktree config that points at another host", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-stale-config-"));
const baseRoot = path.join(tempRoot, "base");
const worktreeRoot = path.join(tempRoot, "worktree");
const fakeBin = path.join(tempRoot, "bin");
const fakePnpmPath = path.join(fakeBin, "pnpm");
const scriptPath = path.join(worktreeRoot, "provision-worktree.sh");
const paperclipDir = path.join(worktreeRoot, ".paperclip");
try {
await fs.mkdir(baseRoot, { recursive: true });
await fs.mkdir(paperclipDir, { recursive: true });
await fs.mkdir(fakeBin, { recursive: true });
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
await fs.chmod(scriptPath, 0o755);
await fs.writeFile(
path.join(paperclipDir, "config.json"),
JSON.stringify({
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/Users/example/.paperclip-worktrees/instances/stale/db",
},
logging: {
mode: "file",
logDir: "/Users/example/.paperclip-worktrees/instances/stale/logs",
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: "/Users/example/.paperclip-worktrees/instances/stale/data/storage",
},
},
secrets: {
provider: "local_encrypted",
localEncrypted: {
keyFilePath: "/Users/example/.paperclip-worktrees/instances/stale/secrets/master.key",
},
},
}),
"utf8",
);
await fs.writeFile(
path.join(paperclipDir, ".env"),
[
"PAPERCLIP_HOME=/Users/example/.paperclip-worktrees",
"PAPERCLIP_INSTANCE_ID=stale",
`PAPERCLIP_CONFIG=/Users/example/paperclip/${path.basename(worktreeRoot)}/.paperclip/config.json`,
"",
].join("\n"),
"utf8",
);
await fs.writeFile(
fakePnpmPath,
[
"#!/bin/sh",
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
" exit 0",
"fi",
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"worktree\" ] && [ \"$3\" = \"init\" ]; then",
" mkdir -p \"$PWD/.paperclip\"",
" printf '%s\\n' '{\"database\":{\"embeddedPostgresDataDir\":\"'$PWD'/.paperclip/runtime/db\"}}' > \"$PWD/.paperclip/config.json\"",
" printf '%s\\n' \"PAPERCLIP_HOME=$PWD/.paperclip/runtime\" \"PAPERCLIP_INSTANCE_ID=healthy\" \"PAPERCLIP_CONFIG=$PWD/.paperclip/config.json\" > \"$PWD/.paperclip/.env\"",
" exit 0",
"fi",
"exit 0",
"",
].join("\n"),
"utf8",
);
await fs.chmod(fakePnpmPath, 0o755);
const result = await execFileAsync(scriptPath, [], {
cwd: worktreeRoot,
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot,
PAPERCLIP_WORKSPACE_CWD: worktreeRoot,
},
});
expect(result.stderr).toContain("Existing isolated Paperclip worktree config is stale for this host; regenerating.");
await expect(fs.readFile(path.join(paperclipDir, ".env"), "utf8")).resolves.toContain(
`PAPERCLIP_CONFIG=${worktreeRoot}/.paperclip/config.json`,
);
await expect(fs.readFile(path.join(paperclipDir, "config.json"), "utf8")).resolves.toContain(worktreeRoot);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("retries worktree-local pnpm install without a frozen lockfile when the lockfile is outdated", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-outdated-lockfile-"));
const baseRoot = path.join(tempRoot, "base");

View File

@ -148,11 +148,18 @@ import {
resolveCoreTrustPreset,
type TrustPresetResolution,
} from "../services/trust-preset-resolver.js";
import { externalObjectService } from "../services/external-objects.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
const refreshExternalObjectsSchema = z.object({
objectIds: z.array(z.string().uuid()).max(50).optional(),
}).strict();
const externalObjectSummariesSchema = z.object({
issueIds: z.array(z.string().uuid()).max(1000),
}).strict();
const promoteLowTrustOutputSchema = z.object({
sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]),
@ -1105,6 +1112,10 @@ export function issueRoutes(
? heartbeat.wakeup
: opts.taskWatchdogEnqueueWakeup ?? undefined,
}) ?? noopTaskWatchdogService();
const externalObjectsSvc = externalObjectService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
});
const routinesSvc = routineService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
});
@ -3730,6 +3741,69 @@ export function issueRoutes(
res.json(workProducts);
});
router.get("/issues/:id/external-objects", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const objects = await externalObjectsSvc.listForIssue(issue.id);
res.json(objects);
});
router.get("/issues/:id/external-object-summary", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const summary = await externalObjectsSvc.getIssueSummary(issue.id);
res.json(summary);
});
router.post("/companies/:companyId/issues/external-object-summaries", validate(externalObjectSummariesSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const summaries = await externalObjectsSvc.getIssueSummaries(companyId, req.body.issueIds);
res.json({ summaries: Object.fromEntries(summaries) });
});
router.post("/issues/:id/external-objects/refresh", validate(refreshExternalObjectsSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const actor = getActorInfo(req);
const results = await externalObjectsSvc.refreshIssueObjects(issue.id, {
companyId: issue.companyId,
objectIds: req.body.objectIds,
actor,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "external_object.refresh_requested",
entityType: "issue",
entityId: issue.id,
details: {
issueId: issue.id,
objectIds: results.map((result) => result.object.id),
},
});
res.json({ refreshed: results });
});
router.get("/issues/:id/documents", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
@ -4016,6 +4090,7 @@ export function issueRoutes(
const redirectedFromLockedDocument =
"redirectedFromLockedDocument" in result ? result.redirectedFromLockedDocument : null;
await issueReferencesSvc.syncDocument(doc.id);
await externalObjectsSvc.syncDocumentSafely(doc.id);
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
const remappedAnnotations = result.created
@ -4245,6 +4320,7 @@ export function issueRoutes(
});
await issueReferencesSvc.syncDocument(result.document.id);
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
await externalObjectsSvc.syncDocumentSafely(result.document.id);
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
const remappedAnnotations = await documentAnnotationsSvc.remapOpenThreadsForDocument({
issueId: issue.id,
@ -4358,6 +4434,7 @@ export function issueRoutes(
}
await issueReferencesSvc.deleteDocumentSource(removed.id);
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
if (removed) await externalObjectsSvc.syncDocumentSafely(removed.id);
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
const actor = getActorInfo(req);
await logActivity(db, {
@ -5025,6 +5102,7 @@ export function issueRoutes(
watchdogActorRunId: actor.runId,
});
await issueReferencesSvc.syncIssue(issue.id);
await externalObjectsSvc.syncIssueSafely(issue.id);
const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
issueReferencesSvc.emptySummary(),
@ -5190,6 +5268,7 @@ export function issueRoutes(
actorUserId: actor.actorType === "user" ? actor.actorId : null,
watchdogActorRunId: actor.runId,
});
await externalObjectsSvc.syncIssueSafely(issue.id);
await logActivity(db, {
companyId: parent.companyId,
@ -5958,6 +6037,7 @@ export function issueRoutes(
if (titleOrDescriptionChanged) {
await issueReferencesSvc.syncIssue(issue.id);
await externalObjectsSvc.syncIssueSafely(issue.id);
}
const updateReferenceSummaryAfter = titleOrDescriptionChanged
? await issueReferencesSvc.listIssueReferenceSummary(issue.id)
@ -6263,6 +6343,7 @@ export function issueRoutes(
sourceTrust: await sourceTrustForActorWrite(issue, actor),
});
await issueReferencesSvc.syncComment(comment.id);
await externalObjectsSvc.syncCommentSafely(comment.id);
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
commentReferenceSummaryBefore,
@ -7256,6 +7337,7 @@ export function issueRoutes(
{
afterTombstone: async (deletedComment, tx) => {
await issueReferencesSvc.syncComment(deletedComment.id, tx);
await externalObjectsSvc.syncCommentSafely(deletedComment.id, tx);
annotationCleanup = await documentAnnotationsSvc.cleanupForIssueCommentDeletion(issue.id, deletedComment.id, {
actorType: actor.actorType,
actorId: actor.actorId,
@ -7265,7 +7347,10 @@ export function issueRoutes(
}, tx);
await Promise.all(
annotationCleanup.deletedCommentIds.map((annotationCommentId) =>
issueReferencesSvc.deleteCommentSource(annotationCommentId, tx)
Promise.all([
issueReferencesSvc.deleteCommentSource(annotationCommentId, tx),
externalObjectsSvc.syncCommentSafely(annotationCommentId, tx),
])
),
);
},
@ -7705,6 +7790,7 @@ export function issueRoutes(
}
await issueReferencesSvc.syncComment(comment.id);
await externalObjectsSvc.syncCommentSafely(comment.id);
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
commentReferenceSummaryBefore,

View File

@ -447,6 +447,14 @@ const jsonBody = (schema: z.ZodTypeAny) => ({
const r = responses;
const externalObjectSummariesBodySchema = z.object({
issueIds: z.array(z.string().uuid()).max(1000),
}).strict();
const refreshExternalObjectsBodySchema = z.object({
objectIds: z.array(z.string().uuid()).max(50).optional(),
}).strict();
function paramsSchemaFromPath(routePath: string): z.ZodObject<z.ZodRawShape> | undefined {
const names = [...routePath.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]);
if (names.length === 0) return undefined;
@ -3847,6 +3855,57 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
registry.registerPath({
method: "get",
path: "/api/issues/{id}/external-objects",
tags: ["issues"],
summary: "List external objects mentioned by an issue",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "get",
path: "/api/issues/{id}/external-object-summary",
tags: ["issues"],
summary: "Get external object status summary for an issue",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/issues/external-object-summaries",
tags: ["issues"],
summary: "Get external object status summaries for issues",
request: {
params: z.object({ companyId: z.string() }),
body: jsonBody(externalObjectSummariesBodySchema),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
});
registry.registerPath({
method: "post",
path: "/api/issues/{id}/external-objects/refresh",
tags: ["issues"],
summary: "Refresh external objects mentioned by an issue",
request: {
params: z.object({ id: z.string() }),
body: jsonBody(refreshExternalObjectsBodySchema),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "get",
path: "/api/projects/{id}/external-object-summary",
tags: ["projects"],
summary: "Get external object status summary for a project",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
// ─── Org chart images ─────────────────────────────────────────────────────────
registry.registerPath({

View File

@ -15,6 +15,8 @@ import { trackProjectCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js";
import { conflict, forbidden } from "../errors.js";
import { externalObjectService } from "../services/external-objects.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import {
buildWorkspaceRuntimeDesiredStatePatch,
@ -44,6 +46,10 @@ export function projectRoutes(db: Db) {
const access = accessService(db);
const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(db);
const instanceSettings = instanceSettingsService(db);
const externalObjectsSvc = externalObjectService(db, {
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
});
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
const environmentsSvc = environmentService(db);
@ -139,6 +145,18 @@ export function projectRoutes(db: Db) {
res.json(project);
});
router.get("/projects/:id/external-object-summary", async (req, res) => {
const id = req.params.id as string;
const project = await svc.getById(id);
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, project.companyId);
const summary = await externalObjectsSvc.getProjectSummary(project.id);
res.json(summary);
});
router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);

View File

@ -0,0 +1,945 @@
import { and, asc, eq, inArray, isNull, lte, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues, plugins } from "@paperclipai/db";
import {
formatExternalObjectMentionSourceLabel,
type ExternalObjectCanonicalUrl,
type ExternalObjectLivenessState,
type ExternalObjectMentionConfidence,
type ExternalObjectMentionSourceKind,
type ExternalObjectStatusCategory,
type ExternalObjectStatusTone,
type PaperclipPluginManifestV1,
} from "@paperclipai/shared";
import { extractExternalObjectCanonicalUrls } from "@paperclipai/shared/external-objects-server";
import type { PluginExternalObjectRecordSnapshot, PluginExternalObjectResolveResult } from "@paperclipai/plugin-sdk";
import { notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { logActivity, type LogActivityInput } from "./activity-log.js";
import { createGitHubExternalObjectProvider, type GitHubExternalObjectProviderOptions } from "./github-external-object-provider.js";
import { publishLiveEvent } from "./live-events.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
export interface ExternalObjectSourceContext {
companyId: string;
sourceIssueId: string;
sourceKind: ExternalObjectMentionSourceKind;
sourceRecordId: string | null;
documentKey: string | null;
propertyKey: string | null;
}
export interface ExternalObjectDetection {
canonical: ExternalObjectCanonicalUrl;
detectorKey: string;
providerKey: string;
objectType: string;
externalId: string;
displayKey?: string | null;
iconKey?: string | null;
displayTitle?: string | null;
confidence?: ExternalObjectMentionConfidence;
pluginId?: string | null;
}
export interface ExternalObjectDetector {
key: string;
detect(input: {
companyId: string;
urls: ExternalObjectCanonicalUrl[];
sourceContext: ExternalObjectSourceContext;
}): Promise<ExternalObjectDetection[]> | ExternalObjectDetection[];
}
export interface ExternalObjectResolverSnapshot {
displayKey?: string | null;
iconKey?: string | null;
displayTitle?: string | null;
statusKey?: string | null;
statusLabel?: string | null;
statusIconKey?: string | null;
statusCategory: ExternalObjectStatusCategory;
statusTone: ExternalObjectStatusTone;
isTerminal?: boolean;
data?: Record<string, unknown>;
remoteVersion?: string | null;
etag?: string | null;
ttlSeconds?: number;
}
export type ExternalObjectResolveResult =
| { ok: true; snapshot: ExternalObjectResolverSnapshot }
| {
ok: false;
liveness: Extract<ExternalObjectLivenessState, "auth_required" | "unreachable">;
errorCode: string;
errorMessage?: string | null;
retryAfterSeconds?: number;
};
export interface ExternalObjectResolver {
providerKey: string;
objectType?: string;
resolve(input: {
companyId: string;
object: ExternalObjectRecord;
}): Promise<ExternalObjectResolveResult>;
}
type ExternalObjectRecord = typeof externalObjects.$inferSelect;
type ExternalObjectMentionRecord = typeof externalObjectMentions.$inferSelect;
const DEFAULT_REFRESH_TTL_SECONDS = 300;
const DEFAULT_RETRY_AFTER_SECONDS = 300;
function sourceWhere(input: ExternalObjectSourceContext) {
const conditions = [
eq(externalObjectMentions.companyId, input.companyId),
eq(externalObjectMentions.sourceIssueId, input.sourceIssueId),
eq(externalObjectMentions.sourceKind, input.sourceKind),
];
if (input.sourceRecordId) {
conditions.push(eq(externalObjectMentions.sourceRecordId, input.sourceRecordId));
} else {
conditions.push(isNull(externalObjectMentions.sourceRecordId));
}
if (input.documentKey) {
conditions.push(eq(externalObjectMentions.documentKey, input.documentKey));
} else {
conditions.push(isNull(externalObjectMentions.documentKey));
}
if (input.propertyKey) {
conditions.push(eq(externalObjectMentions.propertyKey, input.propertyKey));
} else {
conditions.push(isNull(externalObjectMentions.propertyKey));
}
return and(...conditions);
}
function addSeconds(date: Date, seconds: number) {
return new Date(date.getTime() + Math.max(1, seconds) * 1000);
}
function visibleLiveness(object: ExternalObjectRecord, now = new Date()): ExternalObjectLivenessState {
if (object.liveness === "fresh" && object.nextRefreshAt && object.nextRefreshAt <= now) {
return "stale";
}
return object.liveness;
}
function objectChanged(before: ExternalObjectRecord, after: ExternalObjectRecord) {
return (
before.statusKey !== after.statusKey ||
before.statusLabel !== after.statusLabel ||
before.statusIconKey !== after.statusIconKey ||
before.statusCategory !== after.statusCategory ||
before.statusTone !== after.statusTone ||
before.isTerminal !== after.isTerminal
);
}
function sanitizeErrorMessage(message: string | null | undefined) {
if (!message) return null;
return message
.replace(/https?:\/\/[^\s<>()]+/gi, "[redacted-url]")
.replace(/\b(token|key|secret|authorization|bearer)=\S+/gi, "$1=[redacted]");
}
function genericUrlDetector(): ExternalObjectDetector {
return {
key: "generic-url",
detect({ urls }) {
return urls.map((canonical) => ({
canonical,
detectorKey: "generic-url",
providerKey: "url",
objectType: "link",
externalId: canonical.canonicalIdentityHash,
displayTitle: canonical.sanitizedDisplayUrl,
confidence: "possible",
}));
},
};
}
export function createExternalObjectDetectorRegistry(detectors: ExternalObjectDetector[] = []) {
const entries = [...detectors, genericUrlDetector()];
async function detect(input: {
companyId: string;
urls: ExternalObjectCanonicalUrl[];
sourceContext: ExternalObjectSourceContext;
}) {
const claimed = new Set<string>();
const detections: ExternalObjectDetection[] = [];
for (const detector of entries) {
const remaining = input.urls.filter((url) => !claimed.has(url.canonicalIdentityHash));
if (remaining.length === 0) break;
try {
const detected = await detector.detect({ ...input, urls: remaining });
for (const detection of detected) {
if (claimed.has(detection.canonical.canonicalIdentityHash)) continue;
claimed.add(detection.canonical.canonicalIdentityHash);
detections.push({ ...detection, detectorKey: detection.detectorKey || detector.key });
}
} catch (err) {
logger.warn({ err, detectorKey: detector.key }, "external object detector failed");
}
}
return detections;
}
return { detect };
}
export function createExternalObjectResolverRegistry(resolvers: ExternalObjectResolver[] = []) {
function find(object: Pick<ExternalObjectRecord, "providerKey" | "objectType">) {
return resolvers.find(
(resolver) =>
resolver.providerKey === object.providerKey &&
(!resolver.objectType || resolver.objectType === object.objectType),
) ?? null;
}
return { find };
}
function manifestProvidesObject(
manifest: PaperclipPluginManifestV1,
object: Pick<ExternalObjectRecord, "providerKey" | "objectType">,
) {
return (manifest.objectReferences ?? []).some(
(provider) =>
provider.providerKey === object.providerKey &&
provider.objectTypes.includes(object.objectType),
);
}
function objectSnapshot(object: ExternalObjectRecord): PluginExternalObjectRecordSnapshot {
return {
id: object.id,
companyId: object.companyId,
providerKey: object.providerKey,
objectType: object.objectType,
externalId: object.externalId,
sanitizedCanonicalUrl: object.sanitizedCanonicalUrl,
canonicalIdentityHash: object.canonicalIdentityHash,
displayKey: object.displayKey,
iconKey: object.iconKey,
displayTitle: object.displayTitle,
statusKey: object.statusKey,
statusLabel: object.statusLabel,
statusIconKey: object.statusIconKey,
statusCategory: object.statusCategory,
statusTone: object.statusTone,
liveness: object.liveness,
isTerminal: object.isTerminal,
data: object.data as Record<string, unknown>,
remoteVersion: object.remoteVersion,
etag: object.etag,
};
}
async function readyObjectReferencePlugins(db: Db) {
return db
.select({
id: plugins.id,
pluginKey: plugins.pluginKey,
manifestJson: plugins.manifestJson,
})
.from(plugins)
.where(eq(plugins.status, "ready"))
.orderBy(asc(plugins.installOrder))
.then((rows) =>
rows.filter((row) => (row.manifestJson.objectReferences?.length ?? 0) > 0),
);
}
function createPluginProviderDetector(
db: Db,
pluginWorkerManager: PluginWorkerManager,
): ExternalObjectDetector {
return {
key: "plugin-object-reference-providers",
async detect(input) {
const providers = await readyObjectReferencePlugins(db);
const detections: ExternalObjectDetection[] = [];
for (const provider of providers) {
const manifest = provider.manifestJson;
if (!manifest.capabilities.includes("external.objects.detect")) continue;
try {
const result = await pluginWorkerManager.call(provider.id, "detectExternalObjects", {
companyId: input.companyId,
urls: input.urls.map((url) => ({
sanitizedCanonicalUrl: url.sanitizedCanonicalUrl,
sanitizedDisplayUrl: url.sanitizedDisplayUrl,
canonicalIdentityHash: url.canonicalIdentityHash,
canonicalIdentity: url.canonicalIdentity as unknown as Record<string, unknown>,
redactedMatchedText: url.redactedMatchedText,
})),
sourceContext: input.sourceContext,
});
const urlsByHash = new Map(input.urls.map((url) => [url.canonicalIdentityHash, url]));
const declaredProviderKeys = new Set((manifest.objectReferences ?? []).map((entry) => entry.providerKey));
for (const detection of result.detections ?? []) {
const canonical = urlsByHash.get(detection.urlIdentityHash);
if (!canonical) continue;
if (!declaredProviderKeys.has(detection.providerKey)) continue;
const declaration = (manifest.objectReferences ?? []).find((entry) => entry.providerKey === detection.providerKey);
if (!declaration?.objectTypes.includes(detection.objectType)) continue;
detections.push({
canonical,
detectorKey: `${provider.pluginKey}:${detection.providerKey}`,
providerKey: detection.providerKey,
objectType: detection.objectType,
externalId: detection.externalId,
displayKey: detection.displayKey,
iconKey: detection.iconKey,
displayTitle: detection.displayTitle,
confidence: detection.confidence ?? "exact",
pluginId: provider.id,
});
}
} catch (err) {
logger.warn(
{ err, pluginId: provider.id, pluginKey: provider.pluginKey },
"plugin external object detector failed",
);
}
}
return detections;
},
};
}
async function resolveViaPluginProvider(
db: Db,
pluginWorkerManager: PluginWorkerManager | undefined,
object: ExternalObjectRecord,
): Promise<PluginExternalObjectResolveResult | null> {
if (!pluginWorkerManager) return null;
const providers = await readyObjectReferencePlugins(db);
for (const provider of providers) {
const manifest = provider.manifestJson;
if (!manifest.capabilities.includes("external.objects.read")) continue;
if (!manifestProvidesObject(manifest, object)) continue;
try {
return await pluginWorkerManager.call(provider.id, "resolveExternalObject", {
companyId: object.companyId,
providerKey: object.providerKey,
objectType: object.objectType,
externalId: object.externalId,
object: objectSnapshot(object),
});
} catch (err) {
logger.warn(
{ err, pluginId: provider.id, pluginKey: provider.pluginKey, objectId: object.id },
"plugin external object resolver failed",
);
return {
ok: false,
liveness: "unreachable",
errorCode: "plugin_resolver_failed",
errorMessage: err instanceof Error ? err.message : String(err),
};
}
}
return null;
}
export function externalObjectService(
db: Db,
opts: {
detectors?: ExternalObjectDetector[];
resolvers?: ExternalObjectResolver[];
pluginWorkerManager?: PluginWorkerManager;
github?: GitHubExternalObjectProviderOptions | false;
enabled?: boolean | (() => boolean | Promise<boolean>);
} = {},
) {
const githubProvider = opts.github === false ? null : createGitHubExternalObjectProvider(db, opts.github);
const pluginProviderDetector = opts.pluginWorkerManager
? createPluginProviderDetector(db, opts.pluginWorkerManager)
: null;
const detectorRegistry = createExternalObjectDetectorRegistry([
...(pluginProviderDetector ? [pluginProviderDetector] : []),
...(opts.detectors ?? []),
...(githubProvider ? [githubProvider.detector] : []),
]);
const resolverRegistry = createExternalObjectResolverRegistry([
...(opts.resolvers ?? []),
...(githubProvider?.resolvers ?? []),
]);
async function isEnabled() {
if (typeof opts.enabled === "function") return await opts.enabled();
return opts.enabled ?? true;
}
function emptySummary() {
return summarizeObjectPayloads([]);
}
async function issueById(issueId: string, dbOrTx: any = db) {
return dbOrTx
.select({
id: issues.id,
companyId: issues.companyId,
title: issues.title,
description: issues.description,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows: Array<{ id: string; companyId: string; title: string; description: string | null }>) => rows[0] ?? null);
}
async function upsertObjectFromDetection(
companyId: string,
detection: ExternalObjectDetection,
dbOrTx: any,
): Promise<ExternalObjectRecord> {
const now = new Date();
const canonical = detection.canonical;
const values = {
companyId,
providerKey: detection.providerKey,
pluginId: detection.pluginId ?? null,
objectType: detection.objectType,
externalId: detection.externalId,
sanitizedCanonicalUrl: canonical.sanitizedCanonicalUrl,
canonicalIdentityHash: canonical.canonicalIdentityHash,
displayKey: detection.displayKey ?? null,
iconKey: detection.iconKey ?? null,
displayTitle: detection.displayTitle ?? canonical.sanitizedDisplayUrl,
nextRefreshAt: now,
updatedAt: now,
};
const inserted = await dbOrTx
.insert(externalObjects)
.values(values)
.onConflictDoUpdate({
target: [
externalObjects.companyId,
externalObjects.providerKey,
externalObjects.objectType,
externalObjects.externalId,
],
set: {
sanitizedCanonicalUrl: values.sanitizedCanonicalUrl,
canonicalIdentityHash: values.canonicalIdentityHash,
displayKey: values.displayKey,
iconKey: values.iconKey,
displayTitle: values.displayTitle,
nextRefreshAt: sql`coalesce(${externalObjects.nextRefreshAt}, now())`,
updatedAt: now,
},
})
.returning();
return inserted[0]!;
}
async function replaceSourceMentions(
input: ExternalObjectSourceContext & { text: string | null | undefined },
dbOrTx: any = db,
) {
const urls = extractExternalObjectCanonicalUrls(input.text ?? "");
await dbOrTx.delete(externalObjectMentions).where(sourceWhere(input));
if (urls.length === 0) return;
const detections = await detectorRegistry.detect({
companyId: input.companyId,
urls,
sourceContext: input,
});
if (detections.length === 0) return;
const seen = new Set<string>();
const values: Array<typeof externalObjectMentions.$inferInsert> = [];
for (const detection of detections) {
const canonicalHash = detection.canonical.canonicalIdentityHash;
const sourceKey = `${detection.providerKey}:${detection.objectType}:${canonicalHash}`;
if (seen.has(sourceKey)) continue;
seen.add(sourceKey);
const object = await upsertObjectFromDetection(input.companyId, detection, dbOrTx);
values.push({
companyId: input.companyId,
sourceIssueId: input.sourceIssueId,
sourceKind: input.sourceKind,
sourceRecordId: input.sourceRecordId,
documentKey: input.documentKey,
propertyKey: input.propertyKey,
matchedTextRedacted: detection.canonical.redactedMatchedText,
sanitizedDisplayUrl: detection.canonical.sanitizedDisplayUrl,
canonicalIdentityHash: canonicalHash,
canonicalIdentity: detection.canonical.canonicalIdentity as unknown as Record<string, unknown>,
objectId: object.id,
providerKey: detection.providerKey,
detectorKey: detection.detectorKey,
objectType: detection.objectType,
confidence: detection.confidence ?? "exact",
createdByPluginId: detection.pluginId ?? null,
});
}
if (values.length > 0) {
await dbOrTx.insert(externalObjectMentions).values(values);
}
}
async function syncIssue(issueId: string, dbOrTx: any = db) {
if (!(await isEnabled())) return;
const runSync = async (tx: any) => {
const issue = await issueById(issueId, tx);
if (!issue) throw notFound("Issue not found");
await replaceSourceMentions({
companyId: issue.companyId,
sourceIssueId: issue.id,
sourceKind: "title",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
text: issue.title,
}, tx);
await replaceSourceMentions({
companyId: issue.companyId,
sourceIssueId: issue.id,
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
text: issue.description,
}, tx);
};
return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx);
}
async function syncComment(commentId: string, dbOrTx: any = db) {
const runSync = async (tx: any) => {
if (!(await isEnabled())) return;
const comment = await tx
.select({
id: issueComments.id,
companyId: issueComments.companyId,
issueId: issueComments.issueId,
body: issueComments.body,
})
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows: Array<{ id: string; companyId: string; issueId: string; body: string }>) => rows[0] ?? null);
if (!comment) {
await tx
.delete(externalObjectMentions)
.where(and(eq(externalObjectMentions.sourceKind, "comment"), eq(externalObjectMentions.sourceRecordId, commentId)));
return;
}
await replaceSourceMentions({
companyId: comment.companyId,
sourceIssueId: comment.issueId,
sourceKind: "comment",
sourceRecordId: comment.id,
documentKey: null,
propertyKey: null,
text: comment.body,
}, tx);
};
return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx);
}
async function syncDocument(documentId: string, dbOrTx: any = db) {
const runSync = async (tx: any) => {
if (!(await isEnabled())) return;
const document = await tx
.select({
documentId: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
body: documents.latestBody,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(eq(documents.id, documentId))
.then((rows: Array<{ documentId: string; companyId: string; issueId: string; key: string; body: string }>) => rows[0] ?? null);
if (!document) {
await tx
.delete(externalObjectMentions)
.where(and(eq(externalObjectMentions.sourceKind, "document"), eq(externalObjectMentions.sourceRecordId, documentId)));
return;
}
await replaceSourceMentions({
companyId: document.companyId,
sourceIssueId: document.issueId,
sourceKind: "document",
sourceRecordId: document.documentId,
documentKey: document.key,
propertyKey: null,
text: document.body,
}, tx);
};
return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx);
}
async function safeSync(label: string, fn: () => Promise<void>) {
try {
await fn();
} catch (err) {
logger.warn({ err }, `external object ${label} sync failed`);
}
}
async function syncIssueSafely(issueId: string) {
await safeSync("issue", () => syncIssue(issueId));
}
async function syncCommentSafely(commentId: string, dbOrTx: any = db) {
await safeSync("comment", () => syncComment(commentId, dbOrTx));
}
async function syncDocumentSafely(documentId: string) {
await safeSync("document", () => syncDocument(documentId));
}
function toObjectPayload(object: ExternalObjectRecord, now = new Date()) {
return {
...object,
liveness: visibleLiveness(object, now),
};
}
async function listForIssue(issueId: string) {
if (!(await isEnabled())) return [];
const issue = await issueById(issueId);
if (!issue) throw notFound("Issue not found");
const rows = await db
.select({
mention: externalObjectMentions,
object: externalObjects,
})
.from(externalObjectMentions)
.leftJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id))
.where(and(
eq(externalObjectMentions.companyId, issue.companyId),
eq(externalObjectMentions.sourceIssueId, issue.id),
))
.orderBy(asc(externalObjectMentions.sourceKind), asc(externalObjectMentions.createdAt));
const now = new Date();
const grouped = new Map<string, {
object: ReturnType<typeof toObjectPayload> | null;
mentions: ExternalObjectMentionRecord[];
mentionCount: number;
sourceLabels: string[];
}>();
for (const row of rows) {
const key = row.object?.id ?? `mention:${row.mention.id}`;
const existing = grouped.get(key) ?? {
object: row.object ? toObjectPayload(row.object, now) : null,
mentions: [],
mentionCount: 0,
sourceLabels: [],
};
existing.mentions.push(row.mention);
existing.mentionCount += 1;
const label = formatExternalObjectMentionSourceLabel({
sourceKind: row.mention.sourceKind,
documentKey: row.mention.documentKey,
propertyKey: row.mention.propertyKey,
});
if (!existing.sourceLabels.includes(label)) existing.sourceLabels.push(label);
grouped.set(key, existing);
}
return [...grouped.values()];
}
function summarizeObjects(objects: Array<ReturnType<typeof toObjectPayload>>) {
const byStatusCategory: Record<string, number> = {};
const byLiveness: Record<string, number> = {};
let highestSeverity: ExternalObjectStatusTone = "neutral";
const severityRank: Record<ExternalObjectStatusTone, number> = {
neutral: 0,
muted: 0,
success: 1,
info: 2,
warning: 3,
danger: 4,
};
for (const object of objects) {
byStatusCategory[object.statusCategory] = (byStatusCategory[object.statusCategory] ?? 0) + 1;
byLiveness[object.liveness] = (byLiveness[object.liveness] ?? 0) + 1;
const livenessTone = object.liveness === "auth_required" || object.liveness === "unreachable"
? "danger"
: object.liveness === "stale"
? "warning"
: object.statusTone;
if (severityRank[livenessTone] > severityRank[highestSeverity]) highestSeverity = livenessTone;
}
return {
total: objects.length,
byStatusCategory,
byLiveness,
highestSeverity,
staleCount: byLiveness.stale ?? 0,
authRequiredCount: byLiveness.auth_required ?? 0,
unreachableCount: byLiveness.unreachable ?? 0,
};
}
function summarizeObjectPayloads(objects: Array<ReturnType<typeof toObjectPayload>>, objectLimit = objects.length) {
return {
...summarizeObjects(objects),
objects: objects.slice(0, objectLimit).map((object) => ({
id: object.id,
providerKey: object.providerKey,
objectType: object.objectType,
displayKey: object.displayKey,
iconKey: object.iconKey,
displayTitle: object.displayTitle,
statusIconKey: object.statusIconKey,
statusCategory: object.statusCategory,
statusTone: object.statusTone,
liveness: object.liveness,
isTerminal: object.isTerminal,
})),
};
}
async function getIssueSummary(issueId: string) {
if (!(await isEnabled())) return emptySummary();
const groups = await listForIssue(issueId);
const objects = groups.flatMap((group) => (group.object ? [group.object] : []));
return summarizeObjectPayloads(objects);
}
async function getIssueSummaries(companyId: string, issueIds: string[]) {
if (!(await isEnabled())) return new Map<string, ReturnType<typeof summarizeObjectPayloads>>();
const uniqueIssueIds = [...new Set(issueIds)].filter((id) => id.length > 0);
const summaries = new Map<string, ReturnType<typeof summarizeObjectPayloads>>();
if (uniqueIssueIds.length === 0) return summaries;
const rows = await db
.select({
issueId: externalObjectMentions.sourceIssueId,
object: externalObjects,
})
.from(externalObjectMentions)
.innerJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id))
.where(and(
eq(externalObjectMentions.companyId, companyId),
inArray(externalObjectMentions.sourceIssueId, uniqueIssueIds),
));
const now = new Date();
const objectsByIssueId = new Map<string, Map<string, ReturnType<typeof toObjectPayload>>>();
for (const row of rows) {
const issueObjects = objectsByIssueId.get(row.issueId) ?? new Map<string, ReturnType<typeof toObjectPayload>>();
issueObjects.set(row.object.id, toObjectPayload(row.object, now));
objectsByIssueId.set(row.issueId, issueObjects);
}
for (const [issueId, issueObjects] of objectsByIssueId) {
const objects = [...issueObjects.values()];
if (objects.length > 0) summaries.set(issueId, summarizeObjectPayloads(objects));
}
return summaries;
}
async function getProjectSummary(projectId: string) {
if (!(await isEnabled())) return emptySummary();
const projectIssues = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(and(eq(issues.projectId, projectId), inArray(issues.status, ["todo", "in_progress", "in_review", "blocked"])));
if (projectIssues.length === 0) return { ...summarizeObjects([]), objects: [] };
const companyIds = new Set(projectIssues.map((issue) => issue.companyId));
if (companyIds.size !== 1) return { ...summarizeObjects([]), objects: [] };
const issueIds = projectIssues.map((issue) => issue.id);
const rows = await db
.select({ object: externalObjects })
.from(externalObjectMentions)
.innerJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id))
.where(and(
eq(externalObjectMentions.companyId, projectIssues[0]!.companyId),
inArray(externalObjectMentions.sourceIssueId, issueIds),
));
const now = new Date();
const objectsById = new Map<string, ReturnType<typeof toObjectPayload>>();
for (const row of rows) objectsById.set(row.object.id, toObjectPayload(row.object, now));
const objects = [...objectsById.values()];
return summarizeObjectPayloads(objects, 25);
}
async function refreshObject(
objectId: string,
input: {
companyId: string;
actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId">;
force?: boolean;
now?: Date;
},
) {
const now = input.now ?? new Date();
const object = await db
.select()
.from(externalObjects)
.where(and(eq(externalObjects.id, objectId), eq(externalObjects.companyId, input.companyId)))
.then((rows) => rows[0] ?? null);
if (!object) throw notFound("External object not found");
if (!input.force && object.nextRefreshAt && object.nextRefreshAt > now) {
return { object: toObjectPayload(object, now), refreshed: false, reason: "backoff" as const };
}
const pluginResult = await resolveViaPluginProvider(db, opts.pluginWorkerManager, object);
const resolver = pluginResult ? null : resolverRegistry.find(object);
if (!pluginResult && !resolver) {
const [updated] = await db
.update(externalObjects)
.set({
liveness: visibleLiveness(object, now) === "fresh" ? "stale" : object.liveness,
nextRefreshAt: addSeconds(now, DEFAULT_RETRY_AFTER_SECONDS),
updatedAt: now,
})
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
.returning();
return { object: toObjectPayload(updated ?? object, now), refreshed: false, reason: "no_resolver" as const };
}
const result = pluginResult ?? await resolver!.resolve({ companyId: object.companyId, object });
if (!result.ok) {
const [updated] = await db
.update(externalObjects)
.set({
liveness: result.liveness,
lastErrorAt: now,
lastErrorCode: result.errorCode,
lastErrorMessage: sanitizeErrorMessage(result.errorMessage),
nextRefreshAt: addSeconds(now, result.retryAfterSeconds ?? DEFAULT_RETRY_AFTER_SECONDS),
updatedAt: now,
})
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
.returning();
publishLiveEvent({
companyId: object.companyId,
type: "external_object.updated",
payload: { objectId: object.id, liveness: result.liveness },
});
return { object: toObjectPayload(updated ?? object, now), refreshed: true, reason: result.liveness };
}
const snapshot = result.snapshot;
const patch = {
displayKey: snapshot.displayKey ?? object.displayKey,
iconKey: snapshot.iconKey ?? object.iconKey,
displayTitle: snapshot.displayTitle ?? object.displayTitle,
statusKey: snapshot.statusKey ?? object.statusKey,
statusLabel: snapshot.statusLabel ?? object.statusLabel,
statusIconKey: snapshot.statusIconKey ?? object.statusIconKey,
statusCategory: snapshot.statusCategory,
statusTone: snapshot.statusTone,
isTerminal: snapshot.isTerminal ?? object.isTerminal,
data: snapshot.data ?? object.data,
remoteVersion: snapshot.remoteVersion ?? object.remoteVersion,
etag: snapshot.etag ?? object.etag,
liveness: "fresh" as ExternalObjectLivenessState,
lastResolvedAt: now,
lastErrorAt: null,
lastErrorCode: null,
lastErrorMessage: null,
nextRefreshAt: addSeconds(now, snapshot.ttlSeconds ?? DEFAULT_REFRESH_TTL_SECONDS),
updatedAt: now,
};
const [updated] = await db
.update(externalObjects)
.set({
...patch,
lastChangedAt: objectChanged(object, { ...object, ...patch }) ? now : object.lastChangedAt,
})
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
.returning();
const next = updated ?? object;
if (objectChanged(object, next) && input.actor) {
await logActivity(db, {
companyId: object.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
action: "external_object.status_changed",
entityType: "external_object",
entityId: object.id,
details: {
providerKey: object.providerKey,
objectType: object.objectType,
statusCategory: next.statusCategory,
statusLabel: next.statusLabel,
_previous: {
statusCategory: object.statusCategory,
statusLabel: object.statusLabel,
},
},
});
}
publishLiveEvent({
companyId: object.companyId,
type: "external_object.updated",
payload: { objectId: object.id, statusCategory: next.statusCategory, liveness: next.liveness },
});
return { object: toObjectPayload(next, now), refreshed: true, reason: "resolved" as const };
}
async function refreshIssueObjects(issueId: string, input: {
companyId: string;
objectIds?: string[];
actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId">;
}) {
if (!(await isEnabled())) return [];
const groups = await listForIssue(issueId);
const objectIds = groups
.flatMap((group) => (group.object ? [group.object.id] : []))
.filter((id) => !input.objectIds || input.objectIds.includes(id));
const results = [];
for (const objectId of objectIds) {
results.push(await refreshObject(objectId, { companyId: input.companyId, actor: input.actor }));
}
return results;
}
async function refreshDueObjects(companyId: string, limit = 50, now = new Date()) {
if (!(await isEnabled())) return [];
const due = await db
.select({ id: externalObjects.id })
.from(externalObjects)
.where(
and(
eq(externalObjects.companyId, companyId),
eq(externalObjects.isTerminal, false),
lte(externalObjects.nextRefreshAt, now),
),
)
.limit(limit);
const results = [];
for (const row of due) {
results.push(await refreshObject(row.id, {
companyId,
actor: { actorType: "system", actorId: "external-object-resolver", agentId: null, runId: null },
now,
}));
}
return results;
}
return {
syncIssue,
syncComment,
syncDocument,
syncIssueSafely,
syncCommentSafely,
syncDocumentSafely,
listForIssue,
getIssueSummary,
getIssueSummaries,
getProjectSummary,
refreshObject,
refreshIssueObjects,
refreshDueObjects,
};
}

View File

@ -0,0 +1,445 @@
import type { Db } from "@paperclipai/db";
import type { ExternalObjectCanonicalUrl } from "@paperclipai/shared";
import { ghFetch, gitHubApiBase } from "./github-fetch.js";
import { secretService } from "./secrets.js";
import type {
ExternalObjectDetection,
ExternalObjectDetector,
ExternalObjectResolver,
ExternalObjectResolverSnapshot,
ExternalObjectResolveResult,
} from "./external-objects.js";
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
export interface GitHubExternalObjectProviderOptions {
fetch?: FetchLike;
tokenProvider?: (companyId: string) => Promise<string | null> | string | null;
secretNames?: readonly string[];
}
interface GitHubObjectIdentity {
host: string;
owner: string;
repo: string;
number: number;
objectType: "pull_request" | "issue";
pathKind: "pull" | "issues";
}
const DEFAULT_GITHUB_TOKEN_SECRET_NAMES = ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"] as const;
const GITHUB_OBJECT_TTL_SECONDS = 300;
function isGitHubHost(host: string) {
const h = host.toLowerCase();
return h === "github.com" || h === "www.github.com";
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function asString(value: unknown) {
return typeof value === "string" ? value : null;
}
function asBoolean(value: unknown) {
return typeof value === "boolean" ? value : null;
}
function asNestedString(record: Record<string, unknown>, key: string, nestedKey: string) {
const nested = asRecord(record[key]);
return nested ? asString(nested[nestedKey]) : null;
}
function parseGitHubCanonicalUrl(canonical: ExternalObjectCanonicalUrl): GitHubObjectIdentity | null {
if (canonical.canonicalIdentity.scheme !== "https") return null;
const host = canonical.canonicalIdentity.host.toLowerCase();
if (!isGitHubHost(host)) return null;
const parts = canonical.canonicalIdentity.path.split("/").filter(Boolean);
if (parts.length !== 4) return null;
const [owner, repo, kind, rawNumber] = parts;
if (!owner || !repo || !kind || !rawNumber) return null;
if (!/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) return null;
if (!/^[1-9][0-9]*$/.test(rawNumber)) return null;
if (kind !== "pull" && kind !== "issues") return null;
return {
host: host === "www.github.com" ? "github.com" : host,
owner,
repo,
number: Number(rawNumber),
pathKind: kind,
objectType: kind === "pull" ? "pull_request" : "issue",
};
}
function parseGitHubObject(object: { externalId: string; sanitizedCanonicalUrl: string | null }): GitHubObjectIdentity | null {
const match = /^([^/]+)\/([^/]+)#(pull|issues)\/([1-9][0-9]*)$/.exec(object.externalId);
if (!match) return null;
let host = "github.com";
if (object.sanitizedCanonicalUrl) {
try {
const url = new URL(object.sanitizedCanonicalUrl);
if (isGitHubHost(url.hostname)) host = url.hostname === "www.github.com" ? "github.com" : url.hostname;
} catch {
return null;
}
}
return {
host,
owner: match[1]!,
repo: match[2]!,
pathKind: match[3] as "pull" | "issues",
number: Number(match[4]),
objectType: match[3] === "pull" ? "pull_request" : "issue",
};
}
function externalIdFor(identity: GitHubObjectIdentity) {
return `${identity.owner.toLowerCase()}/${identity.repo.toLowerCase()}#${identity.pathKind}/${identity.number}`;
}
function displayTitleFor(identity: GitHubObjectIdentity) {
return `${identity.owner}/${identity.repo}#${identity.number}`;
}
function displayKeyFor(identity: Pick<GitHubObjectIdentity, "objectType">) {
return identity.objectType === "pull_request" ? "GitHub Pull Request" : "GitHub Issue";
}
function retryAfterSeconds(response: Response) {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^[0-9]+$/.test(retryAfter)) return Number(retryAfter);
const reset = response.headers.get("x-ratelimit-reset");
if (reset && /^[0-9]+$/.test(reset)) {
return Math.max(1, Number(reset) - Math.floor(Date.now() / 1000));
}
return 300;
}
function failureFromGitHubResponse(response: Response): ExternalObjectResolveResult | null {
if (response.status === 401) {
return {
ok: false,
liveness: "auth_required",
errorCode: "github_auth_required",
errorMessage: "GitHub authentication is required to refresh this object.",
retryAfterSeconds: retryAfterSeconds(response),
};
}
if (response.status === 403) {
const rateLimitRemaining = response.headers.get("x-ratelimit-remaining");
if (rateLimitRemaining === "0") {
return {
ok: false,
liveness: "unreachable",
errorCode: "github_rate_limited",
errorMessage: "GitHub rate limit reached while refreshing this object.",
retryAfterSeconds: retryAfterSeconds(response),
};
}
return {
ok: false,
liveness: "auth_required",
errorCode: "github_forbidden",
errorMessage: "GitHub rejected the configured credentials for this object.",
retryAfterSeconds: retryAfterSeconds(response),
};
}
if (response.status === 429 || response.status >= 500) {
return {
ok: false,
liveness: "unreachable",
errorCode: response.status === 429 ? "github_rate_limited" : "github_unreachable",
errorMessage: `GitHub returned HTTP ${response.status} while refreshing this object.`,
retryAfterSeconds: retryAfterSeconds(response),
};
}
return null;
}
function notFoundSnapshot(identity: GitHubObjectIdentity, etag: string | null): ExternalObjectResolverSnapshot {
return {
displayKey: displayKeyFor(identity),
iconKey: "github",
displayTitle: displayTitleFor(identity),
statusKey: "not_found",
statusLabel: "Not found",
statusIconKey: "archive",
statusCategory: "archived",
statusTone: "muted",
isTerminal: true,
etag,
ttlSeconds: GITHUB_OBJECT_TTL_SECONDS,
data: {
provider: "github",
owner: identity.owner,
repo: identity.repo,
number: identity.number,
notFound: true,
},
};
}
function pullRequestSnapshot(identity: GitHubObjectIdentity, body: Record<string, unknown>, etag: string | null): ExternalObjectResolverSnapshot {
const title = asString(body.title);
const state = asString(body.state) ?? "unknown";
const draft = asBoolean(body.draft) ?? false;
const merged = (asBoolean(body.merged) ?? false) || Boolean(asString(body.merged_at));
const authorLogin = asNestedString(body, "user", "login");
const headRef = asNestedString(body, "head", "ref");
const baseRef = asNestedString(body, "base", "ref");
const reviewDecision = asString(body.review_decision);
let statusKey = state;
let statusLabel = state === "open" ? "Open" : state === "closed" ? "Closed" : "Unknown";
let statusCategory: ExternalObjectResolverSnapshot["statusCategory"] = state === "open" ? "open" : "unknown";
let statusTone: ExternalObjectResolverSnapshot["statusTone"] = state === "open" ? "info" : "neutral";
let isTerminal = false;
if (merged) {
statusKey = "merged";
statusLabel = "Merged";
statusCategory = "succeeded";
statusTone = "success";
isTerminal = true;
} else if (state === "closed") {
statusKey = "closed";
statusLabel = "Closed";
statusCategory = "closed";
statusTone = "muted";
isTerminal = true;
} else if (draft) {
statusKey = "draft";
statusLabel = "Draft";
statusCategory = "waiting";
statusTone = "warning";
}
return {
displayKey: displayKeyFor(identity),
iconKey: "github",
displayTitle: title ? `${displayTitleFor(identity)}: ${title}` : displayTitleFor(identity),
statusKey,
statusLabel,
statusIconKey: merged
? "git-merge"
: state === "closed"
? "x-circle"
: draft
? "clock"
: "git-pull-request",
statusCategory,
statusTone,
isTerminal,
remoteVersion: asString(body.updated_at),
etag,
ttlSeconds: GITHUB_OBJECT_TTL_SECONDS,
data: {
provider: "github",
owner: identity.owner,
repo: identity.repo,
number: identity.number,
state,
merged,
draft,
...(authorLogin ? { authorLogin } : {}),
...(headRef ? { headRef } : {}),
...(baseRef ? { baseRef } : {}),
...(reviewDecision ? { reviewDecision } : {}),
},
};
}
function issueSnapshot(identity: GitHubObjectIdentity, body: Record<string, unknown>, etag: string | null): ExternalObjectResolverSnapshot {
const title = asString(body.title);
const state = asString(body.state) ?? "unknown";
const stateReason = asString(body.state_reason);
const authorLogin = asNestedString(body, "user", "login");
const statusKey = state === "closed" && stateReason ? `closed_${stateReason}` : state;
const statusLabel = state === "closed"
? stateReason
? `Closed: ${stateReason.replace(/_/g, " ")}`
: "Closed"
: state === "open"
? "Open"
: "Unknown";
return {
displayKey: displayKeyFor(identity),
iconKey: "github",
displayTitle: title ? `${displayTitleFor(identity)}: ${title}` : displayTitleFor(identity),
statusKey,
statusLabel,
statusIconKey: state === "closed" ? "circle" : "circle-dot",
statusCategory: state === "open" ? "open" : state === "closed" ? "closed" : "unknown",
statusTone: state === "open" ? "info" : state === "closed" ? "muted" : "neutral",
isTerminal: state === "closed",
remoteVersion: asString(body.updated_at),
etag,
ttlSeconds: GITHUB_OBJECT_TTL_SECONDS,
data: {
provider: "github",
owner: identity.owner,
repo: identity.repo,
number: identity.number,
state,
...(stateReason ? { stateReason } : {}),
...(authorLogin ? { authorLogin } : {}),
},
};
}
async function safeJson(response: Response) {
try {
return asRecord(await response.json());
} catch {
return null;
}
}
async function defaultTokenProvider(db: Db, companyId: string, secretNames: readonly string[]) {
const secrets = secretService(db);
for (const secretName of secretNames) {
const secret = await secrets.getByName(companyId, secretName);
if (!secret) continue;
const token = await secrets.resolveSecretValue(companyId, secret.id, "latest");
const trimmed = token.trim();
if (trimmed) return trimmed;
}
return null;
}
export function createGitHubExternalObjectProvider(
db: Db,
opts: GitHubExternalObjectProviderOptions = {},
): { detector: ExternalObjectDetector; resolvers: ExternalObjectResolver[] } {
const fetchImpl = opts.fetch ?? ghFetch;
const secretNames = opts.secretNames ?? DEFAULT_GITHUB_TOKEN_SECRET_NAMES;
const tokenProvider = Object.prototype.hasOwnProperty.call(opts, "tokenProvider") && opts.tokenProvider !== undefined
? opts.tokenProvider
: ((companyId: string) => defaultTokenProvider(db, companyId, secretNames));
const detector: ExternalObjectDetector = {
key: "github",
detect({ urls }): ExternalObjectDetection[] {
return urls.flatMap((canonical) => {
const identity = parseGitHubCanonicalUrl(canonical);
if (!identity) return [];
return [{
canonical,
detectorKey: "github",
providerKey: "github",
objectType: identity.objectType,
externalId: externalIdFor(identity),
displayKey: displayKeyFor(identity),
iconKey: "github",
displayTitle: displayTitleFor(identity),
confidence: "exact",
}];
});
},
};
function resolver(objectType: GitHubObjectIdentity["objectType"]): ExternalObjectResolver {
return {
providerKey: "github",
objectType,
async resolve({ companyId, object }) {
const identity = parseGitHubObject(object);
if (!identity || identity.objectType !== objectType) {
return {
ok: false,
liveness: "unreachable",
errorCode: "github_invalid_identity",
errorMessage: "GitHub object identity is invalid.",
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
};
}
let token: string | null = null;
try {
token = typeof tokenProvider === "function" ? await tokenProvider(companyId) : tokenProvider;
} catch {
return {
ok: false,
liveness: "auth_required",
errorCode: "github_token_unavailable",
errorMessage: "Configured GitHub credentials could not be resolved.",
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
};
}
token = token?.trim() || null;
const headers: Record<string, string> = {
accept: "application/vnd.github+json",
"user-agent": "paperclip-external-object-resolver",
"x-github-api-version": "2022-11-28",
};
if (token) headers.authorization = `Bearer ${token}`;
const apiKind = objectType === "pull_request" ? "pulls" : "issues";
const url = `${gitHubApiBase(identity.host)}/repos/${encodeURIComponent(identity.owner)}/${encodeURIComponent(identity.repo)}/${apiKind}/${identity.number}`;
let response: Response;
try {
response = await fetchImpl(url, { headers });
} catch {
return {
ok: false,
liveness: "unreachable",
errorCode: "github_fetch_failed",
errorMessage: "GitHub could not be reached while refreshing this object.",
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
};
}
const etag = response.headers.get("etag");
if (response.status === 404) {
return { ok: true, snapshot: notFoundSnapshot(identity, etag) };
}
const failure = failureFromGitHubResponse(response);
if (failure) return failure;
if (!response.ok) {
return {
ok: false,
liveness: "unreachable",
errorCode: "github_unexpected_response",
errorMessage: `GitHub returned HTTP ${response.status} while refreshing this object.`,
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
};
}
const body = await safeJson(response);
if (!body) {
return {
ok: false,
liveness: "unreachable",
errorCode: "github_invalid_response",
errorMessage: "GitHub returned an invalid object response.",
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
};
}
return {
ok: true,
snapshot: objectType === "pull_request"
? pullRequestSnapshot(identity, body, etag)
: issueSnapshot(identity, body, etag),
};
},
};
}
return {
detector,
resolvers: [resolver("pull_request"), resolver("issue")],
};
}

View File

@ -33,6 +33,15 @@ export {
resolveTaskWatchdogMutationScope,
taskWatchdogScopeAllowsIssueMutation,
} from "./task-watchdog-scope.js";
export {
createExternalObjectDetectorRegistry,
createExternalObjectResolverRegistry,
externalObjectService,
type ExternalObjectDetector,
type ExternalObjectResolver,
type ExternalObjectResolveResult,
type ExternalObjectResolverSnapshot,
} from "./external-objects.js";
export { goalService } from "./goals.js";
export { activityService, type ActivityFilters } from "./activity.js";
export { approvalService } from "./approvals.js";

View File

@ -52,6 +52,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
enableCloudSync: parsed.data.enableCloudSync ?? false,
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
issueGraphLivenessAutoRecoveryLookbackHours:
@ -68,6 +69,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableCloudSync: false,
enableExternalObjects: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false,
issueGraphLivenessAutoRecoveryLookbackHours:

View File

@ -99,6 +99,10 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"telemetry.track": ["telemetry.track"],
"db.migrate": ["database.namespace.migrate"],
"db.execute": ["database.namespace.write"],
"external.objects.detect": ["external.objects.detect"],
"external.objects.read": ["external.objects.read"],
"external.objects.write": ["external.objects.write"],
"external.objects.refresh": ["external.objects.refresh"],
// Plugin state operations
"plugin.state.get": ["plugin.state.read"],
@ -188,6 +192,7 @@ const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
agents: "agents.managed",
projects: "projects.managed",
routines: "routines.managed",
objectReferences: "external.objects.detect",
};
// ---------------------------------------------------------------------------
@ -452,6 +457,14 @@ export function pluginCapabilityValidator(): PluginCapabilityValidator {
}
}
if ((manifest.objectReferences?.length ?? 0) > 0) {
for (const requiredCap of ["external.objects.detect", "external.objects.read"] as const) {
if (!declared.has(requiredCap) && !allMissing.includes(requiredCap)) {
allMissing.push(requiredCap);
}
}
}
// Check UI slots → required capabilities
const uiSlots = manifest.ui?.slots ?? [];
if (uiSlots.length > 0) {

View File

@ -0,0 +1,18 @@
import type { ExternalObjectMentionGroup, ExternalObjectSummary } from "@paperclipai/shared";
import { api } from "./client";
export const externalObjectsApi = {
listForIssue: (issueId: string) =>
api.get<ExternalObjectMentionGroup[]>(`/issues/${issueId}/external-objects`),
getIssueSummary: (issueId: string) =>
api.get<ExternalObjectSummary>(`/issues/${issueId}/external-object-summary`),
getIssueSummaries: (companyId: string, issueIds: string[]) =>
api.post<{ summaries: Record<string, ExternalObjectSummary> }>(
`/companies/${companyId}/issues/external-object-summaries`,
{ issueIds },
),
refreshIssueObjects: (issueId: string, data?: { objectIds?: string[] }) =>
api.post<{ refreshed: unknown[] }>(`/issues/${issueId}/external-objects/refresh`, data ?? {}),
getProjectSummary: (projectId: string) =>
api.get<ExternalObjectSummary>(`/projects/${projectId}/external-object-summary`),
};

View File

@ -6,6 +6,7 @@ export { companiesApi } from "./companies";
export { agentsApi } from "./agents";
export { projectsApi } from "./projects";
export { issuesApi } from "./issues";
export { externalObjectsApi } from "./externalObjects";
export { routinesApi } from "./routines";
export { goalsApi } from "./goals";
export { approvalsApi } from "./approvals";

View File

@ -0,0 +1,114 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Approval } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "../context/ThemeContext";
import { CommentThread } from "./CommentThread";
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: () => null,
}));
vi.mock("./InlineEntitySelector", () => ({
InlineEntitySelector: () => null,
}));
vi.mock("./ApprovalCard", () => ({
ApprovalCard: ({ approval }: { approval: Approval }) => <div>{approval.type}</div>,
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotOutlet: () => null,
}));
vi.mock("../api/issues", () => ({
issuesApi: { get: vi.fn() },
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("CommentThread external object decoration (integration)", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("decorates a resolved URL and leaves an unknown URL unchanged when rendered through the real MarkdownBody", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MemoryRouter>
<CommentThread
comments={[{
id: "comment-mixed",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "user-1",
authorType: "user",
presentation: null,
metadata: null,
body: [
"Tracked: https://github.com/example/repo/pull/77",
"Untracked: https://elsewhere.example.com/page",
].join("\n\n"),
createdAt: new Date("2026-04-24T12:00:00.000Z"),
updatedAt: new Date("2026-04-24T12:00:00.000Z"),
}]}
externalReferences={{
"https://github.com/example/repo/pull/77": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "open",
liveness: "fresh",
statusLabel: "Open",
displayTitle: "PR #77",
},
}}
onAdd={async () => {}}
/>
</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>,
);
});
const resolvedLink = container.querySelector(
'a[href="https://github.com/example/repo/pull/77"]',
);
expect(resolvedLink, "resolved URL should be wrapped by the external-link decorator").not.toBeNull();
expect(resolvedLink?.getAttribute("data-external-link")).toBe("resolved");
expect(resolvedLink?.getAttribute("data-external-status")).toBe("open");
expect(resolvedLink?.classList.contains("paperclip-markdown-external-ref")).toBe(true);
const unknownLink = container.querySelector(
'a[href="https://elsewhere.example.com/page"]',
);
expect(unknownLink, "unknown URL should still render as a plain link").not.toBeNull();
expect(unknownLink?.getAttribute("data-external-link")).toBeNull();
expect(unknownLink?.classList.contains("paperclip-markdown-external-ref")).toBe(false);
act(() => {
root.unmount();
});
queryClient.clear();
});
});

View File

@ -9,8 +9,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CommentThread } from "./CommentThread";
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
MarkdownBody: ({
children,
className,
externalReferences,
}: {
children: ReactNode;
className?: string;
externalReferences?: Record<string, unknown>;
}) => (
<div
className={className}
data-testid="markdown-body"
data-external-reference-keys={externalReferences ? Object.keys(externalReferences).join(",") : ""}
>
{children}
</div>
),
}));
@ -395,4 +409,51 @@ describe("CommentThread", () => {
root.unmount();
});
});
it("passes externalReferences to MarkdownBody for comment bodies", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<CommentThread
comments={[{
id: "comment-ref",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "user-1",
authorType: "user",
presentation: null,
metadata: null,
body: "See https://github.com/example/repo/pull/42 for context.",
createdAt: new Date("2026-03-11T11:00:00.000Z"),
updatedAt: new Date("2026-03-11T11:00:00.000Z"),
}]}
externalReferences={{
"https://github.com/example/repo/pull/42": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "open",
liveness: "fresh",
statusLabel: "Open",
displayTitle: "PR #42",
},
}}
onAdd={async () => {}}
/>
</MemoryRouter>,
);
});
const commentRow = container.querySelector("#comment-comment-ref") as HTMLDivElement | null;
expect(commentRow).not.toBeNull();
const markdownBody = commentRow?.querySelector('[data-testid="markdown-body"]') as HTMLElement | null;
expect(markdownBody?.getAttribute("data-external-reference-keys"))
.toContain("https://github.com/example/repo/pull/42");
act(() => {
root.unmount();
});
});
});

View File

@ -14,7 +14,7 @@ import { ArrowRight, Check, Copy, Paperclip } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { ApprovalCard } from "./ApprovalCard";
@ -105,6 +105,7 @@ interface CommentThreadProps {
onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null;
composerDisabledReason?: string | null;
externalReferences?: MarkdownExternalReferenceMap;
}
const DRAFT_DEBOUNCE_MS = 800;
@ -324,6 +325,7 @@ function CommentCard({
voting = false,
highlightCommentId,
queued = false,
externalReferences,
}: {
comment: CommentWithRunMeta;
agentMap?: Map<string, Agent>;
@ -339,6 +341,7 @@ function CommentCard({
voting?: boolean;
highlightCommentId?: string | null;
queued?: boolean;
externalReferences?: MarkdownExternalReferenceMap;
}) {
const isHighlighted = highlightCommentId === comment.id;
const isPending = comment.clientStatus === "pending";
@ -412,7 +415,7 @@ function CommentCard({
{isDeleted ? (
<div className="text-sm italic text-muted-foreground">Comment deleted</div>
) : (
<MarkdownBody className="text-sm" softBreaks>{comment.body}</MarkdownBody>
<MarkdownBody className="text-sm" softBreaks externalReferences={externalReferences}>{comment.body}</MarkdownBody>
)}
{companyId && !isPending && !isDeleted ? (
<div className="mt-2 space-y-2">
@ -575,6 +578,7 @@ const TimelineList = memo(function TimelineList({
onVote,
votingTargetId,
highlightCommentId,
externalReferences,
}: {
timeline: TimelineItem[];
agentMap?: Map<string, Agent>;
@ -597,6 +601,7 @@ const TimelineList = memo(function TimelineList({
) => Promise<void>;
votingTargetId?: string | null;
highlightCommentId?: string | null;
externalReferences?: MarkdownExternalReferenceMap;
}) {
if (timeline.length === 0) {
return <p className="text-sm text-muted-foreground">No timeline entries yet.</p>;
@ -718,6 +723,7 @@ const TimelineList = memo(function TimelineList({
onVote={onVote ? (vote, options) => onVote(comment.id, vote, options) : undefined}
voting={votingTargetId === comment.id}
highlightCommentId={highlightCommentId}
externalReferences={externalReferences}
/>
);
})}
@ -756,6 +762,7 @@ export function CommentThread({
onInterruptQueued,
interruptingQueuedRunId = null,
composerDisabledReason = null,
externalReferences,
}: CommentThreadProps) {
const [body, setBody] = useState("");
const [submitting, setSubmitting] = useState(false);
@ -974,6 +981,7 @@ export function CommentThread({
votingTargetId={votingTargetId}
highlightCommentId={highlightCommentId}
feedbackTermsUrl={feedbackTermsUrl}
externalReferences={externalReferences}
/>
{liveRunSlot}
@ -1006,6 +1014,7 @@ export function CommentThread({
projectId={projectId}
highlightCommentId={highlightCommentId}
queued
externalReferences={externalReferences}
/>
))}
</div>

View File

@ -0,0 +1,170 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ExternalObjectPill } from "./ExternalObjectPill";
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
describe("ExternalObjectPill", () => {
it("renders a clickable anchor when a URL is provided", () => {
const html = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
statusCategory: "succeeded",
liveness: "fresh",
displayTitle: "Add external refs",
url: "https://github.com/acme/web/pull/241",
}}
sourceCount={4}
sourceSummary="description, 3 comments"
/>,
);
expect(html).toContain('href="https://github.com/acme/web/pull/241"');
expect(html).toContain('data-mention-kind="external-object"');
expect(html).toContain('data-external-status="succeeded"');
expect(html).toContain('data-external-liveness="fresh"');
expect(html).toContain("×4");
expect(html).toContain('aria-label="GitHub pull request — Succeeded: Add external refs"');
});
it("falls back to a non-interactive span when no URL is supplied", () => {
const html = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: null,
objectType: null,
statusCategory: "unknown",
liveness: "unknown",
url: null,
}}
/>,
);
expect(html).toContain('data-mention-kind="external-object"');
expect(html).not.toContain("<a ");
expect(html).toContain('aria-label="External object — Not yet resolved"');
});
it("applies the dashed-border liveness overlay when stale or auth_required", () => {
const stale = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
statusCategory: "failed",
liveness: "stale",
url: "https://github.com/acme/web/pull/242",
}}
/>,
);
expect(stale).toContain("opacity-70");
expect(stale).toContain("[border-style:dashed]");
const auth = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
url: "https://app.hubspot.com/leads/99",
}}
/>,
);
expect(auth).toContain("[border-style:dashed]");
});
it("does not show a source count when only a single mention is present", () => {
const html = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
statusCategory: "succeeded",
liveness: "fresh",
url: "https://github.com/acme/web/pull/241",
}}
sourceCount={1}
/>,
);
expect(html).not.toContain("×");
});
it("uses the object link label, provider icon, and visible status when supplied", () => {
const html = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
displayKey: "Github Pull Request",
iconKey: "github",
statusCategory: "succeeded",
statusIconKey: null,
liveness: "fresh",
statusLabel: "Merged",
displayTitle: "acme/web#241: Add rich object presentation metadata",
url: "https://github.com/acme/web/pull/241",
}}
/>,
);
expect(html).toContain("Merged");
expect(html).toContain("PR 241 - Merged");
expect(html).not.toContain("acme/web#241</span>");
expect(html).toContain("text-violet-600");
expect(html).not.toContain("Github Pull Request");
expect(html).toContain('aria-label="GitHub pull request — Merged: acme/web#241: Add rich object presentation metadata"');
});
});
describe("ExternalObjectStatusSummary", () => {
it("hides itself when there are no external objects", () => {
const html = renderToStaticMarkup(<ExternalObjectStatusSummary summary={null} />);
expect(html).toBe("");
});
it("hides itself when the highest severity is muted", () => {
const html = renderToStaticMarkup(
<ExternalObjectStatusSummary
summary={{
total: 2,
byStatusCategory: { closed: 2 },
byLiveness: { fresh: 2 },
highestSeverity: "muted",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
}}
/>,
);
expect(html).toBe("");
});
it("shows the dominant-severity icon and count", () => {
const html = renderToStaticMarkup(
<ExternalObjectStatusSummary
summary={{
total: 5,
byStatusCategory: { failed: 3, succeeded: 2 },
byLiveness: { fresh: 5 },
highestSeverity: "danger",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [
{ id: "a", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
{ id: "b", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
{ id: "c", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
{ id: "d", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded", statusTone: "success", liveness: "fresh", isTerminal: true },
{ id: "e", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded", statusTone: "success", liveness: "fresh", isTerminal: true },
],
}}
/>,
);
expect(html).toContain('data-external-status="failed"');
expect(html).toContain('data-external-tone="danger"');
expect(html).toContain(">3<");
expect(html).toContain("aria-label=\"External objects: 3 failed, 2 succeeded, 5 total\"");
});
});

View File

@ -0,0 +1,198 @@
import type { ReactNode } from "react";
import type {
ExternalObjectLivenessState,
ExternalObjectStatusCategory,
} from "@paperclipai/shared";
import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon";
import {
externalObjectStatusIcon,
externalObjectStatusIconDefault,
externalObjectLivenessOverlay,
} from "../lib/status-colors";
import {
externalObjectCategoryLabel,
externalObjectLivenessLabel,
externalObjectIconForKey,
externalObjectProviderLabel,
externalObjectTypeLabel,
} from "../lib/external-objects";
import { cn } from "../lib/utils";
export interface ExternalObjectPillData {
providerKey: string | null;
objectType: string | null;
displayKey?: string | null;
iconKey?: string | null;
statusCategory: ExternalObjectStatusCategory;
liveness: ExternalObjectLivenessState;
displayTitle?: string | null;
statusLabel?: string | null;
statusIconKey?: string | null;
url?: string | null;
}
function githubObjectLabel(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 externalObjectValueLabel(
object: ExternalObjectPillData,
fallback: string,
statusLabel: string,
): string {
const githubLabel = object.providerKey === "github" ? githubObjectLabel(object.url) : null;
const base = githubLabel ?? object.displayTitle?.trim() ?? fallback;
return statusLabel ? `${base} - ${statusLabel}` : base;
}
function isMergedExternalObject(object: ExternalObjectPillData, statusLabel: string): boolean {
return object.statusIconKey === "git-merge" || statusLabel.toLowerCase() === "merged";
}
function externalObjectPillTone(object: ExternalObjectPillData, statusLabel: string): string {
if (isMergedExternalObject(object, statusLabel)) {
return "text-violet-600 border-violet-600 dark:text-violet-400 dark:border-violet-400";
}
return externalObjectStatusIcon[object.statusCategory] ?? externalObjectStatusIconDefault;
}
function externalObjectStatusIconKey(
object: ExternalObjectPillData,
statusLabel: string,
): string | null | undefined {
if (isMergedExternalObject(object, statusLabel)) return object.statusIconKey ?? "git-merge";
return object.statusIconKey;
}
interface ExternalObjectPillProps {
object: ExternalObjectPillData;
/** Optional external mention count (renders as `×N` superscript when > 1). */
sourceCount?: number;
/** Optional source-mention summary used as the pill's `title` attribute. */
sourceSummary?: string | null;
className?: string;
/** Optional rendered label override. Defaults to `provider object-type`. */
children?: ReactNode;
/**
* If true the pill renders without a hover treatment (used inside
* non-interactive contexts like the property panel). Defaults to false.
*/
inert?: boolean;
/**
* Hide the provider icon when the surrounding UI already names the provider.
* The status icon still renders as the single state glyph inside the pill.
*/
showProviderIcon?: boolean;
}
/**
* External-object equivalent of `IssueReferencePill`. Same `paperclip-mention-chip`
* base so external references feel native to readers (Jakob's Law).
*/
export function ExternalObjectPill({
object,
sourceCount,
sourceSummary,
className,
children,
inert,
showProviderIcon = true,
}: ExternalObjectPillProps) {
const overlay = externalObjectLivenessOverlay[object.liveness] ?? "";
const providerLabel = externalObjectProviderLabel(object.providerKey);
const typeLabel = externalObjectTypeLabel(object.objectType);
const displayKey = object.displayKey?.trim() || `${providerLabel} ${typeLabel}`;
const statusLabel = object.statusLabel ?? externalObjectCategoryLabel(object.statusCategory);
const tone = externalObjectPillTone(object, statusLabel);
const valueLabel = externalObjectValueLabel(object, displayKey, statusLabel);
const statusIconKey = externalObjectStatusIconKey(object, statusLabel);
const livenessLabel = externalObjectLivenessLabel(object.liveness);
const ProviderIcon = externalObjectIconForKey(object.iconKey);
const ariaLabel = `${providerLabel} ${typeLabel}${statusLabel}${
object.liveness === "fresh" || object.liveness === "unknown" ? "" : ` (${livenessLabel})`
}${object.displayTitle ? `: ${object.displayTitle}` : ""}`;
const interactive = !inert && Boolean(object.url);
const classNames = cn(
"paperclip-mention-chip paperclip-mention-chip--external-object",
"inline-flex max-w-full items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
// Tone is applied as text classes only — the border style comes from the
// overlay (dashed for stale/auth/unreachable).
tone.split(" ").filter((c) => c.startsWith("text-")).join(" "),
overlay,
interactive
&& "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring",
className,
);
const titleAttr = sourceSummary
? `${object.displayTitle ?? displayKey}${sourceSummary}`
: object.displayTitle ?? displayKey;
const labelText = children ?? (
<>
<ExternalObjectStatusIcon
category={object.statusCategory}
liveness={object.liveness}
statusIconKey={statusIconKey}
sizeClassName="h-3 w-3"
label={`${providerLabel}: ${statusLabel}`}
/>
<span className="max-w-[16rem] truncate font-medium">{valueLabel}</span>
</>
);
const countSuffix = typeof sourceCount === "number" && sourceCount > 1 ? (
<span className="tabular-nums text-[10px] font-medium opacity-80">×{sourceCount}</span>
) : null;
const innerContent = (
<>
{showProviderIcon && ProviderIcon ? (
<ProviderIcon aria-hidden="true" className="h-3 w-3 shrink-0" />
) : null}
<span className="inline-flex min-w-0 items-center gap-1">
{labelText}
</span>
{countSuffix}
</>
);
if (interactive && object.url) {
return (
<a
href={object.url}
target="_blank"
rel="noopener noreferrer"
data-mention-kind="external-object"
data-external-status={object.statusCategory}
data-external-liveness={object.liveness}
className={classNames}
title={titleAttr}
aria-label={ariaLabel}
>
{innerContent}
</a>
);
}
return (
<span
data-mention-kind="external-object"
data-external-status={object.statusCategory}
data-external-liveness={object.liveness}
className={classNames}
title={titleAttr}
aria-label={ariaLabel}
>
{innerContent}
</span>
);
}

View File

@ -0,0 +1,94 @@
import type {
ExternalObjectLivenessState,
ExternalObjectStatusCategory,
} from "@paperclipai/shared";
import { Clock } from "lucide-react";
import {
externalObjectStatusIcon,
externalObjectStatusIconDefault,
} from "../lib/status-colors";
import {
externalObjectCategoryLabel,
externalObjectIconForCategory,
externalObjectIconForKey,
externalObjectLivenessLabel,
} from "../lib/external-objects";
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion";
import { cn } from "../lib/utils";
interface ExternalObjectStatusIconProps {
category: ExternalObjectStatusCategory;
liveness: ExternalObjectLivenessState;
statusIconKey?: string | null;
/** Optional override label used in `aria-label` (e.g. provider-specific copy). */
label?: string | null;
className?: string;
/** Tailwind size class — defaults to `h-3.5 w-3.5` (compact pill use). */
sizeClassName?: string;
/**
* When true the icon renders inline at 12 px with the `mr-1`/`align`
* adjustments used by `MarkdownIssueLink`. Used by the markdown decorator.
*/
inline?: boolean;
}
/**
* Pure presentational icon for an external object's status. Combines:
*
* - A category icon (lucide) from `externalObjectIconForCategory`.
* - Tone classes (text + border) from `externalObjectStatusIcon`.
* - An overlaid clock micro-mark when liveness is `stale`.
* - Reduced-motion-aware spinner for the `running` category.
*
* Never mounts plugin React; the host is the sole renderer of identity glyphs.
*/
export function ExternalObjectStatusIcon({
category,
liveness,
statusIconKey,
label,
className,
sizeClassName = "h-3.5 w-3.5",
inline = false,
}: ExternalObjectStatusIconProps) {
const reducedMotion = usePrefersReducedMotion();
const Icon = externalObjectIconForKey(statusIconKey) ?? externalObjectIconForCategory(category);
const tone = statusIconKey === "git-merge"
? "text-violet-600 border-violet-600 dark:text-violet-400 dark:border-violet-400"
: externalObjectStatusIcon[category] ?? externalObjectStatusIconDefault;
const livenessSuffix = liveness === "fresh" || liveness === "unknown"
? ""
: ` (${externalObjectLivenessLabel(liveness)})`;
const ariaLabel = `${label ?? externalObjectCategoryLabel(category)}${livenessSuffix}`;
// The clock overlay needs a positioned wrapper. Inline mode keeps the icon
// tight to the surrounding text; pill mode expects to size by sizeClassName.
const wrapperBase = inline
? "relative mr-1 inline-flex shrink-0 align-[-0.125em]"
: "relative inline-flex shrink-0";
const iconSize = inline ? "h-3 w-3" : sizeClassName;
const isSpinner = category === "running";
const animateClass = isSpinner && !reducedMotion ? "animate-spin" : "";
return (
<span
role="img"
aria-label={ariaLabel}
className={cn(wrapperBase, className)}
>
<Icon
aria-hidden="true"
className={cn(iconSize, tone.split(" ").filter((c) => c.startsWith("text-")).join(" "), animateClass)}
/>
{liveness === "stale" ? (
<Clock
aria-hidden="true"
className={cn(
"absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full bg-background text-muted-foreground",
inline ? "" : "p-px",
)}
/>
) : null}
</span>
);
}

View File

@ -0,0 +1,83 @@
import type { ExternalObjectSummary } from "@paperclipai/shared";
import {
dominantExternalObjectTone,
externalObjectCategoryLabel,
externalObjectDominantCount,
externalObjectIconForCategory,
externalObjectIconForKey,
} from "../lib/external-objects";
import { externalObjectStatusBadge, externalObjectStatusBadgeDefault } from "../lib/status-colors";
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion";
import { cn } from "../lib/utils";
interface ExternalObjectStatusSummaryProps {
summary: ExternalObjectSummary | null | undefined;
/** Compact mode trims everything down to the icon + count, no label text. */
compact?: boolean;
className?: string;
}
function dominantCategory(summary: ExternalObjectSummary): string {
// Prefer the first object that matches the highestSeverity tone, since the
// server has already ranked them server-side.
const match = summary.objects.find((object) => object.statusTone === summary.highestSeverity);
return match?.statusCategory ?? "unknown";
}
function dominantObject(summary: ExternalObjectSummary) {
return summary.objects.find((object) => object.statusTone === summary.highestSeverity) ?? null;
}
function buildBreakdownTitle(summary: ExternalObjectSummary): string {
const parts: string[] = [];
for (const [category, count] of Object.entries(summary.byStatusCategory)) {
if (!count) continue;
parts.push(`${count} ${externalObjectCategoryLabel(category).toLowerCase()}`);
}
if (summary.staleCount > 0) parts.push(`${summary.staleCount} stale`);
parts.push(`${summary.total} total`);
return `External objects: ${parts.join(", ")}`;
}
/**
* Compact rollup marker used by sidebar projects and issue list rows. Renders
* the dominant severity icon plus a count badge. Hidden when there are zero
* external objects or every object is in a muted tone.
*/
export function ExternalObjectStatusSummary({
summary,
compact,
className,
}: ExternalObjectStatusSummaryProps) {
const reducedMotion = usePrefersReducedMotion();
const tone = dominantExternalObjectTone(summary);
const total = summary?.total ?? 0;
if (!summary || total === 0 || !tone) return null;
const object = dominantObject(summary);
const category = object?.statusCategory ?? dominantCategory(summary);
const Icon = externalObjectIconForKey(object?.statusIconKey) ?? externalObjectIconForCategory(category);
const badgeClass = externalObjectStatusBadge[category] ?? externalObjectStatusBadgeDefault;
const dominantCount = externalObjectDominantCount(summary);
const title = buildBreakdownTitle(summary);
const animateClass = category === "running" && !reducedMotion ? "animate-spin" : "";
return (
<span
role="img"
aria-label={title}
title={title}
className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none",
badgeClass,
compact && "px-1 py-0.5",
className,
)}
data-external-status={category}
data-external-tone={tone}
>
<Icon aria-hidden="true" className={cn("h-3 w-3 shrink-0", animateClass)} />
<span>{dominantCount > 0 ? dominantCount : total}</span>
</span>
);
}

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>
)}

View File

@ -101,7 +101,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { WorkspaceFileMarkdownBody } from "./WorkspaceFileMarkdownBody";
import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor";
import { Identity } from "./Identity";
@ -217,6 +217,7 @@ interface IssueChatMessageContext {
onUploadImage?: (file: File) => Promise<string>;
issueStatus?: string;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
externalReferences?: MarkdownExternalReferenceMap;
}
const IssueChatCtx = createContext<IssueChatMessageContext>({
@ -450,6 +451,7 @@ interface IssueChatThreadProps {
* comment is in the loaded set before we scroll to it.
*/
onRefreshLatestComments?: () => Promise<unknown> | void;
externalReferences?: MarkdownExternalReferenceMap;
}
type IssueChatErrorBoundaryProps = {
@ -457,6 +459,7 @@ type IssueChatErrorBoundaryProps = {
messages: readonly ThreadMessage[];
emptyMessage: string;
variant: "full" | "embedded";
externalReferences?: MarkdownExternalReferenceMap;
children: ReactNode;
};
@ -491,6 +494,7 @@ class IssueChatErrorBoundary extends Component<IssueChatErrorBoundaryProps, Issu
messages={this.props.messages}
emptyMessage={this.props.emptyMessage}
variant={this.props.variant}
externalReferences={this.props.externalReferences}
/>
);
}
@ -555,10 +559,12 @@ function IssueChatFallbackThread({
messages,
emptyMessage,
variant,
externalReferences,
}: {
messages: readonly ThreadMessage[];
emptyMessage: string;
variant: "full" | "embedded";
externalReferences?: MarkdownExternalReferenceMap;
}) {
return (
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
@ -599,7 +605,9 @@ function IssueChatFallbackThread({
</div>
<div className="space-y-2">
{lines.length > 0 ? lines.map((line, index) => (
<MarkdownBody key={`${message.id}:fallback:${index}`}>{line}</MarkdownBody>
<MarkdownBody key={`${message.id}:fallback:${index}`} externalReferences={externalReferences}>
{line}
</MarkdownBody>
)) : (
<p className="text-sm text-muted-foreground">No message content.</p>
)}
@ -705,7 +713,7 @@ function commentDateLabel(date: Date | string | undefined): string {
}
const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed, onAccent }: { text: string; recessed?: boolean; onAccent?: boolean }) {
const { onImageClick } = useContext(IssueChatCtx);
const { onImageClick, externalReferences } = useContext(IssueChatCtx);
if (isSuccessfulRunHandoffComment(text)) {
return <SuccessfulRunHandoffCommentCallout text={text} recessed={recessed} onImageClick={onImageClick} />;
}
@ -717,6 +725,7 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed, onAc
style={recessed ? { opacity: 0.55 } : undefined}
softBreaks
onImageClick={onImageClick}
externalReferences={externalReferences}
>
{text}
</WorkspaceFileMarkdownBody>
@ -2113,6 +2122,7 @@ function ExpiredRequestConfirmationActivity({
onRejectInteraction,
onCancelInteraction,
onUploadImage,
externalReferences,
} = useContext(IssueChatCtx);
const [expanded, setExpanded] = useState(false);
const hasResolvedActor = Boolean(interaction.resolvedByAgentId || interaction.resolvedByUserId);
@ -2193,6 +2203,7 @@ function ExpiredRequestConfirmationActivity({
onRejectInteraction={onRejectInteraction}
onCancelInteraction={onCancelInteraction}
onUploadImage={onUploadImage}
externalReferences={externalReferences}
/>
</div>
) : null}
@ -2620,6 +2631,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
onSubmitInteractionAnswers,
onCancelInteraction,
onUploadImage,
externalReferences,
} = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
@ -2677,6 +2689,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
onUploadImage={onUploadImage}
externalReferences={externalReferences}
/>
</div>
</div>
@ -4094,6 +4107,7 @@ export function IssueChatThread({
assigneeUserId = null,
onResumeFromBacklog,
resumeFromBacklogPending = false,
externalReferences,
}: IssueChatThreadProps) {
const location = useLocation();
const lastScrolledHashRef = useRef<string | null>(null);
@ -4618,6 +4632,7 @@ export function IssueChatThread({
onUploadImage: stableOnUploadImage,
issueStatus,
successfulRunHandoff,
externalReferences,
}),
[
feedbackDataSharingPreference,
@ -4643,6 +4658,7 @@ export function IssueChatThread({
stableOnUploadImage,
issueStatus,
successfulRunHandoff,
externalReferences,
],
);
@ -4680,6 +4696,7 @@ export function IssueChatThread({
messages={messages}
emptyMessage={resolvedEmptyMessage}
variant={variant}
externalReferences={externalReferences}
>
<div data-testid="thread-root">
<div

View File

@ -3,17 +3,19 @@ import type { IssueDocument } from "@paperclipai/shared";
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { cn, relativeTime } from "../lib/utils";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { Check, ChevronDown, ChevronRight, Copy, History } from "lucide-react";
type IssueContinuationHandoffProps = {
document: IssueDocument | null | undefined;
focusSignal?: number;
externalReferences?: MarkdownExternalReferenceMap;
};
export function IssueContinuationHandoff({
document,
focusSignal = 0,
externalReferences,
}: IssueContinuationHandoffProps) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
@ -91,7 +93,11 @@ export function IssueContinuationHandoff({
</div>
{expanded ? (
<div className="mt-3 rounded-md border border-border bg-background/80 p-3">
<MarkdownBody className="paperclip-edit-in-place-content text-sm leading-6" softBreaks={false}>
<MarkdownBody
className="paperclip-edit-in-place-content text-sm leading-6"
softBreaks={false}
externalReferences={externalReferences}
>
{document.body}
</MarkdownBody>
</div>

View File

@ -43,8 +43,22 @@ vi.mock("@/lib/router", () => ({
}));
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children, className }: { children: string; className?: string }) => (
<div className={className}>{children}</div>
MarkdownBody: ({
children,
className,
externalReferences,
}: {
children: string;
className?: string;
externalReferences?: Record<string, unknown>;
}) => (
<div
className={className}
data-testid="markdown-body"
data-external-reference-keys={externalReferences ? Object.keys(externalReferences).join(",") : ""}
>
{children}
</div>
),
}));
@ -684,6 +698,61 @@ describe("IssueDocumentsSection", () => {
queryClient.clear();
});
it("forwards externalReferences to the rendered document body so URL decoration applies", async () => {
const issue = createIssue();
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
mockIssuesApi.listDocuments.mockResolvedValue([
createIssueDocument({
body: "Linked work: https://github.com/example/repo/pull/99",
}),
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={false}
externalReferences={{
"https://github.com/example/repo/pull/99": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "open",
liveness: "fresh",
statusLabel: "Open",
displayTitle: "PR #99",
},
}}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
const markdownBodies = Array.from(
container.querySelectorAll('[data-testid="markdown-body"]'),
) as HTMLElement[];
expect(markdownBodies.length).toBeGreaterThan(0);
const rendered = markdownBodies.find((element) =>
(element.textContent ?? "").includes("Linked work"),
);
expect(rendered?.getAttribute("data-external-reference-keys"))
.toContain("https://github.com/example/repo/pull/99");
await act(async () => {
root.unmount();
});
queryClient.clear();
});
it("wraps the documents header actions so mobile layouts do not overflow", async () => {
const issue = createIssue();
const root = createRoot(container);

View File

@ -20,7 +20,7 @@ import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { FoldCurtain } from "./FoldCurtain";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { Button } from "@/components/ui/button";
@ -75,10 +75,16 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
}
function renderFoldableBody(body: string, className?: string) {
function renderFoldableBody(
body: string,
className?: string,
externalReferences?: MarkdownExternalReferenceMap,
) {
return (
<FoldCurtain>
<MarkdownBody className={className} softBreaks={false}>{body}</MarkdownBody>
<MarkdownBody className={className} softBreaks={false} externalReferences={externalReferences}>
{body}
</MarkdownBody>
</FoldCurtain>
);
}
@ -161,6 +167,7 @@ export function IssueDocumentsSection({
defaultAnnotationPanelOpenKeys,
defaultAnnotationFocusedThreadIds,
forceEditDocumentKey,
externalReferences,
}: {
issue: Issue;
canDeleteDocuments: boolean;
@ -187,6 +194,7 @@ export function IssueDocumentsSection({
defaultAnnotationFocusedThreadIds?: Readonly<Record<string, string>>;
/** Force a doc into edit mode on mount (Storybook-only). */
forceEditDocumentKey?: string | null;
externalReferences?: MarkdownExternalReferenceMap;
}) {
const queryClient = useQueryClient();
const location = useLocation();
@ -755,6 +763,7 @@ export function IssueDocumentsSection({
}, [autosaveState, commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
const documentBodyShellClassName = "mt-3";
const documentBodyPaddingClassName = "";
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
const toggleFoldedDocument = (key: string) => {
setFoldedDocumentKeys((current) =>
@ -878,7 +887,9 @@ export function IssueDocumentsSection({
PLAN
</span>
</div>
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
<div className={documentBodyPaddingClassName}>
{renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName, externalReferences)}
</div>
</div>
) : null}
@ -1197,7 +1208,7 @@ export function IssueDocumentsSection({
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
) : null}
{renderFoldableBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
{renderFoldableBody(activeConflict.serverDocument.body, "text-[14px] leading-7", externalReferences)}
</div>
)}
</div>
@ -1235,7 +1246,7 @@ export function IssueDocumentsSection({
defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]}
>
{isHistoricalPreview ? (
renderFoldableBody(displayedBody, documentBodyContentClassName)
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences)
) : activeDraft ? (
<MarkdownEditor
value={displayedBody}
@ -1257,7 +1268,7 @@ export function IssueDocumentsSection({
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
/>
) : (
renderFoldableBody(displayedBody, documentBodyContentClassName)
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences)
)}
</IssueDocumentAnnotations>
</div>

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 = {
@ -55,6 +59,7 @@ export function IssueFiltersPopover({
projects,
labels,
currentUserId,
enableExternalObjectFilters = true,
enableRoutineVisibilityFilter = false,
buttonVariant = "ghost",
iconOnly = false,
@ -68,6 +73,7 @@ export function IssueFiltersPopover({
projects?: ProjectOption[];
labels?: LabelOption[];
currentUserId?: string | null;
enableExternalObjectFilters?: boolean;
enableRoutineVisibilityFilter?: boolean;
buttonVariant?: "ghost" | "outline";
iconOnly?: boolean;
@ -344,6 +350,39 @@ export function IssueFiltersPopover({
</div>
) : null}
{enableExternalObjectFilters ? (
<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>
) : null}
<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">

View File

@ -1797,4 +1797,81 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("renders each external object as its own properties row using display metadata", async () => {
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
externalObjects: [
{
mentionCount: 1,
sourceLabels: ["Description"],
pill: {
providerKey: "github",
objectType: "pull_request",
displayKey: null,
iconKey: "github",
statusCategory: "succeeded",
statusIconKey: null,
statusLabel: "Merged",
liveness: "fresh",
displayTitle: "acme/web#241: Add rich object presentation metadata",
url: "https://github.com/acme/web/pull/241",
},
group: {
object: null,
mentions: [],
mentionCount: 1,
sourceLabels: ["Description"],
},
},
{
mentionCount: 1,
sourceLabels: ["Comment"],
pill: {
providerKey: "github",
objectType: "issue",
displayKey: "Github Issue",
iconKey: "github",
statusCategory: "open",
statusIconKey: "circle-dot",
statusLabel: "Open",
liveness: "fresh",
displayTitle: "acme/web#12: Follow-up",
url: "https://github.com/acme/web/issues/12",
},
group: {
object: null,
mentions: [],
mentionCount: 1,
sourceLabels: ["Comment"],
},
},
],
});
await flush();
expect(container.textContent).toContain("Github Pull Request");
expect(container.textContent).toContain("Github Issue");
expect(container.textContent).toContain("PR 241 - Merged");
expect(container.textContent).toContain("Merged");
expect(container.textContent).toContain("Open");
expect(container.textContent).not.toContain("External objects");
const label = Array.from(container.querySelectorAll("span"))
.find((span) => span.textContent === "Github Pull Request");
expect(label?.querySelector("svg")).toBeTruthy();
const pullRequestLink = Array.from(container.querySelectorAll("a"))
.find((anchor) => anchor.getAttribute("href") === "https://github.com/acme/web/pull/241");
expect(pullRequestLink?.textContent).toContain("PR 241 - Merged");
expect(pullRequestLink?.textContent).not.toContain("acme/web#241");
expect(pullRequestLink?.textContent).not.toContain("Github Pull Request");
expect(pullRequestLink?.querySelectorAll("svg")).toHaveLength(1);
expect(pullRequestLink?.className).not.toContain("paperclip-mention-chip");
expect(pullRequestLink?.className).not.toContain("rounded-full");
expect(pullRequestLink?.className).not.toContain("border");
act(() => root.unmount());
});
});

View File

@ -35,6 +35,19 @@ import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
import { IssueReferencePill } from "./IssueReferencePill";
import { formatDate, formatDateTime, cn, projectUrl } from "../lib/utils";
import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon";
import type { IssueExternalObjectGroup } from "../hooks/useIssueExternalObjects";
import {
externalObjectCategoryLabel,
externalObjectIconForKey,
externalObjectProviderLabel,
externalObjectToneSeverity,
externalObjectTypeLabel,
} from "../lib/external-objects";
import {
externalObjectStatusIcon,
externalObjectStatusIconDefault,
} from "../lib/status-colors";
import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
@ -153,20 +166,173 @@ 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;
const ISSUE_PROPERTY_RELATION_PREVIEW_COUNT = 5;
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
function PropertyRow({
label,
children,
labelClassName,
}: {
label: React.ReactNode;
children: React.ReactNode;
labelClassName?: string;
}) {
return (
<div className="flex items-start gap-3 py-1.5">
<span className="text-xs text-muted-foreground shrink-0 w-20 mt-0.5">{label}</span>
<span className={cn("text-xs text-muted-foreground shrink-0 w-20 mt-0.5", labelClassName)}>{label}</span>
<div className="flex items-center gap-1.5 min-w-0 flex-1 flex-wrap">{children}</div>
</div>
);
}
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 Pull Request";
if (pill.objectType === "issue") return "Github Issue";
}
return `${externalObjectProviderLabel(pill.providerKey)} ${externalObjectTypeLabel(pill.objectType)}`;
}
function externalObjectRowLabel(group: IssueExternalObjectGroup): React.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">
{Icon ? <Icon aria-hidden="true" className="h-3 w-3 shrink-0 mt-0.5" /> : null}
<span className="whitespace-normal break-words leading-tight">{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 group.pill.statusLabel ?? externalObjectCategoryLabel(group.pill.statusCategory);
}
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 {
if (isMergedExternalObject(group)) {
return "text-violet-600 dark:text-violet-400";
}
const tone = 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, mentionCount } = 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 w-3"
label={`${providerLabel}: ${statusLabel}`}
/>
<span className="min-w-0 truncate">{value}</span>
{mentionCount > 1 ? (
<span className="tabular-nums text-[10px] font-medium opacity-80">x{mentionCount}</span>
) : null}
</>
);
const className = cn(
"inline-flex min-w-0 max-w-full items-center gap-1 text-xs font-medium no-underline",
externalObjectPropertyTone(group),
pill.url ? "hover:underline focus-visible:outline-none focus-visible:ring-[3px] 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>
);
}
const ISSUE_THINKING_EFFORT_OPTIONS = {
claude_local: [
{ value: "", label: "Default" },
@ -414,6 +580,10 @@ export function IssueProperties({
onUpdate,
inline,
hasActiveRun = false,
externalObjects,
externalObjectsLoading,
externalObjectsError,
onRetryExternalObjects,
}: IssuePropertiesProps) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
@ -2168,6 +2338,46 @@ 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 ? (
<>
{sortExternalObjectGroups(externalObjects)
.map((externalObject) => {
const { pill, group } = externalObject;
return (
<PropertyRow
key={group.object?.id ?? `${pill.providerKey}:${pill.objectType}:${pill.url ?? "anon"}`}
label={externalObjectRowLabel(externalObject)}
labelClassName="w-20 max-w-20 whitespace-normal leading-tight"
>
<ExternalObjectPropertyValue group={externalObject} />
</PropertyRow>
);
})}
</>
) : null}
<PropertyPicker
inline={inline}
label="Parent"

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,103 @@ 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,
externalObjectsEnabled = true,
externalObjects,
externalObjectsLoading,
externalObjectsError,
onRetryExternalObjects,
}: {
relatedWork?: IssueRelatedWorkSummary | null;
externalObjectsEnabled?: boolean;
externalObjects?: IssueExternalObjectGroup[];
externalObjectsLoading?: boolean;
externalObjectsError?: boolean;
onRetryExternalObjects?: () => void;
}) {
const outbound = relatedWork?.outbound ?? [];
const inbound = relatedWork?.inbound ?? [];
@ -99,6 +195,14 @@ export function IssueRelatedWorkPanel({
items={outbound}
emptyLabel="This task does not reference any other tasks yet."
/>
{externalObjectsEnabled ? (
<ExternalObjectsSection
groups={externalObjects ?? []}
isLoading={Boolean(externalObjectsLoading)}
isError={Boolean(externalObjectsError)}
onRetry={onRetryExternalObjects}
/>
) : null}
<Section
title="Referenced by"
description="Other tasks that currently point at this task."

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>

View File

@ -21,7 +21,7 @@ import {
type SuggestedTaskTreeNode,
} from "../lib/issue-thread-interactions";
import { cn, formatDateTime, formatShortDate } from "../lib/utils";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { PriorityIcon } from "./PriorityIcon";
@ -58,6 +58,7 @@ interface IssueThreadInteractionCardProps {
interaction: AskUserQuestionsInteraction,
) => Promise<void> | void;
onUploadImage?: (file: File) => Promise<string>;
externalReferences?: MarkdownExternalReferenceMap;
}
function resolveActorLabel(args: {
@ -706,6 +707,7 @@ function AskUserQuestionsCard({
interaction,
onSubmitInteractionAnswers,
onCancelInteraction,
externalReferences,
}: {
interaction: AskUserQuestionsInteraction;
onSubmitInteractionAnswers?: (
@ -715,6 +717,7 @@ function AskUserQuestionsCard({
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise<void> | void;
externalReferences?: MarkdownExternalReferenceMap;
}) {
const [draftAnswers, setDraftAnswers] = useState<Record<string, string[]>>(() =>
Object.fromEntries(
@ -1013,7 +1016,7 @@ function AskUserQuestionsCard({
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700">
Submitted summary
</div>
<MarkdownBody>{interaction.result.summaryMarkdown}</MarkdownBody>
<MarkdownBody externalReferences={externalReferences}>{interaction.result.summaryMarkdown}</MarkdownBody>
</div>
) : null}
</div>
@ -1173,6 +1176,7 @@ function RequestConfirmationCard({
onAcceptInteraction,
onRejectInteraction,
onUploadImage,
externalReferences,
}: {
interaction: RequestConfirmationInteraction;
isPlan?: boolean;
@ -1184,6 +1188,7 @@ function RequestConfirmationCard({
reason?: string,
) => Promise<void> | void;
onUploadImage?: (file: File) => Promise<string>;
externalReferences?: MarkdownExternalReferenceMap;
}) {
const [rejecting, setRejecting] = useState(false);
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
@ -1283,7 +1288,7 @@ function RequestConfirmationCard({
</div>
{interaction.payload.detailsMarkdown ? (
<div className="border-t border-border/60 pt-3 text-sm">
<MarkdownBody>{interaction.payload.detailsMarkdown}</MarkdownBody>
<MarkdownBody externalReferences={externalReferences}>{interaction.payload.detailsMarkdown}</MarkdownBody>
</div>
) : null}
<RequestConfirmationTargetChip
@ -1575,6 +1580,7 @@ function RequestCheckboxConfirmationCard({
interaction,
onAcceptInteraction,
onRejectInteraction,
externalReferences,
}: {
interaction: RequestCheckboxConfirmationInteraction;
onAcceptInteraction?: (
@ -1586,6 +1592,7 @@ function RequestCheckboxConfirmationCard({
interaction: RequestCheckboxConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
externalReferences?: MarkdownExternalReferenceMap;
}) {
const options = interaction.payload.options;
const optionIds = useMemo(() => options.map((option) => option.id), [options]);
@ -1721,7 +1728,7 @@ function RequestCheckboxConfirmationCard({
<div className="text-sm leading-6 text-foreground">{interaction.payload.prompt}</div>
{interaction.payload.detailsMarkdown ? (
<div className="border-t border-border/60 pt-3 text-sm">
<MarkdownBody>{interaction.payload.detailsMarkdown}</MarkdownBody>
<MarkdownBody externalReferences={externalReferences}>{interaction.payload.detailsMarkdown}</MarkdownBody>
</div>
) : null}
<RequestConfirmationTargetChip
@ -1881,6 +1888,7 @@ export function IssueThreadInteractionCard({
onSubmitInteractionAnswers,
onCancelInteraction,
onUploadImage,
externalReferences,
}: IssueThreadInteractionCardProps) {
const isPlan = isPlanConfirmation(interaction);
const planStyles = isPlan ? planStatusClasses(interaction.status) : null;
@ -1973,12 +1981,14 @@ export function IssueThreadInteractionCard({
interaction={interaction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
externalReferences={externalReferences}
/>
) : interaction.kind === "request_checkbox_confirmation" ? (
<RequestCheckboxConfirmationCard
interaction={interaction}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
externalReferences={externalReferences}
/>
) : (
<RequestConfirmationCard
@ -1987,6 +1997,7 @@ export function IssueThreadInteractionCard({
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onUploadImage={onUploadImage}
externalReferences={externalReferences}
/>
)}
</div>

View File

@ -42,6 +42,10 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
const mockExternalObjectsApi = vi.hoisted(() => ({
getIssueSummaries: vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => companyState,
}));
@ -87,6 +91,10 @@ vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../api/externalObjects", () => ({
externalObjectsApi: mockExternalObjectsApi,
}));
vi.mock("./IssueRow", () => ({
IssueRow: ({
issue,
@ -97,6 +105,7 @@ vi.mock("./IssueRow", () => ({
checklistCurrentStep,
checklistDependencyChips,
checklistRowId,
externalObjectSummary,
}: {
issue: Issue;
desktopMetaLeading?: ReactNode;
@ -106,6 +115,7 @@ vi.mock("./IssueRow", () => ({
checklistCurrentStep?: boolean;
checklistDependencyChips?: ReactNode;
checklistRowId?: string;
externalObjectSummary?: { total: number } | null;
}) => (
<div
data-testid="issue-row"
@ -115,6 +125,9 @@ vi.mock("./IssueRow", () => ({
data-title-class={titleClassName ?? undefined}
>
<span>{issue.title}</span>
{externalObjectSummary ? (
<span data-testid="external-object-summary">{externalObjectSummary.total}</span>
) : null}
{desktopMetaLeading}
{desktopTrailing}
{checklistDependencyChips}
@ -286,6 +299,7 @@ describe("IssuesList", () => {
mockExecutionWorkspacesApi.list.mockReset();
mockExecutionWorkspacesApi.listSummaries.mockReset();
mockInstanceSettingsApi.getExperimental.mockReset();
mockExternalObjectsApi.getIssueSummaries.mockReset();
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
@ -293,8 +307,12 @@ describe("IssuesList", () => {
mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] });
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableExternalObjects: false,
});
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 });
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: {} });
localStorage.clear();
});
@ -303,6 +321,130 @@ describe("IssuesList", () => {
container.remove();
});
it("forwards external-object summaries into issue rows", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableExternalObjects: true,
});
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({
summaries: {
"issue-1": {
total: 2,
byStatusCategory: { failed: 1, succeeded: 1 },
byLiveness: { fresh: 2 },
highestSeverity: "danger",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
},
},
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[createIssue()]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith("company-1", ["issue-1"]);
expect(container.querySelector("[data-testid='external-object-summary']")?.textContent).toBe("2");
});
act(() => {
root.unmount();
});
});
it("does not load external-object summaries when the experimental flag is disabled", async () => {
const { root } = renderWithQueryClient(
<IssuesList
issues={[createIssue()]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(mockInstanceSettingsApi.getExperimental).toHaveBeenCalled();
expect(container.querySelector("[data-testid='issue-row']")).not.toBeNull();
});
expect(mockExternalObjectsApi.getIssueSummaries).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it("filters issue rows by external-object status summaries", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableExternalObjects: true,
});
const failedIssue = createIssue({ id: "issue-failed", identifier: "PAP-10", title: "Failed external object" });
const freshIssue = createIssue({ id: "issue-fresh", identifier: "PAP-11", title: "Fresh external object" });
const noObjectIssue = createIssue({ id: "issue-none", identifier: "PAP-12", title: "No external object" });
localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] }));
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({
summaries: {
"issue-failed": {
total: 1,
byStatusCategory: { failed: 1 },
byLiveness: { fresh: 1 },
highestSeverity: "danger",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
},
"issue-fresh": {
total: 1,
byStatusCategory: { succeeded: 1 },
byLiveness: { fresh: 1 },
highestSeverity: "success",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
},
},
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[failedIssue, freshIssue, noObjectIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith(
"company-1",
["issue-failed", "issue-fresh", "issue-none"],
);
expect(container.textContent).toContain("Failed external object");
expect(container.textContent).not.toContain("Fresh external object");
expect(container.textContent).not.toContain("No external object");
});
act(() => {
root.unmount();
});
});
it("renders server search results instead of filtering the full issue list locally", async () => {
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" });

View File

@ -9,6 +9,7 @@ import { issuesApi } from "../api/issues";
import { authApi } from "../api/auth";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects";
import {
shouldBlurPageSearchOnEnter,
shouldBlurPageSearchOnEscape,
@ -642,7 +643,9 @@ export function IssuesList({
retry: false,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const experimentalSettingsLoaded = experimentalSettings !== undefined;
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true;
// Scope the storage key per company so folding/view state is independent across companies.
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
@ -692,6 +695,11 @@ export function IssuesList({
});
}, [scopedKey]);
useEffect(() => {
if (!experimentalSettingsLoaded || externalObjectsEnabled || viewState.externalObjectStatuses.length === 0) return;
updateView({ externalObjectStatuses: [] });
}, [experimentalSettingsLoaded, externalObjectsEnabled, updateView, viewState.externalObjectStatuses.length]);
// Prune stale IDs from collapsedParents whenever the issue list changes.
// Deleted or reassigned issues leave orphan IDs in localStorage; this keeps
// the stored array bounded to only current parent IDs.
@ -967,32 +975,58 @@ export function IssuesList({
[boardIssueQueries, searchWithinLoadedIssues, viewState.viewMode],
);
const filtered = useMemo(() => {
const sourceIssues = useMemo(() => {
const useRemoteSearch = normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues;
const sourceIssues = boardIssues ?? (useRemoteSearch ? searchedIssues : issues);
const searchScopedIssues = normalizedIssueSearch.length > 0 && searchWithinLoadedIssues
return boardIssues ?? (useRemoteSearch ? searchedIssues : issues);
}, [boardIssues, issues, normalizedIssueSearch, searchedIssues, searchWithinLoadedIssues]);
const searchScopedIssues = useMemo(
() => normalizedIssueSearch.length > 0 && searchWithinLoadedIssues
? sourceIssues.filter((issue) => issueMatchesLocalSearch(issue, normalizedIssueSearch))
: sourceIssues;
: sourceIssues,
[normalizedIssueSearch, searchWithinLoadedIssues, sourceIssues],
);
const hasExternalObjectStatusFilters = viewState.externalObjectStatuses.length > 0;
const issueIdsForExternalObjectSummaries = useMemo(
() => (viewState.viewMode === "list" || hasExternalObjectStatusFilters
? searchScopedIssues.map((issue) => issue.id)
: []),
[hasExternalObjectStatusFilters, searchScopedIssues, viewState.viewMode],
);
const {
summaries: externalObjectSummaryByIssueId,
isLoading: externalObjectSummariesLoading,
isReady: externalObjectSummariesReady,
} = useIssueExternalObjectSummaries(
selectedCompanyId,
issueIdsForExternalObjectSummaries,
);
const issueFilterContext = useMemo(() => ({
...issueFilterWorkspaceContext,
externalObjectSummaryByIssueId,
externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading,
}), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId, issueFilterWorkspaceContext]);
const externalObjectFilterLoading = hasExternalObjectStatusFilters
&& externalObjectSummariesLoading
&& !externalObjectSummariesReady;
const filtered = useMemo(() => {
const filteredByControls = applyIssueFilters(
searchScopedIssues,
viewState,
currentUserId,
enableRoutineVisibilityFilter,
liveIssueIds,
issueFilterWorkspaceContext,
issueFilterContext,
);
return sortIssues(filteredByControls, viewState);
}, [
boardIssues,
issues,
searchedIssues,
searchWithinLoadedIssues,
searchScopedIssues,
viewState,
normalizedIssueSearch,
currentUserId,
enableRoutineVisibilityFilter,
liveIssueIds,
issueFilterWorkspaceContext,
issueFilterContext,
]);
const progressSummary = useMemo(
@ -1479,6 +1513,7 @@ export function IssuesList({
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
enableExternalObjectFilters={externalObjectsEnabled}
enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
iconOnly
workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined}
@ -1565,7 +1600,7 @@ export function IssuesList({
</div>
</div>
{isLoading && <PageSkeleton variant="issues-list" />}
{(isLoading || externalObjectFilterLoading) && <PageSkeleton variant="issues-list" />}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{!searchWithinLoadedIssues && normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (
<p className="text-xs text-muted-foreground">
@ -1577,7 +1612,7 @@ export function IssuesList({
Some board columns are showing up to {ISSUE_BOARD_COLUMN_RESULT_LIMIT} tasks. Refine filters or search to reveal the rest.
</p>
)}
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
{!isLoading && !externalObjectFilterLoading && filtered.length === 0 && viewState.viewMode === "list" && (
<EmptyState
icon={CircleDot}
message="No tasks match the current filters or search."
@ -1749,6 +1784,7 @@ export function IssuesList({
checklistDependencyChips={checklistDependencyChips}
checklistRowId={checklistRowId}
titleClassName={doneRowTitleClass}
externalObjectSummary={externalObjectSummaryByIssueId.get(issue.id) ?? null}
titleSuffix={(
<>
{hasChildren && !isExpanded ? (

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,36 @@ 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;
displayKey?: string | null;
iconKey?: string | null;
statusCategory: ExternalObjectStatusCategory;
liveness: ExternalObjectLivenessState;
statusLabel?: string | null;
statusIconKey?: string | null;
displayTitle?: string | null;
}
export type MarkdownExternalReferenceMap = Record<string, MarkdownExternalReference>;
interface MarkdownBodyProps {
children: string;
@ -28,6 +58,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 +108,51 @@ function MarkdownIssueLink({
);
}
function MarkdownExternalLink({
href,
reference,
children,
}: {
href: string;
reference: MarkdownExternalReference;
children: ReactNode;
}) {
const provider = externalObjectProviderLabel(reference.providerKey);
const displayKey = reference.displayKey?.trim() || provider;
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 ?? `${displayKey} ${statusLabel}`,
`${displayKey}${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={`${displayKey} ${statusLabel}${livenessSuffix}: ${reference.displayTitle ?? href}`}
className="paperclip-markdown-external-ref"
>
<ExternalObjectStatusIcon
category={reference.statusCategory}
liveness={reference.liveness}
statusIconKey={reference.statusIconKey}
label={`${displayKey}: ${statusLabel}`}
inline
/>
{children}
</a>
);
}
function loadMermaid() {
if (!mermaidLoaderPromise) {
mermaidLoaderPromise = import("mermaid").then((module) => module.default);
@ -572,6 +653,7 @@ export function MarkdownBody({
enableWikiLinks = false,
wikiLinkRoot,
resolveWikiLinkHref,
externalReferences,
resolveImageSrc,
onImageClick,
linkWorkspaceFileRefs = false,
@ -584,6 +666,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 +797,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 ? (

View File

@ -0,0 +1,94 @@
// @vitest-environment node
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
...props
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: { get: vi.fn() },
}));
function render(children: string, externalReferences?: MarkdownExternalReferenceMap) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MarkdownBody externalReferences={externalReferences}>{children}</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
}
describe("MarkdownBody external object decoration", () => {
const successReference: MarkdownExternalReferenceMap = {
"https://github.com/acme/web/pull/241": {
providerKey: "github",
objectType: "pull_request",
displayKey: "Github Pull Request",
iconKey: "github",
statusCategory: "succeeded",
statusIconKey: "git-merge",
liveness: "fresh",
statusLabel: "Merged",
displayTitle: "Add external refs",
},
};
it("decorates a known URL with the external status icon and metadata attributes", () => {
const html = render("Take a look: https://github.com/acme/web/pull/241", successReference);
expect(html).toContain('class="paperclip-markdown-external-ref"');
expect(html).toContain('data-external-link="resolved"');
expect(html).toContain('data-external-status="succeeded"');
expect(html).toContain('data-external-liveness="fresh"');
expect(html).toContain('aria-label="Github Pull Request Merged: Add external refs"');
expect(html).toContain("github.com/acme/web/pull/241");
});
it("matches a URL even when the user pasted it with different host case or trailing punctuation", () => {
const html = render("see HTTPS://Github.com/acme/web/pull/241#frag.", successReference);
expect(html).toContain('data-external-status="succeeded"');
});
it("renders an unresolved URL as a plain external link with no status affordance", () => {
const html = render("https://random.example.com/path");
expect(html).not.toContain("paperclip-markdown-external-ref");
expect(html).toContain('href="https://random.example.com/path"');
});
it("never decorates a URL that lives inside a fenced or inline code block", () => {
const html = render(
"```\nhttps://github.com/acme/web/pull/241\n```\n\nInline: `https://github.com/acme/web/pull/241`",
successReference,
);
// The fenced/inline literal should still be present as text but the
// decorated anchor should not appear since no `<a>` is created in code.
expect(html).not.toContain('class="paperclip-markdown-external-ref"');
});
it("shows liveness suffix in the aria-label when the object is stale or auth_required", () => {
const html = render("Auth-blocked: https://app.hubspot.com/leads/99", {
"https://app.hubspot.com/leads/99": {
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
statusLabel: "Reconnect",
displayTitle: "Acme deal",
},
});
expect(html).toContain('aria-label="HubSpot Reconnect (Requires auth): Acme deal"');
});
});

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>
);

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { TranscriptEntry } from "../../adapters";
import { MarkdownBody } from "../MarkdownBody";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "../MarkdownBody";
import { cn, formatTokens } from "../../lib/utils";
import {
Check,
@ -31,6 +31,7 @@ interface RunTranscriptViewProps {
emptyMessage?: string;
className?: string;
thinkingClassName?: string;
externalReferences?: MarkdownExternalReferenceMap;
}
type TranscriptBlock =
@ -636,9 +637,11 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
function TranscriptMessageBlock({
block,
density,
externalReferences,
}: {
block: Extract<TranscriptBlock, { type: "message" }>;
density: TranscriptDensity;
externalReferences?: MarkdownExternalReferenceMap;
}) {
const isAssistant = block.role === "assistant";
const compact = density === "compact";
@ -656,6 +659,7 @@ function TranscriptMessageBlock({
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
compact ? "text-xs leading-5 text-foreground/85" : "text-sm",
)}
externalReferences={externalReferences}
>
{block.text}
</MarkdownBody>
@ -676,10 +680,12 @@ function TranscriptThinkingBlock({
block,
density,
className,
externalReferences,
}: {
block: Extract<TranscriptBlock, { type: "thinking" }>;
density: TranscriptDensity;
className?: string;
externalReferences?: MarkdownExternalReferenceMap;
}) {
return (
<MarkdownBody
@ -688,6 +694,7 @@ function TranscriptThinkingBlock({
density === "compact" ? "text-[11px] leading-5" : "text-sm leading-6",
className,
)}
externalReferences={externalReferences}
>
{block.text}
</MarkdownBody>
@ -1090,9 +1097,11 @@ function TranscriptActivityRow({
function TranscriptEventRow({
block,
density,
externalReferences,
}: {
block: Extract<TranscriptBlock, { type: "event" }>;
density: TranscriptDensity;
externalReferences?: MarkdownExternalReferenceMap;
}) {
const compact = density === "compact";
const toneClasses =
@ -1121,6 +1130,7 @@ function TranscriptEventRow({
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 text-sky-700 dark:text-sky-300",
compact ? "text-[11px] leading-5" : "text-xs leading-5",
)}
externalReferences={externalReferences}
>
{block.text}
</MarkdownBody>
@ -1473,6 +1483,7 @@ export function RunTranscriptView({
emptyMessage = "No transcript yet.",
className,
thinkingClassName,
externalReferences,
}: RunTranscriptViewProps) {
const blocks = useMemo(
() => (mode === "raw" ? [] : normalizeTranscript(entries, streaming)),
@ -1504,9 +1515,20 @@ export function RunTranscriptView({
key={`${block.type}-${block.ts}-${index}`}
className={cn(index === visibleBlocks.length - 1 && streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300")}
>
{block.type === "message" && <TranscriptMessageBlock block={block} density={density} />}
{block.type === "message" && (
<TranscriptMessageBlock
block={block}
density={density}
externalReferences={externalReferences}
/>
)}
{block.type === "thinking" && (
<TranscriptThinkingBlock block={block} density={density} className={thinkingClassName} />
<TranscriptThinkingBlock
block={block}
density={density}
className={thinkingClassName}
externalReferences={externalReferences}
/>
)}
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
@ -1518,7 +1540,13 @@ export function RunTranscriptView({
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
)}
{block.type === "activity" && <TranscriptActivityRow block={block} density={density} />}
{block.type === "event" && <TranscriptEventRow block={block} density={density} />}
{block.type === "event" && (
<TranscriptEventRow
block={block}
density={density}
externalReferences={externalReferences}
/>
)}
</div>
))}
</div>

View File

@ -0,0 +1,250 @@
import { useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type {
ExternalObjectMention,
ExternalObjectMentionGroup,
ExternalObjectSummary,
} from "@paperclipai/shared";
import { externalObjectsApi } from "../api/externalObjects";
import { queryKeys } from "../lib/queryKeys";
import { normalizeExternalObjectHref } from "../lib/external-object-href";
import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody";
import type { ExternalObjectPillData } from "../components/ExternalObjectPill";
import { instanceSettingsApi } from "../api/instanceSettings";
export const EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE = 500;
export async function fetchIssueExternalObjectSummariesInBatches(
companyId: string,
issueIds: readonly string[],
) {
const summaries: Record<string, ExternalObjectSummary> = {};
for (let index = 0; index < issueIds.length; index += EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE) {
const batch = issueIds.slice(index, index + EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE);
const response = await externalObjectsApi.getIssueSummaries(companyId, batch);
Object.assign(summaries, response.summaries);
}
return { summaries };
}
/**
* Browser-side mention-source label. Keep in sync with the shared formatter
* without coupling this hook to the server-only URL canonicalization helpers.
*/
function formatMentionSourceLabel(mention: ExternalObjectMention): string {
switch (mention.sourceKind) {
case "title":
return "Title";
case "description":
return "Description";
case "comment":
return "Comment";
case "document":
return mention.documentKey ? `Document: ${mention.documentKey}` : "Document";
case "property":
return mention.propertyKey ? `Property: ${mention.propertyKey}` : "Property";
case "plugin":
return "Plugin";
default:
return "Source";
}
}
export interface IssueExternalObjectGroup {
pill: ExternalObjectPillData;
mentionCount: number;
sourceLabels: string[];
group: ExternalObjectMentionGroup;
}
export interface IssueExternalObjectsResult {
isEnabled: boolean;
groups: IssueExternalObjectGroup[];
/** Lookup map for `MarkdownBody`'s `externalReferences` prop. */
markdownReferences: MarkdownExternalReferenceMap;
isLoading: boolean;
isError: boolean;
refetch: () => void;
}
function useExternalObjectsFeature() {
const query = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
return {
isEnabled: query.data?.enableExternalObjects === true,
isLoaded: query.data !== undefined || query.isError,
};
}
/**
* 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 externalObjectsFeature = useExternalObjectsFeature();
const enabled = externalObjectsFeature.isEnabled && 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(formatMentionSourceLabel)));
return {
group: entry,
mentionCount: entry.mentionCount ?? entry.mentions.length,
sourceLabels,
pill: {
providerKey: object.providerKey,
objectType: object.objectType,
displayKey: object.displayKey,
iconKey: object.iconKey,
statusCategory: object.statusCategory,
liveness: object.liveness,
displayTitle: object.displayTitle,
statusLabel: object.statusLabel,
statusIconKey: object.statusIconKey,
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,
displayKey: object.displayKey,
iconKey: object.iconKey,
statusCategory: object.statusCategory,
liveness: object.liveness,
statusLabel: object.statusLabel,
statusIconKey: object.statusIconKey,
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,
displayKey: object.displayKey,
iconKey: object.iconKey,
statusCategory: object.statusCategory,
liveness: object.liveness,
statusLabel: object.statusLabel,
statusIconKey: object.statusIconKey,
displayTitle: object.displayTitle,
};
}
}
}
return result;
}, [groups]);
const refetch = useCallback(() => {
void query.refetch();
}, [query.refetch]);
return {
isEnabled: externalObjectsFeature.isEnabled,
groups,
markdownReferences,
isLoading: enabled && query.isLoading,
isError: query.isError,
refetch,
};
}
export function useIssueExternalObjectSummary(issueId: string | null | undefined): {
summary: ExternalObjectSummary | null;
isLoading: boolean;
} {
const externalObjectsFeature = useExternalObjectsFeature();
const enabled = externalObjectsFeature.isEnabled && 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 useIssueExternalObjectSummaries(
companyId: string | null | undefined,
issueIds: readonly string[],
): {
summaries: Map<string, ExternalObjectSummary>;
isLoading: boolean;
isReady: boolean;
} {
const externalObjectsFeature = useExternalObjectsFeature();
const normalizedIssueIds = useMemo(
() => [...new Set(issueIds.filter((issueId) => issueId.length > 0))].sort(),
[issueIds],
);
const enabled = externalObjectsFeature.isEnabled && Boolean(companyId) && normalizedIssueIds.length > 0;
const query = useQuery({
queryKey: queryKeys.externalObjects.issueSummaries(companyId ?? "__none__", normalizedIssueIds),
queryFn: () => fetchIssueExternalObjectSummariesInBatches(companyId!, normalizedIssueIds),
enabled,
staleTime: 60_000,
});
const summaries = useMemo(
() => new Map(Object.entries(query.data?.summaries ?? {})),
[query.data?.summaries],
);
return {
summaries,
isLoading: enabled && query.isLoading,
isReady: externalObjectsFeature.isLoaded && (!enabled || query.isSuccess),
};
}
export function useProjectExternalObjectSummary(projectId: string | null | undefined): {
summary: ExternalObjectSummary | null;
isLoading: boolean;
} {
const externalObjectsFeature = useExternalObjectsFeature();
const enabled = externalObjectsFeature.isEnabled && 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,
};
}

View File

@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
const QUERY = "(prefers-reduced-motion: reduce)";
function getInitialValue(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia(QUERY).matches;
}
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(getInitialValue);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const media = window.matchMedia(QUERY);
const handler = (event: MediaQueryListEvent) => setReduced(event.matches);
setReduced(media.matches);
media.addEventListener("change", handler);
return () => media.removeEventListener("change", handler);
}, []);
return reduced;
}

View File

@ -1097,6 +1097,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
white-space: nowrap;
}
/* External object URL decorations follow the same pattern: the host renders
the icon prefix, the link text is left untouched (Postel's Law). */
.paperclip-markdown-external-ref {
display: inline;
white-space: nowrap;
}
.dark .paperclip-markdown a {
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
}
@ -1206,6 +1213,25 @@ span.paperclip-project-mention-chip {
white-space: nowrap;
}
/* Touch target floor: WCAG 2.5.5 expects 24px on coarse pointers. The base
chip is 22px; bump it on touch viewports without disturbing inline metrics. */
@media (pointer: coarse) {
a.paperclip-mention-chip,
a.paperclip-project-mention-chip,
span.paperclip-mention-chip,
span.paperclip-project-mention-chip {
min-height: 24px;
}
}
/* External object pills: variant marker the host renders for known-resolved
external URLs. Inline status icon is rendered by ExternalObjectStatusIcon. */
a.paperclip-mention-chip[data-mention-kind="external-object"],
span.paperclip-mention-chip[data-mention-kind="external-object"] {
/* Inherit base chip styling; status colour comes from inline classes so the
host can vary tone per category without defining a CSS variant per state. */
}
/* When the identifier inside a chip is backtick-wrapped in markdown, strip the
inline-code monospace/gray styling so the pill label reads cleanly. */
.paperclip-markdown a.paperclip-mention-chip code,

View File

@ -0,0 +1,32 @@
/**
* Browser-safe URL key for matching markdown hrefs against the
* `external_objects.sanitizedCanonicalUrl` returned by the host API.
*
* The shared/server canonicalizer is the source of truth for the canonical
* URL string, but it imports from `node:crypto` (it also produces an identity
* hash) and therefore cannot run in the browser. We replicate just the URL
* normalization here:
*
* - protocol must be http or https
* - reject userinfo (`username:password@`)
* - lowercase host
* - drop query string + fragment
* - default empty pathname to `/`
*/
export function normalizeExternalObjectHref(value: string | null | undefined): string | null {
if (!value) return null;
let url: URL;
try {
url = new URL(value.trim());
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.username || url.password) return null;
const scheme = url.protocol === "https:" ? "https" : "http";
const path = url.pathname || "/";
return `${scheme}://${url.host.toLowerCase()}${path}`;
}

View File

@ -0,0 +1,190 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ExternalObjectSummary } from "@paperclipai/shared";
import { externalObjectsApi } from "../api/externalObjects";
import {
EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE,
fetchIssueExternalObjectSummariesInBatches,
} from "../hooks/useIssueExternalObjects";
import {
dominantExternalObjectTone,
externalObjectCategoryLabel,
externalObjectDominantCount,
externalObjectFallbackTone,
externalObjectIconForCategory,
externalObjectIconForKey,
externalObjectLivenessLabel,
externalObjectProviderLabel,
externalObjectToneSeverity,
externalObjectTypeLabel,
sortExternalObjectsBySeverity,
} from "./external-objects";
import { normalizeExternalObjectHref } from "./external-object-href";
vi.mock("../api/externalObjects", () => ({
externalObjectsApi: {
getIssueSummaries: vi.fn(),
},
}));
const emptySummary: ExternalObjectSummary = {
total: 0,
byStatusCategory: {},
byLiveness: {},
highestSeverity: "neutral",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
};
describe("normalizeExternalObjectHref", () => {
it("lowercases the host (case preserving the path) and drops query/fragment", () => {
expect(
normalizeExternalObjectHref("HTTPS://Github.com/Acme/Web/pull/241?token=abc#frag"),
).toBe("https://github.com/Acme/Web/pull/241");
});
it("rejects non-http(s) and userinfo-bearing URLs", () => {
expect(normalizeExternalObjectHref("javascript:alert(1)")).toBeNull();
expect(normalizeExternalObjectHref("ftp://example.com/file")).toBeNull();
expect(normalizeExternalObjectHref("https://user:pass@example.com/")).toBeNull();
expect(normalizeExternalObjectHref(null)).toBeNull();
expect(normalizeExternalObjectHref(undefined)).toBeNull();
expect(normalizeExternalObjectHref("not a url")).toBeNull();
});
it("defaults pathless URLs to /", () => {
expect(normalizeExternalObjectHref("https://example.com")).toBe("https://example.com/");
});
});
describe("fetchIssueExternalObjectSummariesInBatches", () => {
afterEach(() => {
vi.mocked(externalObjectsApi.getIssueSummaries).mockReset();
});
it("chunks bulk summary requests below the server validation cap and merges results", async () => {
const issueIds = Array.from(
{ length: EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE * 2 + 3 },
(_entry, index) => `issue-${index}`,
);
vi.mocked(externalObjectsApi.getIssueSummaries).mockImplementation(async (_companyId, ids) => ({
summaries: Object.fromEntries(ids.map((id) => [id, { ...emptySummary, total: id.endsWith("-0") ? 1 : 0 }])),
}));
const result = await fetchIssueExternalObjectSummariesInBatches("company-1", issueIds);
expect(externalObjectsApi.getIssueSummaries).toHaveBeenCalledTimes(3);
expect(vi.mocked(externalObjectsApi.getIssueSummaries).mock.calls.map((call) => call[1].length)).toEqual([
EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE,
EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE,
3,
]);
expect(Object.keys(result.summaries)).toHaveLength(issueIds.length);
expect(result.summaries["issue-0"]?.total).toBe(1);
});
});
describe("external-objects helpers", () => {
it("labels categories with copy from the UX spec", () => {
expect(externalObjectCategoryLabel("auth_required")).toBe("Authorization required");
expect(externalObjectCategoryLabel("succeeded")).toBe("Succeeded");
expect(externalObjectCategoryLabel("unknown")).toBe("Not yet resolved");
});
it("labels liveness states with non-tech copy", () => {
expect(externalObjectLivenessLabel("stale")).toBe("Stale");
expect(externalObjectLivenessLabel("auth_required")).toBe("Requires auth");
expect(externalObjectLivenessLabel("fresh")).toBe("Fresh");
});
it("falls back to a humanised label for unknown providers and types", () => {
expect(externalObjectProviderLabel("github")).toBe("GitHub");
expect(externalObjectProviderLabel("hubspot_marketing")).toBe("Hubspot Marketing");
expect(externalObjectProviderLabel(null)).toBe("External");
expect(externalObjectTypeLabel("workflow_run")).toBe("workflow run");
expect(externalObjectTypeLabel(null)).toBe("object");
});
it("orders tones from danger down to muted", () => {
expect(externalObjectToneSeverity("danger")).toBeGreaterThan(externalObjectToneSeverity("warning"));
expect(externalObjectToneSeverity("warning")).toBeGreaterThan(externalObjectToneSeverity("info"));
expect(externalObjectToneSeverity("info")).toBeGreaterThan(externalObjectToneSeverity("success"));
expect(externalObjectToneSeverity("success")).toBeGreaterThan(externalObjectToneSeverity("muted"));
expect(externalObjectToneSeverity(null)).toBe(0);
expect(externalObjectToneSeverity("nonsense")).toBe(0);
});
it("maps every spec category to a fallback tone", () => {
expect(externalObjectFallbackTone("failed")).toBe("danger");
expect(externalObjectFallbackTone("waiting")).toBe("warning");
expect(externalObjectFallbackTone("running")).toBe("info");
expect(externalObjectFallbackTone("succeeded")).toBe("success");
expect(externalObjectFallbackTone("auth_required")).toBe("warning");
expect(externalObjectFallbackTone("unreachable")).toBe("danger");
});
it("returns the spec lucide icon names for every category", () => {
expect(externalObjectIconForCategory("succeeded").displayName ?? "").toMatch(/CheckCircle2|Check/);
expect(externalObjectIconForCategory("failed").displayName ?? "").toMatch(/XCircle|X/);
expect(externalObjectIconForCategory("auth_required").displayName ?? "").toMatch(/KeyRound|Key/);
expect(externalObjectIconForCategory("unreachable").displayName ?? "").toMatch(/CloudOff|Cloud/);
expect(externalObjectIconForCategory("running").displayName ?? "").toMatch(/Loader2|Loader/);
});
it("maps provider-controlled icon keys through host-owned icons", () => {
expect(externalObjectIconForKey("github")?.displayName ?? "").toMatch(/Github/i);
expect(externalObjectIconForKey("git-pull-request")?.displayName ?? "").toMatch(/GitPullRequest/i);
expect(externalObjectIconForKey("unknown-provider-icon")).toBeNull();
});
it("sorts items by severity first, preserving insertion order within a tone", () => {
const items = [
{ id: "a", statusTone: "info", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "running", liveness: "fresh", isTerminal: false },
{ id: "b", statusTone: "danger", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", liveness: "fresh", isTerminal: false },
{ id: "c", statusTone: "warning", providerKey: "hubspot", objectType: "lead", displayTitle: null, statusCategory: "waiting", liveness: "fresh", isTerminal: false },
{ id: "d", statusTone: "danger", providerKey: "github", objectType: "issue", displayTitle: null, statusCategory: "blocked", liveness: "fresh", isTerminal: false },
] as const;
const sorted = sortExternalObjectsBySeverity(items as never);
expect(sorted.map((item) => item.id)).toEqual(["b", "d", "c", "a"]);
});
it("hides the rollup when every item is in a muted tone", () => {
const summary = {
total: 2,
byStatusCategory: { closed: 2 },
byLiveness: { fresh: 2 },
highestSeverity: "muted" as const,
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [
{ id: "a", providerKey: "x", objectType: "y", displayTitle: null, statusCategory: "closed" as const, statusTone: "muted" as const, liveness: "fresh" as const, isTerminal: true },
{ id: "b", providerKey: "x", objectType: "y", displayTitle: null, statusCategory: "closed" as const, statusTone: "muted" as const, liveness: "fresh" as const, isTerminal: true },
],
};
expect(dominantExternalObjectTone(summary)).toBeNull();
expect(externalObjectDominantCount(summary)).toBe(0);
});
it("counts only the dominant-severity items in the rollup", () => {
const summary = {
total: 5,
byStatusCategory: { failed: 3, succeeded: 2 },
byLiveness: { fresh: 5 },
highestSeverity: "danger" as const,
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [
{ id: "a", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false },
{ id: "b", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false },
{ id: "c", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false },
{ id: "d", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded" as const, statusTone: "success" as const, liveness: "fresh" as const, isTerminal: true },
{ id: "e", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded" as const, statusTone: "success" as const, liveness: "fresh" as const, isTerminal: true },
],
};
expect(dominantExternalObjectTone(summary)).toBe("danger");
expect(externalObjectDominantCount(summary)).toBe(3);
});
});

View File

@ -0,0 +1,236 @@
import {
AlertCircle,
AlertOctagon,
Archive,
CheckCircle2,
Circle,
CircleDashed,
CircleDot,
Clock,
CloudOff,
GitMerge,
Github,
GitPullRequest,
KeyRound,
Loader2,
XCircle,
type LucideIcon,
} from "lucide-react";
import type {
ExternalObjectLivenessState,
ExternalObjectStatusCategory,
ExternalObjectStatusTone,
ExternalObjectSummary,
ExternalObjectSummaryItem,
} from "@paperclipai/shared";
/**
* Lucide icon for each status category. The mapping is host-owned per the
* Phase 1B security review providers never inject inline React.
*/
export const externalObjectCategoryIcon: Record<string, LucideIcon> = {
unknown: CircleDashed,
open: CircleDot,
waiting: Clock,
running: Loader2,
succeeded: CheckCircle2,
failed: XCircle,
blocked: AlertOctagon,
closed: Circle,
archived: Archive,
auth_required: KeyRound,
unreachable: CloudOff,
};
export const externalObjectCategoryIconDefault: LucideIcon = CircleDashed;
export function externalObjectIconForCategory(category: string): LucideIcon {
return externalObjectCategoryIcon[category] ?? externalObjectCategoryIconDefault;
}
const EXTERNAL_OBJECT_ICON_KEYS: Record<string, LucideIcon> = {
archive: Archive,
check: CheckCircle2,
"check-circle": CheckCircle2,
circle: Circle,
"circle-dot": CircleDot,
clock: Clock,
github: Github,
"git-merge": GitMerge,
"git-pull-request": GitPullRequest,
key: KeyRound,
loader: Loader2,
"x-circle": XCircle,
};
export function externalObjectIconForKey(iconKey: string | null | undefined): LucideIcon | null {
if (!iconKey) return null;
return EXTERNAL_OBJECT_ICON_KEYS[iconKey] ?? null;
}
export function externalObjectIconForLiveness(liveness: string): LucideIcon | null {
if (liveness === "auth_required") return KeyRound;
if (liveness === "unreachable") return CloudOff;
return null;
}
const CATEGORY_LABELS: Record<string, string> = {
unknown: "Not yet resolved",
open: "Open",
waiting: "Waiting",
running: "Running",
succeeded: "Succeeded",
failed: "Failed",
blocked: "Blocked",
closed: "Closed",
archived: "Archived",
auth_required: "Authorization required",
unreachable: "Unreachable",
};
export function externalObjectCategoryLabel(category: string): string {
return CATEGORY_LABELS[category] ?? category.replace(/_/g, " ");
}
const LIVENESS_LABELS: Record<string, string> = {
unknown: "Not yet refreshed",
fresh: "Fresh",
stale: "Stale",
auth_required: "Requires auth",
unreachable: "Unreachable",
};
export function externalObjectLivenessLabel(liveness: string): string {
return LIVENESS_LABELS[liveness] ?? liveness.replace(/_/g, " ");
}
/**
* Higher number = more attention-worthy. The rollups in §5 sort by tone first.
* Mirrors `externalObjectStatusToneSeverity` in `status-colors.ts`.
*/
const TONE_SEVERITY: Record<string, number> = {
muted: 0,
neutral: 1,
success: 2,
info: 3,
warning: 4,
danger: 5,
};
export function externalObjectToneSeverity(tone: string | null | undefined): number {
if (!tone) return 0;
return TONE_SEVERITY[tone] ?? 0;
}
const CATEGORY_TONE_FALLBACK: Record<string, ExternalObjectStatusTone> = {
unknown: "muted",
open: "info",
waiting: "warning",
running: "info",
succeeded: "success",
failed: "danger",
blocked: "danger",
closed: "muted",
archived: "muted",
auth_required: "warning",
unreachable: "danger",
};
export function externalObjectFallbackTone(
category: ExternalObjectStatusCategory,
): ExternalObjectStatusTone {
return CATEGORY_TONE_FALLBACK[category] ?? "neutral";
}
const PROVIDER_LABELS: Record<string, string> = {
github: "GitHub",
github_pull_request: "GitHub",
github_issue: "GitHub",
hubspot: "HubSpot",
linear: "Linear",
jira: "Jira",
notion: "Notion",
asana: "Asana",
};
export function externalObjectProviderLabel(providerKey: string | null | undefined): string {
if (!providerKey) return "External";
const lookup = PROVIDER_LABELS[providerKey];
if (lookup) return lookup;
return providerKey
.split(/[._-]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
const OBJECT_TYPE_LABELS: Record<string, string> = {
pull_request: "pull request",
issue: "issue",
deployment: "deployment",
workflow_run: "workflow run",
ticket: "ticket",
lead: "lead",
};
export function externalObjectTypeLabel(objectType: string | null | undefined): string {
if (!objectType) return "object";
return OBJECT_TYPE_LABELS[objectType] ?? objectType.replace(/_/g, " ");
}
/**
* Sort summary items by severity-first ordering: danger warning info
* success muted/neutral. Within a tone, items keep their incoming order so
* server-side ordering (e.g. most recent change first) is preserved.
*/
export function sortExternalObjectsBySeverity<T extends ExternalObjectSummaryItem>(
items: readonly T[],
): T[] {
return [...items]
.map((item, index) => ({ item, index }))
.sort((a, b) => {
const aTone = externalObjectToneSeverity(a.item.statusTone);
const bTone = externalObjectToneSeverity(b.item.statusTone);
if (aTone !== bTone) return bTone - aTone;
return a.index - b.index;
})
.map(({ item }) => item);
}
/**
* Compute the dominant tone in a summary used by sidebar / list rollups.
* Falls back to `null` when no objects are present or every tone is `muted`.
*/
export function dominantExternalObjectTone(
summary: Pick<ExternalObjectSummary, "highestSeverity" | "objects"> | null | undefined,
): ExternalObjectStatusTone | null {
if (!summary) return null;
const tone = summary.highestSeverity;
if (!tone) return null;
if (externalObjectToneSeverity(tone) <= TONE_SEVERITY.muted) return null;
return tone;
}
/**
* For the sidebar / list rollup we want the count of objects matching the
* dominant severity (e.g. "3 failed PRs"), not the global total. Returns 0
* whenever the dominant tone is muted so callers can render based on the
* count without double-checking the rollup-hide rule.
*/
export function externalObjectDominantCount(
summary: Pick<ExternalObjectSummary, "highestSeverity" | "objects"> | null | undefined,
): number {
if (!summary) return 0;
const tone = dominantExternalObjectTone(summary);
if (!tone) return 0;
return summary.objects.filter((object) => object.statusTone === tone).length;
}
/**
* Reduced motion support match `prefers-reduced-motion: reduce` so the
* spinning Loader2 stays static when requested. Hooks consume this via React
* to react to runtime changes; non-hook callers can use the helper directly.
*/
export function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

View File

@ -5,6 +5,7 @@ import type {
Approval,
DashboardSummary,
ExecutionWorkspace,
ExternalObjectSummary,
HeartbeatRun,
Issue,
JoinRequest,
@ -909,6 +910,7 @@ describe("inbox helpers", () => {
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: [],
hideRoutineExecutions: true,
},
}).map((issue) => issue.id),
@ -929,6 +931,7 @@ describe("inbox helpers", () => {
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: [],
hideRoutineExecutions: true,
},
}),
@ -949,12 +952,65 @@ describe("inbox helpers", () => {
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: [],
hideRoutineExecutions: true,
},
}),
).toEqual([]);
});
it("applies external-object filters to remote inbox search supplements", () => {
const failedMatch = makeIssue("failed-match", false);
const freshMatch = makeIssue("fresh-match", false);
const summaries = new Map<string, ExternalObjectSummary>([
["failed-match", {
total: 1,
byStatusCategory: { failed: 1 },
byLiveness: { fresh: 1 },
highestSeverity: "danger",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
}],
["fresh-match", {
total: 1,
byStatusCategory: { succeeded: 1 },
byLiveness: { fresh: 1 },
highestSeverity: "success",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
}],
]);
expect(
getInboxSearchSupplementIssues({
query: "github",
filteredWorkItems: [],
archivedSearchIssues: [],
remoteIssues: [failedMatch, freshMatch],
issueFilters: {
statuses: [],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: ["failed"],
hideRoutineExecutions: true,
},
issueFilterContext: {
externalObjectSummaryByIssueId: summaries,
externalObjectSummariesReady: true,
},
}).map((issue) => issue.id),
).toEqual(["failed-match"]);
});
it("keeps inbox search matches ahead of archived and other result sections", () => {
const inboxIssue = makeIssue("inbox", false);
inboxIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
@ -1021,6 +1077,7 @@ describe("inbox helpers", () => {
projects: ["project-1"],
workspaces: ["workspace-1"],
liveOnly: true,
externalObjectStatuses: [],
hideRoutineExecutions: false,
},
});
@ -1036,6 +1093,7 @@ describe("inbox helpers", () => {
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: [],
hideRoutineExecutions: true,
},
});
@ -1052,6 +1110,7 @@ describe("inbox helpers", () => {
projects: ["project-1"],
workspaces: ["workspace-1"],
liveOnly: true,
externalObjectStatuses: [],
hideRoutineExecutions: false,
},
});
@ -1067,6 +1126,7 @@ describe("inbox helpers", () => {
projects: [],
workspaces: [],
liveOnly: false,
externalObjectStatuses: [],
hideRoutineExecutions: true,
},
});
@ -1101,6 +1161,7 @@ describe("inbox helpers", () => {
projects: ["project-1"],
workspaces: ["workspace-1"],
liveOnly: false,
externalObjectStatuses: [],
hideRoutineExecutions: false,
},
});

View File

@ -11,6 +11,7 @@ import {
defaultIssueFilterState,
normalizeIssueFilterState,
type IssueFilterState,
type IssueFilterWorkspaceContext,
} from "./issue-filters";
import { formatAssigneeUserLabel } from "./assignees";
@ -462,6 +463,7 @@ export function getInboxSearchSupplementIssues({
currentUserId,
enableRoutineVisibilityFilter = false,
liveIssueIds,
issueFilterContext = {},
}: {
query: string;
filteredWorkItems: InboxWorkItem[];
@ -471,6 +473,7 @@ export function getInboxSearchSupplementIssues({
currentUserId?: string | null;
enableRoutineVisibilityFilter?: boolean;
liveIssueIds?: ReadonlySet<string>;
issueFilterContext?: IssueFilterWorkspaceContext;
}): Issue[] {
const normalizedQuery = query.trim();
if (!normalizedQuery) return [];
@ -480,7 +483,14 @@ export function getInboxSearchSupplementIssues({
.map((item) => item.issue.id),
...archivedSearchIssues.map((issue) => issue.id),
]);
return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter, liveIssueIds)
return applyIssueFilters(
remoteIssues,
issueFilters,
currentUserId,
enableRoutineVisibilityFilter,
liveIssueIds,
issueFilterContext,
)
.filter((issue) => !visibleIssueIds.has(issue.id));
}

View File

@ -1,7 +1,7 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import type { ExternalObjectSummary, Issue } from "@paperclipai/shared";
import {
applyIssueFilters,
countActiveIssueFilters,
@ -51,6 +51,20 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
};
}
function makeExternalObjectSummary(overrides: Partial<ExternalObjectSummary> = {}): ExternalObjectSummary {
return {
total: 1,
byStatusCategory: {},
byLiveness: { fresh: 1 },
highestSeverity: "neutral",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
...overrides,
};
}
describe("issue filters", () => {
it("filters issues by creator across agents and users", () => {
const issues = [
@ -169,4 +183,62 @@ describe("issue filters", () => {
new Set(["workspace-default"]),
)).toBe(true);
});
it.each([
["failed", ["failed-issue", "blocked-issue"]],
["auth_required", ["auth-issue"]],
["stale", ["stale-issue"]],
["none", ["none-issue"]],
])("filters issues by external-object status token %s", (externalObjectStatus, expectedIssueIds) => {
const issues = [
makeIssue({ id: "failed-issue" }),
makeIssue({ id: "blocked-issue" }),
makeIssue({ id: "auth-issue" }),
makeIssue({ id: "stale-issue" }),
makeIssue({ id: "none-issue" }),
makeIssue({ id: "fresh-issue" }),
];
const summaries = new Map<string, ExternalObjectSummary>([
["failed-issue", makeExternalObjectSummary({ byStatusCategory: { failed: 1 }, highestSeverity: "danger" })],
["blocked-issue", makeExternalObjectSummary({ byStatusCategory: { blocked: 1 }, highestSeverity: "danger" })],
["auth-issue", makeExternalObjectSummary({
byLiveness: { auth_required: 1 },
highestSeverity: "danger",
authRequiredCount: 1,
})],
["stale-issue", makeExternalObjectSummary({
byLiveness: { stale: 1 },
highestSeverity: "warning",
staleCount: 1,
})],
["fresh-issue", makeExternalObjectSummary({ byStatusCategory: { succeeded: 1 }, highestSeverity: "success" })],
]);
const filtered = applyIssueFilters(
issues,
{ ...defaultIssueFilterState, externalObjectStatuses: [externalObjectStatus] },
null,
false,
undefined,
{
externalObjectSummaryByIssueId: summaries,
externalObjectSummariesReady: true,
},
);
expect(filtered.map((issue) => issue.id)).toEqual(expectedIssueIds);
});
it("does not apply external-object filters before summaries are ready", () => {
const filtered = applyIssueFilters(
[makeIssue({ id: "issue-1" })],
{ ...defaultIssueFilterState, externalObjectStatuses: ["none"] },
null,
false,
undefined,
{ externalObjectSummaryByIssueId: new Map(), externalObjectSummariesReady: false },
);
expect(filtered).toEqual([]);
});
});

View File

@ -1,4 +1,4 @@
import type { Issue } from "@paperclipai/shared";
import type { ExternalObjectSummary, Issue } from "@paperclipai/shared";
export type IssueFilterWorkspaceLookup = {
mode?: string | null;
@ -8,6 +8,8 @@ export type IssueFilterWorkspaceLookup = {
export type IssueFilterWorkspaceContext = {
executionWorkspaceById?: ReadonlyMap<string, IssueFilterWorkspaceLookup>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
externalObjectSummaryByIssueId?: ReadonlyMap<string, ExternalObjectSummary>;
externalObjectSummariesReady?: boolean;
};
export type IssueFilterState = {
@ -19,6 +21,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 +48,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 +114,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,
};
}
@ -118,6 +161,39 @@ export function shouldIncludeIssueFilterWorkspaceOption(
&& defaultProjectWorkspaceIds.has(workspace.projectWorkspaceId));
}
function summaryRecordCount(record: Record<string, number> | undefined, key: string): number {
return record?.[key] ?? 0;
}
function issueMatchesExternalObjectStatusFilter(
summary: ExternalObjectSummary | null | undefined,
value: string,
): boolean {
const total = summary?.total ?? 0;
switch (value) {
case "failed":
return summaryRecordCount(summary?.byStatusCategory, "failed") > 0
|| summaryRecordCount(summary?.byStatusCategory, "blocked") > 0;
case "waiting":
return summaryRecordCount(summary?.byStatusCategory, "waiting") > 0;
case "running":
return summaryRecordCount(summary?.byStatusCategory, "running") > 0;
case "auth_required":
return (summary?.authRequiredCount ?? 0) > 0
|| summaryRecordCount(summary?.byLiveness, "auth_required") > 0;
case "unreachable":
return (summary?.unreachableCount ?? 0) > 0
|| summaryRecordCount(summary?.byLiveness, "unreachable") > 0;
case "stale":
return (summary?.staleCount ?? 0) > 0
|| summaryRecordCount(summary?.byLiveness, "stale") > 0;
case "none":
return total === 0;
default:
return false;
}
}
export function applyIssueFilters(
issues: Issue[],
state: IssueFilterState,
@ -166,6 +242,16 @@ export function applyIssueFilters(
return workspaceId != null && state.workspaces.includes(workspaceId);
});
}
if (state.externalObjectStatuses.length > 0) {
const summaries = workspaceContext.externalObjectSummaryByIssueId;
if (!summaries || workspaceContext.externalObjectSummariesReady !== true) return [];
result = result.filter((issue) => {
const summary = summaries.get(issue.id) ?? null;
return state.externalObjectStatuses.some((status) =>
issueMatchesExternalObjectStatusFilter(summary, status),
);
});
}
return result;
}
@ -182,6 +268,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;
}

View File

@ -139,6 +139,13 @@ 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,
issueSummaries: (companyId: string, issueIds: readonly string[]) =>
["external-objects", "issue-summaries", companyId, issueIds] as const,
projectSummary: (projectId: string) => ["external-objects", "project-summary", projectId] as const,
},
goals: {
list: (companyId: string) => ["goals", companyId] as const,
detail: (id: string) => ["goals", "detail", id] as const,

View File

@ -217,3 +217,86 @@ export const priorityColor: Record<string, string> = {
};
export const priorityColorDefault = "text-yellow-600 dark:text-yellow-400";
// ---------------------------------------------------------------------------
// External object status — colors & severity ranking
// ---------------------------------------------------------------------------
//
// Categories come from `EXTERNAL_OBJECT_STATUS_CATEGORIES` in @paperclipai/shared.
// The map keys here intentionally mirror the union — keep them in sync.
//
// Tone reuse rationale (see UX spec §1):
// unknown → backlog hue (muted, dashed circle)
// open → todo / blue
// waiting → amber (distinct from internal in_progress yellow)
// running → cyan, animated when motion is allowed
// succeeded → done / green
// failed → red
// blocked → red
// closed → muted neutral
// archived → muted neutral
// auth_required → amber + dashed
// unreachable → red + dashed
export const externalObjectStatusIcon: Record<string, string> = {
unknown: "text-muted-foreground border-muted-foreground",
open: "text-blue-600 border-blue-600 dark:text-blue-400 dark:border-blue-400",
waiting: "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400",
running: "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400",
succeeded: "text-green-600 border-green-600 dark:text-green-400 dark:border-green-400",
failed: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400",
blocked: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400",
closed: "text-neutral-500 border-neutral-500",
archived: "text-neutral-500 border-neutral-500",
auth_required: "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400",
unreachable: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400",
};
export const externalObjectStatusIconDefault = "text-muted-foreground border-muted-foreground";
export const externalObjectStatusBadge: Record<string, string> = {
unknown: "bg-muted text-muted-foreground",
open: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
waiting: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
running: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300",
succeeded: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
failed: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
blocked: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
closed: "bg-muted text-muted-foreground",
archived: "bg-muted text-muted-foreground",
auth_required: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
unreachable: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
};
export const externalObjectStatusBadgeDefault = "bg-muted text-muted-foreground";
/**
* Liveness overlay applied on top of the base status tone. We deliberately
* encode it as utility classes (not a tone change) so callers can append the
* overlay to any pill, icon, or marker without redefining colors.
*
* The dashed border + reduced opacity guarantees a non-color differentiator
* for stale / auth_required / unreachable per WCAG 1.4.1.
*/
export const externalObjectLivenessOverlay: Record<string, string> = {
unknown: "",
fresh: "",
stale: "opacity-70 [border-style:dashed]",
auth_required: "[border-style:dashed]",
unreachable: "[border-style:dashed]",
};
/**
* Severity ranking used by sidebar/list rollups. Higher number = more
* attention-worthy. Anything `muted` should be hidden when summarising.
*/
export const externalObjectStatusToneSeverity: Record<string, number> = {
muted: 0,
neutral: 1,
success: 2,
info: 3,
warning: 4,
danger: 5,
};
export const externalObjectStatusToneSeverityDefault = 0;

View File

@ -25,6 +25,7 @@ import { useGeneralSettings } from "../context/GeneralSettingsContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
import { useDialogActions } from "../context/DialogContext";
import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects";
import {
applyIssueFilters,
countActiveIssueFilters,
@ -732,6 +733,7 @@ export function Inbox() {
enabled: !!selectedCompanyId,
});
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true;
const { data: executionWorkspaces = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.executionWorkspaces.summaryList(selectedCompanyId)
@ -863,13 +865,49 @@ export function Inbox() {
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
const shouldUseIssueSearchSupplement =
!!selectedCompanyId
&& normalizedSearchQuery.length > 0;
const { data: remoteIssueSearchResults = [] } = useQuery({
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25),
"inbox-supplement",
],
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
q: normalizedSearchQuery,
limit: 25,
includeRoutineExecutions: true,
}),
enabled: shouldUseIssueSearchSupplement,
placeholderData: (previousData) => previousData,
});
const inboxIssueIdsForExternalObjectSummaries = useMemo(() => {
const issueIds = new Set<string>();
for (const issue of mineIssues) issueIds.add(issue.id);
for (const issue of touchedIssues) issueIds.add(issue.id);
for (const issue of remoteIssueSearchResults) issueIds.add(issue.id);
return [...issueIds];
}, [mineIssues, remoteIssueSearchResults, touchedIssues]);
const {
summaries: externalObjectSummaryByIssueId,
isLoading: externalObjectSummariesLoading,
isReady: externalObjectSummariesReady,
} = useIssueExternalObjectSummaries(
selectedCompanyId,
inboxIssueIdsForExternalObjectSummaries,
);
const issueFilterContext = useMemo(() => ({
externalObjectSummaryByIssueId,
externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading,
}), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId]);
const visibleMineIssues = useMemo(
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds),
[mineIssues, issueFilters, currentUserId, liveIssueIds],
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext),
[mineIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext],
);
const visibleTouchedIssues = useMemo(
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds),
[touchedIssues, issueFilters, currentUserId, liveIssueIds],
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext),
[touchedIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext],
);
const unreadTouchedIssues = useMemo(
() => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe),
@ -1160,23 +1198,6 @@ export function Inbox() {
visibleTouchedIssues,
],
);
const shouldUseIssueSearchSupplement =
!!selectedCompanyId
&& normalizedSearchQuery.length > 0;
const { data: remoteIssueSearchResults = [] } = useQuery({
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25),
"inbox-supplement",
],
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
q: normalizedSearchQuery,
limit: 25,
includeRoutineExecutions: true,
}),
enabled: shouldUseIssueSearchSupplement,
placeholderData: (previousData) => previousData,
});
const issueSearchSupplementResults = useMemo(
() =>
getInboxSearchSupplementIssues({
@ -1188,11 +1209,13 @@ export function Inbox() {
currentUserId,
enableRoutineVisibilityFilter: true,
liveIssueIds,
issueFilterContext,
}),
[
archivedSearchIssues,
currentUserId,
filteredWorkItems,
issueFilterContext,
issueFilters,
liveIssueIds,
normalizedSearchQuery,
@ -1367,6 +1390,15 @@ export function Inbox() {
issueFilters: { ...previous.issueFilters, ...patch },
}));
}, [updateFilterPreferences]);
useEffect(() => {
if (!experimentalSettingsLoaded || externalObjectsEnabled || issueFilters.externalObjectStatuses.length === 0) return;
updateIssueFilters({ externalObjectStatuses: [] });
}, [
experimentalSettingsLoaded,
externalObjectsEnabled,
issueFilters.externalObjectStatuses.length,
updateIssueFilters,
]);
const updateAllCategoryFilter = useCallback((value: InboxCategoryFilter) => {
updateFilterPreferences((previous) => ({ ...previous, allCategoryFilter: value }));
}, [updateFilterPreferences]);
@ -2027,6 +2059,7 @@ export function Inbox() {
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
enableExternalObjectFilters={externalObjectsEnabled}
enableRoutineVisibilityFilter
buttonVariant="outline"
iconOnly
@ -2124,6 +2157,7 @@ export function Inbox() {
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
enableExternalObjectFilters={externalObjectsEnabled}
enableRoutineVisibilityFilter
buttonVariant="outline"
iconOnly
@ -2334,6 +2368,7 @@ export function Inbox() {
key={`issue:${issue.id}`}
issue={issue}
issueLinkState={issueLinkState}
externalObjectSummary={externalObjectSummaryByIssueId.get(issue.id) ?? null}
selected={selected}
className={
isArchiving

View File

@ -53,6 +53,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableConferenceRoomChat: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableTaskWatchdogs: false,
enableCloudSync: false,
autoRestartDevServerWhenIdle: false,

View File

@ -243,6 +243,7 @@ export function InstanceExperimentalSettings() {
experimentalQuery.data?.enableExperimentalFileViewer === true;
const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true;
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
const enableIssueGraphLivenessAutoRecovery =
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
@ -355,6 +356,24 @@ export function InstanceExperimentalSettings() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Enable External Objects</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Detect external URLs in issues and show resolved status for pull requests, tickets, and other referenced
work objects.
</p>
</div>
<ToggleSwitch
checked={enableExternalObjects}
onCheckedChange={() => toggleMutation.mutate({ enableExternalObjects: !enableExternalObjects })}
disabled={toggleMutation.isPending}
aria-label="Toggle external objects experimental setting"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">

View File

@ -957,6 +957,7 @@ describe("IssueDetail", () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
conferenceRoomChatFlag.enabled = true;

View File

@ -79,6 +79,7 @@ import { IssueOutputSection } from "../components/issue-output/IssueOutputSectio
import { isImageAttachment } from "../lib/issue-attachments";
import { getPromotedOutputAttachmentIds } from "../lib/issue-output";
import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody";
import { IssuesList } from "../components/IssuesList";
import { AgentIcon } from "../components/AgentIconPicker";
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
@ -88,6 +89,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";
@ -743,6 +745,7 @@ type IssueDetailChatTabProps = {
assigneeUserId: string | null;
onResumeFromBacklog?: () => Promise<void> | void;
resumeFromBacklogPending?: boolean;
externalReferences?: MarkdownExternalReferenceMap;
};
const IssueDetailChatTab = memo(function IssueDetailChatTab({
@ -804,6 +807,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
assigneeUserId,
onResumeFromBacklog,
resumeFromBacklogPending,
externalReferences,
}: IssueDetailChatTabProps) {
// Conference Room Chat experimental flag (PAP-136/PAP-139): ON renders the
// NUX thread (bubbles, metadata rows, composer chrome); OFF renders the
@ -1028,6 +1032,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onResumeFromBacklog={onResumeFromBacklog}
resumeFromBacklogPending={resumeFromBacklogPending}
footer={footer}
externalReferences={externalReferences}
/>
</div>
);
@ -1048,6 +1053,7 @@ type IssueDetailActivityTabProps = {
onCheckMonitorNow: () => void;
checkingMonitorNow: boolean;
handoffFocusSignal?: number;
externalReferences?: MarkdownExternalReferenceMap;
};
function IssueDetailActivityTab({
@ -1065,6 +1071,7 @@ function IssueDetailActivityTab({
onCheckMonitorNow,
checkingMonitorNow,
handoffFocusSignal = 0,
externalReferences,
}: IssueDetailActivityTabProps) {
const { data: activity, isLoading: activityLoading } = useQuery({
queryKey: queryKeys.issues.activity(issueId),
@ -1266,6 +1273,11 @@ function IssueDetailActivityTab({
}}
/>
</div>
<IssueContinuationHandoff
document={continuationHandoff}
focusSignal={handoffFocusSignal}
externalReferences={externalReferences}
/>
{linkedApprovals && linkedApprovals.length > 0 && (
<div className="mb-3 space-y-3">
{linkedApprovals.map((approval) => (
@ -1286,7 +1298,6 @@ function IssueDetailActivityTab({
))}
</div>
)}
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
<IssueScheduledRetryCard issueId={issue.id} scheduledRetry={issue.scheduledRetry ?? null} />
<IssueMonitorActivityCard
issue={issue}
@ -1355,6 +1366,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 +2871,10 @@ export function IssueDetail() {
onAddSubIssue={openNewSubIssue}
onUpdate={handleIssuePropertiesUpdate}
hasActiveRun={resolvedHasActiveRun}
externalObjects={externalObjectsState.isEnabled ? externalObjectsState.groups : undefined}
externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined}
externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined}
onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined}
/>
);
return () => closePanel();
@ -2871,6 +2887,11 @@ export function IssueDetail() {
panelChildIssues,
panelIssue,
resolvedHasActiveRun,
externalObjectsState.isEnabled,
externalObjectsState.groups,
externalObjectsState.isLoading,
externalObjectsState.isError,
externalObjectsState.refetch,
]);
const goToInboxShortcutArmedRef = useRef(false);
@ -3947,6 +3968,7 @@ export function IssueDetail() {
multiline
foldable
mentions={mentionOptions}
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
@ -4049,6 +4071,7 @@ export function IssueDetail() {
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
mentions={mentionOptions}
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
@ -4240,6 +4263,7 @@ export function IssueDetail() {
resumeFromBacklogPending={
updateIssue.isPending && updateIssue.variables?.status === "todo"
}
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
/>
) : null}
</TabsContent>
@ -4263,12 +4287,20 @@ export function IssueDetail() {
}}
onCheckMonitorNow={() => checkIssueMonitorNow.mutate()}
checkingMonitorNow={checkIssueMonitorNow.isPending}
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
/>
) : null}
</TabsContent>
<TabsContent value="related-work">
<IssueRelatedWorkPanel relatedWork={issue.relatedWork} />
<IssueRelatedWorkPanel
relatedWork={issue.relatedWork}
externalObjectsEnabled={externalObjectsState.isEnabled}
externalObjects={externalObjectsState.isEnabled ? externalObjectsState.groups : undefined}
externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined}
externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined}
onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined}
/>
</TabsContent>
{activePluginTab && (
@ -4456,6 +4488,10 @@ export function IssueDetail() {
onUpdate={(data) => updateIssue.mutate(data)}
inline
hasActiveRun={resolvedHasActiveRun}
externalObjects={externalObjectsState.isEnabled ? externalObjectsState.groups : undefined}
externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined}
externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined}
onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined}
/>
</div>
</ScrollArea>

View File

@ -24,6 +24,11 @@ const config: StorybookConfig = {
alias: {
"@": path.resolve(storybookConfigDir, "../../src"),
lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/Lexical.mjs"),
// Vite's bundled `node:crypto` polyfill omits `createHash`, which
// `@paperclipai/shared/external-objects.ts` imports server-side. Use
// a no-op browser shim so the import resolves; the canonicalizer
// only runs server-side.
"node:crypto": path.resolve(storybookConfigDir, "node-crypto-browser-shim.ts"),
},
},
}),

View File

@ -0,0 +1,31 @@
/**
* Browser-safe shim for the slice of `node:crypto` that
* `@paperclipai/shared/external-objects.ts` imports.
*
* The shared canonicalizer runs server-side and never executes in the browser,
* but the static `import { createHash } from "node:crypto"` is still pulled
* into Storybook's module graph. Vite's built-in `node:crypto` polyfill does
* not export `createHash`, so the build fails before our code runs. This shim
* provides a no-op implementation so the bundler can resolve the import; if a
* code path ever reaches it in the browser we throw loudly so we notice.
*/
class BrowserHash {
update(_value: string): this {
throw new Error(
"createHash from node:crypto is not available in the browser bundle",
);
}
digest(_encoding: string): string {
throw new Error(
"createHash from node:crypto is not available in the browser bundle",
);
}
}
export function createHash(_algorithm: string): BrowserHash {
return new BrowserHash();
}
export default { createHash };

View File

@ -4,6 +4,7 @@ import type { TranscriptEntry } from "@/adapters";
import type { LiveRunForIssue } from "@/api/heartbeats";
import { CommentThread } from "@/components/CommentThread";
import { IssueChatThread } from "@/components/IssueChatThread";
import type { MarkdownExternalReferenceMap } from "@/components/MarkdownBody";
import { RunChatSurface } from "@/components/RunChatSurface";
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
import type { MentionOption } from "@/components/MarkdownEditor";
@ -376,14 +377,14 @@ const liveRunTranscript: TranscriptEntry[] = [
const issueChatComments: IssueChatComment[] = [
createComment({
id: "comment-issue-board",
body: "Please turn the comment thread into a reviewable chat surface. I need to see operator messages, agent output, system events, and live run progress together.",
body: "Please turn the comment thread into a reviewable chat surface. I need to see operator messages, agent output, system events, and live run progress together.\n\nFollow-up tracked in https://github.com/acme/web/pull/241 (merged) and https://github.com/acme/web/pull/243 (review pending).",
createdAt: new Date("2026-04-20T13:44:00.000Z"),
}),
createComment({
id: "comment-issue-agent",
authorAgentId: codexAgent.id,
authorUserId: null,
body: "I kept the existing component contracts and added fixtures with realistic Paperclip work: checkout, comments, linked runs, and review feedback.",
body: "I kept the existing component contracts and added fixtures with realistic Paperclip work: checkout, comments, linked runs, and review feedback.\n\nFlaky CI lives in https://github.com/acme/web/pull/242 — re-running. Plain control link: https://random.example.com/path stays undecorated.",
createdAt: new Date("2026-04-20T13:50:00.000Z"),
runId: "run-issue-chat-01",
runAgentId: codexAgent.id,
@ -592,10 +593,12 @@ function ThreadProps({
comments,
queuedComments = [],
timelineEvents = [],
externalReferences,
}: {
comments: StoryComment[];
queuedComments?: StoryComment[];
timelineEvents?: IssueTimelineEvent[];
externalReferences?: MarkdownExternalReferenceMap;
}) {
return (
<CommentThread
@ -615,10 +618,77 @@ function ThreadProps({
suggestedAssigneeValue={`agent:${codexAgent.id}`}
mentions={mentionOptions}
onInterruptQueued={async () => {}}
externalReferences={externalReferences}
/>
);
}
const externalReferenceComments: StoryComment[] = [
createComment({
id: "comment-external-board",
body: [
"Tracking work that just landed:",
"",
"- Merged PR: https://github.com/acme/web/pull/241",
"- Awaiting review: https://github.com/acme/web/pull/243",
"- Auth-blocked: https://app.hubspot.com/leads/99",
"- Plain control link (no decoration): https://random.example.com/path",
].join("\n"),
createdAt: new Date("2026-04-20T14:02:00.000Z"),
}),
createComment({
id: "comment-external-agent",
authorAgentId: codexAgent.id,
authorUserId: null,
body: [
"Confirmed handoff updated.",
"Failed CI on https://github.com/acme/web/pull/242 needs a rerun.",
"",
"```",
"Code-fenced URLs stay plain: https://github.com/acme/web/pull/241",
"```",
].join("\n"),
createdAt: new Date("2026-04-20T14:05:00.000Z"),
runId: "run-external-01",
runAgentId: codexAgent.id,
}),
];
const externalReferences: MarkdownExternalReferenceMap = {
"https://github.com/acme/web/pull/241": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "succeeded",
liveness: "fresh",
statusLabel: "Merged",
displayTitle: "Add external refs",
},
"https://github.com/acme/web/pull/242": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "failed",
liveness: "stale",
statusLabel: "CI failed",
displayTitle: "Flaky tests",
},
"https://github.com/acme/web/pull/243": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "waiting",
liveness: "fresh",
statusLabel: "Awaiting review",
displayTitle: "Add liveness overlay",
},
"https://app.hubspot.com/leads/99": {
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
statusLabel: "Reconnect",
displayTitle: "Acme deal",
},
};
function CommentThreadMatrix() {
return (
<Section eyebrow="CommentThread" title="Timeline comments across empty, single, long, markdown, and queued states">
@ -635,6 +705,15 @@ function CommentThreadMatrix() {
<ScenarioCard title="Markdown, code, mentions, and links" description="Markdown rendering with code fences, @mentions, links, and a queued reply.">
<ThreadProps comments={markdownComments} queuedComments={[queuedComment]} />
</ScenarioCard>
<ScenarioCard
title="External object decoration"
description="Resolved URLs render with the §2 status chip; an unknown URL stays plain. Code-fenced URLs are not decorated."
>
<ThreadProps
comments={externalReferenceComments}
externalReferences={externalReferences}
/>
</ScenarioCard>
</div>
</Section>
);
@ -709,6 +788,7 @@ function IssueChatMatrix() {
includeSucceededRunsWithoutOutput
onInterruptQueued={async () => {}}
onCancelQueued={() => undefined}
externalReferences={externalReferences}
/>
</div>
<div className="space-y-5">

View File

@ -0,0 +1,720 @@
import { useEffect, useRef, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
EXTERNAL_OBJECT_LIVENESS_STATES,
EXTERNAL_OBJECT_STATUS_CATEGORIES,
type ExternalObject,
type ExternalObjectLivenessState,
type ExternalObjectMention,
type ExternalObjectStatusCategory,
type ExternalObjectSummary,
} from "@paperclipai/shared";
import { ExternalObjectPill } from "@/components/ExternalObjectPill";
import { ExternalObjectStatusIcon } from "@/components/ExternalObjectStatusIcon";
import { ExternalObjectStatusSummary } from "@/components/ExternalObjectStatusSummary";
import { IssueFiltersPopover } from "@/components/IssueFiltersPopover";
import { IssueProperties } from "@/components/IssueProperties";
import { IssueRelatedWorkPanel } from "@/components/IssueRelatedWorkPanel";
import { IssueRow } from "@/components/IssueRow";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "@/components/MarkdownBody";
import {
countActiveIssueFilters,
defaultIssueFilterState,
type IssueFilterState,
} from "@/lib/issue-filters";
import {
externalObjectCategoryLabel,
externalObjectFallbackTone,
externalObjectLivenessLabel,
externalObjectProviderLabel,
externalObjectTypeLabel,
} from "@/lib/external-objects";
import type { IssueExternalObjectGroup } from "@/hooks/useIssueExternalObjects";
import {
storybookAgents,
storybookExecutionWorkspaces,
storybookIssueLabels,
storybookIssues,
storybookProjects,
} from "../fixtures/paperclipData";
function makeObject(args: {
id: string;
providerKey: string;
objectType: string;
statusCategory: ExternalObjectStatusCategory;
liveness: ExternalObjectLivenessState;
displayTitle?: string;
url: string;
statusLabel?: string;
}): ExternalObject {
return {
id: args.id,
companyId: "company-1",
providerKey: args.providerKey,
pluginId: null,
objectType: args.objectType,
externalId: args.id,
sanitizedCanonicalUrl: args.url,
canonicalIdentityHash: args.id,
displayTitle: args.displayTitle ?? null,
statusKey: args.statusCategory,
statusLabel: args.statusLabel ?? externalObjectCategoryLabel(args.statusCategory),
statusCategory: args.statusCategory,
statusTone: externalObjectFallbackTone(args.statusCategory),
liveness: args.liveness,
isTerminal: ["succeeded", "failed", "closed", "archived"].includes(args.statusCategory),
data: {},
remoteVersion: null,
etag: null,
lastResolvedAt: "2026-04-24T22:45:00.000Z",
lastChangedAt: "2026-04-24T22:45:00.000Z",
lastErrorAt: args.liveness === "unreachable" ? "2026-04-24T22:50:00.000Z" : null,
nextRefreshAt: null,
lastErrorCode: null,
lastErrorMessage: null,
createdAt: "2026-04-24T20:00:00.000Z",
updatedAt: "2026-04-24T22:45:00.000Z",
};
}
function makeMention(args: {
id: string;
objectId: string;
sourceKind: ExternalObjectMention["sourceKind"];
documentKey?: string | null;
}): ExternalObjectMention {
return {
id: args.id,
companyId: "company-1",
sourceIssueId: "issue-1",
sourceKind: args.sourceKind,
sourceRecordId: null,
documentKey: args.documentKey ?? null,
propertyKey: null,
matchedTextRedacted: null,
sanitizedDisplayUrl: "https://example.com/object",
canonicalIdentityHash: args.objectId,
canonicalIdentity: null,
objectId: args.objectId,
providerKey: null,
detectorKey: null,
objectType: null,
confidence: "exact",
createdByPluginId: null,
createdAt: "2026-04-24T20:00:00.000Z",
updatedAt: "2026-04-24T22:45:00.000Z",
};
}
function makeGroup(args: {
object: ExternalObject;
mentionCount?: number;
sourceLabels?: string[];
}): IssueExternalObjectGroup {
return {
group: {
object: args.object,
mentions: [makeMention({ id: `${args.object.id}-m`, objectId: args.object.id, sourceKind: "description" })],
mentionCount: args.mentionCount ?? 1,
sourceLabels: args.sourceLabels ?? ["description"],
},
pill: {
providerKey: args.object.providerKey,
objectType: args.object.objectType,
statusCategory: args.object.statusCategory,
liveness: args.object.liveness,
displayTitle: args.object.displayTitle,
statusLabel: args.object.statusLabel,
url: args.object.sanitizedCanonicalUrl,
},
mentionCount: args.mentionCount ?? 1,
sourceLabels: args.sourceLabels ?? ["description"],
};
}
function StateMatrix() {
return (
<div className="paperclip-story space-y-8">
<header className="space-y-2">
<h1 className="text-2xl font-semibold">External object status matrix</h1>
<p className="text-sm text-muted-foreground">
Every status category × liveness combination from the UX spec §6, used as the canonical
presentational reference for inline markdown, pills, properties, and rollups.
</p>
</header>
<table className="w-full table-auto border-collapse text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2">Category</th>
{EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => (
<th key={liveness} className="px-3 py-2">{externalObjectLivenessLabel(liveness)}</th>
))}
</tr>
</thead>
<tbody>
{EXTERNAL_OBJECT_STATUS_CATEGORIES.map((category) => (
<tr key={category} className="border-b border-border">
<td className="px-3 py-3 font-mono text-xs text-muted-foreground">{category}</td>
{EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => (
<td key={liveness} className="px-3 py-3">
<ExternalObjectStatusIcon
category={category}
liveness={liveness}
sizeClassName="h-5 w-5"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
<section className="space-y-3">
<h2 className="text-lg font-medium">Pills (host-rendered identity, no plugin React)</h2>
<div className="flex flex-wrap items-center gap-2">
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
statusCategory: "succeeded",
liveness: "fresh",
displayTitle: "Add external refs",
url: "https://github.com/acme/web/pull/241",
}}
sourceCount={4}
sourceSummary="description, 3 comments"
/>
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
statusCategory: "failed",
liveness: "stale",
displayTitle: "Bad CI run",
url: "https://github.com/acme/web/pull/242",
}}
sourceCount={2}
/>
<ExternalObjectPill
object={{
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
displayTitle: "Acme deal",
url: "https://app.hubspot.com/leads/99",
}}
/>
<ExternalObjectPill
object={{
providerKey: "linear",
objectType: "issue",
statusCategory: "running",
liveness: "fresh",
displayTitle: "Spike: queues",
url: "https://linear.app/acme/issue/INF-44",
}}
/>
<ExternalObjectPill
object={{
providerKey: "ci",
objectType: "deployment",
statusCategory: "unreachable",
liveness: "unreachable",
displayTitle: "deploy prod-0412",
url: "https://ci.example.com/runs/88421",
}}
/>
</div>
</section>
</div>
);
}
function inlineMarkdownStory() {
const references: MarkdownExternalReferenceMap = {
"https://github.com/acme/web/pull/241": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "succeeded",
liveness: "fresh",
statusLabel: "Merged",
displayTitle: "Add external refs",
},
"https://github.com/acme/web/pull/242": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "failed",
liveness: "stale",
statusLabel: "CI failed",
displayTitle: "Flaky tests",
},
"https://github.com/acme/web/pull/243": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "waiting",
liveness: "fresh",
statusLabel: "Awaiting review",
displayTitle: "Add liveness overlay",
},
"https://app.hubspot.com/leads/99": {
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
statusLabel: "Reconnect",
displayTitle: "Acme deal",
},
"https://ci.example.com/runs/88421": {
providerKey: "ci",
objectType: "deployment",
statusCategory: "unreachable",
liveness: "unreachable",
statusLabel: "Unreachable",
displayTitle: "Prod-0412",
},
};
const markdown = `Status of recent integrations:\n\n- Merged PR: https://github.com/acme/web/pull/241\n- Stale failed CI: https://github.com/acme/web/pull/242\n- Awaiting review: https://github.com/acme/web/pull/243\n- Auth required: https://app.hubspot.com/leads/99\n- Unreachable deploy: https://ci.example.com/runs/88421\n- Unrelated control link: https://random.example.com/path\n\n\`\`\`\n# Code blocks must be left alone — https://github.com/acme/web/pull/241\n\`\`\`\n\nInline code stays plain too: \`https://github.com/acme/web/pull/241\`.`;
return (
<div className="paperclip-story space-y-4 text-sm">
<h2 className="text-lg font-medium">Inline markdown decoration</h2>
<MarkdownBody externalReferences={references}>{markdown}</MarkdownBody>
</div>
);
}
function relatedWorkStory() {
const externalObjects = [
makeGroup({
object: makeObject({
id: "obj-1",
providerKey: "github",
objectType: "pull_request",
statusCategory: "failed",
liveness: "fresh",
displayTitle: "Add external refs",
url: "https://github.com/acme/web/pull/241",
}),
mentionCount: 4,
sourceLabels: ["description", "comments", "plan document"],
}),
makeGroup({
object: makeObject({
id: "obj-2",
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
displayTitle: "Acme deal",
url: "https://app.hubspot.com/leads/99",
}),
mentionCount: 1,
sourceLabels: ["External links property"],
}),
makeGroup({
object: makeObject({
id: "obj-3",
providerKey: "ci",
objectType: "deployment",
statusCategory: "running",
liveness: "fresh",
displayTitle: "deploy prod-0412",
url: "https://ci.example.com/runs/88421",
}),
mentionCount: 2,
sourceLabels: ["comments"],
}),
makeGroup({
object: makeObject({
id: "obj-4",
providerKey: "github",
objectType: "issue",
statusCategory: "succeeded",
liveness: "stale",
displayTitle: "Closed parent issue",
url: "https://github.com/acme/web/issues/15",
}),
mentionCount: 1,
sourceLabels: ["comments"],
}),
];
return (
<IssueRelatedWorkPanel
relatedWork={{
outbound: [],
inbound: [],
}}
externalObjects={externalObjects}
/>
);
}
function projectsRollupStory() {
function summary(args: {
failed?: number;
waiting?: number;
running?: number;
succeeded?: number;
auth?: number;
stale?: number;
}): ExternalObjectSummary {
const objects: ExternalObjectSummary["objects"] = [];
function pushMany(category: ExternalObjectStatusCategory, count: number) {
for (let i = 0; i < count; i += 1) {
objects.push({
id: `obj-${category}-${i}`,
providerKey: "github",
objectType: "pull_request",
displayTitle: null,
statusCategory: category,
statusTone: externalObjectFallbackTone(category),
liveness: args.stale && i % 2 === 0 ? "stale" : "fresh",
isTerminal: false,
});
}
}
pushMany("failed", args.failed ?? 0);
pushMany("waiting", args.waiting ?? 0);
pushMany("running", args.running ?? 0);
pushMany("succeeded", args.succeeded ?? 0);
pushMany("auth_required", args.auth ?? 0);
const tones = objects.map((o) => o.statusTone);
const dominant: ExternalObjectSummary["highestSeverity"] =
tones.includes("danger") ? "danger"
: tones.includes("warning") ? "warning"
: tones.includes("info") ? "info"
: tones.includes("success") ? "success"
: "muted";
const byStatusCategory: Record<string, number> = {};
for (const obj of objects) {
byStatusCategory[obj.statusCategory] = (byStatusCategory[obj.statusCategory] ?? 0) + 1;
}
return {
total: objects.length,
byStatusCategory,
byLiveness: { fresh: objects.length, stale: 0, auth_required: 0, unreachable: 0, unknown: 0 },
highestSeverity: dominant,
staleCount: args.stale ?? 0,
authRequiredCount: args.auth ?? 0,
unreachableCount: 0,
objects,
};
}
const projects = [
{ name: "Paperclip App", color: "#6366f1", summary: summary({ failed: 3, running: 12 }) },
{ name: "Marketing site", color: "#22c55e", summary: summary({ waiting: 2 }) },
{ name: "Experimental", color: "#a855f7", summary: summary({ succeeded: 6 }) },
{ name: "Auth provider", color: "#f97316", summary: summary({ auth: 1 }) },
];
return (
<div className="paperclip-story w-72 rounded border border-border bg-background p-2">
<div className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">Projects (sidebar)</div>
<ul className="flex flex-col">
{projects.map((project) => (
<li
key={project.name}
className="flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium hover:bg-accent/50"
>
<span className="h-3.5 w-3.5 shrink-0 rounded-sm" style={{ backgroundColor: project.color }} />
<span className="flex-1 truncate">{project.name}</span>
<ExternalObjectStatusSummary summary={project.summary} compact />
</li>
))}
</ul>
</div>
);
}
function StateMatrixStory() {
return (
<div className="paperclip-story space-y-12 p-6">
<StateMatrix />
{inlineMarkdownStory()}
{relatedWorkStory()}
{projectsRollupStory()}
</div>
);
}
function makeIntegrationGroups(): IssueExternalObjectGroup[] {
return [
makeGroup({
object: makeObject({
id: "obj-int-failed",
providerKey: "github",
objectType: "pull_request",
statusCategory: "failed",
liveness: "fresh",
displayTitle: "CI broken on main",
url: "https://github.com/acme/web/pull/241",
statusLabel: "CI failed",
}),
mentionCount: 4,
sourceLabels: ["Description", "3 comments"],
}),
makeGroup({
object: makeObject({
id: "obj-int-auth",
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
displayTitle: "Acme deal — needs reconnect",
url: "https://app.hubspot.com/leads/99",
statusLabel: "Reconnect",
}),
mentionCount: 1,
sourceLabels: ["External links property"],
}),
makeGroup({
object: makeObject({
id: "obj-int-running",
providerKey: "ci",
objectType: "deployment",
statusCategory: "running",
liveness: "fresh",
displayTitle: "deploy prod-0412",
url: "https://ci.example.com/runs/88421",
statusLabel: "Running",
}),
mentionCount: 2,
sourceLabels: ["2 comments"],
}),
];
}
function makeIntegrationSummary(): ExternalObjectSummary {
const objects = makeIntegrationGroups()
.map((entry) => entry.group.object)
.filter((object): object is ExternalObject => Boolean(object))
.map((object) => ({
id: object.id,
providerKey: object.providerKey,
objectType: object.objectType,
displayTitle: object.displayTitle,
statusCategory: object.statusCategory,
statusTone: object.statusTone,
liveness: object.liveness,
isTerminal: object.isTerminal,
}));
const tones = objects.map((o) => o.statusTone);
const dominant: ExternalObjectSummary["highestSeverity"] =
tones.includes("danger") ? "danger"
: tones.includes("warning") ? "warning"
: tones.includes("info") ? "info"
: tones.includes("success") ? "success"
: "muted";
const byStatusCategory: Record<string, number> = {};
for (const o of objects) {
byStatusCategory[o.statusCategory] = (byStatusCategory[o.statusCategory] ?? 0) + 1;
}
return {
total: objects.length,
byStatusCategory,
byLiveness: { fresh: objects.length - 1, stale: 0, auth_required: 1, unreachable: 0, unknown: 0 },
highestSeverity: dominant,
staleCount: 0,
authRequiredCount: 1,
unreachableCount: 0,
objects,
};
}
function PropertiesPanelDesktop() {
const issue = storybookIssues[0]!;
return (
<div className="paperclip-story w-[420px] rounded-lg border border-border bg-background/70 p-4">
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
Issue properties desktop @ 1440×900
</div>
<IssueProperties
issue={issue}
childIssues={[]}
externalObjects={makeIntegrationGroups()}
onAddSubIssue={() => undefined}
onUpdate={() => undefined}
/>
</div>
);
}
function PropertiesPanelMobile() {
const issue = storybookIssues[0]!;
return (
<div className="paperclip-story mx-auto w-[358px] rounded-lg border border-border bg-background/70 p-3">
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
Issue properties mobile sheet @ 390×844
</div>
<IssueProperties
issue={issue}
childIssues={[]}
externalObjects={makeIntegrationGroups()}
onAddSubIssue={() => undefined}
onUpdate={() => undefined}
inline
/>
</div>
);
}
function RelatedWorkEmptyDesktop() {
return (
<div className="paperclip-story space-y-3 p-6">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Related work empty external objects (zero refs, empty copy visible)
</div>
<IssueRelatedWorkPanel
relatedWork={{ outbound: [], inbound: [] }}
externalObjects={[]}
/>
</div>
);
}
function SidebarMobileDrawer() {
const summary = makeIntegrationSummary();
return (
<div className="paperclip-story mx-auto w-[320px] rounded border border-border bg-background p-2">
<div className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">Projects (mobile drawer)</div>
<ul className="flex flex-col">
{[
{ name: "Paperclip App", color: "#6366f1", summary },
{ name: "Marketing site", color: "#22c55e", summary: { ...summary, highestSeverity: "warning", byStatusCategory: { waiting: 2 }, total: 2, objects: [] } },
{ name: "Experimental", color: "#a855f7", summary: { ...summary, highestSeverity: "muted", byStatusCategory: {}, total: 0, objects: [] } },
].map((project) => (
<li
key={project.name}
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium hover:bg-accent/50"
>
<span className="h-3.5 w-3.5 shrink-0 rounded-sm" style={{ backgroundColor: project.color }} />
<span className="flex-1 truncate">{project.name}</span>
<ExternalObjectStatusSummary summary={project.summary as ExternalObjectSummary} compact />
</li>
))}
</ul>
</div>
);
}
function IssueListWithBadge() {
const summary = makeIntegrationSummary();
return (
<div className="paperclip-story p-6">
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
Issue list desktop @ 1440×900 (badge + control row)
</div>
<div className="overflow-hidden rounded-xl border border-border bg-background/70">
{storybookIssues.slice(0, 2).map((issue, index) => (
<IssueRow
key={issue.id}
issue={issue}
externalObjectSummary={index === 0 ? summary : null}
desktopTrailing={
<span className="text-xs text-muted-foreground">{issue.priority}</span>
}
/>
))}
</div>
</div>
);
}
function FilterPopoverWithExternalChecked() {
const [state, setState] = useState<IssueFilterState>({
...defaultIssueFilterState,
externalObjectStatuses: ["failed", "auth_required"],
});
const triggerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const timer = window.setTimeout(() => {
triggerRef.current?.querySelector("button")?.click();
}, 150);
return () => window.clearTimeout(timer);
}, []);
return (
<div className="paperclip-story flex min-h-[640px] items-start justify-end p-6">
<div ref={triggerRef}>
<IssueFiltersPopover
state={state}
onChange={(patch) => setState((current) => ({ ...current, ...patch }))}
activeFilterCount={countActiveIssueFilters(state, true)}
agents={storybookAgents.map((agent) => ({ id: agent.id, name: agent.name }))}
projects={storybookProjects.map((project) => ({ id: project.id, name: project.name }))}
labels={storybookIssueLabels.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId="user-board"
enableRoutineVisibilityFilter
buttonVariant="outline"
workspaces={storybookExecutionWorkspaces.map((workspace) => ({ id: workspace.id, name: workspace.name }))}
creators={[
{ id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" },
]}
/>
</div>
</div>
);
}
function IntegrationSurfacesStory() {
return (
<div className="paperclip-story space-y-10 p-6">
<PropertiesPanelDesktop />
<PropertiesPanelMobile />
<RelatedWorkEmptyDesktop />
<SidebarMobileDrawer />
<IssueListWithBadge />
<FilterPopoverWithExternalChecked />
</div>
);
}
const meta = {
title: "Foundations/External Objects",
component: StateMatrixStory,
parameters: {
docs: {
description: {
component:
"External-object surface gallery: the §6 state matrix, the inline markdown decoration, the related-work section, and the project-rollup sidebar marker. Mirrors the Phase 6 acceptance set from the UX spec.",
},
},
},
} satisfies Meta<typeof StateMatrixStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FullSurface: Story = {};
export const PropertiesRowDesktop: StoryObj = {
render: () => <PropertiesPanelDesktop />,
};
export const PropertiesRowMobileSheet: StoryObj = {
render: () => <PropertiesPanelMobile />,
};
export const RelatedWorkEmpty: StoryObj = {
render: () => <RelatedWorkEmptyDesktop />,
};
export const SidebarMobile: StoryObj = {
render: () => <SidebarMobileDrawer />,
};
export const IssueListRow: StoryObj = {
render: () => <IssueListWithBadge />,
};
export const FilterPopoverOpen: StoryObj = {
render: () => <FilterPopoverWithExternalChecked />,
};
export const IntegrationSurfaces: StoryObj = {
render: () => <IntegrationSurfacesStory />,
};