mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09: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:
parent
09d0678840
commit
a26e1288b6
40 changed files with 1218 additions and 132 deletions
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue