From 8e21e31a1ae0e3c5311525402d91d2d0c6c59d90 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:55:03 -0500 Subject: [PATCH] Fix UI detail regressions (#8613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- ui/src/components/IssueChatThread.tsx | 9 ++ ui/src/components/IssueProperties.test.tsx | 35 ++++++ ui/src/components/IssueProperties.tsx | 8 +- ui/src/lib/external-objects.test.ts | 1 + ui/src/lib/external-objects.ts | 1 + ui/src/lib/issue-chat-messages.ts | 12 +- ui/src/pages/IssueDetail.test.tsx | 136 ++++++++++++++++++++- ui/src/pages/IssueDetail.tsx | 26 ++-- 8 files changed, 215 insertions(+), 13 deletions(-) diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 7e3d651940..18c8768b91 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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), }); diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index c4b85dfcf4..f9814b2909 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -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, diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 3224ecf76b..b6bc31ea1f 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1249,14 +1249,16 @@ export function IssueProperties({ (child) => child.id === issue.watchdog?.watchdogIssueId, ); const watchdogTrigger = issue.watchdog ? ( - + {(() => { const agent = (agents ?? []).find((candidate) => candidate.id === issue.watchdog?.watchdogAgentId); return agent ? : null; })()} - {agentName(issue.watchdog.watchdogAgentId)} + {agentName(issue.watchdog.watchdogAgentId)} {issue.watchdog.instructions?.trim() ? ( - · {issue.watchdog.instructions.trim()} + + · {issue.watchdog.instructions.trim()} + ) : null} {issue.watchdog.status === "disabled" ? ( (disabled) diff --git a/ui/src/lib/external-objects.test.ts b/ui/src/lib/external-objects.test.ts index 70ae3560c4..3032d07056 100644 --- a/ui/src/lib/external-objects.test.ts +++ b/ui/src/lib/external-objects.test.ts @@ -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"); }); diff --git a/ui/src/lib/external-objects.ts b/ui/src/lib/external-objects.ts index 76ca0c91c0..89b6667c33 100644 --- a/ui/src/lib/external-objects.ts +++ b/ui/src/lib/external-objects.ts @@ -170,6 +170,7 @@ const OBJECT_TYPE_LABELS: Record = { workflow_run: "workflow run", ticket: "ticket", lead: "lead", + url_link: "URL", }; export function externalObjectTypeLabel(objectType: string | null | undefined): string { diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index 3a3dcfe9e6..78351d6be5 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -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() diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 6543417401..900409ef17 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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 { } as Issue; } +function createIssueComment(overrides: Partial = {}): 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 & { 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( + + + , + ); + }); + 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(); + 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( + + + , + ); + }); + await flushReact(); + await flushReact(); + + const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { + onAdd: (body: string) => Promise; + 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()); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 44a83c6363..02857381a7 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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 | null | undefined, +) { const liveRuns = queryClient.getQueryData( queryKeys.issues.liveRuns(issueId), ); const activeRun = queryClient.getQueryData( 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(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(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,