[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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1218 additions and 132 deletions

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}
/>
)