mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
[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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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```");
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -254,7 +254,6 @@ describe("ProjectWorkspaceSummaryCard", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("colors live service urls green", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
64
ui/src/lib/liveIssueIds.test.ts
Normal file
64
ui/src/lib/liveIssueIds.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
9
ui/src/lib/liveIssueIds.ts
Normal file
9
ui/src/lib/liveIssueIds.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user