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,