Fix UI detail regressions (#8613)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board UI needs to render issue details, comments, properties,
and rich external object labels clearly during normal operator
workflows.
> - Three small UI regressions made those workflows harder to scan: rich
object URLs could show weak labels, interrupting comments could briefly
flash in the wrong state, and watchdog labels could overflow the
properties panel.
> - These are related quality fixes in the same UI surface, with focused
regression coverage for each behavior.
> - This pull request groups the fixes so they go through normal review
instead of bypassing CI.
> - The benefit is a quieter, more predictable issue detail experience
for board operators.

## Linked Issues or Issue Description

No public GitHub issue was found for these regressions after searching
related PRs and issues.

### Pre-submission checklist

- Existing open and closed GitHub issues and PRs were searched for
duplicates.
- The fixes are based on current `master` behavior.
- The regressions originate in Paperclip board UI code, not an adapter,
API provider, or local configuration.

### What happened?

In the board UI, three issue-detail regressions made normal review
workflows harder to scan:

- URL-rich external objects could fall back to a weak generic label
instead of showing a useful URL label.
- An interrupting issue comment could briefly flash through the wrong
state while issue run data refreshed.
- Long watchdog property instructions could truncate or overflow instead
of wrapping inside the properties panel.

### Expected behavior

- URL-rich external objects should surface a clear URL label.
- Interrupting comments should stay visually stable while live run state
refreshes.
- Long watchdog property values should wrap within the available
properties panel width.

### Steps to reproduce

1. Open an issue detail view that includes URL-rich external object
metadata, an interrupting run/comment state, or long watchdog
instructions.
2. Observe the rendered issue detail thread and properties panel.
3. Compare the rendered label, comment state, and watchdog row wrapping
against the expected stable/readable behavior above.

### Paperclip version or commit

Current `master`, fixed by this PR branch at
`1c763001c9a16fa6cf3faad6f58a83c4b202c928`.

### Deployment mode

Local dev (`pnpm dev`) / board UI.

### Installation method

Built from source (`pnpm dev` / `pnpm build`).

### Agent adapter(s) involved

Not adapter-specific (core board UI bug).

### Database mode

Not database-related.

### Access context

Board operator UI.

### Relevant logs or output

Not applicable.

### Relevant config

Not applicable.

### Additional context

The PR includes focused regression tests for all three behaviors.
Browser-visible before/after evidence for the watchdog wrapping change
was posted in
https://github.com/paperclipai/paperclip/pull/8613#issuecomment-4794863305.

### Privacy checklist

- All pasted output was reviewed for sensitive data; this PR body
contains no private config, logs, or internal instance links.

## What Changed

- Prefer direct URLs as rich object labels when rendering external
object metadata.
- Keep interrupting issue comments from flashing through the wrong
thread state while issue chat data is refreshing.
- Allow watchdog property labels to wrap cleanly inside the issue
properties panel.
- Added focused regression coverage for the external object helper,
issue detail interrupt behavior, and watchdog property wrapping.

## Verification

- `pnpm exec vitest run ui/src/lib/external-objects.test.ts
ui/src/lib/issue-chat-messages.test.ts
ui/src/components/IssueProperties.test.tsx
ui/src/pages/IssueDetail.test.tsx`

## Risks

Low risk. The changes are scoped to UI rendering/state handling and add
regression coverage for the touched behavior. No database, API,
workflow, lockfile, or migration changes are included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex using GPT-5 (`gpt-5`) with repository shell/tool access.
The changes were prepared by an AI coding agent with local command
execution for git inspection and focused Vitest verification.

## 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-25 21:55:03 -05:00 committed by GitHub
parent 569b7affc4
commit 8e21e31a1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 215 additions and 13 deletions

View File

@ -4196,6 +4196,8 @@ export function IssueChatThread({
status: activeRun.status,
invocationSource: activeRun.invocationSource,
triggerDetail: activeRun.triggerDetail,
contextCommentId: activeRun.contextCommentId,
contextWakeCommentId: activeRun.contextWakeCommentId,
startedAt: toIsoString(activeRun.startedAt),
finishedAt: toIsoString(activeRun.finishedAt),
createdAt: toIsoString(activeRun.createdAt) ?? new Date().toISOString(),
@ -4204,6 +4206,13 @@ export function IssueChatThread({
adapterType: activeRun.adapterType,
logBytes: activeRun.logBytes,
lastOutputBytes: activeRun.lastOutputBytes,
issueId: activeRun.issueId,
livenessState: activeRun.livenessState,
livenessReason: activeRun.livenessReason,
continuationAttempt: activeRun.continuationAttempt,
lastUsefulActionAt: toIsoString(activeRun.lastUsefulActionAt),
nextAction: activeRun.nextAction,
outputSilence: activeRun.outputSilence,
currentStatusMessage: activeRun.currentStatusMessage ?? null,
currentStatusUpdatedAt: toIsoString(activeRun.currentStatusUpdatedAt),
});

View File

@ -1851,6 +1851,41 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("allows long watchdog instructions to wrap inside the properties value column", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,
});
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
const root = renderProperties(container, {
issue: createIssue({
watchdog: createWatchdogSummary({
instructions: "get greptile to stop re-reviewing the same task unless a fresh code change lands",
}),
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
let instructionNode: HTMLSpanElement | undefined;
await waitForAssertion(() => {
instructionNode = Array.from(container.querySelectorAll("span"))
.find((node) =>
node.textContent?.includes("get greptile")
&& node.className.includes("text-muted-foreground")
&& !node.className.includes("inline-flex")
) as HTMLSpanElement | undefined;
expect(instructionNode).toBeTruthy();
});
expect(instructionNode!.className).toContain("whitespace-normal");
expect(instructionNode!.className).toContain("break-words");
expect(instructionNode!.className).not.toContain("truncate");
act(() => root.unmount());
});
it("links to the generated watchdog task when one exists", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: true,

View File

@ -1249,14 +1249,16 @@ export function IssueProperties({
(child) => child.id === issue.watchdog?.watchdogIssueId,
);
const watchdogTrigger = issue.watchdog ? (
<span className="inline-flex min-w-0 items-center gap-1.5 text-sm">
<span className="inline-flex min-w-0 max-w-full flex-wrap items-start gap-x-1.5 gap-y-0.5 text-sm leading-5">
{(() => {
const agent = (agents ?? []).find((candidate) => candidate.id === issue.watchdog?.watchdogAgentId);
return agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
})()}
<span className="truncate">{agentName(issue.watchdog.watchdogAgentId)}</span>
<span className="min-w-0 max-w-40 truncate">{agentName(issue.watchdog.watchdogAgentId)}</span>
{issue.watchdog.instructions?.trim() ? (
<span className="truncate text-muted-foreground">· {issue.watchdog.instructions.trim()}</span>
<span className="min-w-0 flex-1 basis-32 whitespace-normal break-words text-muted-foreground">
· {issue.watchdog.instructions.trim()}
</span>
) : null}
{issue.watchdog.status === "disabled" ? (
<span className="shrink-0 text-xs text-muted-foreground">(disabled)</span>

View File

@ -103,6 +103,7 @@ describe("external-objects helpers", () => {
expect(externalObjectProviderLabel("hubspot_marketing")).toBe("Hubspot Marketing");
expect(externalObjectProviderLabel(null)).toBe("External");
expect(externalObjectTypeLabel("workflow_run")).toBe("workflow run");
expect(externalObjectTypeLabel("url_link")).toBe("URL");
expect(externalObjectTypeLabel(null)).toBe("object");
});

View File

@ -170,6 +170,7 @@ const OBJECT_TYPE_LABELS: Record<string, string> = {
workflow_run: "workflow run",
ticket: "ticket",
lead: "lead",
url_link: "URL",
};
export function externalObjectTypeLabel(objectType: string | null | undefined): string {

View File

@ -850,13 +850,23 @@ function normalizeLiveRuns(
status: activeRun.status,
invocationSource: activeRun.invocationSource,
triggerDetail: activeRun.triggerDetail,
contextCommentId: activeRun.contextCommentId,
contextWakeCommentId: activeRun.contextWakeCommentId,
startedAt: activeRun.startedAt ? toDate(activeRun.startedAt).toISOString() : null,
finishedAt: activeRun.finishedAt ? toDate(activeRun.finishedAt).toISOString() : null,
createdAt: toDate(activeRun.createdAt).toISOString(),
agentId: activeRun.agentId,
agentName: activeRun.agentName,
adapterType: activeRun.adapterType,
issueId,
logBytes: activeRun.logBytes,
lastOutputBytes: activeRun.lastOutputBytes,
issueId: activeRun.issueId ?? issueId,
livenessState: activeRun.livenessState,
livenessReason: activeRun.livenessReason,
continuationAttempt: activeRun.continuationAttempt,
lastUsefulActionAt: activeRun.lastUsefulActionAt ? toDate(activeRun.lastUsefulActionAt).toISOString() : null,
nextAction: activeRun.nextAction,
outputSilence: activeRun.outputSilence,
currentStatusMessage: activeRun.currentStatusMessage ?? null,
currentStatusUpdatedAt: activeRun.currentStatusUpdatedAt
? toDate(activeRun.currentStatusUpdatedAt).toISOString()

View File

@ -1,13 +1,14 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, Issue, IssueAttachment, IssueTreeControlPreview, IssueTreeHold, IssueWorkProduct } from "@paperclipai/shared";
import type { Agent, Issue, IssueAttachment, IssueComment, IssueTreeControlPreview, IssueTreeHold, IssueWorkProduct } from "@paperclipai/shared";
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react";
import { NavigationType } from "react-router-dom";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { canBoardResolveRecoveryAction, IssueDetail, shouldScrollIssueDetailToTopOnNavigation } from "./IssueDetail";
import { queryKeys } from "../lib/queryKeys";
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
@ -451,6 +452,23 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
} as Issue;
}
function createIssueComment(overrides: Partial<IssueComment> = {}): IssueComment {
return {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "Fresh comment",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-21T00:00:05.000Z"),
updatedAt: new Date("2026-04-21T00:00:05.000Z"),
...overrides,
};
}
function createAttachment(overrides: Partial<IssueAttachment> & { id: string }): IssueAttachment {
const { id, ...attachmentOverrides } = overrides;
return {
@ -983,6 +1001,122 @@ describe("IssueDetail", () => {
).toBe(false);
});
it("does not mark the wake comment for the current live run as queued when active-run cache is stale", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "in_progress",
executionRunId: "run-stale",
}));
mockIssuesApi.listComments.mockResolvedValue([
createIssueComment({
id: "comment-fresh",
createdAt: new Date("2026-04-21T00:00:05.000Z"),
updatedAt: new Date("2026-04-21T00:00:05.000Z"),
}),
]);
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue({
id: "run-stale",
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:00.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
issueId: "issue-1",
});
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([
{
id: "run-current",
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: "comment-fresh",
contextWakeCommentId: "comment-fresh",
startedAt: "2026-04-21T00:00:01.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:01.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
issueId: "issue-1",
},
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { comments?: Array<{ id: string; queueState?: string }> };
const freshComment = props.comments?.find((comment) => comment.id === "comment-fresh");
expect(freshComment?.queueState).toBeUndefined();
});
it("does not optimistically queue a fresh comment from an unlocked stale active-run cache", async () => {
const postedComment = createDeferred<IssueComment>();
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "todo",
executionRunId: null,
}));
mockIssuesApi.addComment.mockReturnValue(postedComment.promise);
queryClient.setQueryData(queryKeys.issues.activeRun("PAP-1"), {
id: "run-stale",
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:00.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
issueId: "issue-1",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
onAdd: (body: string) => Promise<void>;
comments?: Array<{ body: string; clientStatus?: string; queueState?: string }>;
};
await act(async () => {
void props.onAdd("Fresh comment");
await Promise.resolve();
});
await flushReact();
const nextProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<{ body: string; clientStatus?: string; queueState?: string }>;
};
const optimisticComment = nextProps.comments?.find((comment) => comment.body === "Fresh comment");
expect(optimisticComment).toMatchObject({ clientStatus: "pending" });
expect(optimisticComment?.queueState).toBeUndefined();
await act(async () => {
postedComment.resolve(createIssueComment({ body: "Fresh comment" }));
});
await flushReact();
});
it("hides the plan decomposition panel by default", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());

View File

@ -287,9 +287,8 @@ function resolveRunningIssueRun(
activeRun: ActiveRunForIssue | null | undefined,
liveRuns: readonly LiveRunForIssue[] | undefined,
) {
return activeRun?.status === "running"
? activeRun
: (liveRuns ?? []).find((run) => run.status === "running") ?? null;
const runningLiveRun = (liveRuns ?? []).find((run) => run.status === "running") ?? null;
return runningLiveRun ?? (activeRun?.status === "running" ? activeRun : null);
}
function dedupeLiveRunsById(liveRuns: readonly LiveRunForIssue[]) {
@ -301,17 +300,28 @@ function dedupeLiveRunsById(liveRuns: readonly LiveRunForIssue[]) {
});
}
function readIssueRunStateFromCache(queryClient: QueryClient, issueId: string) {
function readIssueRunStateFromCache(
queryClient: QueryClient,
issueId: string,
issue: Pick<Issue, "executionRunId"> | null | undefined,
) {
const liveRuns = queryClient.getQueryData<LiveRunForIssue[]>(
queryKeys.issues.liveRuns(issueId),
);
const activeRun = queryClient.getQueryData<ActiveRunForIssue | null>(
queryKeys.issues.activeRun(issueId),
);
const activeRunIsLive = Boolean(
activeRun && liveRuns?.some((run) => run.id === activeRun.id),
);
const activeRunMatchesIssueLock = Boolean(
activeRun && issue?.executionRunId && activeRun.id === issue.executionRunId,
);
const resolvedActiveRun = activeRunIsLive || activeRunMatchesIssueLock ? activeRun : null;
return {
liveRuns,
activeRun,
runningIssueRun: resolveRunningIssueRun(activeRun, liveRuns),
activeRun: resolvedActiveRun,
runningIssueRun: resolveRunningIssueRun(resolvedActiveRun, liveRuns),
};
}
@ -2192,7 +2202,7 @@ export function IssueDetail() {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!).runningIssueRun : null;
const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!, issue).runningIssueRun : null;
const optimisticComment = issue
? createOptimisticIssueComment({
companyId: issue.companyId,
@ -2418,7 +2428,7 @@ export function IssueDetail() {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!).runningIssueRun : null;
const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!, issue).runningIssueRun : null;
const optimisticComment = issue
? createOptimisticIssueComment({
companyId: issue.companyId,