From a26e1288b627e82c554445732c7d844648e6b5e1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:25:34 -0500 Subject: [PATCH] [codex] Polish issue board workflows (#4224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Human operators supervise that work through issue lists, issue detail, comments, inbox groups, markdown references, and profile/activity surfaces > - The branch had many small UI fixes that improve the operator loop but do not need to ship with backend runtime migrations > - These changes belong together as board workflow polish because they affect scanning, navigation, issue context, comment state, and markdown clarity > - This pull request groups the UI-only slice so it can merge independently from runtime/backend changes > - The benefit is a clearer board experience with better issue context, steadier optimistic updates, and more predictable keyboard navigation ## What Changed - Improves issue properties, sub-issue actions, blocker chips, and issue list/detail refresh behavior. - Adds blocker context above the issue composer and stabilizes queued/interrupted comment UI state. - Improves markdown issue/GitHub link rendering and opens external markdown links in a new tab. - Adds inbox group keyboard navigation and fold/unfold support. - Polishes activity/avatar/profile/settings/workspace presentation details. ## Verification - `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx ui/src/components/IssueChatThread.test.tsx ui/src/components/MarkdownBody.test.tsx ui/src/lib/inbox.test.ts ui/src/lib/optimistic-issue-comments.test.ts` ## Risks - Low to medium risk: changes are UI-focused but cover high-traffic issue and inbox surfaces. - This branch intentionally does not include the backend runtime changes from the companion PR; where UI calls newer API filters, unsupported servers should continue to fail visibly through existing API error handling. - Visual screenshots were not captured in this heartbeat; targeted component/helper tests cover the changed behavior. > 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, GPT-5-based coding agent runtime, shell/git tool use enabled. Exact hosted model build and context window are not exposed in this Paperclip heartbeat 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- ui/src/api/issues.test.ts | 8 + ui/src/api/issues.ts | 2 + ui/src/components/ActivityRow.tsx | 2 +- ui/src/components/CommentThread.tsx | 4 +- ui/src/components/Identity.tsx | 4 +- ui/src/components/IssueChatThread.test.tsx | 96 ++++++++++ ui/src/components/IssueChatThread.tsx | 91 ++++++++- ui/src/components/IssueProperties.test.tsx | 176 +++++++++++++++++ ui/src/components/IssueProperties.tsx | 181 +++++++++++++----- ui/src/components/IssuesList.test.tsx | 36 ++++ ui/src/components/IssuesList.tsx | 29 ++- .../KeyboardShortcutsCheatsheet.tsx | 4 + ui/src/components/MarkdownBody.test.tsx | 45 +++++ ui/src/components/MarkdownBody.tsx | 34 +++- .../ProjectWorkspaceSummaryCard.test.tsx | 1 - .../WorkspaceRuntimeControls.test.tsx | 38 ++++ ui/src/components/ui/avatar.tsx | 2 +- ui/src/context/LiveUpdatesProvider.test.ts | 48 +++-- ui/src/context/LiveUpdatesProvider.tsx | 18 ++ ui/src/lib/inbox.test.ts | 49 ++++- ui/src/lib/inbox.ts | 18 +- ui/src/lib/issue-properties-panel-key.test.ts | 47 +++++ ui/src/lib/issue-properties-panel-key.ts | 24 +++ ui/src/lib/issue-reference.test.ts | 5 + ui/src/lib/issue-reference.ts | 4 +- ui/src/lib/liveIssueIds.test.ts | 64 +++++++ ui/src/lib/liveIssueIds.ts | 9 + ui/src/lib/optimistic-issue-comments.test.ts | 48 +++++ ui/src/lib/optimistic-issue-comments.ts | 27 ++- ui/src/lib/optimistic-issue-runs.test.ts | 32 +++- ui/src/lib/optimistic-issue-runs.ts | 15 ++ ui/src/lib/utils.ts | 7 +- ui/src/pages/Agents.tsx | 4 +- ui/src/pages/ExecutionWorkspaceDetail.tsx | 14 +- ui/src/pages/Inbox.tsx | 44 ++++- ui/src/pages/InstanceGeneralSettings.tsx | 9 +- ui/src/pages/IssueDetail.tsx | 66 ++++++- ui/src/pages/Issues.tsx | 25 ++- ui/src/pages/OrgChart.test.tsx | 1 - ui/src/pages/UserProfile.tsx | 19 +- 40 files changed, 1218 insertions(+), 132 deletions(-) create mode 100644 ui/src/lib/liveIssueIds.test.ts create mode 100644 ui/src/lib/liveIssueIds.ts diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index d0b3fab06f..a32add42e6 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -23,4 +23,12 @@ describe("issuesApi.list", () => { "/companies/company-1/issues?parentId=issue-parent-1&limit=25", ); }); + + it("passes generic workspaceId filters through to the company issues endpoint", async () => { + await issuesApi.list("company-1", { workspaceId: "workspace-1", limit: 1000 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/issues?workspaceId=workspace-1&limit=1000", + ); + }); }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 7fbaab0e35..ba456af9af 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -32,6 +32,7 @@ export const issuesApi = { inboxArchivedByUserId?: string; unreadForUserId?: string; labelId?: string; + workspaceId?: string; executionWorkspaceId?: string; originKind?: string; originId?: string; @@ -51,6 +52,7 @@ export const issuesApi = { if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId); if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId); if (filters?.labelId) params.set("labelId", filters.labelId); + if (filters?.workspaceId) params.set("workspaceId", filters.workspaceId); if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId); if (filters?.originKind) params.set("originKind", filters.originKind); if (filters?.originId) params.set("originId", filters.originId); diff --git a/ui/src/components/ActivityRow.tsx b/ui/src/components/ActivityRow.tsx index 7be5998e48..83a502cb5f 100644 --- a/ui/src/components/ActivityRow.tsx +++ b/ui/src/components/ActivityRow.tsx @@ -58,7 +58,7 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en name={actorName} avatarUrl={actorAvatarUrl} size="xs" - className="align-baseline" + className="align-middle" /> {verb} {name && {name}} diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 06fe76813d..cf2d30f1b1 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -135,8 +135,8 @@ function parseReassignment(target: string): CommentReassignment | null { } function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) { - const isClosed = issueStatus === "done" || issueStatus === "cancelled"; - return isClosed && assigneeValue.startsWith("agent:"); + const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked"; + return resumesToTodo && assigneeValue.startsWith("agent:"); } function humanizeValue(value: string | null): string { diff --git a/ui/src/components/Identity.tsx b/ui/src/components/Identity.tsx index 6f9af851dc..b57c59aeca 100644 --- a/ui/src/components/Identity.tsx +++ b/ui/src/components/Identity.tsx @@ -28,8 +28,8 @@ export function Identity({ name, avatarUrl, initials, size = "default", classNam const displayInitials = initials ?? deriveInitials(name); return ( - - + + {avatarUrl && } {displayInitials} diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 44d56f9147..f00c3f2755 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Agent } from "@paperclipai/shared"; import { IssueChatThread, canStopIssueChatRun, @@ -113,6 +114,24 @@ vi.mock("./StatusBadge", () => ({ StatusBadge: ({ status }: { status: string }) => {status}, })); +vi.mock("./IssueLinkQuicklook", () => ({ + IssueLinkQuicklook: ({ + children, + to, + issuePathId, + className, + }: { + children: ReactNode; + to: string; + issuePathId: string; + className?: string; + }) => ( + + {children} + + ), +})); + vi.mock("../hooks/usePaperclipIssueRuntime", () => ({ usePaperclipIssueRuntime: () => ({}), })); @@ -171,6 +190,83 @@ describe("IssueChatThread", () => { }); }); + it("shows unresolved blocker context above the composer", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).toContain("Work on this issue is blocked by the linked issue"); + expect(container.textContent).toContain("Comments still wake the assignee for questions or triage"); + expect(container.textContent).toContain("PAP-1723"); + expect(container.textContent).toContain("QA the install flow"); + expect(container.querySelector('[data-issue-path-id="PAP-1723"]')).not.toBeNull(); + + act(() => { + root.unmount(); + }); + }); + + it("shows paused assigned agent context above the composer", () => { + const root = createRoot(container); + const pausedAgent = { + id: "agent-1", + companyId: "company-1", + name: "CodexCoder", + status: "paused", + pauseReason: "manual", + } as Agent; + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).toContain("CodexCoder is paused"); + expect(container.textContent).toContain("New runs will not start until the agent is resumed"); + expect(container.textContent).toContain("It was paused manually"); + + act(() => { + root.unmount(); + }); + }); + it("supports the embedded read-only variant without the jump control", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index b81fa2ed31..3febbc05ae 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -30,6 +30,7 @@ import type { FeedbackDataSharingPreference, FeedbackVote, FeedbackVoteValue, + IssueRelationIssueSummary, } from "@paperclipai/shared"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; @@ -75,6 +76,7 @@ import { } from "../lib/issue-chat-scroll"; import { formatAssigneeUserLabel } from "../lib/assignees"; import type { CompanyUserProfile } from "../lib/company-members"; +import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { timeAgo } from "../lib/timeAgo"; import { describeToolInput, @@ -89,7 +91,8 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; -import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react"; +import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react"; +import { IssueLinkQuicklook } from "./IssueLinkQuicklook"; interface IssueChatMessageContext { feedbackVoteByTargetId: Map; @@ -215,6 +218,7 @@ interface IssueChatThreadProps { timelineEvents?: IssueTimelineEvent[]; liveRuns?: LiveRunForIssue[]; activeRun?: ActiveRunForIssue | null; + blockedBy?: IssueRelationIssueSummary[]; companyId?: string | null; projectId?: string | null; issueStatus?: string; @@ -301,6 +305,75 @@ class IssueChatErrorBoundary extends Component +
+ +
+

+ {blockers.length > 0 + ? <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage. + : <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.} +

+ {blockers.length > 0 ? ( +
+ {blockers.map((blocker) => { + const issuePathId = blocker.identifier ?? blocker.id; + return ( + + {blocker.identifier ?? blocker.id.slice(0, 8)} + + {blocker.title} + + + ); + })} +
+ ) : null} +
+
+ + ); +} + +function IssueAssigneePausedNotice({ agent }: { agent: Agent | null }) { + if (!agent || agent.status !== "paused") return null; + + const pauseDetail = + agent.pauseReason === "budget" + ? "It was paused by a budget hard stop." + : agent.pauseReason === "system" + ? "It was paused by the system." + : "It was paused manually."; + + return ( +
+
+ +

+ {agent.name} is paused. New runs will not start until the agent is resumed. {pauseDetail} +

+
+
+ ); +} + function fallbackAuthorLabel(message: ThreadMessage) { const custom = message.metadata?.custom as Record | undefined; if (typeof custom?.["authorName"] === "string") return custom["authorName"]; @@ -446,8 +519,8 @@ function parseReassignment(target: string): PaperclipIssueRuntimeReassignment | } function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) { - const isClosed = issueStatus === "done" || issueStatus === "cancelled"; - return isClosed && assigneeValue.startsWith("agent:"); + const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked"; + return resumesToTodo && assigneeValue.startsWith("agent:"); } const WEEK_MS = 7 * 24 * 60 * 60 * 1000; @@ -2011,6 +2084,7 @@ export function IssueChatThread({ timelineEvents = [], liveRuns = [], activeRun = null, + blockedBy = [], companyId, projectId, issueStatus, @@ -2142,6 +2216,15 @@ export function IssueChatThread({ }, [rawMessages]); const isRunning = displayLiveRuns.some((run) => run.status === "queued" || run.status === "running"); + const unresolvedBlockers = useMemo( + () => blockedBy.filter((blocker) => blocker.status !== "done" && blocker.status !== "cancelled"), + [blockedBy], + ); + const assignedAgent = useMemo(() => { + if (!currentAssigneeValue.startsWith("agent:")) return null; + const assigneeAgentId = currentAssigneeValue.slice("agent:".length); + return agentMap?.get(assigneeAgentId) ?? null; + }, [agentMap, currentAssigneeValue]); const feedbackVoteByTargetId = useMemo(() => { const map = new Map(); for (const feedbackVote of feedbackVotes) { @@ -2290,6 +2373,8 @@ export function IssueChatThread({ {showComposer ? (
+ + ({ const mockIssuesApi = vi.hoisted(() => ({ list: vi.fn(), listLabels: vi.fn(), + createLabel: vi.fn(), })); const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), })); +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", @@ -54,6 +60,10 @@ vi.mock("../api/auth", () => ({ authApi: mockAuthApi, })); +vi.mock("../api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + vi.mock("../hooks/useProjectOrder", () => ({ useProjectOrder: ({ projects }: { projects: unknown[] }) => ({ orderedProjects: projects, @@ -153,6 +163,18 @@ function createIssue(overrides: Partial = {}): Issue { }; } +function createLabel(overrides: Partial = {}): IssueLabel { + return { + id: "label-1", + companyId: "company-1", + name: "Bug", + color: "#ef4444", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + updatedAt: new Date("2026-04-06T12:00:00.000Z"), + ...overrides, + }; +} + function createRuntimeService(overrides: Partial = {}): WorkspaceRuntimeService { return { id: "service-1", @@ -330,7 +352,13 @@ describe("IssueProperties", () => { mockProjectsApi.list.mockResolvedValue([]); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); + mockIssuesApi.createLabel.mockResolvedValue(createLabel({ + id: "label-new", + name: "New label", + color: "#6366f1", + })); mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); }); afterEach(() => { @@ -363,6 +391,63 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("renders blocked-by issues as direct chips and edits them from an add action", async () => { + const onUpdate = vi.fn(); + mockIssuesApi.list.mockResolvedValue([ + createIssue({ id: "issue-3", identifier: "PAP-3", title: "New blocker", status: "todo" }), + ]); + + const root = renderProperties(container, { + issue: createIssue({ + blockedBy: [ + { + id: "issue-2", + identifier: "PAP-2", + title: "Existing blocker", + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + }), + childIssues: [], + onUpdate, + inline: true, + }); + await flush(); + + const blockerLink = container.querySelector('a[href="/issues/PAP-2"]'); + expect(blockerLink).not.toBeNull(); + expect(blockerLink?.textContent).toContain("PAP-2"); + expect(blockerLink?.closest("button")).toBeNull(); + expect(container.textContent).toContain("Add blocker"); + expect(container.querySelector('input[placeholder="Search issues..."]')).toBeNull(); + + const addButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Add blocker")); + expect(addButton).not.toBeUndefined(); + + await act(async () => { + addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(container.querySelector('input[placeholder="Search issues..."]')).not.toBeNull(); + + const candidateButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("PAP-3 New blocker")); + expect(candidateButton).not.toBeUndefined(); + + await act(async () => { + candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-2", "issue-3"] }); + + act(() => root.unmount()); + }); + it("shows a green service link above the workspace row for a live non-main workspace", async () => { mockProjectsApi.list.mockResolvedValue([createProject()]); const serviceUrl = "http://127.0.0.1:62475"; @@ -392,6 +477,38 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("shows a workspace tasks link for non-default workspaces when isolated workspaces are enabled", async () => { + mockProjectsApi.list.mockResolvedValue([createProject()]); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + const root = renderProperties(container, { + issue: createIssue({ + projectId: "project-1", + projectWorkspaceId: "workspace-main", + executionWorkspaceId: "workspace-1", + currentExecutionWorkspace: createExecutionWorkspace({ + mode: "isolated_workspace", + }), + }), + childIssues: [], + onUpdate: vi.fn(), + }); + await flush(); + await flush(); + + const tasksLink = Array.from(container.querySelectorAll("a")).find( + (link) => link.textContent?.includes("View workspace tasks"), + ); + const workspaceLink = Array.from(container.querySelectorAll("a")).find( + (link) => link.textContent?.trim() === "View workspace", + ); + expect(tasksLink).not.toBeUndefined(); + expect(tasksLink?.getAttribute("href")).toBe("/issues?workspace=workspace-1"); + expect(workspaceLink).not.toBeUndefined(); + expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1"); + + act(() => root.unmount()); + }); + it("does not show a service link for the main shared workspace", async () => { mockProjectsApi.list.mockResolvedValue([createProject()]); const serviceUrl = "http://127.0.0.1:62475"; @@ -412,6 +529,10 @@ describe("IssueProperties", () => { await flush(); expect(container.querySelector(`a[href="${serviceUrl}"]`)).toBeNull(); + expect(container.textContent).not.toContain("View workspace tasks"); + expect(Array.from(container.querySelectorAll("a")).some( + (link) => link.textContent?.trim() === "View workspace", + )).toBe(false); act(() => root.unmount()); }); @@ -563,6 +684,61 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("shows selected labels from labelIds even before the issue labels relation refreshes", async () => { + mockIssuesApi.listLabels.mockResolvedValue([createLabel()]); + + const root = renderProperties(container, { + issue: createIssue({ + labels: [], + labelIds: ["label-1"], + }), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + }); + await flush(); + await flush(); + + expect(container.textContent).toContain("Bug"); + expect(container.textContent).not.toContain("No labels"); + + act(() => root.unmount()); + }); + + it("shows a checkmark on selected labels in the picker", async () => { + mockIssuesApi.listLabels.mockResolvedValue([ + createLabel(), + createLabel({ id: "label-2", name: "Feature", color: "#22c55e" }), + ]); + + const root = renderProperties(container, { + issue: createIssue({ + labels: [createLabel()], + labelIds: ["label-1"], + }), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + }); + await flush(); + + const addLabelButton = container.querySelector('button[aria-label="Add label"]'); + expect(addLabelButton).not.toBeNull(); + await act(async () => { + addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const labelButtons = Array.from(container.querySelectorAll("button")) + .filter((button) => button.textContent?.includes("Bug") || button.textContent?.includes("Feature")); + const bugButton = labelButtons.find((button) => button.textContent?.includes("Bug") && button.querySelector("svg")); + const featureButton = labelButtons.find((button) => button.textContent?.includes("Feature")); + expect(bugButton).not.toBeUndefined(); + expect(featureButton?.querySelector("svg")).toBeNull(); + + act(() => root.unmount()); + }); + it("allows setting and clearing a parent issue from the properties pane", async () => { const onUpdate = vi.fn(); mockIssuesApi.list.mockResolvedValue([ diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index dcb3abd380..5c9a38cad8 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1,14 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link } from "@/lib/router"; -import type { Issue, Project, WorkspaceRuntimeService } from "@paperclipai/shared"; +import type { Issue, IssueLabel, IssueRelationIssueSummary, Project, WorkspaceRuntimeService } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { accessApi } from "../api/access"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; +import { instanceSettingsApi } from "../api/instanceSettings"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; +import { resolveIssueFilterWorkspaceId } from "../lib/issue-filters"; import { queryKeys } from "../lib/queryKeys"; import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members"; import { useProjectOrder } from "../hooks/useProjectOrder"; @@ -110,6 +112,12 @@ function runningRuntimeServiceWithUrl( return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null; } +function issuesWorkspaceFilterHref(workspaceId: string) { + const params = new URLSearchParams(); + params.append("workspace", workspaceId); + return `/issues?${params.toString()}`; +} + interface IssuePropertiesProps { issue: Issue; childIssues?: Issue[]; @@ -189,6 +197,21 @@ function PropertyPicker({ ); } +function IssuePillLink({ + issue, +}: { + issue: Pick | IssueRelationIssueSummary; +}) { + return ( + + {issue.identifier ?? issue.title} + + ); +} + export function IssueProperties({ issue, childIssues = [], @@ -232,6 +255,11 @@ export function IssueProperties({ queryFn: () => accessApi.listUserDirectory(companyId!), enabled: !!companyId, }); + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + retry: false, + }); const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(companyId!), @@ -263,8 +291,16 @@ export function IssueProperties({ const createLabel = useMutation({ mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data), onSuccess: async (created) => { - await queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) }); + queryClient.setQueryData( + queryKeys.issues.labels(companyId!), + (current) => { + if (!current) return [created]; + if (current.some((label) => label.id === created.id)) return current; + return [...current, created]; + }, + ); onUpdate({ labelIds: [...(issue.labelIds ?? []), created.id] }); + void queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) }); setNewLabelName(""); }, }); @@ -292,10 +328,21 @@ export function IssueProperties({ ? orderedProjects.find((project) => project.id === issue.projectId) ?? null : null; const issueProject = issue.project ?? currentProject; + const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; + const issueUsesMainWorkspace = useMemo( + () => isMainIssueWorkspace({ issue, project: issueProject }), + [issue, issueProject], + ); + const workspaceFilterId = useMemo(() => { + if (!isolatedWorkspacesEnabled) return null; + if (issueUsesMainWorkspace) return null; + return resolveIssueFilterWorkspaceId(issue); + }, [isolatedWorkspacesEnabled, issue, issueUsesMainWorkspace]); + const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace; const liveWorkspaceService = useMemo(() => { - if (isMainIssueWorkspace({ issue, project: issueProject })) return null; + if (issueUsesMainWorkspace) return null; return runningRuntimeServiceWithUrl(issue.currentExecutionWorkspace?.runtimeServices); - }, [issue, issueProject]); + }, [issue.currentExecutionWorkspace?.runtimeServices, issueUsesMainWorkspace]); const referencedIssueIdentifiers = issue.referencedIssueIdentifiers ?? []; const relatedTasks = useMemo(() => { const excluded = new Set(); @@ -427,10 +474,22 @@ export function IssueProperties({ } return `${stageLabel} pending${participantLabel ? ` with ${participantLabel}` : ""}`; })(); + const selectedIssueLabels = useMemo(() => { + const selectedIds = issue.labelIds ?? []; + if (selectedIds.length === 0) return issue.labels ?? []; - const labelsTrigger = (issue.labels ?? []).length > 0 ? ( + const labelById = new Map(); + for (const label of labels ?? []) labelById.set(label.id, label); + for (const label of issue.labels ?? []) labelById.set(label.id, label); + + return selectedIds + .map((id) => labelById.get(id)) + .filter((label): label is IssueLabel => Boolean(label)); + }, [issue.labelIds, issue.labels, labels]); + + const labelsTrigger = selectedIssueLabels.length > 0 ? (
- {(issue.labels ?? []).slice(0, 3).map((label) => ( + {selectedIssueLabels.slice(0, 3).map((label) => ( ))} - {(issue.labels ?? []).length > 3 && ( - +{(issue.labels ?? []).length - 3} + {selectedIssueLabels.length > 3 && ( + +{selectedIssueLabels.length - 3} )}
) : ( @@ -492,7 +551,8 @@ export function IssueProperties({ onClick={() => toggleLabel(label.id)} > - {label.name} + {label.name} + {selected &&
); - const blockedByTrigger = blockedByIds.length > 0 ? ( -
- {(issue.blockedBy ?? []).slice(0, 2).map((relation) => ( - - {relation.identifier ?? relation.title} - - ))} - {(issue.blockedBy ?? []).length > 2 && ( - +{(issue.blockedBy ?? []).length - 2} - )} -
- ) : ( - No blockers - ); - const blockingIssues = issue.blocks ?? []; const blockerOptions = (allIssues ?? []) .filter((candidate) => candidate.id !== issue.id) @@ -997,6 +1042,16 @@ export function IssueProperties({ ); + const renderAddBlockedByButton = (onClick?: () => void) => ( + + ); return (
@@ -1087,32 +1142,47 @@ export function IssueProperties({ {parentContent} - { - setBlockedByOpen(open); - if (!open) setBlockedBySearch(""); - }} - triggerContent={blockedByTrigger} - triggerClassName="min-w-0 max-w-full" - popoverClassName="w-72" - > - {blockedByContent} - + {inline ? ( +
+ + {(issue.blockedBy ?? []).map((relation) => ( + + ))} + {renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))} + + {blockedByOpen && ( +
+ {blockedByContent} +
+ )} +
+ ) : ( + + {(issue.blockedBy ?? []).map((relation) => ( + + ))} + { + setBlockedByOpen(open); + if (!open) setBlockedBySearch(""); + }} + > + + {renderAddBlockedByButton()} + + + {blockedByContent} + + + + )} {blockingIssues.length > 0 ? (
{blockingIssues.map((relation) => ( - - {relation.identifier ?? relation.title} - + ))}
) : null} @@ -1122,13 +1192,7 @@ export function IssueProperties({
{childIssues.length > 0 ? childIssues.map((child) => ( - - {child.identifier ?? child.title} - + )) : null} {onAddSubIssue ? ( @@ -1222,7 +1286,7 @@ export function IssueProperties({ )} - {issue.executionWorkspaceId && ( + {showWorkspaceDetailLink && issue.executionWorkspaceId && ( )} + {workspaceFilterId && ( + + + View workspace tasks + + + + )} {issue.currentExecutionWorkspace?.branchName && ( { }); }); + it("applies an initial workspace filter from the issues URL state", async () => { + const alphaIssue = createIssue({ + id: "issue-alpha", + identifier: "PAP-30", + title: "Alpha issue", + executionWorkspaceId: "workspace-alpha", + }); + const betaIssue = createIssue({ + id: "issue-beta", + identifier: "PAP-31", + title: "Beta issue", + executionWorkspaceId: "workspace-beta", + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Alpha issue"); + expect(container.textContent).not.toContain("Beta issue"); + }); + + act(() => { + root.unmount(); + }); + }); + it("shows routine-backed issues by default and hides them when the routine filter is toggled off", async () => { const manualIssue = createIssue({ id: "issue-manual", diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 283b6ba856..72d6cd39d7 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -111,6 +111,20 @@ function getInitialViewState(key: string, initialAssignees?: string[]): IssueVie }; } +function getInitialWorkspaceViewState( + key: string, + initialAssignees?: string[], + initialWorkspaces?: string[], +): IssueViewState { + const stored = getInitialViewState(key, initialAssignees); + if (!initialWorkspaces) return stored; + return { + ...stored, + workspaces: initialWorkspaces, + statuses: [], + }; +} + function getIssueColumnsStorageKey(key: string): string { return `${key}:issue-columns`; } @@ -188,6 +202,7 @@ interface IssuesListProps { viewStateKey: string; issueLinkState?: unknown; initialAssignees?: string[]; + initialWorkspaces?: string[]; initialSearch?: string; searchFilters?: Omit; baseCreateIssueDefaults?: Record; @@ -270,6 +285,7 @@ export function IssuesList({ viewStateKey, issueLinkState, initialAssignees, + initialWorkspaces, initialSearch, searchFilters, baseCreateIssueDefaults, @@ -300,8 +316,11 @@ export function IssuesList({ // Scope the storage key per company so folding/view state is independent across companies. const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey; const initialAssigneesKey = initialAssignees?.join("|") ?? ""; + const initialWorkspacesKey = initialWorkspaces?.join("|") ?? ""; - const [viewState, setViewState] = useState(() => getInitialViewState(scopedKey, initialAssignees)); + const [viewState, setViewState] = useState(() => + getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces), + ); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); @@ -315,14 +334,14 @@ export function IssuesList({ }, [initialSearch]); // Reload view state whenever the persisted context changes. - const prevViewStateContextKey = useRef(`${scopedKey}::${initialAssigneesKey}`); + const prevViewStateContextKey = useRef(`${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`); useEffect(() => { - const nextContextKey = `${scopedKey}::${initialAssigneesKey}`; + const nextContextKey = `${scopedKey}::${initialAssigneesKey}::${initialWorkspacesKey}`; if (prevViewStateContextKey.current !== nextContextKey) { prevViewStateContextKey.current = nextContextKey; - setViewState(getInitialViewState(scopedKey, initialAssignees)); + setViewState(getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces)); } - }, [scopedKey, initialAssignees, initialAssigneesKey]); + }, [scopedKey, initialAssignees, initialAssigneesKey, initialWorkspaces, initialWorkspacesKey]); const prevColumnsScopedKey = useRef(scopedKey); useEffect(() => { diff --git a/ui/src/components/KeyboardShortcutsCheatsheet.tsx b/ui/src/components/KeyboardShortcutsCheatsheet.tsx index 6c09ce0f5a..bc60d08c89 100644 --- a/ui/src/components/KeyboardShortcutsCheatsheet.tsx +++ b/ui/src/components/KeyboardShortcutsCheatsheet.tsx @@ -15,7 +15,11 @@ const sections: ShortcutSection[] = [ title: "Inbox", shortcuts: [ { keys: ["j"], label: "Move down" }, + { keys: ["↓"], label: "Move down" }, { keys: ["k"], label: "Move up" }, + { keys: ["↑"], label: "Move up" }, + { keys: ["←"], label: "Collapse selected group" }, + { keys: ["→"], label: "Expand selected group" }, { keys: ["Enter"], label: "Open selected item" }, { keys: ["a"], label: "Archive item" }, { keys: ["y"], label: "Archive item" }, diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 204be8024e..3f17f39953 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -225,6 +225,51 @@ describe("MarkdownBody", () => { expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"'); }); + it("opens external links in a new tab with safe rel attributes", () => { + const html = renderMarkdown("[docs](https://example.com/docs)"); + + expect(html).toContain('href="https://example.com/docs"'); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + }); + + it("opens GitHub links in a new tab", () => { + const html = renderMarkdown("[pr](https://github.com/paperclipai/paperclip/pull/4099)"); + + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + }); + + it("does not set target on relative internal links", () => { + const html = renderMarkdown("[settings](/company/settings)"); + + expect(html).toContain('href="/company/settings"'); + expect(html).not.toContain('target="_blank"'); + expect(html).toContain('rel="noreferrer"'); + }); + + it("prefixes GitHub markdown links with the GitHub icon", () => { + const html = renderMarkdown("[https://github.com/paperclipai/paperclip/pull/4099](https://github.com/paperclipai/paperclip/pull/4099)"); + + expect(html).toContain('https://github.com/paperclipai/paperclip/pull/4099"); + }); + + it("prefixes GitHub autolinks with the GitHub icon", () => { + const html = renderMarkdown("See https://github.com/paperclipai/paperclip/issues/1778"); + + expect(html).toContain(' { + const html = renderMarkdown("[docs](https://example.com/docs)"); + + expect(html).toContain(' { const html = renderMarkdown("```text\nGET /heartbeat-runs/ca5d23fc-c15b-4826-8ff1-2b6dd11be096/log?offset=2062357&limitBytes=256000\n```"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 1c269c02fc..5ab6ca149c 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,5 +1,6 @@ import { isValidElement, useEffect, useId, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; +import { Github } from "lucide-react"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; @@ -103,6 +104,28 @@ function safeMarkdownUrlTransform(url: string): string { return parseMentionChipHref(url) ? url : defaultUrlTransform(url); } +function isGitHubUrl(href: string | null | undefined): boolean { + if (!href) return false; + try { + const url = new URL(href); + return url.protocol === "https:" && (url.hostname === "github.com" || url.hostname === "www.github.com"); + } catch { + return false; + } +} + +function isExternalHttpUrl(href: string | null | undefined): boolean { + if (!href) return false; + try { + const url = new URL(href); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + if (typeof window === "undefined") return true; + return url.origin !== window.location.origin; + } catch { + return false; + } +} + function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); const [svg, setSvg] = useState(null); @@ -249,8 +272,17 @@ export function MarkdownBody({ ); } + const isGitHubLink = isGitHubUrl(href); + const isExternal = isExternalHttpUrl(href); return ( - + + {isGitHubLink ? ); diff --git a/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx b/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx index 84f2c8e77f..e10e973b98 100644 --- a/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx +++ b/ui/src/components/ProjectWorkspaceSummaryCard.test.tsx @@ -254,7 +254,6 @@ describe("ProjectWorkspaceSummaryCard", () => { root.unmount(); }); }); - it("colors live service urls green", () => { const root = createRoot(container); diff --git a/ui/src/components/WorkspaceRuntimeControls.test.tsx b/ui/src/components/WorkspaceRuntimeControls.test.tsx index 159c1e69f3..0ceedbb477 100644 --- a/ui/src/components/WorkspaceRuntimeControls.test.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.test.tsx @@ -144,6 +144,44 @@ describe("buildWorkspaceRuntimeControlSections", () => { }), ]); }); + + it("surfaces running stale runtime services separately from updated commands", () => { + const sections = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev:once --tailscale-auth" }, + ], + }, + runtimeServices: [ + createRuntimeService({ + id: "service-web", + serviceName: "web", + status: "running", + command: "pnpm dev", + }), + ], + canStartServices: true, + canRunJobs: true, + }); + + expect(sections.services).toEqual([ + expect.objectContaining({ + title: "web", + statusLabel: "stopped", + command: "pnpm dev:once --tailscale-auth", + runtimeServiceId: null, + }), + ]); + expect(sections.otherServices).toEqual([ + expect.objectContaining({ + title: "web", + statusLabel: "running", + command: "pnpm dev", + runtimeServiceId: "service-web", + disabledReason: "This runtime service no longer matches a configured workspace command.", + }), + ]); + }); }); describe("buildWorkspaceRuntimeControlItems", () => { diff --git a/ui/src/components/ui/avatar.tsx b/ui/src/components/ui/avatar.tsx index 9f8b5817fe..c7be61224c 100644 --- a/ui/src/components/ui/avatar.tsx +++ b/ui/src/components/ui/avatar.tsx @@ -30,7 +30,7 @@ function AvatarImage({ return ( ) diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index f9a9cec13f..b11148c053 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -321,30 +321,32 @@ describe("LiveUpdatesProvider issue invalidation", () => { it("refreshes visible issue run queries when the displayed run changes status", () => { const invalidations: unknown[] = []; + const cache = new Map([ + [JSON.stringify(queryKeys.issues.detail("PAP-759")), { + id: "issue-1", + identifier: "PAP-759", + assigneeAgentId: "agent-1", + executionRunId: "run-1", + executionAgentNameKey: "codexcoder", + executionLockedAt: new Date("2026-04-08T21:00:00.000Z"), + }], + [JSON.stringify(queryKeys.issues.activeRun("PAP-759")), { + id: "run-1", + }], + [JSON.stringify(queryKeys.issues.liveRuns("PAP-759")), [{ id: "run-1" }]], + [JSON.stringify(queryKeys.issues.runs("PAP-759")), [{ runId: "run-1" }]], + ]); const queryClient = { invalidateQueries: (input: unknown) => { invalidations.push(input); }, getQueryData: (key: unknown) => { - if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) { - return { - id: "issue-1", - identifier: "PAP-759", - assigneeAgentId: "agent-1", - }; - } - if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.activeRun("PAP-759"))) { - return { - id: "run-1", - }; - } - if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.liveRuns("PAP-759"))) { - return [{ id: "run-1" }]; - } - if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.runs("PAP-759"))) { - return [{ runId: "run-1" }]; - } - return undefined; + return cache.get(JSON.stringify(key)); + }, + setQueryData: (key: unknown, updater: unknown) => { + const cacheKey = JSON.stringify(key); + const current = cache.get(cacheKey); + cache.set(cacheKey, typeof updater === "function" ? updater(current) : updater); }, }; @@ -375,6 +377,13 @@ describe("LiveUpdatesProvider issue invalidation", () => { expect(invalidations).toContainEqual({ queryKey: queryKeys.issues.activeRun("PAP-759"), }); + expect(cache.get(JSON.stringify(queryKeys.issues.activeRun("PAP-759")))).toBeNull(); + expect(cache.get(JSON.stringify(queryKeys.issues.liveRuns("PAP-759")))).toEqual([]); + expect(cache.get(JSON.stringify(queryKeys.issues.detail("PAP-759")))).toMatchObject({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + }); }); it("ignores run status events for other issues", () => { @@ -404,6 +413,7 @@ describe("LiveUpdatesProvider issue invalidation", () => { } return undefined; }, + setQueryData: vi.fn(), }; const invalidated = __liveUpdatesTestUtils.invalidateVisibleIssueRunQueries( diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 95f06ace19..7be15ad30a 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -10,6 +10,7 @@ import { useCompany } from "./CompanyContext"; import type { ToastInput } from "./ToastContext"; import { useToastActions } from "./ToastContext"; import { upsertIssueCommentInPages } from "../lib/optimistic-issue-comments"; +import { clearIssueExecutionRun, removeLiveRunById } from "../lib/optimistic-issue-runs"; import { queryKeys } from "../lib/queryKeys"; import { toCompanyRelativePath } from "../lib/company-routes"; import { useLocation } from "../lib/router"; @@ -19,6 +20,7 @@ const TOAST_COOLDOWN_MAX = 3; const RECONNECT_SUPPRESS_MS = 2000; const SOCKET_CONNECTING = 0; const SOCKET_OPEN = 1; +const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); type LiveUpdatesSocketLike = { readyState: number; @@ -275,6 +277,22 @@ function invalidateVisibleIssueRunQueries( (!!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId); if (!matchesVisibleIssue) return false; + const status = readString(payload.status); + if (runId && status && TERMINAL_RUN_STATUSES.has(status)) { + queryClient.setQueryData( + queryKeys.issues.liveRuns(context.routeIssueRef), + (current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId), + ); + queryClient.setQueryData( + queryKeys.issues.activeRun(context.routeIssueRef), + (current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current), + ); + queryClient.setQueryData( + queryKeys.issues.detail(context.routeIssueRef), + (current: Issue | undefined) => clearIssueExecutionRun(current, runId), + ); + } + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(context.routeIssueRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(context.routeIssueRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(context.routeIssueRef) }); diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 340d302e86..977187ecb3 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -611,7 +611,9 @@ describe("inbox helpers", () => { expect( buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set()).map((entry) => entry.type === "top" ? entry.itemKey - : entry.issueId), + : entry.type === "child" + ? entry.issueId + : entry.groupKey), ).toEqual([ `workspace:default:${getInboxWorkItemKey({ kind: "issue", timestamp: 2, issue: parentIssue })}`, childIssue.id, @@ -620,12 +622,55 @@ describe("inbox helpers", () => { expect( buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set([parentIssue.id])).map((entry) => entry.type === "top" ? entry.itemKey - : entry.issueId), + : entry.type === "child" + ? entry.issueId + : entry.groupKey), ).toEqual([ `workspace:default:${getInboxWorkItemKey({ kind: "issue", timestamp: 2, issue: parentIssue })}`, ]); }); + it("emits a group nav entry for labeled groups and omits children when the group is collapsed", () => { + const visibleIssue = makeIssue("visible", true); + const hiddenIssue = makeIssue("hidden", true); + const groupedSections = [ + { + key: "priority:high", + label: "High priority", + displayItems: [{ kind: "issue", timestamp: 3, issue: visibleIssue } satisfies InboxWorkItem], + childrenByIssueId: new Map(), + }, + { + key: "priority:medium", + label: "Medium priority", + displayItems: [{ kind: "issue", timestamp: 2, issue: hiddenIssue } satisfies InboxWorkItem], + childrenByIssueId: new Map(), + }, + ]; + + const expanded = buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set()); + expect(expanded.map((entry) => entry.type)).toEqual(["group", "top", "group", "top"]); + expect(expanded[0]).toEqual({ + type: "group", + groupKey: "priority:high", + label: "High priority", + collapsed: false, + }); + + const collapsed = buildInboxKeyboardNavEntries( + groupedSections, + new Set(["priority:medium"]), + new Set(), + ); + expect(collapsed.map((entry) => entry.type)).toEqual(["group", "top", "group"]); + expect(collapsed[2]).toEqual({ + type: "group", + groupKey: "priority:medium", + label: "Medium priority", + collapsed: true, + }); + }); + it("sorts self-touched issues without external comments by updatedAt", () => { const recentSelfTouched = makeIssue("recent", false); recentSelfTouched.lastExternalCommentAt = null as unknown as Date; diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index a282f5dc0a..355b14d63d 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -100,11 +100,18 @@ export interface InboxGroupedSection { export interface InboxKeyboardGroupSection { key: string; + label?: string | null; displayItems: InboxWorkItem[]; childrenByIssueId: ReadonlyMap; } export type InboxKeyboardNavEntry = + | { + type: "group"; + groupKey: string; + label: string; + collapsed: boolean; + } | { type: "top"; itemKey: string; @@ -965,7 +972,16 @@ export function buildInboxKeyboardNavEntries( const entries: InboxKeyboardNavEntry[] = []; for (const group of groupedSections) { - if (collapsedGroupKeys.has(group.key)) continue; + const isCollapsed = collapsedGroupKeys.has(group.key); + if (group.label) { + entries.push({ + type: "group", + groupKey: group.key, + label: group.label, + collapsed: isCollapsed, + }); + } + if (isCollapsed) continue; for (const item of group.displayItems) { entries.push({ diff --git a/ui/src/lib/issue-properties-panel-key.test.ts b/ui/src/lib/issue-properties-panel-key.test.ts index 248f3e25ef..c1b557fe78 100644 --- a/ui/src/lib/issue-properties-panel-key.test.ts +++ b/ui/src/lib/issue-properties-panel-key.test.ts @@ -10,12 +10,17 @@ function createIssue(overrides: Partial = {}) { assigneeAgentId: "agent-1", assigneeUserId: null, projectId: "project-1", + projectWorkspaceId: null, parentId: null, createdByUserId: "user-1", hiddenAt: null, labelIds: ["label-1"], executionPolicy: null, executionState: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + currentExecutionWorkspace: null, blocks: [], blockedBy: [], ancestors: [], @@ -44,4 +49,46 @@ describe("buildIssuePropertiesPanelKey", () => { expect(second).not.toBe(first); }); + + it("changes when workspace detail hydrates after opening from a cached issue", () => { + const first = buildIssuePropertiesPanelKey(createIssue(), []); + const second = buildIssuePropertiesPanelKey( + createIssue({ + executionWorkspaceId: "workspace-1", + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { mode: "isolated_workspace" }, + currentExecutionWorkspace: { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "project-workspace-1", + sourceIssueId: "issue-1", + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "PAP-1 workspace", + status: "active", + cwd: "/tmp/paperclip/PAP-1", + repoUrl: null, + baseRef: "master", + branchName: "PAP-1-workspace", + providerType: "git_worktree", + providerRef: "/tmp/paperclip/PAP-1", + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date("2026-04-12T12:01:00.000Z"), + openedAt: new Date("2026-04-12T12:01:00.000Z"), + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: null, + metadata: null, + runtimeServices: [], + createdAt: new Date("2026-04-12T12:01:00.000Z"), + updatedAt: new Date("2026-04-12T12:01:00.000Z"), + }, + }), + [], + ); + + expect(second).not.toBe(first); + }); }); diff --git a/ui/src/lib/issue-properties-panel-key.ts b/ui/src/lib/issue-properties-panel-key.ts index a4ae4895f7..5bb0f6ce21 100644 --- a/ui/src/lib/issue-properties-panel-key.ts +++ b/ui/src/lib/issue-properties-panel-key.ts @@ -8,12 +8,17 @@ type IssuePropertiesPanelKeyIssue = Pick< | "assigneeAgentId" | "assigneeUserId" | "projectId" + | "projectWorkspaceId" | "parentId" | "createdByUserId" | "hiddenAt" | "labelIds" | "executionPolicy" | "executionState" + | "executionWorkspaceId" + | "executionWorkspacePreference" + | "executionWorkspaceSettings" + | "currentExecutionWorkspace" | "blocks" | "blockedBy" | "ancestors" @@ -34,10 +39,29 @@ export function buildIssuePropertiesPanelKey( assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, projectId: issue.projectId, + projectWorkspaceId: issue.projectWorkspaceId, parentId: issue.parentId, createdByUserId: issue.createdByUserId, hiddenAt: issue.hiddenAt, labelIds: issue.labelIds ?? [], + executionWorkspaceId: issue.executionWorkspaceId, + executionWorkspacePreference: issue.executionWorkspacePreference, + executionWorkspaceSettings: issue.executionWorkspaceSettings ?? null, + currentExecutionWorkspace: issue.currentExecutionWorkspace + ? { + id: issue.currentExecutionWorkspace.id, + mode: issue.currentExecutionWorkspace.mode, + status: issue.currentExecutionWorkspace.status, + projectWorkspaceId: issue.currentExecutionWorkspace.projectWorkspaceId, + branchName: issue.currentExecutionWorkspace.branchName, + cwd: issue.currentExecutionWorkspace.cwd, + runtimeServices: (issue.currentExecutionWorkspace.runtimeServices ?? []).map((service) => ({ + id: service.id, + status: service.status, + url: service.url, + })), + } + : null, executionPolicy: issue.executionPolicy ?? null, executionState: issue.executionState ? { diff --git a/ui/src/lib/issue-reference.test.ts b/ui/src/lib/issue-reference.test.ts index d1d613e032..8eeca53a19 100644 --- a/ui/src/lib/issue-reference.test.ts +++ b/ui/src/lib/issue-reference.test.ts @@ -12,6 +12,11 @@ describe("issue-reference", () => { expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179"); }); + it("does not treat GitHub issue URLs as internal Paperclip issue links", () => { + expect(parseIssuePathIdFromPath("https://github.com/paperclipai/paperclip/issues/1778")).toBeNull(); + expect(parseIssueReferenceFromHref("https://github.com/paperclipai/paperclip/issues/1778")).toBeNull(); + }); + it("ignores placeholder issue paths", () => { expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull(); expect(parseIssuePathIdFromPath("http://localhost:3100/issues/:id")).toBeNull(); diff --git a/ui/src/lib/issue-reference.ts b/ui/src/lib/issue-reference.ts index 25b4a4a27f..5e03e10eb2 100644 --- a/ui/src/lib/issue-reference.ts +++ b/ui/src/lib/issue-reference.ts @@ -16,7 +16,9 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): if (/^https?:\/\//i.test(pathname)) { try { - pathname = new URL(pathname).pathname; + const url = new URL(pathname); + if (url.hostname === "github.com" || url.hostname === "www.github.com") return null; + pathname = url.pathname; } catch { return null; } diff --git a/ui/src/lib/liveIssueIds.test.ts b/ui/src/lib/liveIssueIds.test.ts new file mode 100644 index 0000000000..b4db99910e --- /dev/null +++ b/ui/src/lib/liveIssueIds.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { LiveRunForIssue } from "../api/heartbeats"; +import { collectLiveIssueIds } from "./liveIssueIds"; + +describe("collectLiveIssueIds", () => { + it("keeps only runs linked to issues", () => { + const liveRuns: LiveRunForIssue[] = [ + { + id: "run-1", + status: "running", + invocationSource: "scheduler", + triggerDetail: null, + startedAt: "2026-04-20T10:00:00.000Z", + finishedAt: null, + createdAt: "2026-04-20T10:00:00.000Z", + agentId: "agent-1", + agentName: "Coder", + adapterType: "codex_local", + issueId: "issue-1", + }, + { + id: "run-2", + status: "queued", + invocationSource: "scheduler", + triggerDetail: null, + startedAt: null, + finishedAt: null, + createdAt: "2026-04-20T10:01:00.000Z", + agentId: "agent-2", + agentName: "Reviewer", + adapterType: "codex_local", + issueId: null, + }, + { + id: "run-3", + status: "running", + invocationSource: "scheduler", + triggerDetail: null, + startedAt: "2026-04-20T10:02:00.000Z", + finishedAt: null, + createdAt: "2026-04-20T10:02:00.000Z", + agentId: "agent-3", + agentName: "Builder", + adapterType: "codex_local", + issueId: "issue-1", + }, + { + id: "run-4", + status: "running", + invocationSource: "scheduler", + triggerDetail: null, + startedAt: "2026-04-20T10:03:00.000Z", + finishedAt: null, + createdAt: "2026-04-20T10:03:00.000Z", + agentId: "agent-4", + agentName: "Fixer", + adapterType: "codex_local", + issueId: "issue-2", + }, + ]; + + expect([...collectLiveIssueIds(liveRuns)]).toEqual(["issue-1", "issue-2"]); + }); +}); diff --git a/ui/src/lib/liveIssueIds.ts b/ui/src/lib/liveIssueIds.ts new file mode 100644 index 0000000000..40865fe95a --- /dev/null +++ b/ui/src/lib/liveIssueIds.ts @@ -0,0 +1,9 @@ +import type { LiveRunForIssue } from "../api/heartbeats"; + +export function collectLiveIssueIds(liveRuns: readonly LiveRunForIssue[] | null | undefined): Set { + const ids = new Set(); + for (const run of liveRuns ?? []) { + if (run.issueId) ids.add(run.issueId); + } + return ids; +} diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts index da5197eea4..d9a0a3376a 100644 --- a/ui/src/lib/optimistic-issue-comments.test.ts +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { Issue } from "@paperclipai/shared"; import { + applyLocalQueuedIssueCommentState, applyOptimisticIssueFieldUpdate, applyOptimisticIssueFieldUpdateToCollection, applyOptimisticIssueCommentUpdate, @@ -704,4 +705,51 @@ describe("optimistic issue comments", () => { }), ).toBe(false); }); + + it("keeps a confirmed queued comment queued while the target run is still live", () => { + const comment = { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Follow up after the active run", + createdAt: new Date("2026-03-28T16:20:05.000Z"), + updatedAt: new Date("2026-03-28T16:20:05.000Z"), + }; + + const result = applyLocalQueuedIssueCommentState(comment, { + queuedTargetRunId: "run-1", + hasLiveRuns: true, + runningRunId: "run-1", + }); + + expect(result).toMatchObject({ + id: "comment-1", + clientStatus: "queued", + queueState: "queued", + queueTargetRunId: "run-1", + }); + }); + + it("does not keep local queued state after the target run is no longer live", () => { + const comment = { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Follow up after the active run", + createdAt: new Date("2026-03-28T16:20:05.000Z"), + updatedAt: new Date("2026-03-28T16:20:05.000Z"), + }; + + const result = applyLocalQueuedIssueCommentState(comment, { + queuedTargetRunId: "run-1", + hasLiveRuns: false, + runningRunId: null, + }); + + expect(result).toBe(comment); + }); }); diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts index d756d820a6..30cf5cbaf3 100644 --- a/ui/src/lib/optimistic-issue-comments.ts +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -12,6 +12,11 @@ export interface OptimisticIssueComment extends IssueComment { } export type IssueTimelineComment = IssueComment | OptimisticIssueComment; +export type LocallyQueuedIssueComment = T & { + clientStatus: "queued"; + queueState: "queued"; + queueTargetRunId: string; +}; function toTimestamp(value: Date | string) { return new Date(value).getTime(); @@ -82,6 +87,26 @@ export function isQueuedIssueComment(params: { return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt); } +export function applyLocalQueuedIssueCommentState( + comment: T, + params: { + queuedTargetRunId?: string | null; + hasLiveRuns: boolean; + runningRunId?: string | null; + }, +): T | LocallyQueuedIssueComment { + const queuedTargetRunId = params.queuedTargetRunId ?? null; + if (!queuedTargetRunId || !params.hasLiveRuns) return comment; + if (params.runningRunId && params.runningRunId !== queuedTargetRunId) return comment; + + return { + ...comment, + clientStatus: "queued", + queueState: "queued", + queueTargetRunId: queuedTargetRunId, + }; +} + export function mergeIssueComments( comments: IssueComment[] | undefined, optimisticComments: OptimisticIssueComment[], @@ -150,7 +175,7 @@ export function applyOptimisticIssueCommentUpdate( if (!issue) return issue; const nextIssue: Issue = { ...issue }; - if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled")) { + if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled" || issue.status === "blocked")) { nextIssue.status = "todo"; } diff --git a/ui/src/lib/optimistic-issue-runs.test.ts b/ui/src/lib/optimistic-issue-runs.test.ts index f8614a6410..d649db1215 100644 --- a/ui/src/lib/optimistic-issue-runs.test.ts +++ b/ui/src/lib/optimistic-issue-runs.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; import type { RunForIssue } from "../api/activity"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; -import { removeLiveRunById, upsertInterruptedRun } from "./optimistic-issue-runs"; +import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "./optimistic-issue-runs"; function createLiveRun(overrides: Partial = {}): LiveRunForIssue { return { @@ -91,3 +92,32 @@ describe("removeLiveRunById", () => { expect(runs?.map((run) => run.id)).toEqual(["run-2"]); }); }); + +describe("clearIssueExecutionRun", () => { + it("clears the cached execution run when the interrupted run matches the issue lock", () => { + const issue = { + id: "issue-1", + executionRunId: "run-1", + executionAgentNameKey: "codexcoder", + executionLockedAt: new Date("2026-04-08T21:00:00.000Z"), + updatedAt: new Date("2026-04-08T21:00:00.000Z"), + } as Issue; + + expect(clearIssueExecutionRun(issue, "run-1")).toMatchObject({ + id: "issue-1", + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + }); + }); + + it("leaves the cached issue alone when another run is interrupted", () => { + const issue = { + id: "issue-1", + executionRunId: "run-2", + executionAgentNameKey: "codexcoder", + } as Issue; + + expect(clearIssueExecutionRun(issue, "run-1")).toBe(issue); + }); +}); diff --git a/ui/src/lib/optimistic-issue-runs.ts b/ui/src/lib/optimistic-issue-runs.ts index bf3a592e44..a307fd6e91 100644 --- a/ui/src/lib/optimistic-issue-runs.ts +++ b/ui/src/lib/optimistic-issue-runs.ts @@ -1,3 +1,4 @@ +import type { Issue } from "@paperclipai/shared"; import type { RunForIssue } from "../api/activity"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; @@ -68,3 +69,17 @@ export function removeLiveRunById( const nextRuns = runs.filter((run) => run.id !== runId); return nextRuns.length === runs.length ? runs : nextRuns; } + +export function clearIssueExecutionRun( + issue: Issue | undefined, + runId: string, +) { + if (!issue || issue.executionRunId !== runId) return issue; + return { + ...issue, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: new Date(), + }; +} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 1e6b015ee1..65a2e42c22 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -8,7 +8,11 @@ export function cn(...inputs: ClassValue[]) { } export function formatCents(cents: number): string { - return `$${(cents / 100).toFixed(2)}`; + return `$${(cents / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +export function formatNumber(n: number): string { + return n.toLocaleString("en-US"); } export function formatDate(date: Date | string): string { @@ -51,6 +55,7 @@ export function relativeTime(date: Date | string): string { } export function formatTokens(n: number): string { + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return String(n); diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 5119b74ef3..6e3f0f3dc5 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -253,7 +253,7 @@ export function Agents() { liveCount={liveRunByAgent.get(agent.id)!.liveCount} /> )} - + {getAdapterLabel(agent.adapterType)} @@ -356,7 +356,7 @@ function OrgTreeNode({ )} {agent && ( <> - + {getAdapterLabel(agent.adapterType)} diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index a5488f2beb..0e5aedaf8c 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -535,7 +535,7 @@ export function ExecutionWorkspaceDetail() {

- + Services and jobs @@ -584,7 +584,7 @@ export function ExecutionWorkspaceDetail() { {activeTab === "configuration" ? (
- + Workspace settings @@ -594,7 +594,7 @@ export function ExecutionWorkspaceDetail() {
) : activeTab === "runtime_logs" ? ( - + Runtime and cleanup logs Recent operations @@ -913,7 +913,7 @@ export function ExecutionWorkspaceDetail() { ) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
{workspaceOperationsQuery.data.map((operation) => ( -
+
{operation.command ?? operation.phase}
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 3382763429..a2d1b54142 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1203,6 +1203,16 @@ export function Inbox() { return next; }); }, [selectedCompanyId]); + const setGroupCollapsed = useCallback((groupKey: string, collapsed: boolean) => { + setCollapsedGroupKeys((prev) => { + if (collapsed ? prev.has(groupKey) : !prev.has(groupKey)) return prev; + const next = new Set(prev); + if (collapsed) next.add(groupKey); + else next.delete(groupKey); + saveCollapsedInboxGroupKeys(selectedCompanyId, next); + return next; + }); + }, [selectedCompanyId]); const groupedSections = useMemo(() => [ ...buildGroupedInboxSections(filteredWorkItems, groupBy, inboxWorkspaceGrouping, { nestingEnabled }), ...buildGroupedInboxSections( @@ -1256,6 +1266,13 @@ export function Inbox() { }); return map; }, [flatNavItems]); + const groupFlatIndex = useMemo(() => { + const map = new Map(); + flatNavItems.forEach((entry, index) => { + if (entry.type === "group") map.set(entry.groupKey, index); + }); + return map; + }, [flatNavItems]); const agentName = (id: string | null) => { if (!id) return null; @@ -1623,6 +1640,7 @@ export function Inbox() { markUnreadIssue: (id: string) => markUnreadMutation.mutate(id), markNonIssueRead: handleMarkNonIssueRead, markNonIssueUnread: markItemUnread, + setGroupCollapsed, navigate, }); kbActionsRef.current = { @@ -1633,6 +1651,7 @@ export function Inbox() { markUnreadIssue: (id: string) => markUnreadMutation.mutate(id), markNonIssueRead: handleMarkNonIssueRead, markNonIssueUnread: markItemUnread, + setGroupCollapsed, navigate, }; @@ -1689,20 +1708,32 @@ export function Inbox() { const entry = navItems[idx]; if (!entry) return {}; if (entry.type === "child") return { issue: entry.issue }; - return { item: entry.item }; + if (entry.type === "top") return { item: entry.item }; + return {}; }; switch (e.key) { - case "j": { + case "j": + case "ArrowDown": { e.preventDefault(); setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "next")); break; } - case "k": { + case "k": + case "ArrowUp": { e.preventDefault(); setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "previous")); break; } + case "ArrowLeft": + case "ArrowRight": { + if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return; + const entry = navItems[st.selectedIndex]; + if (!entry || entry.type !== "group") return; + e.preventDefault(); + act.setGroupCollapsed(entry.groupKey, e.key === "ArrowLeft"); + break; + } case "a": case "y": { if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return; @@ -2237,13 +2268,20 @@ export function Inbox() { ); } if (group.label) { + const groupNavIdx = groupFlatIndex.get(group.key) ?? -1; + const isGroupSelected = groupNavIdx >= 0 && selectedIndex === groupNavIdx; elements.push(
0 && "pt-2", + isGroupSelected && "bg-accent/50", )} + onClick={() => { + if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx); + }} > General

- Configure instance-wide defaults that affect how operator-visible logs are displayed. + Configure instance-wide preferences including log display, keyboard shortcuts, backup + retention, and data sharing.

@@ -175,9 +176,9 @@ export function InstanceGeneralSettings() {

Backup retention

- Configure how long to keep automatic database backups at each tier. Daily backups - are kept in full, then thinned to one per week and one per month. Backups are - compressed with gzip. + Configure how long automatic database backups are retained. Backups run roughly + every hour and are compressed with gzip. Within the daily window all backups are + kept; beyond that, one backup per week and one per month are preserved.

diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 9bd278d45e..1718362218 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -23,6 +23,7 @@ import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUs import { extractIssueTimelineEvents } from "../lib/issue-timeline-events"; import { queryKeys } from "../lib/queryKeys"; import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data"; +import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { hasLegacyIssueDetailQuery, createIssueDetailPath, @@ -42,6 +43,7 @@ import { applyOptimisticIssueFieldUpdate, applyOptimisticIssueFieldUpdateToCollection, applyOptimisticIssueCommentUpdate, + applyLocalQueuedIssueCommentState, createOptimisticIssueComment, flattenIssueCommentPages, getNextIssueCommentPageParam, @@ -54,7 +56,7 @@ import { type IssueCommentReassignment, type OptimisticIssueComment, } from "../lib/optimistic-issue-comments"; -import { removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs"; +import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { ApprovalCard } from "../components/ApprovalCard"; @@ -504,7 +506,9 @@ type IssueDetailChatTabProps = { projectId: string | null; issueStatus: Issue["status"]; executionRunId: string | null; + blockedBy: Issue["blockedBy"]; comments: IssueDetailComment[]; + locallyQueuedCommentRunIds: ReadonlyMap; hasOlderComments: boolean; commentsLoadingOlder: boolean; onLoadOlderComments: () => void; @@ -542,7 +546,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ projectId, issueStatus, executionRunId, + blockedBy, comments, + locallyQueuedCommentRunIds, hasOlderComments, commentsLoadingOlder, onLoadOlderComments, @@ -645,6 +651,14 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ return comments.map((comment) => { const meta = runMetaByCommentId.get(comment.id); const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment }; + const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, { + queuedTargetRunId: locallyQueuedCommentRunIds.get(comment.id) ?? null, + hasLiveRuns, + runningRunId: runningIssueRun?.id ?? null, + }); + if (locallyQueuedComment !== nextComment) { + return locallyQueuedComment; + } if ( isQueuedIssueComment({ comment: nextComment, @@ -662,7 +676,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ } return nextComment; }); - }, [comments, resolvedActivity, resolvedLinkedRuns, runningIssueRun]); + }, [comments, hasLiveRuns, locallyQueuedCommentRunIds, resolvedActivity, resolvedLinkedRuns, runningIssueRun]); const timelineEvents = useMemo( () => extractIssueTimelineEvents(resolvedActivity), [resolvedActivity], @@ -693,6 +707,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ timelineEvents={timelineEvents} liveRuns={resolvedLiveRuns} activeRun={resolvedActiveRun} + blockedBy={blockedBy ?? []} companyId={companyId} projectId={projectId} issueStatus={issueStatus} @@ -931,6 +946,7 @@ export function IssueDetail() { const [galleryOpen, setGalleryOpen] = useState(false); const [galleryIndex, setGalleryIndex] = useState(0); const [optimisticComments, setOptimisticComments] = useState([]); + const [locallyQueuedCommentRunIds, setLocallyQueuedCommentRunIds] = useState>(() => new Map()); const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0); const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); @@ -1013,6 +1029,11 @@ export function IssueDetail() { }); const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun; const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun; + useEffect(() => { + if (!hasLiveRuns && locallyQueuedCommentRunIds.size > 0) { + setLocallyQueuedCommentRunIds(new Map()); + } + }, [hasLiveRuns, locallyQueuedCommentRunIds.size]); const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search], @@ -1027,6 +1048,13 @@ export function IssueDetail() { enabled: !!resolvedCompanyId && !!issue?.id, placeholderData: keepPreviousDataForSameQueryTail(issue?.id ?? "pending"), }); + const { data: companyLiveRuns } = useQuery({ + queryKey: resolvedCompanyId ? queryKeys.liveRuns(resolvedCompanyId) : ["live-runs", "pending"], + queryFn: () => heartbeatsApi.liveRunsForCompany(resolvedCompanyId!), + enabled: !!resolvedCompanyId, + refetchInterval: 5000, + placeholderData: keepPreviousDataForSameQueryTail(resolvedCompanyId ?? "pending"), + }); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -1113,6 +1141,7 @@ export function IssueDetail() { () => [...rawChildIssues].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()), [rawChildIssues], ); + const liveIssueIds = useMemo(() => collectLiveIssueIds(companyLiveRuns), [companyLiveRuns]); const issuePanelKey = useMemo( () => buildIssuePropertiesPanelKey(issue ?? null, childIssues), [childIssues, issue], @@ -1393,6 +1422,7 @@ export function IssueDetail() { return { optimisticCommentId: optimisticComment?.clientId ?? null, + queuedCommentTargetRunId: queuedComment?.id ?? null, previousIssue, }; }, @@ -1418,6 +1448,13 @@ export function IssueDetail() { }); } } + if (context?.queuedCommentTargetRunId) { + setLocallyQueuedCommentRunIds((current) => { + const next = new Map(current); + next.set(comment.id, context.queuedCommentTargetRunId!); + return next; + }); + } queryClient.setQueryData>( queryKeys.issues.comments(issueId!), (current) => current ? { @@ -1503,6 +1540,7 @@ export function IssueDetail() { return { optimisticCommentId: optimisticComment?.clientId ?? null, + queuedCommentTargetRunId: queuedComment?.id ?? null, previousIssue, }; }, @@ -1531,6 +1569,13 @@ export function IssueDetail() { }); } } + if (comment && context?.queuedCommentTargetRunId) { + setLocallyQueuedCommentRunIds((current) => { + const next = new Map(current); + next.set(comment.id, context.queuedCommentTargetRunId!); + return next; + }); + } if (comment) { queryClient.setQueryData>( queryKeys.issues.comments(issueId!), @@ -1574,10 +1619,12 @@ export function IssueDetail() { await queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(issueId!) }); await queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }); await queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); + await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); const previousRuns = queryClient.getQueryData(queryKeys.issues.runs(issueId!)); const previousLiveRuns = queryClient.getQueryData(queryKeys.issues.liveRuns(issueId!)); const previousActiveRun = queryClient.getQueryData(queryKeys.issues.activeRun(issueId!)); + const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); const liveRunList = previousLiveRuns ?? []; const cachedActiveRun = previousActiveRun ?? null; const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList); @@ -1602,11 +1649,16 @@ export function IssueDetail() { queryKeys.issues.activeRun(issueId!), (current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current), ); + queryClient.setQueryData( + queryKeys.issues.detail(issueId!), + (current: Issue | undefined) => clearIssueExecutionRun(current, runId), + ); return { previousRuns, previousLiveRuns, previousActiveRun, + previousIssue, }; }, onSuccess: () => { @@ -1622,6 +1674,7 @@ export function IssueDetail() { queryClient.setQueryData(queryKeys.issues.runs(issueId!), context?.previousRuns); queryClient.setQueryData(queryKeys.issues.liveRuns(issueId!), context?.previousLiveRuns); queryClient.setQueryData(queryKeys.issues.activeRun(issueId!), context?.previousActiveRun); + queryClient.setQueryData(queryKeys.issues.detail(issueId!), context?.previousIssue); pushToast({ title: "Interrupt failed", body: err instanceof Error ? err.message : "Unable to interrupt the active run", @@ -1633,6 +1686,12 @@ export function IssueDetail() { const cancelQueuedComment = useMutation({ mutationFn: async ({ commentId }: { commentId: string }) => issuesApi.cancelComment(issueId!, commentId), onSuccess: (comment) => { + setLocallyQueuedCommentRunIds((current) => { + if (!current.has(comment.id)) return current; + const next = new Map(current); + next.delete(comment.id); + return next; + }); removeCommentFromCache(comment.id); restoreQueuedCommentDraft(comment.body); invalidateIssueDetail(); @@ -2481,6 +2540,7 @@ export function IssueDetail() { isLoading={childIssuesLoading} agents={agents} projects={projects} + liveIssueIds={liveIssueIds} projectId={issue.projectId ?? undefined} viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`} issueLinkState={resolvedIssueDetailState ?? location.state} @@ -2699,7 +2759,9 @@ export function IssueDetail() { projectId={issue.projectId ?? null} issueStatus={issue.status} executionRunId={issue.executionRunId ?? null} + blockedBy={issue.blockedBy ?? []} comments={threadComments} + locallyQueuedCommentRunIds={locallyQueuedCommentRunIds} hasOlderComments={hasOlderComments} commentsLoadingOlder={commentsLoadingOlder} onLoadOlderComments={loadOlderComments} diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 90db2baec6..63eb7704fc 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -7,12 +7,15 @@ import { projectsApi } from "../api/projects"; import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { queryKeys } from "../lib/queryKeys"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { IssuesList } from "../components/IssuesList"; import { CircleDot } from "lucide-react"; +const WORKSPACE_FILTER_ISSUE_LIMIT = 1000; + export function buildIssuesSearchUrl(currentHref: string, search: string): string | null { const url = new URL(currentHref); const currentSearch = url.searchParams.get("q") ?? ""; @@ -36,6 +39,8 @@ export function Issues() { const initialSearch = searchParams.get("q") ?? ""; const participantAgentId = searchParams.get("participantAgentId") ?? undefined; + const initialWorkspaces = searchParams.getAll("workspace").filter((workspaceId) => workspaceId.length > 0); + const workspaceIdFilter = initialWorkspaces.length === 1 ? initialWorkspaces[0] : undefined; const handleSearchChange = useCallback((search: string) => { const nextUrl = buildIssuesSearchUrl(window.location.href, search); if (!nextUrl) return; @@ -61,13 +66,7 @@ export function Issues() { refetchInterval: 5000, }); - const liveIssueIds = useMemo(() => { - const ids = new Set(); - for (const run of liveRuns ?? []) { - if (run.issueId) ids.add(run.issueId); - } - return ids; - }, [liveRuns]); + const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]); const issueLinkState = useMemo( () => @@ -88,9 +87,16 @@ export function Issues() { ...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__", + "workspace", + workspaceIdFilter ?? "__all__", "with-routine-executions", ], - queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId, includeRoutineExecutions: true }), + queryFn: () => issuesApi.list(selectedCompanyId!, { + participantAgentId, + workspaceId: workspaceIdFilter, + includeRoutineExecutions: true, + ...(workspaceIdFilter ? { limit: WORKSPACE_FILTER_ISSUE_LIMIT } : {}), + }), enabled: !!selectedCompanyId, }); @@ -117,11 +123,12 @@ export function Issues() { viewStateKey="paperclip:issues-view" issueLinkState={issueLinkState} initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined} + initialWorkspaces={initialWorkspaces.length > 0 ? initialWorkspaces : undefined} initialSearch={initialSearch} onSearchChange={handleSearchChange} enableRoutineVisibilityFilter onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} - searchFilters={participantAgentId ? { participantAgentId } : undefined} + searchFilters={participantAgentId || workspaceIdFilter ? { participantAgentId, workspaceId: workspaceIdFilter } : undefined} /> ); } diff --git a/ui/src/pages/OrgChart.test.tsx b/ui/src/pages/OrgChart.test.tsx index ee4b9f36dd..e29b0e8927 100644 --- a/ui/src/pages/OrgChart.test.tsx +++ b/ui/src/pages/OrgChart.test.tsx @@ -245,7 +245,6 @@ describe("OrgChart mobile gestures", () => { expect(navigateMock).toHaveBeenCalledWith("/agents/ceo"); }); - it("pinch-zooms toward the touch center", async () => { const { viewport, layer } = await renderOrgChart(); diff --git a/ui/src/pages/UserProfile.tsx b/ui/src/pages/UserProfile.tsx index ded2dd3eec..3bb5a6f482 100644 --- a/ui/src/pages/UserProfile.tsx +++ b/ui/src/pages/UserProfile.tsx @@ -14,6 +14,7 @@ import { queryKeys } from "../lib/queryKeys"; import { formatCents, formatDate, + formatNumber, formatShortDate, formatTokens, issueUrl, @@ -59,10 +60,10 @@ function WindowColumn({ stats }: { stats: UserProfileWindowStats }) {
- - - - + + + +
@@ -71,9 +72,9 @@ function WindowColumn({ stats }: { stats: UserProfileWindowStats }) { Spend {formatCents(stats.costCents)} Created - {stats.createdIssues} + {formatNumber(stats.createdIssues)} Open - {stats.assignedOpenIssues} + {formatNumber(stats.assignedOpenIssues)}
); @@ -283,9 +284,9 @@ export function UserProfile() {
- - - + + +