[codex] Polish issue board workflows (#4224)

## 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
This commit is contained in:
Dotta
2026-04-21 12:25:34 -05:00
committed by GitHub
parent 09d0678840
commit a26e1288b6
40 changed files with 1218 additions and 132 deletions

View File

@@ -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",
);
});
});

View File

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

View File

@@ -58,7 +58,7 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
name={actorName}
avatarUrl={actorAvatarUrl}
size="xs"
className="align-baseline"
className="align-middle"
/>
<span className="text-muted-foreground ml-1">{verb} </span>
{name && <span className="font-medium">{name}</span>}

View File

@@ -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 {

View File

@@ -28,8 +28,8 @@ export function Identity({ name, avatarUrl, initials, size = "default", classNam
const displayInitials = initials ?? deriveInitials(name);
return (
<span className={cn("inline-flex gap-1.5", size === "xs" ? "items-baseline gap-1" : "items-center", size === "lg" && "gap-2", className)}>
<Avatar size={size} className={size === "xs" ? "relative -top-px" : undefined}>
<span className={cn("inline-flex gap-1.5 items-center", size === "xs" && "gap-1", size === "lg" && "gap-2", className)}>
<Avatar size={size}>
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback>{displayInitials}</AvatarFallback>
</Avatar>

View File

@@ -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 }) => <span>{status}</span>,
}));
vi.mock("./IssueLinkQuicklook", () => ({
IssueLinkQuicklook: ({
children,
to,
issuePathId,
className,
}: {
children: ReactNode;
to: string;
issuePathId: string;
className?: string;
}) => (
<a href={to} data-issue-path-id={issuePathId} className={className}>
{children}
</a>
),
}));
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(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
issueStatus="todo"
blockedBy={[
{
id: "blocker-1",
identifier: "PAP-1723",
title: "QA the install flow",
status: "blocked",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
agentMap={new Map([["agent-1", pausedAgent]])}
currentAssigneeValue="agent:agent-1"
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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);

View File

@@ -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<string, FeedbackVoteValue>;
@@ -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<IssueChatErrorBoundaryProps, Issu
}
}
function IssueBlockedNotice({
issueStatus,
blockers,
}: {
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
}) {
if (blockers.length === 0 && issueStatus !== "blocked") return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
return (
<div className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{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.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map((blocker) => {
const issuePathId = blocker.identifier ?? blocker.id;
return (
<IssueLinkQuicklook
key={blocker.id}
issuePathId={issuePathId}
to={createIssueDetailPath(issuePathId)}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
{blocker.title}
</span>
</IssueLinkQuicklook>
);
})}
</div>
) : null}
</div>
</div>
</div>
);
}
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 (
<div className="mb-3 rounded-md border border-orange-300/70 bg-orange-50/90 px-3 py-2.5 text-sm text-orange-950 shadow-sm dark:border-orange-500/40 dark:bg-orange-500/10 dark:text-orange-100">
<div className="flex items-start gap-2">
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0 text-orange-600 dark:text-orange-300" />
<p className="min-w-0 leading-5">
<span className="font-medium">{agent.name}</span> is paused. New runs will not start until the agent is resumed. {pauseDetail}
</p>
</div>
</div>
);
}
function fallbackAuthorLabel(message: ThreadMessage) {
const custom = message.metadata?.custom as Record<string, unknown> | 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<string, FeedbackVoteValue>();
for (const feedbackVote of feedbackVotes) {
@@ -2290,6 +2373,8 @@ export function IssueChatThread({
{showComposer ? (
<div ref={composerViewportAnchorRef}>
<IssueBlockedNotice issueStatus={issueStatus} blockers={unresolvedBlockers} />
<IssueAssigneePausedNotice agent={assignedAgent} />
<IssueChatComposer
ref={composerRef}
onImageUpload={imageUploadHandler}

View File

@@ -7,6 +7,7 @@ import type {
ExecutionWorkspace,
IssueExecutionPolicy,
IssueExecutionState,
IssueLabel,
Project,
WorkspaceRuntimeService,
} from "@paperclipai/shared";
@@ -26,12 +27,17 @@ const mockProjectsApi = vi.hoisted(() => ({
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> = {}): Issue {
};
}
function createLabel(overrides: Partial<IssueLabel> = {}): 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> = {}): 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([

View File

@@ -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<Issue, "id" | "identifier" | "title"> | IssueRelationIssueSummary;
}) {
return (
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="inline-flex max-w-full items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
>
<span className="truncate">{issue.identifier ?? issue.title}</span>
</Link>
);
}
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<IssueLabel[] | undefined>(
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<string>();
@@ -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<string, IssueLabel>();
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 ? (
<div className="flex items-center gap-1 flex-wrap">
{(issue.labels ?? []).slice(0, 3).map((label) => (
{selectedIssueLabels.slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border"
@@ -443,8 +502,8 @@ export function IssueProperties({
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-xs text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
{selectedIssueLabels.length > 3 && (
<span className="text-xs text-muted-foreground">+{selectedIssueLabels.length - 3}</span>
)}
</div>
) : (
@@ -492,7 +551,8 @@ export function IssueProperties({
onClick={() => toggleLabel(label.id)}
>
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: label.color }} />
<span className="truncate">{label.name}</span>
<span className="truncate flex-1">{label.name}</span>
{selected && <Check className="h-3.5 w-3.5 shrink-0 text-foreground" aria-hidden="true" />}
</button>
);
})}
@@ -917,21 +977,6 @@ export function IssueProperties({
</div>
</>
);
const blockedByTrigger = blockedByIds.length > 0 ? (
<div className="flex items-center gap-1 flex-wrap min-w-0">
{(issue.blockedBy ?? []).slice(0, 2).map((relation) => (
<span key={relation.id} className="inline-flex max-w-full items-center rounded-full border border-border px-2 py-0.5 text-xs">
<span className="truncate">{relation.identifier ?? relation.title}</span>
</span>
))}
{(issue.blockedBy ?? []).length > 2 && (
<span className="text-xs text-muted-foreground">+{(issue.blockedBy ?? []).length - 2}</span>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">No blockers</span>
);
const blockingIssues = issue.blocks ?? [];
const blockerOptions = (allIssues ?? [])
.filter((candidate) => candidate.id !== issue.id)
@@ -997,6 +1042,16 @@ export function IssueProperties({
</div>
</>
);
const renderAddBlockedByButton = (onClick?: () => void) => (
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
onClick={onClick}
>
<Plus className="h-3 w-3" />
Add blocker
</button>
);
return (
<div className="space-y-4">
@@ -1087,32 +1142,47 @@ export function IssueProperties({
{parentContent}
</PropertyPicker>
<PropertyPicker
inline={inline}
label="Blocked by"
open={blockedByOpen}
onOpenChange={(open) => {
setBlockedByOpen(open);
if (!open) setBlockedBySearch("");
}}
triggerContent={blockedByTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-72"
>
{blockedByContent}
</PropertyPicker>
{inline ? (
<div>
<PropertyRow label="Blocked by">
{(issue.blockedBy ?? []).map((relation) => (
<IssuePillLink key={relation.id} issue={relation} />
))}
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
</PropertyRow>
{blockedByOpen && (
<div className="rounded-md border border-border bg-popover p-1 mb-2">
{blockedByContent}
</div>
)}
</div>
) : (
<PropertyRow label="Blocked by">
{(issue.blockedBy ?? []).map((relation) => (
<IssuePillLink key={relation.id} issue={relation} />
))}
<Popover
open={blockedByOpen}
onOpenChange={(open) => {
setBlockedByOpen(open);
if (!open) setBlockedBySearch("");
}}
>
<PopoverTrigger asChild>
{renderAddBlockedByButton()}
</PopoverTrigger>
<PopoverContent className="w-72 p-1" align="end" collisionPadding={16}>
{blockedByContent}
</PopoverContent>
</Popover>
</PropertyRow>
)}
<PropertyRow label="Blocking">
{blockingIssues.length > 0 ? (
<div className="flex flex-wrap gap-1">
{blockingIssues.map((relation) => (
<Link
key={relation.id}
to={`/issues/${relation.identifier ?? relation.id}`}
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
>
{relation.identifier ?? relation.title}
</Link>
<IssuePillLink key={relation.id} issue={relation} />
))}
</div>
) : null}
@@ -1122,13 +1192,7 @@ export function IssueProperties({
<div className="flex flex-wrap items-center gap-1.5">
{childIssues.length > 0
? childIssues.map((child) => (
<Link
key={child.id}
to={`/issues/${child.identifier ?? child.id}`}
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
>
{child.identifier ?? child.title}
</Link>
<IssuePillLink key={child.id} issue={child} />
))
: null}
{onAddSubIssue ? (
@@ -1222,7 +1286,7 @@ export function IssueProperties({
</a>
</PropertyRow>
)}
{issue.executionWorkspaceId && (
{showWorkspaceDetailLink && issue.executionWorkspaceId && (
<PropertyRow label="Workspace">
<Link
to={`/execution-workspaces/${issue.executionWorkspaceId}`}
@@ -1233,6 +1297,17 @@ export function IssueProperties({
</Link>
</PropertyRow>
)}
{workspaceFilterId && (
<PropertyRow label="Tasks">
<Link
to={issuesWorkspaceFilterHref(workspaceFilterId)}
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
View workspace tasks
<ExternalLink className="h-3 w-3" />
</Link>
</PropertyRow>
)}
{issue.currentExecutionWorkspace?.branchName && (
<PropertyRow label="Branch">
<TruncatedCopyable

View File

@@ -659,6 +659,42 @@ describe("IssuesList", () => {
});
});
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(
<IssuesList
issues={[alphaIssue, betaIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
initialWorkspaces={["workspace-alpha"]}
onUpdateIssue={() => 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",

View File

@@ -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<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
baseCreateIssueDefaults?: Record<string, unknown>;
@@ -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<IssueViewState>(() => getInitialViewState(scopedKey, initialAssignees));
const [viewState, setViewState] = useState<IssueViewState>(() =>
getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces),
);
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(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(() => {

View File

@@ -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" },

View File

@@ -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('<a href="https://github.com/paperclipai/paperclip/pull/4099"');
expect(html).toContain('class="lucide lucide-github mr-1 inline h-3.5 w-3.5 align-[-0.125em]"');
expect(html).toContain(">https://github.com/paperclipai/paperclip/pull/4099</a>");
});
it("prefixes GitHub autolinks with the GitHub icon", () => {
const html = renderMarkdown("See https://github.com/paperclipai/paperclip/issues/1778");
expect(html).toContain('<a href="https://github.com/paperclipai/paperclip/issues/1778"');
expect(html).toContain('class="lucide lucide-github mr-1 inline h-3.5 w-3.5 align-[-0.125em]"');
});
it("does not prefix non-GitHub markdown links with the GitHub icon", () => {
const html = renderMarkdown("[docs](https://example.com/docs)");
expect(html).toContain('<a href="https://example.com/docs"');
expect(html).not.toContain("lucide-github");
});
it("keeps fenced code blocks width-bounded and horizontally scrollable", () => {
const html = renderMarkdown("```text\nGET /heartbeat-runs/ca5d23fc-c15b-4826-8ff1-2b6dd11be096/log?offset=2062357&limitBytes=256000\n```");

View File

@@ -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<string | null>(null);
@@ -249,8 +272,17 @@ export function MarkdownBody({
</a>
);
}
const isGitHubLink = isGitHubUrl(href);
const isExternal = isExternalHttpUrl(href);
return (
<a href={href} rel="noreferrer" style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}>
<a
href={href}
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: { rel: "noreferrer" })}
style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}
>
{isGitHubLink ? <Github aria-hidden="true" className="mr-1 inline h-3.5 w-3.5 align-[-0.125em]" /> : null}
{linkChildren}
</a>
);

View File

@@ -254,7 +254,6 @@ describe("ProjectWorkspaceSummaryCard", () => {
root.unmount();
});
});
it("colors live service urls green", () => {
const root = createRoot(container);

View File

@@ -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", () => {

View File

@@ -30,7 +30,7 @@ function AvatarImage({
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
className={cn("aspect-square size-full object-cover", className)}
{...props}
/>
)

View File

@@ -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<string, unknown>([
[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(

View File

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

View File

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

View File

@@ -100,11 +100,18 @@ export interface InboxGroupedSection {
export interface InboxKeyboardGroupSection {
key: string;
label?: string | null;
displayItems: InboxWorkItem[];
childrenByIssueId: ReadonlyMap<string, Issue[]>;
}
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({

View File

@@ -10,12 +10,17 @@ function createIssue(overrides: Partial<Issue> = {}) {
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);
});
});

View File

@@ -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
? {

View File

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

View File

@@ -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;
}

View File

@@ -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"]);
});
});

View File

@@ -0,0 +1,9 @@
import type { LiveRunForIssue } from "../api/heartbeats";
export function collectLiveIssueIds(liveRuns: readonly LiveRunForIssue[] | null | undefined): Set<string> {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}

View File

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

View File

@@ -12,6 +12,11 @@ export interface OptimisticIssueComment extends IssueComment {
}
export type IssueTimelineComment = IssueComment | OptimisticIssueComment;
export type LocallyQueuedIssueComment<T extends IssueComment> = 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<T extends IssueComment>(
comment: T,
params: {
queuedTargetRunId?: string | null;
hasLiveRuns: boolean;
runningRunId?: string | null;
},
): T | LocallyQueuedIssueComment<T> {
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";
}

View File

@@ -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> = {}): 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);
});
});

View File

@@ -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(),
};
}

View File

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

View File

@@ -253,7 +253,7 @@ export function Agents() {
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
)}
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
<span className="w-28 whitespace-nowrap text-right font-mono text-xs text-muted-foreground">
{getAdapterLabel(agent.adapterType)}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
@@ -356,7 +356,7 @@ function OrgTreeNode({
)}
{agent && (
<>
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
<span className="w-28 whitespace-nowrap text-right font-mono text-xs text-muted-foreground">
{getAdapterLabel(agent.adapterType)}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">

View File

@@ -535,7 +535,7 @@ export function ExecutionWorkspaceDetail() {
</p>
</div>
<Card>
<Card className="rounded-none">
<CardHeader>
<CardTitle>Services and jobs</CardTitle>
<CardDescription>
@@ -584,7 +584,7 @@ export function ExecutionWorkspaceDetail() {
{activeTab === "configuration" ? (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card className="rounded-none">
<CardHeader>
<CardTitle>Workspace settings</CardTitle>
<CardDescription>
@@ -594,7 +594,7 @@ export function ExecutionWorkspaceDetail() {
<Button
variant="destructive"
size="sm"
className="w-full sm:w-auto"
className="w-full rounded-none sm:w-auto"
onClick={() => setCloseDialogOpen(true)}
disabled={workspace.status === "archived"}
>
@@ -804,7 +804,7 @@ export function ExecutionWorkspaceDetail() {
</CardContent>
</Card>
<Card>
<Card className="rounded-none">
<CardHeader>
<CardTitle>Workspace context</CardTitle>
<CardDescription>Linked objects and relationships</CardDescription>
@@ -850,7 +850,7 @@ export function ExecutionWorkspaceDetail() {
</CardContent>
</Card>
<Card>
<Card className="rounded-none">
<CardHeader>
<CardTitle>Concrete location</CardTitle>
<CardDescription>Paths and refs</CardDescription>
@@ -896,7 +896,7 @@ export function ExecutionWorkspaceDetail() {
</Card>
</div>
) : activeTab === "runtime_logs" ? (
<Card>
<Card className="rounded-none">
<CardHeader>
<CardTitle>Runtime and cleanup logs</CardTitle>
<CardDescription>Recent operations</CardDescription>
@@ -913,7 +913,7 @@ export function ExecutionWorkspaceDetail() {
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
<div className="space-y-3">
{workspaceOperationsQuery.data.map((operation) => (
<div key={operation.id} className="rounded-md border border-border/80 bg-background px-4 py-3">
<div key={operation.id} className="rounded-none border border-border/80 bg-background px-4 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>

View File

@@ -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<InboxGroupedSection[]>(() => [
...buildGroupedInboxSections(filteredWorkItems, groupBy, inboxWorkspaceGrouping, { nestingEnabled }),
...buildGroupedInboxSections(
@@ -1256,6 +1266,13 @@ export function Inbox() {
});
return map;
}, [flatNavItems]);
const groupFlatIndex = useMemo(() => {
const map = new Map<string, number>();
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(
<div
key={`group-${group.key}`}
data-inbox-item
className={cn(
"px-3 sm:px-4",
groupIndex > 0 && "pt-2",
isGroupSelected && "bg-accent/50",
)}
onClick={() => {
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
}}
>
<IssueGroupHeader
label={group.label}

View File

@@ -90,7 +90,8 @@ export function InstanceGeneralSettings() {
<h1 className="text-lg font-semibold">General</h1>
</div>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
@@ -175,9 +176,9 @@ export function InstanceGeneralSettings() {
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Backup retention</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
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.
</p>
</div>

View File

@@ -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<string, string>;
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<OptimisticIssueComment[]>([]);
const [locallyQueuedCommentRunIds, setLocallyQueuedCommentRunIds] = useState<Map<string, string>>(() => new Map());
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(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[]>(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<LiveRunForIssue[]>(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<InfiniteData<IssueComment[], string | null>>(
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<InfiniteData<IssueComment[], string | null>>(
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<RunForIssue[]>(queryKeys.issues.runs(issueId!));
const previousLiveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueId!));
const previousActiveRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueId!));
const previousIssue = queryClient.getQueryData<Issue>(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}

View File

@@ -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<string>();
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}
/>
);
}

View File

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

View File

@@ -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 }) {
</div>
<div className="grid grid-cols-2 gap-x-5 gap-y-3">
<Metric value={String(stats.touchedIssues)} label="Touched" />
<Metric value={String(stats.completedIssues)} label="Completed" />
<Metric value={String(stats.commentCount)} label="Comments" />
<Metric value={String(stats.activityCount)} label="Actions" />
<Metric value={formatNumber(stats.touchedIssues)} label="Touched" />
<Metric value={formatNumber(stats.completedIssues)} label="Completed" />
<Metric value={formatNumber(stats.commentCount)} label="Comments" />
<Metric value={formatNumber(stats.activityCount)} label="Actions" />
</div>
<div className="grid grid-cols-2 gap-x-5 gap-y-1.5 pt-3 text-xs tabular-nums text-muted-foreground">
@@ -71,9 +72,9 @@ function WindowColumn({ stats }: { stats: UserProfileWindowStats }) {
<span>Spend</span>
<span className="text-right text-foreground">{formatCents(stats.costCents)}</span>
<span>Created</span>
<span className="text-right text-foreground">{stats.createdIssues}</span>
<span className="text-right text-foreground">{formatNumber(stats.createdIssues)}</span>
<span>Open</span>
<span className="text-right text-foreground">{stats.assignedOpenIssues}</span>
<span className="text-right text-foreground">{formatNumber(stats.assignedOpenIssues)}</span>
</div>
</div>
);
@@ -283,9 +284,9 @@ export function UserProfile() {
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<HeroStat label="All-time tokens" value={formatTokens(allTimeTokens)} hint={formatCents(allTime?.costCents ?? 0) + " spent"} />
<HeroStat label="Completed" value={String(allTime?.completedIssues ?? 0)} hint={allTime ? `${completionRate(allTime)} rate` : undefined} />
<HeroStat label="Open assigned" value={String(allTime?.assignedOpenIssues ?? 0)} hint={`${allTime?.createdIssues ?? 0} created`} />
<HeroStat label="7-day actions" value={String(last7?.activityCount ?? 0)} hint={`${last7?.commentCount ?? 0} comments`} />
<HeroStat label="Completed" value={formatNumber(allTime?.completedIssues ?? 0)} hint={allTime ? `${completionRate(allTime)} rate` : undefined} />
<HeroStat label="Open assigned" value={formatNumber(allTime?.assignedOpenIssues ?? 0)} hint={`${formatNumber(allTime?.createdIssues ?? 0)} created`} />
<HeroStat label="7-day actions" value={formatNumber(last7?.activityCount ?? 0)} hint={`${formatNumber(last7?.commentCount ?? 0)} comments`} />
</div>
</section>