mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
Improve operator workflow QoL (#5291)
## Thinking Path > - Paperclip is a control plane operators use repeatedly to supervise agent companies. > - Common operator workflows depend on fast scanning of inboxes, issue sidebars, workspaces, cost totals, and runtime services. > - Several small UI and service gaps made those workflows slower or less clear. > - This pull request groups the operator-facing QoL changes that can stand alone from recovery and adapter work. > - The benefit is a denser, clearer board experience for issue triage and workspace operation. ## What Changed - Added inbox assignee/project grouping and issue list token/runtime totals. - Improved issue properties with removable blocker chips and workspace task links. - Improved execution workspace layout, runtime controls, issues tab default, and stopped-port reuse behavior. - Added mobile markdown/routine dialog fixes, page title company names, sidebar polish, and dashboard run task label cleanup. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run ui/src/lib/inbox.test.ts ui/src/components/IssueProperties.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx server/src/__tests__/workspace-runtime.test.ts server/src/__tests__/costs-service.test.ts` ## Risks - Medium UI risk because this touches several operator surfaces. The branch is intentionally grouped around workflow/QoL files and keeps the file count below the Greptile limit. ## Model Used - OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with shell/git/GitHub CLI tool use. ## 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 - [x] 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
11ffd6f2c5
commit
424e81d087
47 changed files with 1739 additions and 250 deletions
|
|
@ -11,7 +11,7 @@ const mockHeartbeatsApi = vi.hoisted(() => ({
|
|||
}));
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
|
|
@ -55,6 +55,20 @@ async function flushReact() {
|
|||
});
|
||||
}
|
||||
|
||||
async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) {
|
||||
let lastError: unknown;
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
await flushReact();
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function createRun(index: number) {
|
||||
return {
|
||||
id: `run-${index}`,
|
||||
|
|
@ -71,6 +85,37 @@ function createRun(index: number) {
|
|||
};
|
||||
}
|
||||
|
||||
function createIssueRun(index: number, issueId: string) {
|
||||
return {
|
||||
...createRun(index),
|
||||
issueId,
|
||||
};
|
||||
}
|
||||
|
||||
function createIssue(id: string, identifier: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
identifier,
|
||||
title,
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
parentId: null,
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
goalId: null,
|
||||
labels: [],
|
||||
blockedByIssueIds: [],
|
||||
blocksIssueIds: [],
|
||||
createdAt: "2026-04-24T12:00:00.000Z",
|
||||
updatedAt: "2026-04-24T12:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("ActiveAgentsPanel", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
|
|
@ -78,7 +123,7 @@ describe("ActiveAgentsPanel", () => {
|
|||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([1, 2, 3, 4, 5].map(createRun));
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.get.mockRejectedValue(new Error("Issue not found"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -149,4 +194,42 @@ describe("ActiveAgentsPanel", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("loads exact visible run issues so task names render even when the issue list page would miss them", async () => {
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
|
||||
createIssueRun(1, "65274215-0000-4000-8000-000000000000"),
|
||||
]);
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue(
|
||||
"65274215-0000-4000-8000-000000000000",
|
||||
"PAP-3562",
|
||||
"Phase 4B: Implement LLM Wiki distillation UI",
|
||||
));
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveAgentsPanel companyId="company-1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await waitForMicrotaskAssertion(() => {
|
||||
expect(mockIssuesApi.get).toHaveBeenCalledWith("65274215-0000-4000-8000-000000000000");
|
||||
const issueLink = [...container.querySelectorAll("a")].find((anchor) =>
|
||||
anchor.textContent?.includes("Phase 4B"),
|
||||
);
|
||||
expect(issueLink?.textContent).toBe("PAP-3562 - Phase 4B: Implement LLM Wiki distillation UI");
|
||||
expect(issueLink?.getAttribute("href")).toBe("/issues/PAP-3562");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { memo, useMemo } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||
import type { TranscriptEntry } from "../adapters";
|
||||
|
|
@ -56,19 +56,28 @@ export function ActiveAgentsPanel({
|
|||
const runs = liveRuns ?? [];
|
||||
const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]);
|
||||
const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
|
||||
queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
|
||||
enabled: visibleRuns.length > 0,
|
||||
const visibleIssueIds = useMemo(
|
||||
() => [...new Set(visibleRuns.map((run) => run.issueId).filter((issueId): issueId is string => Boolean(issueId)))],
|
||||
[visibleRuns],
|
||||
);
|
||||
|
||||
const issueQueries = useQueries({
|
||||
queries: visibleIssueIds.map((issueId) => ({
|
||||
queryKey: queryKeys.issues.detail(issueId),
|
||||
queryFn: () => issuesApi.get(issueId),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})),
|
||||
});
|
||||
|
||||
const issueById = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
for (const issue of issues ?? []) {
|
||||
map.set(issue.id, issue);
|
||||
for (const query of issueQueries) {
|
||||
const issue = query.data;
|
||||
if (issue) map.set(issue.id, issue);
|
||||
}
|
||||
return map;
|
||||
}, [issues]);
|
||||
}, [issueQueries]);
|
||||
|
||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||
runs: visibleRuns,
|
||||
|
|
|
|||
|
|
@ -60,4 +60,46 @@ describe("IssueBlockedNotice", () => {
|
|||
expect(node.textContent).not.toContain("Work on this issue is blocked until");
|
||||
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not render when the issue is done even if a stale handoff state is required", () => {
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
issueStatus="done"
|
||||
blockers={[]}
|
||||
agentName="CodexCoder"
|
||||
successfulRunHandoff={{
|
||||
state: "required",
|
||||
required: true,
|
||||
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
|
||||
correctiveRunId: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
detectedProgressSummary: "Updated the plan and left follow-up work.",
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(node.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("does not render when the issue is cancelled even if blockers remain", () => {
|
||||
const node = render(
|
||||
<IssueBlockedNotice
|
||||
issueStatus="cancelled"
|
||||
blockers={[
|
||||
{
|
||||
id: "blocker-1",
|
||||
identifier: "PAP-123",
|
||||
title: "Blocker",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(node.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export function IssueBlockedNotice({
|
|||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||
agentName?: string | null;
|
||||
}) {
|
||||
if (issueStatus === "done" || issueStatus === "cancelled") return null;
|
||||
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
|
||||
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -476,6 +476,59 @@ describe("IssueProperties", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("removes a blocked-by issue from the chip remove action after confirmation", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: "issue-4",
|
||||
identifier: "PAP-4",
|
||||
title: "Keep blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
|
||||
expect(removeButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this issue.");
|
||||
const confirmButton = Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Remove blocker"));
|
||||
expect(confirmButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] });
|
||||
|
||||
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";
|
||||
|
|
@ -530,7 +583,7 @@ describe("IssueProperties", () => {
|
|||
(link) => link.textContent?.trim() === "View workspace",
|
||||
);
|
||||
expect(tasksLink).not.toBeUndefined();
|
||||
expect(tasksLink?.getAttribute("href")).toBe("/issues?workspace=workspace-1");
|
||||
expect(tasksLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1/issues");
|
||||
expect(workspaceLink).not.toBeUndefined();
|
||||
expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1");
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ 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";
|
||||
|
|
@ -32,9 +31,19 @@ import { Identity } from "./Identity";
|
|||
import { IssueReferencePill } from "./IssueReferencePill";
|
||||
import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, Clock } from "lucide-react";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
|
||||
|
|
@ -113,10 +122,8 @@ 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()}`;
|
||||
function executionWorkspaceIssuesHref(workspaceId: string) {
|
||||
return `/execution-workspaces/${workspaceId}/issues`;
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(value: string | null | undefined) {
|
||||
|
|
@ -144,6 +151,87 @@ function PropertyRow({ label, children }: { label: string; children: React.React
|
|||
);
|
||||
}
|
||||
|
||||
function RemovableIssueReferencePill({
|
||||
issue,
|
||||
onRemove,
|
||||
}: {
|
||||
issue: NonNullable<Issue["blockedBy"]>[number];
|
||||
onRemove: (issueId: string) => void;
|
||||
}) {
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const issueLabel = issue.identifier ?? issue.title;
|
||||
const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title;
|
||||
const content = (
|
||||
<>
|
||||
<StatusIcon status={issue.status} className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{issueLabel}</span>
|
||||
</>
|
||||
);
|
||||
const removeLabel = `Remove ${issueLabel} as blocker`;
|
||||
const handleRemove = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsConfirmOpen(true);
|
||||
};
|
||||
const confirmRemove = () => {
|
||||
onRemove(issue.id);
|
||||
setIsConfirmOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
data-mention-kind="issue"
|
||||
className={cn(
|
||||
"paperclip-mention-chip paperclip-mention-chip--issue group",
|
||||
"inline-flex items-center gap-1 rounded-full border border-border py-0.5 pl-1 pr-2 text-xs",
|
||||
)}
|
||||
title={issue.title}
|
||||
aria-label={`Issue ${issueLabel}: ${issue.title}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring group-hover:opacity-100"
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
{issue.identifier ? (
|
||||
<Link
|
||||
to={`/issues/${issueLabel}`}
|
||||
className="inline-flex min-w-0 items-center gap-1 no-underline hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
aria-label={`Issue ${issueLabel}: ${issue.title}`}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">{content}</span>
|
||||
)}
|
||||
</span>
|
||||
<Dialog open={isConfirmOpen} onOpenChange={setIsConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove blocker?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remove {confirmLabel} as a blocker for this issue.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" variant="destructive" onClick={confirmRemove}>
|
||||
Remove blocker
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */
|
||||
function PropertyPicker({
|
||||
inline,
|
||||
|
|
@ -331,10 +419,10 @@ export function IssueProperties({
|
|||
() => isMainIssueWorkspace({ issue, project: issueProject }),
|
||||
[issue, issueProject],
|
||||
);
|
||||
const workspaceFilterId = useMemo(() => {
|
||||
const workspaceTasksExecutionWorkspaceId = useMemo(() => {
|
||||
if (!isolatedWorkspacesEnabled) return null;
|
||||
if (issueUsesMainWorkspace) return null;
|
||||
return resolveIssueFilterWorkspaceId(issue);
|
||||
return issue.executionWorkspaceId ?? issue.currentExecutionWorkspace?.id ?? null;
|
||||
}, [isolatedWorkspacesEnabled, issue, issueUsesMainWorkspace]);
|
||||
const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace;
|
||||
const liveWorkspaceService = useMemo(() => {
|
||||
|
|
@ -1137,6 +1225,9 @@ export function IssueProperties({
|
|||
: [...blockedByIds, blockedByIssueId];
|
||||
onUpdate({ blockedByIssueIds: nextBlockedByIds });
|
||||
};
|
||||
const removeBlockedBy = (blockedByIssueId: string) => {
|
||||
onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) });
|
||||
};
|
||||
|
||||
const blockedByContent = (
|
||||
<>
|
||||
|
|
@ -1284,7 +1375,7 @@ export function IssueProperties({
|
|||
<div>
|
||||
<PropertyRow label="Blocked by">
|
||||
{(issue.blockedBy ?? []).map((relation) => (
|
||||
<IssueReferencePill key={relation.id} issue={relation} />
|
||||
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
|
||||
))}
|
||||
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
|
||||
</PropertyRow>
|
||||
|
|
@ -1297,7 +1388,7 @@ export function IssueProperties({
|
|||
) : (
|
||||
<PropertyRow label="Blocked by">
|
||||
{(issue.blockedBy ?? []).map((relation) => (
|
||||
<IssueReferencePill key={relation.id} issue={relation} />
|
||||
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
|
||||
))}
|
||||
<Popover
|
||||
open={blockedByOpen}
|
||||
|
|
@ -1448,10 +1539,10 @@ export function IssueProperties({
|
|||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{workspaceFilterId && (
|
||||
{workspaceTasksExecutionWorkspaceId && (
|
||||
<PropertyRow label="Tasks">
|
||||
<Link
|
||||
to={issuesWorkspaceFilterHref(workspaceFilterId)}
|
||||
to={executionWorkspaceIssuesHref(workspaceTasksExecutionWorkspaceId)}
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
View workspace tasks
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import {
|
|||
resolveIssueWorkspaceName,
|
||||
type InboxIssueColumn,
|
||||
} from "../lib/inbox";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, formatDurationMs, formatTokens } from "../lib/utils";
|
||||
import {
|
||||
InboxIssueMetaLeading,
|
||||
InboxIssueTrailingColumns,
|
||||
|
|
@ -114,7 +114,7 @@ export type IssueSortField = "status" | "priority" | "title" | "created" | "upda
|
|||
export type IssueViewState = IssueFilterState & {
|
||||
sortField: IssueSortField;
|
||||
sortDir: "asc" | "desc";
|
||||
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
||||
groupBy: "status" | "priority" | "assignee" | "project" | "workspace" | "parent" | "none";
|
||||
viewMode: "list" | "board";
|
||||
nestingEnabled: boolean;
|
||||
collapsedGroups: string[];
|
||||
|
|
@ -364,6 +364,12 @@ interface IssuesListProps {
|
|||
createIssueLabel?: string;
|
||||
defaultSortField?: IssueSortField;
|
||||
showProgressSummary?: boolean;
|
||||
/**
|
||||
* When set together with `showProgressSummary`, the progress strip fetches
|
||||
* the recursive cost-summary for this parent issue and renders aggregate
|
||||
* tokens + wall-clock runtime for every run in the tree.
|
||||
*/
|
||||
parentIssueIdForCostSummary?: string;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
hasMoreIssues?: boolean;
|
||||
isLoadingMoreIssues?: boolean;
|
||||
|
|
@ -439,9 +445,11 @@ function IssueSearchInput({
|
|||
function SubIssueProgressSummaryStrip({
|
||||
summary,
|
||||
issueLinkState,
|
||||
parentIssueIdForCostSummary,
|
||||
}: {
|
||||
summary: SubIssueProgressSummary;
|
||||
issueLinkState?: unknown;
|
||||
parentIssueIdForCostSummary?: string;
|
||||
}) {
|
||||
const target = summary.target;
|
||||
const targetIssue = target?.issue ?? null;
|
||||
|
|
@ -451,6 +459,21 @@ function SubIssueProgressSummaryStrip({
|
|||
.map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 }))
|
||||
.filter((entry) => entry.count > 0);
|
||||
|
||||
// Refresh fast enough that the runtime ticks up while a sub-issue is still
|
||||
// running, but slow enough not to hammer the recursive CTE on idle trees.
|
||||
const hasInProgress = summary.inProgressCount > 0;
|
||||
const { data: costSummary } = useQuery({
|
||||
queryKey: queryKeys.issues.costSummary(parentIssueIdForCostSummary ?? "pending", { excludeRoot: true }),
|
||||
queryFn: () => issuesApi.getCostSummary(parentIssueIdForCostSummary!, { excludeRoot: true }),
|
||||
enabled: !!parentIssueIdForCostSummary,
|
||||
refetchInterval: hasInProgress ? 5_000 : false,
|
||||
});
|
||||
|
||||
const totalTokens = costSummary
|
||||
? costSummary.inputTokens + costSummary.cachedInputTokens + costSummary.outputTokens
|
||||
: 0;
|
||||
const showCostSummary = !!costSummary && (costSummary.runCount > 0 || totalTokens > 0);
|
||||
|
||||
return (
|
||||
<div className="border border-border bg-background p-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
|
|
@ -465,6 +488,23 @@ function SubIssueProgressSummaryStrip({
|
|||
<span className="text-muted-foreground">
|
||||
{summary.blockedCount} blocked
|
||||
</span>
|
||||
{showCostSummary && (
|
||||
<>
|
||||
<span
|
||||
className="text-muted-foreground tabular-nums"
|
||||
title={`${costSummary.runCount.toLocaleString()} run${
|
||||
costSummary.runCount === 1 ? "" : "s"
|
||||
} across ${costSummary.issueCount} sub-issue${
|
||||
costSummary.issueCount === 1 ? "" : "s"
|
||||
}`}
|
||||
>
|
||||
{formatTokens(totalTokens)} tokens
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDurationMs(costSummary.runtimeMs)} runtime
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
|
|
@ -536,6 +576,7 @@ export function IssuesList({
|
|||
createIssueLabel,
|
||||
defaultSortField,
|
||||
showProgressSummary = false,
|
||||
parentIssueIdForCostSummary,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
hasMoreIssues = false,
|
||||
isLoadingMoreIssues = false,
|
||||
|
|
@ -996,6 +1037,22 @@ export function IssuesList({
|
|||
items: groups[key]!,
|
||||
}));
|
||||
}
|
||||
if (viewState.groupBy === "project") {
|
||||
const groups = groupBy(filtered, (issue) => issue.projectId ?? "__no_project");
|
||||
return Object.keys(groups)
|
||||
.sort((a, b) => {
|
||||
if (a === "__no_project") return 1;
|
||||
if (b === "__no_project") return -1;
|
||||
const labelA = projectById.get(a)?.name ?? a;
|
||||
const labelB = projectById.get(b)?.name ?? b;
|
||||
return labelA.localeCompare(labelB);
|
||||
})
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: key === "__no_project" ? "No Project" : (projectById.get(key)?.name ?? key.slice(0, 8)),
|
||||
items: groups[key]!,
|
||||
}));
|
||||
}
|
||||
if (viewState.groupBy === "parent") {
|
||||
const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent");
|
||||
return Object.keys(groups)
|
||||
|
|
@ -1036,6 +1093,7 @@ export function IssuesList({
|
|||
workspaceNameMap,
|
||||
issueTitleMap,
|
||||
companyUserLabelMap,
|
||||
projectById,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1131,6 +1189,7 @@ export function IssuesList({
|
|||
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
||||
else defaults.assigneeAgentId = groupKey;
|
||||
}
|
||||
else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey;
|
||||
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
|
||||
const parentIssue = issueById.get(groupKey);
|
||||
if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId));
|
||||
|
|
@ -1175,7 +1234,11 @@ export function IssuesList({
|
|||
return (
|
||||
<div ref={rootRef} className="space-y-4">
|
||||
{progressSummary ? (
|
||||
<SubIssueProgressSummaryStrip summary={progressSummary} issueLinkState={issueLinkState} />
|
||||
<SubIssueProgressSummaryStrip
|
||||
summary={progressSummary}
|
||||
issueLinkState={issueLinkState}
|
||||
parentIssueIdForCostSummary={parentIssueIdForCostSummary}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Toolbar */}
|
||||
|
|
@ -1307,6 +1370,7 @@ export function IssuesList({
|
|||
["status", "Status"],
|
||||
["priority", "Priority"],
|
||||
["assignee", "Assignee"],
|
||||
["project", "Project"],
|
||||
["workspace", "Workspace"],
|
||||
["parent", "Parent Issue"],
|
||||
["none", "None"],
|
||||
|
|
|
|||
|
|
@ -356,6 +356,20 @@ describe("MarkdownBody", () => {
|
|||
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
|
||||
});
|
||||
|
||||
it("renders markdown tables in a horizontally scrollable region", () => {
|
||||
const html = renderMarkdown([
|
||||
"| Time UTC | Source | Finding | Stalled leaf | Escalation |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
"| 2026-04-30T14:31:35Z | PAP-2505 | in_review_without_action_path | PAP-2779 | PAP-2910 |",
|
||||
].join("\n"));
|
||||
|
||||
expect(html).toContain('class="paperclip-markdown-table-scroll"');
|
||||
expect(html).toContain('aria-label="Scrollable table"');
|
||||
expect(html).toContain('tabindex="0"');
|
||||
expect(html).toContain("<table>");
|
||||
expect(html).toContain('style="overflow-wrap:anywhere;word-break:normal"');
|
||||
});
|
||||
|
||||
it("opens external links in a new tab with safe rel attributes", () => {
|
||||
const html = renderMarkdown("[docs](https://example.com/docs)");
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ const scrollableBlockStyle: React.CSSProperties = {
|
|||
overflowX: "auto",
|
||||
};
|
||||
|
||||
const tableCellWrapStyle: React.CSSProperties = {
|
||||
overflowWrap: "anywhere",
|
||||
wordBreak: "normal",
|
||||
};
|
||||
|
||||
function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
|
||||
return {
|
||||
...wrapAnywhereStyle,
|
||||
|
|
@ -91,6 +96,13 @@ function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
|
|||
};
|
||||
}
|
||||
|
||||
function mergeTableCellStyle(style?: React.CSSProperties): React.CSSProperties {
|
||||
return {
|
||||
...tableCellWrapStyle,
|
||||
...style,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProperties {
|
||||
return {
|
||||
...scrollableBlockStyle,
|
||||
|
|
@ -514,13 +526,20 @@ export function MarkdownBody({
|
|||
{blockquoteChildren}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ node: _node, style: tableStyle, children: tableChildren, ...tableProps }) => (
|
||||
<div className="paperclip-markdown-table-scroll" role="region" aria-label="Scrollable table" tabIndex={0}>
|
||||
<table {...tableProps} style={tableStyle as React.CSSProperties | undefined}>
|
||||
{tableChildren}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
td: ({ node: _node, style: tableCellStyle, children: tableCellChildren, ...tableCellProps }) => (
|
||||
<td {...tableCellProps} style={mergeWrapStyle(tableCellStyle as React.CSSProperties | undefined)}>
|
||||
<td {...tableCellProps} style={mergeTableCellStyle(tableCellStyle as React.CSSProperties | undefined)}>
|
||||
{tableCellChildren}
|
||||
</td>
|
||||
),
|
||||
th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => (
|
||||
<th {...tableHeaderProps} style={mergeWrapStyle(tableHeaderStyle as React.CSSProperties | undefined)}>
|
||||
<th {...tableHeaderProps} style={mergeTableCellStyle(tableHeaderStyle as React.CSSProperties | undefined)}>
|
||||
{tableHeaderChildren}
|
||||
</th>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -411,6 +411,86 @@ describe("NewIssueDialog", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("applies project and execution workspace defaults for normal new issues", async () => {
|
||||
mockProjectsApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-1",
|
||||
name: "Alpha",
|
||||
description: null,
|
||||
archivedAt: null,
|
||||
color: "#445566",
|
||||
workspaces: [
|
||||
{
|
||||
id: "project-workspace-1",
|
||||
name: "Primary",
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: "project-workspace-2",
|
||||
name: "Isolated checkout",
|
||||
isPrimary: false,
|
||||
},
|
||||
],
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "shared_workspace",
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "workspace-1",
|
||||
name: "PAP-100",
|
||||
mode: "isolated_workspace",
|
||||
status: "active",
|
||||
branchName: "feature/pap-100",
|
||||
cwd: "/tmp/workspace-1",
|
||||
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
dialogState.newIssueDefaults = {
|
||||
title: "Follow-up issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-2",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
};
|
||||
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("New issue");
|
||||
expect(container.textContent).not.toContain("New sub-issue");
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Reusing PAP-100");
|
||||
});
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Create Issue"));
|
||||
expect(submitButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
title: "Follow-up issue",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-2",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: {
|
||||
mode: "isolated_workspace",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("submits the latest locally typed title and description", async () => {
|
||||
let resolveProjects: (projects: Array<{
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -242,6 +242,21 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
|||
return "shared_workspace";
|
||||
}
|
||||
|
||||
function defaultExecutionWorkspaceModeForIssueDefaults(
|
||||
defaults: {
|
||||
executionWorkspaceId?: unknown;
|
||||
executionWorkspaceMode?: unknown;
|
||||
},
|
||||
project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined,
|
||||
) {
|
||||
if (typeof defaults.executionWorkspaceId === "string" && defaults.executionWorkspaceId.length > 0) {
|
||||
return "reuse_existing";
|
||||
}
|
||||
return typeof defaults.executionWorkspaceMode === "string" && defaults.executionWorkspaceMode.length > 0
|
||||
? defaults.executionWorkspaceMode
|
||||
: defaultExecutionWorkspaceModeForProject(project);
|
||||
}
|
||||
|
||||
const IssueTitleTextarea = memo(function IssueTitleTextarea({
|
||||
value,
|
||||
pending,
|
||||
|
|
@ -686,9 +701,7 @@ export function NewIssueDialog() {
|
|||
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
||||
const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId
|
||||
?? defaultProjectWorkspaceIdForProject(defaultProject);
|
||||
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
|
||||
? "reuse_existing"
|
||||
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||
const defaultExecutionWorkspaceMode = defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject);
|
||||
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
|
|
@ -710,8 +723,9 @@ export function NewIssueDialog() {
|
|||
setPriority(newIssueDefaults.priority ?? "");
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
||||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
|
|
@ -720,12 +734,17 @@ export function NewIssueDialog() {
|
|||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
|
||||
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
|
||||
? defaultProjectId || null
|
||||
: null;
|
||||
} else if (draft && draft.title.trim()) {
|
||||
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
|
||||
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
|
||||
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
||||
const hasExplicitExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId !== undefined;
|
||||
const hasExplicitExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceMode !== undefined;
|
||||
setIssueText(draft.title, draft.description);
|
||||
setStatus(draft.status || "todo");
|
||||
setPriority(draft.priority);
|
||||
|
|
@ -739,27 +758,40 @@ export function NewIssueDialog() {
|
|||
setShowReviewerRow(!!(draft.reviewerValue));
|
||||
setShowApproverRow(!!(draft.approverValue));
|
||||
setProjectId(restoredProjectId);
|
||||
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
|
||||
setProjectWorkspaceId(
|
||||
hasExplicitProjectWorkspaceId
|
||||
? (newIssueDefaults.projectWorkspaceId ?? "")
|
||||
: (draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)),
|
||||
);
|
||||
setAssigneeModelLane(draft.assigneeModelLane ?? "primary");
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||
setAssigneeChrome(draft.assigneeChrome ?? false);
|
||||
setExecutionWorkspaceMode(
|
||||
draft.executionWorkspaceMode
|
||||
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)),
|
||||
hasExplicitExecutionWorkspaceId || hasExplicitExecutionWorkspaceMode
|
||||
? defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, restoredProject)
|
||||
: (
|
||||
draft.executionWorkspaceMode
|
||||
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject))
|
||||
),
|
||||
);
|
||||
setSelectedExecutionWorkspaceId(draft.selectedExecutionWorkspaceId ?? "");
|
||||
executionWorkspaceDefaultProjectId.current = draft.projectWorkspaceId || restoredProject
|
||||
setSelectedExecutionWorkspaceId(
|
||||
hasExplicitExecutionWorkspaceId
|
||||
? (newIssueDefaults.executionWorkspaceId ?? "")
|
||||
: (draft.selectedExecutionWorkspaceId ?? ""),
|
||||
);
|
||||
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || hasExplicitExecutionWorkspaceId || draft.projectWorkspaceId || restoredProject
|
||||
? restoredProjectId || null
|
||||
: null;
|
||||
} else {
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
||||
setIssueText("", "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(defaultProjectId);
|
||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
|
||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||
setReviewerValue("");
|
||||
setApproverValue("");
|
||||
|
|
@ -768,9 +800,11 @@ export function NewIssueDialog() {
|
|||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
|
||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
|
||||
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
|
||||
? defaultProjectId || null
|
||||
: null;
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults, orderedProjects, selectedCompanyId, setIssueText]);
|
||||
|
||||
|
|
|
|||
|
|
@ -224,6 +224,74 @@ describe("RoutineRunVariablesDialog", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps the mobile dialog bounded with an internal form scroll region", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RoutineRunVariablesDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
companyId="company-1"
|
||||
projects={[createProject()]}
|
||||
agents={[createAgent()]}
|
||||
defaultProjectId="project-1"
|
||||
defaultAssigneeAgentId="agent-1"
|
||||
variables={[
|
||||
{
|
||||
name: "notes",
|
||||
label: "notes",
|
||||
type: "textarea",
|
||||
defaultValue: null,
|
||||
required: false,
|
||||
options: [],
|
||||
},
|
||||
]}
|
||||
isPending={false}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
const dialogContent = Array.from(document.body.querySelectorAll("div")).find((element) =>
|
||||
typeof element.className === "string" && element.className.includes("max-h-[calc(100dvh-2rem)]"),
|
||||
);
|
||||
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
|
||||
expect(dialogContent?.className).toContain("overflow-hidden");
|
||||
|
||||
const notesInput = document.querySelector("textarea");
|
||||
const formScrollRegion = Array.from(document.body.querySelectorAll("div")).find((element) =>
|
||||
typeof element.className === "string" && element.className.includes("overscroll-contain"),
|
||||
);
|
||||
expect(formScrollRegion?.className).toContain("min-h-0");
|
||||
expect(formScrollRegion?.className).toContain("flex-1");
|
||||
expect(formScrollRegion?.className).toContain("overflow-y-auto");
|
||||
expect(formScrollRegion?.contains(notesInput)).toBe(true);
|
||||
|
||||
const footer = Array.from(document.body.querySelectorAll("div")).find((element) =>
|
||||
typeof element.className === "string" && element.className.includes("pb-[calc(1rem+env(safe-area-inset-bottom))]"),
|
||||
);
|
||||
expect(footer?.className).toContain("shrink-0");
|
||||
expect(footer?.contains(formScrollRegion ?? null)).toBe(false);
|
||||
expect(footer?.textContent).toContain("Run routine");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders workspaceBranch as a read-only selected workspace value", async () => {
|
||||
issueWorkspaceDraft = {
|
||||
executionWorkspaceId: "workspace-1",
|
||||
|
|
|
|||
|
|
@ -335,8 +335,8 @@ export function RoutineRunVariablesDialog({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] max-w-xl flex-col gap-0 overflow-hidden p-0 sm:h-auto sm:max-h-[min(calc(100dvh-2rem),42rem)]">
|
||||
<DialogHeader className="shrink-0 border-b border-border/60 px-6 pb-4 pr-12 pt-6">
|
||||
{routineName && (
|
||||
<p className="text-muted-foreground text-sm">{routineName}</p>
|
||||
)}
|
||||
|
|
@ -346,7 +346,7 @@ export function RoutineRunVariablesDialog({
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Agent *</Label>
|
||||
|
|
@ -520,7 +520,10 @@ export function RoutineRunVariablesDialog({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter showCloseButton={false}>
|
||||
<DialogFooter
|
||||
showCloseButton={false}
|
||||
className="shrink-0 border-t border-border/60 bg-background px-6 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4"
|
||||
>
|
||||
{!selection.assigneeAgentId ? (
|
||||
<p className="mr-auto text-xs text-amber-600">Default agent required for this run.</p>
|
||||
) : missingRequired.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ vi.mock("@/context/CompanyContext", () => ({
|
|||
brandColor: "#36a269",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "company-3",
|
||||
issuePrefix: "ANA",
|
||||
name: "Anachronist Wiki",
|
||||
brandColor: "#a36a21",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
selectedCompany: {
|
||||
id: "company-1",
|
||||
|
|
@ -143,6 +150,7 @@ describe("SidebarCompanyMenu", () => {
|
|||
|
||||
expect(document.body.textContent).toContain("Switch workspace");
|
||||
expect(document.body.textContent).toContain("Strata");
|
||||
expect(document.body.textContent).toContain("ANA");
|
||||
expect(document.body.textContent).toContain("Add company...");
|
||||
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
||||
expect(document.body.textContent).toContain("Company settings");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ChevronsUpDown, LogOut, Plus, Settings, UserPlus } from "lucide-react";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
|
|
@ -46,7 +46,10 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
const navigate = useNavigate();
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = onOpenChange ?? setInternalOpen;
|
||||
const sidebarCompanies = companies.filter((company) => company.status !== "archived");
|
||||
const sidebarCompanies = useMemo(
|
||||
() => companies.filter((company) => company.status !== "archived"),
|
||||
[companies],
|
||||
);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
|
|
@ -110,7 +113,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
<DropdownMenuLabel className="px-2 py-1.5 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
Switch workspace
|
||||
</DropdownMenuLabel>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{sidebarCompanies.map((company) => {
|
||||
const isSelected = company.id === selectedCompany?.id;
|
||||
return (
|
||||
|
|
@ -124,6 +127,9 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
>
|
||||
<WorkspaceIcon company={company} />
|
||||
<span className="min-w-0 flex-1 truncate">{company.name}</span>
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{company.issuePrefix}
|
||||
</span>
|
||||
{isSelected ? <Check className="size-4 text-muted-foreground" /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -71,11 +71,36 @@ describe("StatusIcon", () => {
|
|||
);
|
||||
|
||||
expect(html).not.toContain('data-blocker-attention-state="covered"');
|
||||
expect(html).toContain('aria-label="Blocked · 1 unresolved blocker needs attention"');
|
||||
expect(html).toContain('data-blocker-attention-state="needs_attention"');
|
||||
expect(html).toContain('aria-label="Blocked · 1 blocker needs attention"');
|
||||
expect(html).toContain("border-red-600");
|
||||
expect(html).not.toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("shows active covered work on mixed attention-required blockers", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
status="blocked"
|
||||
blockerAttention={{
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
unresolvedBlockerCount: 5,
|
||||
coveredBlockerCount: 2,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 3,
|
||||
sampleBlockerIdentifier: "PAP-3541",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-blocker-attention-state="needs_attention"');
|
||||
expect(html).toContain('aria-label="Blocked · 3 blockers need attention; 2 covered by active work"');
|
||||
expect(html).toContain("border-red-600");
|
||||
expect(html).not.toContain("border-cyan-600");
|
||||
expect(html).toContain("bg-cyan-600");
|
||||
});
|
||||
|
||||
it("renders stalled review chains with amber visual and stalled-leaf copy", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
|
|
|
|||
|
|
@ -49,8 +49,13 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
|
|||
}
|
||||
|
||||
if (blockerAttention.reason === "attention_required") {
|
||||
const count = blockerAttention.unresolvedBlockerCount;
|
||||
return `Blocked · ${count} unresolved ${count === 1 ? "blocker needs" : "blockers need"} attention`;
|
||||
const count = blockerAttention.attentionBlockerCount || blockerAttention.unresolvedBlockerCount;
|
||||
const attentionCopy = `${count} ${count === 1 ? "blocker needs" : "blockers need"} attention`;
|
||||
const coveredCount = blockerAttention.coveredBlockerCount;
|
||||
if (coveredCount > 0) {
|
||||
return `Blocked · ${attentionCopy}; ${coveredCount} covered by active work`;
|
||||
}
|
||||
return `Blocked · ${attentionCopy}`;
|
||||
}
|
||||
|
||||
return "Blocked";
|
||||
|
|
@ -60,6 +65,8 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
|||
const [open, setOpen] = useState(false);
|
||||
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
|
||||
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
|
||||
const isAttentionBlocked = status === "blocked" && blockerAttention?.state === "needs_attention";
|
||||
const hasCoveredBlockedWork = isAttentionBlocked && (blockerAttention?.coveredBlockerCount ?? 0) > 0;
|
||||
const colorClass = isCoveredBlocked
|
||||
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
|
||||
: isStalledBlocked
|
||||
|
|
@ -71,7 +78,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
|||
? "covered"
|
||||
: isStalledBlocked
|
||||
? "stalled"
|
||||
: undefined;
|
||||
: isAttentionBlocked
|
||||
? "needs_attention"
|
||||
: undefined;
|
||||
|
||||
const circle = (
|
||||
<span
|
||||
|
|
@ -91,6 +100,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
|||
{isCoveredBlocked && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
|
||||
)}
|
||||
{hasCoveredBlockedWork && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-cyan-600 dark:bg-cyan-400" />
|
||||
)}
|
||||
{isStalledBlocked && (
|
||||
<span className="absolute inset-0 m-auto h-1.5 w-1.5 rounded-full bg-current" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import {
|
||||
buildWorkspaceRuntimeControlItems,
|
||||
buildWorkspaceRuntimeControlSections,
|
||||
WorkspaceRuntimeQuickControls,
|
||||
WorkspaceRuntimeControls,
|
||||
} from "./WorkspaceRuntimeControls";
|
||||
|
||||
|
|
@ -293,6 +294,41 @@ describe("WorkspaceRuntimeControls", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("lets quick action buttons inherit the shared button shape tokens", () => {
|
||||
const sections = buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
commands: [
|
||||
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
|
||||
],
|
||||
},
|
||||
runtimeServices: [
|
||||
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
|
||||
],
|
||||
canStartServices: true,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceRuntimeQuickControls
|
||||
sections={sections}
|
||||
onAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
expect(buttons).toHaveLength(2);
|
||||
for (const button of buttons) {
|
||||
expect(button.className).toContain("rounded-md");
|
||||
expect(button.className).not.toContain("rounded-none");
|
||||
expect(button.className).not.toContain("rounded-xl");
|
||||
expect(button.className).not.toContain("shadow-none");
|
||||
}
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows disabled actions when local command prerequisites are missing", () => {
|
||||
const sections = buildWorkspaceRuntimeControlSections({
|
||||
runtimeConfig: {
|
||||
|
|
|
|||
|
|
@ -192,6 +192,15 @@ export function buildWorkspaceRuntimeControlItems(input: {
|
|||
}));
|
||||
}
|
||||
|
||||
export function getRunningRuntimeServiceUrl(
|
||||
sections: WorkspaceRuntimeControlSections,
|
||||
) {
|
||||
const runningService = [...sections.services, ...sections.otherServices].find(
|
||||
(item) => (item.statusLabel === "running" || item.statusLabel === "starting") && item.url,
|
||||
);
|
||||
return runningService?.url ?? null;
|
||||
}
|
||||
|
||||
function requestMatchesPending(
|
||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined,
|
||||
nextRequest: WorkspaceRuntimeControlRequest,
|
||||
|
|
@ -255,9 +264,8 @@ function CommandActionButtons({
|
|||
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 w-full justify-start px-3 shadow-none sm:w-auto",
|
||||
square ? "rounded-none" : "rounded-xl",
|
||||
action === "restart" ? "bg-background" : null,
|
||||
"w-full justify-start sm:w-auto",
|
||||
square ? "rounded-none" : null,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => onAction(request)}
|
||||
|
|
@ -451,3 +459,56 @@ export function WorkspaceRuntimeControls({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceRuntimeQuickControls({
|
||||
sections,
|
||||
isPending = false,
|
||||
pendingRequest = null,
|
||||
onAction,
|
||||
square,
|
||||
}: {
|
||||
sections: WorkspaceRuntimeControlSections;
|
||||
isPending?: boolean;
|
||||
pendingRequest?: WorkspaceRuntimeControlRequest | null;
|
||||
onAction: (request: WorkspaceRuntimeControlRequest) => void;
|
||||
square?: boolean;
|
||||
}) {
|
||||
const controlItems = sections.services.length > 0 ? sections.services : sections.otherServices;
|
||||
const serviceUrl = getRunningRuntimeServiceUrl(sections);
|
||||
|
||||
if (controlItems.length === 0 && !serviceUrl) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-stretch gap-2 sm:items-end">
|
||||
{controlItems.length > 0 ? (
|
||||
<div className="flex max-w-full flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{controlItems.map((item) => (
|
||||
<div key={item.key} className="flex min-w-0 flex-col gap-1 sm:items-end">
|
||||
{controlItems.length > 1 ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.title}</span>
|
||||
) : null}
|
||||
<CommandActionButtons
|
||||
item={item}
|
||||
isPending={isPending}
|
||||
pendingRequest={pendingRequest}
|
||||
onAction={onAction}
|
||||
square={square}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{serviceUrl ? (
|
||||
<a
|
||||
href={serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-w-0 items-center gap-1 self-start break-all text-xs text-muted-foreground hover:text-foreground hover:underline sm:self-end"
|
||||
>
|
||||
{serviceUrl}
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue