Merge public-gh/master into PAP-989-multi-user-implementation-using-plan-from-pap-958

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* public-gh/master:
  release: v2026.416.0 notes (#3782)
  Sync/master post pap1497 followups 2026 04 15 (#3779)
  [codex] improve issue and routine UI responsiveness (#3744)
  fix: disable HTTP caching on run log endpoints (#3724)
  [codex] harden heartbeat run summaries and recovery context (#3742)
  [codex] fix worktree dev dependency ergonomics (#3743)
  chore(ui): drop console.* and legal comments in production builds (#3728)
  fix: proper cache headers for static assets and SPA fallback (#3734)
  fix(a11y): remove maximum-scale and user-scalable=no from viewport (#3726)
  fix: trust PAPERCLIP_PUBLIC_URL in board mutation guard (#3731)
  [codex] harden authenticated routes and issue editor reliability (#3741)
  feat(adapters): add capability flags to ServerAdapterModule (#3540)
  fix: Anthropic subscription quota always shows 100% used (#3589)
  fix(server): respect externally set PAPERCLIP_API_URL env var (#3472)
  fix(routines): include cronExpression and timezone in list trigger response (#3209)
  fix: include routine-execution issues in agent inbox-lite (#3329)

# Conflicts:
#	server/src/__tests__/issues-service.test.ts
#	server/src/routes/access.ts
#	server/src/routes/plugins.ts
#	ui/src/components/IssueChatThread.tsx
#	ui/src/pages/Inbox.tsx
This commit is contained in:
Dotta committed 2026-04-15 21:58:12 -05:00
commit ea7066709b
121 files changed
+6172 -660

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#18181b" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
@@ -0,0 +1,54 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { adaptersApi, type AdapterCapabilities } from "@/api/adapters";
import { queryKeys } from "@/lib/queryKeys";
const ALL_FALSE: AdapterCapabilities = {
supportsInstructionsBundle: false,
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
};
/**
* Synchronous fallback for known built-in adapter types so capability checks
* return correct values on first render before the /api/adapters call resolves.
*/
const KNOWN_DEFAULTS: Record<string, AdapterCapabilities> = {
claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false },
codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false },
cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true },
gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true },
opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true },
pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true },
hermes_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false },
openclaw_gateway: ALL_FALSE,
};
/**
* Returns a lookup function that resolves adapter capabilities by type.
*
* Capabilities are fetched from the server adapter listing API and cached
* via react-query. Before the data loads, known built-in adapter types
* return correct synchronous defaults to avoid cold-load regressions.
*/
export function useAdapterCapabilities(): (type: string) => AdapterCapabilities {
const { data: adapters } = useQuery({
queryKey: queryKeys.adapters.all,
queryFn: () => adaptersApi.list(),
staleTime: 5 * 60 * 1000,
});
const capMap = useMemo(() => {
const map = new Map<string, AdapterCapabilities>();
if (adapters) {
for (const a of adapters) {
map.set(a.type, a.capabilities);
}
}
return map;
}, [adapters]);
return (type: string): AdapterCapabilities =>
capMap.get(type) ?? KNOWN_DEFAULTS[type] ?? ALL_FALSE;
}
+8
View File
@@ -4,6 +4,13 @@
import { api } from "./client";
export interface AdapterCapabilities {
supportsInstructionsBundle: boolean;
supportsSkills: boolean;
supportsLocalAgentJwt: boolean;
requiresMaterializedRuntimeSkills: boolean;
}
export interface AdapterInfo {
type: string;
label: string;
@@ -11,6 +18,7 @@ export interface AdapterInfo {
modelsCount: number;
loaded: boolean;
disabled: boolean;
capabilities: AdapterCapabilities;
/** Installed version (for external npm adapters) */
version?: string;
/** Package name (for external adapters) */
+29
View File
@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockApi = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("./client", () => ({
api: mockApi,
}));
import { executionWorkspacesApi } from "./execution-workspaces";
describe("executionWorkspacesApi.listSummaries", () => {
beforeEach(() => {
mockApi.get.mockReset();
mockApi.get.mockResolvedValue([]);
});
it("requests the lightweight summary payload", async () => {
await executionWorkspacesApi.listSummaries("company-1", {
projectId: "project-1",
reuseEligible: true,
});
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true",
);
});
});
+23
View File
@@ -1,5 +1,6 @@
import type {
ExecutionWorkspace,
ExecutionWorkspaceSummary,
ExecutionWorkspaceCloseReadiness,
WorkspaceOperation,
WorkspaceRuntimeControlTarget,
@@ -8,6 +9,28 @@ import { api } from "./client";
import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control";
export const executionWorkspacesApi = {
listSummaries: (
companyId: string,
filters?: {
projectId?: string;
projectWorkspaceId?: string;
issueId?: string;
status?: string;
reuseEligible?: boolean;
},
) => {
const params = new URLSearchParams();
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId);
if (filters?.issueId) params.set("issueId", filters.issueId);
if (filters?.status) params.set("status", filters.status);
if (filters?.reuseEligible) params.set("reuseEligible", "true");
params.set("summary", "true");
const qs = params.toString();
return api.get<ExecutionWorkspaceSummary[]>(
`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`,
);
},
list: (
companyId: string,
filters?: {
+4 -2
View File
@@ -50,6 +50,7 @@ import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadat
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
/* ---- Create mode values ---- */
@@ -269,8 +270,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const adapterType = isCreate
? props.values.adapterType
: overlay.adapterType ?? props.agent.adapterType;
const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]);
const isLocal = !NONLOCAL_TYPES.has(adapterType);
const getCapabilities = useAdapterCapabilities();
const adapterCaps = getCapabilities(adapterType);
const isLocal = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt;
const showLegacyWorkingDirectoryField =
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
+56
View File
@@ -164,6 +164,62 @@ describe("InlineEditor", () => {
});
outside.remove();
});
it("syncs a new multiline value while focused when the user has not edited locally", () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
act(() => {
root.render(<InlineEditor value="" multiline onSave={onSave} />);
});
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("");
act(() => {
textarea!.focus();
});
act(() => {
root.render(<InlineEditor value="Loaded description" multiline onSave={onSave} />);
});
expect(textarea?.value).toBe("Loaded description");
act(() => {
root.unmount();
});
});
it("preserves focused multiline local edits when the prop value changes underneath them", () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const root = createRoot(container);
act(() => {
root.render(<InlineEditor value="Original" multiline onSave={onSave} />);
});
const textarea = container.querySelector<HTMLTextAreaElement>('[data-testid="multiline-md-mock"]');
expect(textarea).not.toBeNull();
act(() => {
textarea!.focus();
});
act(() => {
setNativeTextareaValue(textarea!, "Local draft");
});
act(() => {
root.render(<InlineEditor value="Remote update" multiline onSave={onSave} />);
});
expect(textarea?.value).toBe("Local draft");
act(() => {
root.unmount();
});
});
});
describe("queueContainedBlurCommit", () => {
+9 -2
View File
@@ -54,6 +54,7 @@ export function InlineEditor({
const [editing, setEditing] = useState(false);
const [multilineFocused, setMultilineFocused] = useState(false);
const [draft, setDraft] = useState(value);
const lastPropValueRef = useRef(value);
const inputRef = useRef<HTMLTextAreaElement>(null);
const markdownRef = useRef<MarkdownEditorRef>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -66,8 +67,14 @@ export function InlineEditor({
} = useAutosaveIndicator();
useEffect(() => {
if (multiline && multilineFocused) return;
setDraft(value);
const previousValue = lastPropValueRef.current;
lastPropValueRef.current = value;
setDraft((currentDraft) => {
if (multiline && multilineFocused && currentDraft !== previousValue) {
return currentDraft;
}
return value;
});
}, [value, multiline, multilineFocused]);
useEffect(() => {
+2 -3
View File
@@ -970,10 +970,9 @@ function IssueChatUserMessage() {
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
</div>
<div
className={cn(
"min-w-0 break-all rounded-2xl px-4 py-2.5",
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
queued
? "bg-amber-50/80 dark:bg-amber-500/10"
: "bg-muted",
@@ -1008,7 +1007,7 @@ function IssueChatUserMessage() {
) : null}
</div>
) : null}
<div className="space-y-3">
<div className="min-w-0 max-w-full space-y-3">
<MessagePrimitive.Parts
components={{
Text: ({ text }) => <IssueChatTextPart text={text} />,
+102 -4
View File
@@ -1,7 +1,10 @@
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Filter, X, User, HardDrive } from "lucide-react";
import { Bot, Filter, HardDrive, Search, User, X } from "lucide-react";
import { PriorityIcon } from "./PriorityIcon";
import { StatusIcon } from "./StatusIcon";
import {
@@ -14,6 +17,7 @@ import {
toggleIssueFilterValue,
type IssueFilterState,
} from "../lib/issue-filters";
import { formatAssigneeUserLabel } from "../lib/assignees";
type AgentOption = {
id: string;
@@ -36,6 +40,13 @@ type WorkspaceOption = {
name: string;
};
type CreatorOption = {
id: string;
label: string;
kind: "agent" | "user";
searchText?: string;
};
export function IssueFiltersPopover({
state,
onChange,
@@ -48,6 +59,7 @@ export function IssueFiltersPopover({
buttonVariant = "ghost",
iconOnly = false,
workspaces,
creators,
}: {
state: IssueFilterState;
onChange: (patch: Partial<IssueFilterState>) => void;
@@ -60,7 +72,39 @@ export function IssueFiltersPopover({
buttonVariant?: "ghost" | "outline";
iconOnly?: boolean;
workspaces?: WorkspaceOption[];
creators?: CreatorOption[];
}) {
const [creatorSearch, setCreatorSearch] = useState("");
const creatorOptions = creators ?? [];
const creatorOptionById = useMemo(
() => new Map(creatorOptions.map((option) => [option.id, option])),
[creatorOptions],
);
const normalizedCreatorSearch = creatorSearch.trim().toLowerCase();
const visibleCreatorOptions = useMemo(() => {
if (!normalizedCreatorSearch) return creatorOptions;
return creatorOptions.filter((option) =>
`${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(normalizedCreatorSearch),
);
}, [creatorOptions, normalizedCreatorSearch]);
const selectedCreatorOptions = useMemo(
() => state.creators.map((creatorId) => {
const knownOption = creatorOptionById.get(creatorId);
if (knownOption) return knownOption;
if (creatorId.startsWith("agent:")) {
const agentId = creatorId.slice("agent:".length);
return { id: creatorId, label: agentId.slice(0, 8), kind: "agent" as const };
}
const userId = creatorId.startsWith("user:") ? creatorId.slice("user:".length) : creatorId;
return {
id: creatorId,
label: formatAssigneeUserLabel(userId, currentUserId) ?? userId.slice(0, 5),
kind: "user" as const,
};
}),
[creatorOptionById, currentUserId, state.creators],
);
return (
<Popover>
<PopoverTrigger asChild>
@@ -191,6 +235,60 @@ export function IssueFiltersPopover({
</div>
</div>
{creatorOptions.length > 0 ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Creator</span>
{selectedCreatorOptions.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedCreatorOptions.map((creator) => (
<Badge key={creator.id} variant="secondary" className="gap-1 pr-1">
{creator.kind === "agent" ? <Bot className="h-3 w-3" /> : <User className="h-3 w-3" />}
<span>{creator.label}</span>
<button
type="button"
className="rounded-full p-0.5 hover:bg-accent"
onClick={() => onChange({ creators: state.creators.filter((value) => value !== creator.id) })}
aria-label={`Remove creator ${creator.label}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
) : null}
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={creatorSearch}
onChange={(event) => setCreatorSearch(event.target.value)}
placeholder="Search creators..."
className="h-8 pl-7 text-xs"
/>
</div>
<div className="max-h-32 space-y-0.5 overflow-y-auto">
{visibleCreatorOptions.length > 0 ? visibleCreatorOptions.map((creator) => {
const selected = state.creators.includes(creator.id);
return (
<button
key={creator.id}
type="button"
className={`flex w-full items-center gap-2 rounded-sm px-2 py-1 text-left text-sm ${
selected ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
onClick={() => onChange({ creators: toggleIssueFilterValue(state.creators, creator.id) })}
>
{creator.kind === "agent" ? <Bot className="h-3.5 w-3.5" /> : <User className="h-3.5 w-3.5" />}
<span className="min-w-0 flex-1 truncate">{creator.label}</span>
{selected ? <X className="h-3 w-3" /> : null}
</button>
);
}) : (
<div className="px-2 py-1 text-xs text-muted-foreground">No creators match.</div>
)}
</div>
</div>
) : null}
{projects && projects.length > 0 ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Project</span>
@@ -251,10 +349,10 @@ export function IssueFiltersPopover({
<span className="text-xs text-muted-foreground">Visibility</span>
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.showRoutineExecutions}
onCheckedChange={(checked) => onChange({ showRoutineExecutions: checked === true })}
checked={state.hideRoutineExecutions}
onCheckedChange={(checked) => onChange({ hideRoutineExecutions: checked === true })}
/>
<span className="text-sm">Show routine runs</span>
<span className="text-sm">Hide routine runs</span>
</label>
</div>
) : null}
+2 -6
View File
@@ -6,8 +6,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { timeAgo } from "@/lib/timeAgo";
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb";
import {
fetchIssueDetail,
getCachedIssueDetail,
getIssueDetailQueryOptions,
ISSUE_DETAIL_STALE_TIME_MS,
prefetchIssueDetail,
} from "@/lib/issueDetailCache";
@@ -98,12 +97,9 @@ export const IssueLinkQuicklook = React.forwardRef<
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const prefetchedState = issuePrefetch ? withIssueDetailHeaderSeed(state, issuePrefetch) : state;
const cachedIssue = getCachedIssueDetail(queryClient, issuePathId, issuePrefetch ?? undefined);
const { data, isLoading } = useQuery({
queryKey: queryKeys.issues.detail(issuePathId),
queryFn: () => fetchIssueDetail(queryClient, issuePathId),
...getIssueDetailQueryOptions(queryClient, issuePathId, { placeholderIssue: issuePrefetch ?? undefined }),
enabled: open,
initialData: () => cachedIssue,
staleTime: ISSUE_DETAIL_STALE_TIME_MS,
});
@@ -323,6 +323,9 @@ describe("IssueProperties", () => {
const selectedParentTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
expect(selectedParentTrigger).not.toBeUndefined();
const parentLink = container.querySelector('a[href="/issues/PAP-2"]');
expect(parentLink).not.toBeNull();
expect(selectedParentTrigger!.contains(parentLink)).toBe(false);
await act(async () => {
selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+32 -22
View File
@@ -22,7 +22,7 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Copy, Check } from "lucide-react";
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
@@ -41,17 +41,15 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.C
return (
<div className="flex items-start gap-1.5 min-w-0 flex-1">
<Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
<span className="text-sm font-mono min-w-0 break-all">
{value}
</span>
<button
type="button"
className="shrink-0 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
className="text-sm font-mono min-w-0 break-all text-left cursor-pointer hover:text-foreground transition-colors"
onClick={handleCopy}
title={copied ? "Copied!" : "Copy"}
title={copied ? "Copied!" : "Click to copy"}
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
{value}
</button>
{copied && <Check className="h-3 w-3 text-green-500 shrink-0 mt-0.5" />}
</div>
);
}
@@ -762,16 +760,25 @@ export function IssueProperties({
if (!issue.parentId) return null;
return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null;
}, [allIssues, issue.parentId]);
const parentIdentifier = issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier;
const parentTitle = issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId?.slice(0, 8);
const parentTrigger = issue.parentId ? (
<span className="text-sm break-words min-w-0">
{issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier
? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} `
: ""}
{issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)}
<span className="text-sm break-words min-w-0 inline">
{parentIdentifier ? `${parentIdentifier} ` : ""}
{parentTitle}
</span>
) : (
<span className="text-sm text-muted-foreground">No parent</span>
);
const parentLink = issue.parentId ? (
<Link
to={`/issues/${parentIdentifier ?? issue.parentId}`}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className="h-3 w-3" />
</Link>
) : undefined;
const parentOptions = (allIssues ?? [])
.filter((candidate) => candidate.id !== issue.id)
.filter((candidate) => !descendantIssueIds.has(candidate.id))
@@ -997,15 +1004,7 @@ export function IssueProperties({
triggerContent={parentTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-72"
extra={issue.parentId ? (
<Link
to={`/issues/${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier ?? issue.parentId}`}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className="h-3 w-3" />
</Link>
) : undefined}
extra={parentLink}
>
{parentContent}
</PropertyPicker>
@@ -1118,10 +1117,21 @@ export function IssueProperties({
)}
</div>
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd ? (
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
<>
<Separator />
<div className="space-y-1">
{issue.executionWorkspaceId && (
<PropertyRow label="Workspace">
<Link
to={`/execution-workspaces/${issue.executionWorkspaceId}`}
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
View workspace
<ExternalLink className="h-3 w-3" />
</Link>
</PropertyRow>
)}
{issue.currentExecutionWorkspace?.branchName && (
<PropertyRow label="Branch">
<TruncatedCopyable
+144 -8
View File
@@ -33,6 +33,7 @@ const mockAccessApi = vi.hoisted(() => ({
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
list: vi.fn(),
listSummaries: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
@@ -194,6 +195,7 @@ describe("IssuesList", () => {
mockAccessApi.listMembers.mockReset();
mockAccessApi.listUserDirectory.mockReset();
mockExecutionWorkspacesApi.list.mockReset();
mockExecutionWorkspacesApi.listSummaries.mockReset();
mockInstanceSettingsApi.getExperimental.mockReset();
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]);
@@ -201,6 +203,7 @@ describe("IssuesList", () => {
mockAccessApi.listMembers.mockResolvedValue({ members: [], access: {} });
mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] });
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
localStorage.clear();
});
@@ -229,7 +232,11 @@ describe("IssuesList", () => {
);
await waitForAssertion(() => {
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "server", projectId: undefined });
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
q: "server",
projectId: undefined,
limit: 200,
});
expect(container.textContent).toContain("Server result");
expect(container.textContent).not.toContain("Local issue");
});
@@ -263,6 +270,7 @@ describe("IssuesList", () => {
q: "server",
projectId: undefined,
parentId: "parent-1",
limit: 200,
});
expect(container.textContent).toContain("Server result");
expect(container.textContent).not.toContain("Local issue");
@@ -346,7 +354,7 @@ describe("IssuesList", () => {
expect(onSearchChange).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(149);
vi.advanceTimersByTime(249);
});
expect(onSearchChange).not.toHaveBeenCalled();
@@ -364,6 +372,109 @@ describe("IssuesList", () => {
});
});
it("shows a refinement hint when search results hit the live search cap", async () => {
const serverIssues = Array.from({ length: 200 }, (_, index) =>
createIssue({
id: `issue-${index + 1}`,
identifier: `PAP-${index + 1}`,
title: `Server result ${index + 1}`,
}),
);
mockIssuesApi.list.mockResolvedValue(serverIssues);
const { root } = renderWithQueryClient(
<IssuesList
issues={[]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
initialSearch="server"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(container.textContent).toContain("Showing up to 200 matches. Refine the search to narrow further.");
});
act(() => {
root.unmount();
});
});
it("caps the first paint for large issue lists", async () => {
const manyIssues = Array.from({ length: 220 }, (_, index) =>
createIssue({
id: `issue-${index + 1}`,
identifier: `PAP-${index + 1}`,
title: `Issue ${index + 1}`,
}),
);
const { root } = renderWithQueryClient(
<IssuesList
issues={manyIssues}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(150);
expect(container.textContent).toContain("Rendering 150 of 220 issues");
});
act(() => {
root.unmount();
});
});
it("skips deferred row sizing for expanded parent rows with visible children", async () => {
const parentIssue = createIssue({
id: "issue-parent",
identifier: "PAP-1",
title: "Parent issue",
});
const childIssue = createIssue({
id: "issue-child",
identifier: "PAP-2",
title: "Child issue",
parentId: "issue-parent",
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[parentIssue, childIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]'));
const parentRow = rows.find((row) => row.textContent?.includes("Parent issue"));
const childRow = rows.find((row) => row.textContent?.includes("Child issue"));
expect(parentRow).not.toBeUndefined();
expect(childRow).not.toBeUndefined();
expect((parentRow?.parentElement as HTMLDivElement | null)?.style.contentVisibility).toBe("");
expect((parentRow?.parentElement as HTMLDivElement | null)?.style.containIntrinsicSize).toBe("");
expect((childRow?.parentElement as HTMLDivElement | null)?.style.contentVisibility).toBe("auto");
expect((childRow?.parentElement as HTMLDivElement | null)?.style.containIntrinsicSize).toBe("44px");
});
act(() => {
root.unmount();
});
});
it("uses context-scoped persisted column visibility", async () => {
localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "assignee"]));
@@ -480,7 +591,7 @@ describe("IssuesList", () => {
it("filters the list to a single workspace when a workspace name is clicked", async () => {
localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "workspace"]));
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockExecutionWorkspacesApi.list.mockResolvedValue([
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
{
id: "workspace-alpha",
name: "Alpha",
@@ -548,7 +659,7 @@ describe("IssuesList", () => {
});
});
it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => {
it("shows routine-backed issues by default and hides them when the routine filter is toggled off", async () => {
const manualIssue = createIssue({
id: "issue-manual",
identifier: "PAP-10",
@@ -576,7 +687,7 @@ describe("IssuesList", () => {
await waitForAssertion(() => {
expect(container.textContent).toContain("Manual issue");
expect(container.textContent).not.toContain("Routine issue");
expect(container.textContent).toContain("Routine issue");
});
await act(async () => {
@@ -589,21 +700,21 @@ describe("IssuesList", () => {
await waitForAssertion(() => {
const toggle = Array.from(document.body.querySelectorAll("label")).find(
(label) => label.textContent?.includes("Show routine runs"),
(label) => label.textContent?.includes("Hide routine runs"),
);
expect(toggle).not.toBeUndefined();
});
await act(async () => {
const toggle = Array.from(document.body.querySelectorAll("label")).find(
(label) => label.textContent?.includes("Show routine runs"),
(label) => label.textContent?.includes("Hide routine runs"),
);
toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
await waitForAssertion(() => {
expect(container.textContent).toContain("Routine issue");
expect(container.textContent).not.toContain("Routine issue");
});
act(() => {
@@ -681,4 +792,29 @@ describe("IssuesList", () => {
root.unmount();
});
});
it("uses workspace summaries instead of the full workspace list on the issues page", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
const { root } = renderWithQueryClient(
<IssuesList
issues={[createIssue()]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1");
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
});
act(() => {
root.unmount();
});
});
});
+138 -9
View File
@@ -21,6 +21,7 @@ import {
defaultIssueFilterState,
issueFilterLabel,
issuePriorityOrder,
normalizeIssueFilterState,
resolveIssueFilterWorkspaceId,
issueStatusOrder,
type IssueFilterState,
@@ -56,7 +57,11 @@ import { KanbanBoard } from "./KanbanBoard";
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import type { Issue, Project } from "@paperclipai/shared";
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 150;
const ISSUE_ROW_RENDER_BATCH_SIZE = 150;
const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0;
/* ── View state ── */
@@ -84,7 +89,10 @@ const defaultViewState: IssueViewState = {
function getViewState(key: string): IssueViewState {
try {
const raw = localStorage.getItem(key);
if (raw) return { ...defaultViewState, ...JSON.parse(raw) };
if (raw) {
const parsed = JSON.parse(raw);
return { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed) };
}
} catch { /* ignore */ }
return { ...defaultViewState };
}
@@ -159,6 +167,13 @@ interface Agent {
name: string;
}
type CreatorOption = {
id: string;
label: string;
kind: "agent" | "user";
searchText?: string;
};
type ProjectOption = Pick<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
type IssueListRequestFilters = NonNullable<Parameters<typeof issuesApi.list>[1]>;
@@ -290,6 +305,7 @@ export function IssuesList({
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT);
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
const deferredIssueSearch = useDeferredValue(issueSearch);
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
@@ -340,12 +356,14 @@ export function IssuesList({
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
searchFilters ?? {},
ISSUE_SEARCH_RESULT_LIMIT,
enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
],
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
q: normalizedIssueSearch,
projectId,
limit: ISSUE_SEARCH_RESULT_LIMIT,
...searchFilters,
...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}),
}),
@@ -354,9 +372,9 @@ export function IssuesList({
});
const { data: executionWorkspaces = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.executionWorkspaces.list(selectedCompanyId)
? queryKeys.executionWorkspaces.summaryList(selectedCompanyId)
: ["execution-workspaces", "__disabled__"],
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
queryFn: () => executionWorkspacesApi.listSummaries(selectedCompanyId!),
enabled: !!selectedCompanyId && isolatedWorkspacesEnabled,
});
@@ -441,6 +459,66 @@ export function IssuesList({
.map(([id, name]) => ({ id, name }));
}, [workspaceNameMap]);
const creatorOptions = useMemo<CreatorOption[]>(() => {
const options = new Map<string, CreatorOption>();
const knownAgentIds = new Set<string>();
if (currentUserId) {
options.set(`user:${currentUserId}`, {
id: `user:${currentUserId}`,
label: currentUserId === "local-board" ? "Board" : "Me",
kind: "user",
searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`,
});
}
for (const issue of issues) {
if (issue.createdByUserId) {
const id = `user:${issue.createdByUserId}`;
if (!options.has(id)) {
options.set(id, {
id,
label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5),
kind: "user",
searchText: `${issue.createdByUserId} board user human`,
});
}
}
}
for (const agent of agents ?? []) {
knownAgentIds.add(agent.id);
const id = `agent:${agent.id}`;
if (!options.has(id)) {
options.set(id, {
id,
label: agent.name,
kind: "agent",
searchText: `${agent.name} ${agent.id} agent`,
});
}
}
for (const issue of issues) {
if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) {
const id = `agent:${issue.createdByAgentId}`;
if (!options.has(id)) {
options.set(id, {
id,
label: issue.createdByAgentId.slice(0, 8),
kind: "agent",
searchText: `${issue.createdByAgentId} agent`,
});
}
}
}
return [...options.values()].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1;
return a.label.localeCompare(b.label);
});
}, [agents, currentUserId, issues]);
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
const availableIssueColumns = useMemo(
() => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled),
@@ -545,6 +623,26 @@ export function IssuesList({
}));
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap, companyUserLabelMap]);
useEffect(() => {
if (viewState.viewMode !== "list") return;
setRenderedIssueRowLimit(Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT));
}, [filtered, viewState.viewMode]);
useEffect(() => {
if (viewState.viewMode !== "list") return;
if (renderedIssueRowLimit >= filtered.length) return;
const timeoutId = window.setTimeout(() => {
startTransition(() => {
setRenderedIssueRowLimit((current) => Math.min(filtered.length, current + ISSUE_ROW_RENDER_BATCH_SIZE));
});
}, ISSUE_ROW_RENDER_BATCH_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [filtered.length, renderedIssueRowLimit, viewState.viewMode]);
const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0);
const newIssueDefaults = useCallback((groupKey?: string) => {
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
@@ -594,6 +692,7 @@ export function IssuesList({
setAssigneeSearch("");
}, [onUpdateIssue]);
let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY;
return (
<div className="space-y-4">
@@ -659,6 +758,7 @@ export function IssuesList({
onChange={updateView}
activeFilterCount={activeFilterCount}
agents={agents}
creators={creatorOptions}
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
@@ -748,7 +848,11 @@ export function IssuesList({
{isLoading && <PageSkeleton variant="issues-list" />}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (
<p className="text-xs text-muted-foreground">
Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further.
</p>
)}
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
<EmptyState
icon={CircleDot}
@@ -766,7 +870,10 @@ export function IssuesList({
onUpdateIssue={onUpdateIssue}
/>
) : (
groupedContent.map((group) => (
<>
{groupedContent.map((group) => {
if (remainingRowsToRender <= 0) return null;
return (
<Collapsible
key={group.key}
open={!viewState.collapsedGroups.includes(group.key)}
@@ -809,10 +916,14 @@ export function IssuesList({
: { roots: group.items, childMap: new Map<string, Issue[]>() };
const renderIssueRow = (issue: Issue, depth: number) => {
if (remainingRowsToRender <= 0) return null;
remainingRowsToRender -= 1;
const children = childMap.get(issue.id) ?? [];
const hasChildren = children.length > 0;
const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0;
const isExpanded = !viewState.collapsedParents.includes(issue.id);
const useDeferredRowRendering = !(hasChildren && isExpanded);
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null;
const assigneeUserProfile = issue.assigneeUserId
@@ -834,7 +945,18 @@ export function IssuesList({
};
return (
<div key={issue.id} style={depth > 0 ? { paddingLeft: `${depth * 16}px` } : undefined}>
<div
key={issue.id}
style={{
...(depth > 0 ? { paddingLeft: `${depth * 16}px` } : {}),
...(useDeferredRowRendering
? {
contentVisibility: "auto",
containIntrinsicSize: "44px",
}
: {}),
}}
>
<IssueRow
issue={issue}
issueLinkState={issueLinkState}
@@ -1010,11 +1132,18 @@ export function IssuesList({
);
};
return roots.map((issue) => renderIssueRow(issue, 0));
return roots.map((issue) => renderIssueRow(issue, 0)).filter((node) => node !== null);
})()}
</CollapsibleContent>
</Collapsible>
))
);
})}
{remainingIssueRowCount > 0 && (
<p className="text-xs text-muted-foreground">
Rendering {Math.min(renderedIssueRowLimit, filtered.length)} of {filtered.length} issues
</p>
)}
</>
)}
</div>
);
+31 -1
View File
@@ -98,6 +98,14 @@ describe("MarkdownBody", () => {
expect(html).toContain('data-mention-kind="skill"');
});
it("sanitizes unsafe javascript markdown links", () => {
const html = renderMarkdown("[click me](javascript:alert(document.cookie))");
expect(html).toContain('<a href="" rel="noreferrer"');
expect(html).toContain(">click me</a>");
expect(html).not.toContain("javascript:");
});
it("uses soft-break styling by default", () => {
const html = renderMarkdown("First line\nSecond line");
@@ -168,7 +176,7 @@ describe("MarkdownBody", () => {
]);
expect(html).toContain('href="/issues/PAP-1271"');
expect(html).toContain("<code>PAP-1271</code>");
expect(html).toContain('<code style="overflow-wrap:anywhere;word-break:break-word">PAP-1271</code>');
expect(html).toContain("text-green-600");
});
@@ -187,4 +195,26 @@ describe("MarkdownBody", () => {
expect(html).toContain("Depends on PAP-1271");
expect(html).toContain('href="PAP-1271"');
});
it("applies wrap-friendly styles to long inline content", () => {
const html = renderMarkdown("averyveryveryveryveryveryveryveryveryverylongtoken");
expect(html).toContain('class="paperclip-markdown prose prose-sm min-w-0 max-w-full break-words overflow-hidden');
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
expect(html).toContain("<p");
});
it("applies wrap-friendly styles to long links", () => {
const html = renderMarkdown("[link](https://example.com/reallyreallyreallyreallyreallyreallyreallyreallylong)");
expect(html).toContain('<a href="https://example.com/reallyreallyreallyreallyreallyreallyreallyreallylong"');
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
});
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```");
expect(html).toContain("<pre");
expect(html).toContain('style="max-width:100%;overflow-x:auto"');
});
});
+70 -8
View File
@@ -1,6 +1,6 @@
import { isValidElement, useEffect, useId, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import Markdown, { type Components, type Options } from "react-markdown";
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils";
import { useTheme } from "../context/ThemeContext";
@@ -56,6 +56,30 @@ function loadMermaid() {
return mermaidLoaderPromise;
}
const wrapAnywhereStyle: React.CSSProperties = {
overflowWrap: "anywhere",
wordBreak: "break-word",
};
const scrollableBlockStyle: React.CSSProperties = {
maxWidth: "100%",
overflowX: "auto",
};
function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
return {
...wrapAnywhereStyle,
...style,
};
}
function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProperties {
return {
...scrollableBlockStyle,
...style,
};
}
function flattenText(value: ReactNode): string {
if (value == null) return "";
if (typeof value === "string" || typeof value === "number") return String(value);
@@ -71,6 +95,10 @@ function extractMermaidSource(children: ReactNode): string | null {
return flattenText(childProps.children).replace(/\n$/, "");
}
function safeMarkdownUrlTransform(url: string): string {
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
}
function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) {
const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
const [svg, setSvg] = useState<string | null>(null);
@@ -144,14 +172,44 @@ export function MarkdownBody({
remarkPlugins.push(remarkSoftBreaks);
}
const components: Components = {
p: ({ node: _node, style: paragraphStyle, children: paragraphChildren, ...paragraphProps }) => (
<p {...paragraphProps} style={mergeWrapStyle(paragraphStyle as React.CSSProperties | undefined)}>
{paragraphChildren}
</p>
),
li: ({ node: _node, style: listItemStyle, children: listItemChildren, ...listItemProps }) => (
<li {...listItemProps} style={mergeWrapStyle(listItemStyle as React.CSSProperties | undefined)}>
{listItemChildren}
</li>
),
blockquote: ({ node: _node, style: blockquoteStyle, children: blockquoteChildren, ...blockquoteProps }) => (
<blockquote {...blockquoteProps} style={mergeWrapStyle(blockquoteStyle as React.CSSProperties | undefined)}>
{blockquoteChildren}
</blockquote>
),
td: ({ node: _node, style: tableCellStyle, children: tableCellChildren, ...tableCellProps }) => (
<td {...tableCellProps} style={mergeWrapStyle(tableCellStyle as React.CSSProperties | undefined)}>
{tableCellChildren}
</td>
),
th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => (
<th {...tableHeaderProps} style={mergeWrapStyle(tableHeaderStyle as React.CSSProperties | undefined)}>
{tableHeaderChildren}
</th>
),
pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren);
if (mermaidSource) {
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
}
return <pre {...preProps}>{preChildren}</pre>;
return <pre {...preProps} style={mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined)}>{preChildren}</pre>;
},
a: ({ href, children: linkChildren }) => {
code: ({ node: _node, style: codeStyle, children: codeChildren, ...codeProps }) => (
<code {...codeProps} style={mergeWrapStyle(codeStyle as React.CSSProperties | undefined)}>
{codeChildren}
</code>
),
a: ({ href, style: linkStyle, children: linkChildren }) => {
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
if (issueRef) {
return (
@@ -179,14 +237,14 @@ export function MarkdownBody({
parsed.kind === "project" && "paperclip-project-mention-chip",
)}
data-mention-kind={parsed.kind}
style={mentionChipInlineStyle(parsed)}
style={{ ...mergeWrapStyle(linkStyle as React.CSSProperties | undefined), ...mentionChipInlineStyle(parsed) }}
>
{linkChildren}
</a>
);
}
return (
<a href={href} rel="noreferrer">
<a href={href} rel="noreferrer" style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}>
{linkChildren}
</a>
);
@@ -211,13 +269,17 @@ export function MarkdownBody({
return (
<div
className={cn(
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
"paperclip-markdown prose prose-sm min-w-0 max-w-full break-words overflow-hidden",
theme === "dark" && "prose-invert",
className,
)}
style={style}
style={mergeWrapStyle(style)}
>
<Markdown remarkPlugins={remarkPlugins} components={components} urlTransform={(url) => url}>
<Markdown
remarkPlugins={remarkPlugins}
components={components}
urlTransform={safeMarkdownUrlTransform}
>
{children}
</Markdown>
</div>
+15 -6
View File
@@ -19,6 +19,7 @@ const mdxEditorMockState = vi.hoisted(() => ({
emitMountParseError: false,
emitMountSilentEmptyState: false,
markdownValues: [] as string[],
suppressHtmlProcessingValues: [] as boolean[],
}));
vi.mock("@mdxeditor/editor", async () => {
@@ -41,16 +42,19 @@ vi.mock("@mdxeditor/editor", async () => {
onChange,
onError,
className,
suppressHtmlProcessing,
}: {
markdown: string;
placeholder?: string;
onChange?: (value: string) => void;
onError?: (error: unknown) => void;
suppressHtmlProcessing?: boolean;
className?: string;
},
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
) {
mdxEditorMockState.markdownValues.push(markdown);
mdxEditorMockState.suppressHtmlProcessingValues.push(Boolean(suppressHtmlProcessing));
const [content, setContent] = React.useState(markdown);
const editableRef = React.useRef<HTMLDivElement>(null);
const handle = React.useMemo(() => ({
@@ -59,8 +63,16 @@ vi.mock("@mdxeditor/editor", async () => {
}), []);
React.useEffect(() => {
if (!suppressHtmlProcessing && markdown.includes("<img ")) {
setContent("");
onError?.({
error: "Error parsing markdown: HTML-like formatting requires suppressHtmlProcessing",
source: markdown,
});
return;
}
setContent(markdown);
}, [markdown]);
}, [markdown, onError, suppressHtmlProcessing]);
React.useEffect(() => {
setForwardedRef(forwardedRef, null);
@@ -165,6 +177,7 @@ describe("MarkdownEditor", () => {
mdxEditorMockState.emitMountParseError = false;
mdxEditorMockState.emitMountSilentEmptyState = false;
mdxEditorMockState.markdownValues = [];
mdxEditorMockState.suppressHtmlProcessingValues = [];
});
it("applies async external value updates once the editor ref becomes ready", async () => {
@@ -238,6 +251,7 @@ describe("MarkdownEditor", () => {
await flush();
expect(mdxEditorMockState.markdownValues.at(-1)).toContain("![image](https://example.com/test.png)");
expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain("<img");
expect(mdxEditorMockState.suppressHtmlProcessingValues).toContain(false);
expect(container.textContent).toContain("Before");
expect(container.textContent).toContain("After");
@@ -262,11 +276,9 @@ describe("MarkdownEditor", () => {
});
await flush();
await vi.waitFor(() => {
expect(container.querySelector("textarea")).not.toBeNull();
});
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
@@ -294,11 +306,9 @@ describe("MarkdownEditor", () => {
});
await flush();
await vi.waitFor(() => {
expect(container.querySelector("textarea")).not.toBeNull();
});
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("Affected versions: <= v0.3.1");
@@ -309,7 +319,6 @@ describe("MarkdownEditor", () => {
root.unmount();
});
});
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
expect(
computeMentionMenuPosition(
+4 -2
View File
@@ -25,6 +25,7 @@ import {
import { getUIAdapter } from "../adapters";
import { listUIAdapters } from "../adapters";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { defaultCreateValues } from "./agent-config-defaults";
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
@@ -198,8 +199,9 @@ export function OnboardingWizard() {
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
});
const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]);
const isLocalAdapter = !NONLOCAL_TYPES.has(adapterType);
const getCapabilities = useAdapterCapabilities();
const adapterCaps = getCapabilities(adapterType);
const isLocalAdapter = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt;
// Build adapter grids dynamically from the UI registry + display metadata.
// External/plugin adapters automatically appear with generic defaults.
@@ -131,6 +131,7 @@ export function RoutineRunVariablesDialog({
open,
onOpenChange,
companyId,
routineName,
projects,
agents,
defaultProjectId,
@@ -142,6 +143,7 @@ export function RoutineRunVariablesDialog({
open: boolean;
onOpenChange: (open: boolean) => void;
companyId: string | null | undefined;
routineName?: string | null;
projects: Project[];
agents: Agent[];
defaultProjectId?: string | null;
@@ -253,6 +255,9 @@ export function RoutineRunVariablesDialog({
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
<DialogContent className="max-w-xl">
<DialogHeader>
{routineName && (
<p className="text-muted-foreground text-sm">{routineName}</p>
)}
<DialogTitle>Run routine</DialogTitle>
<DialogDescription>
Choose the agent and optional project for this one run. Routine defaults are prefilled and won&apos;t be changed.
@@ -54,8 +54,8 @@ describe("RunTranscriptView", () => {
);
expect(html).toContain("<strong>world</strong>");
expect(html).toContain("<li>first</li>");
expect(html).toContain("<li>second</li>");
expect(html).toMatch(/<li[^>]*>first<\/li>/);
expect(html).toMatch(/<li[^>]*>second<\/li>/);
});
it("hides saved-session resume skip stderr from nice mode normalization", () => {
@@ -106,8 +106,8 @@ describe("RunTranscriptView", () => {
);
expect(html).toContain("<h2>Summary</h2>");
expect(html).toContain("<li>fixed deploy config</li>");
expect(html).toContain("<li>posted issue update</li>");
expect(html).toMatch(/<li[^>]*>fixed deploy config<\/li>/);
expect(html).toMatch(/<li[^>]*>posted issue update<\/li>/);
expect(html).not.toContain("result");
});
});
+61
View File
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BreadcrumbProvider, useBreadcrumbs } from "./BreadcrumbContext";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("BreadcrumbContext", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("does not rerender consumers when breadcrumbs are set to the same values", () => {
const renderCounts: number[] = [];
let updateBreadcrumbs: ((crumbs: Array<{ label: string; href?: string }>) => void) | null = null;
function TestConsumer() {
const { breadcrumbs, setBreadcrumbs } = useBreadcrumbs();
renderCounts.push(breadcrumbs.length);
updateBreadcrumbs = setBreadcrumbs;
return null;
}
act(() => {
root.render(
<BreadcrumbProvider>
<TestConsumer />
</BreadcrumbProvider>,
);
});
expect(renderCounts).toHaveLength(1);
act(() => {
updateBreadcrumbs?.([{ label: "Issues", href: "/issues" }, { label: "PAP-1488" }]);
});
expect(renderCounts).toHaveLength(2);
act(() => {
updateBreadcrumbs?.([{ label: "Issues", href: "/issues" }, { label: "PAP-1488" }]);
});
expect(renderCounts).toHaveLength(2);
});
});
+12 -1
View File
@@ -14,12 +14,23 @@ interface BreadcrumbContextValue {
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index]?.label !== right[index]?.label || left[index]?.href !== right[index]?.href) {
return false;
}
}
return true;
}
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
const [breadcrumbs, setBreadcrumbsState] = useState<Breadcrumb[]>([]);
const [mobileToolbar, setMobileToolbarState] = useState<ReactNode | null>(null);
const setBreadcrumbs = useCallback((crumbs: Breadcrumb[]) => {
setBreadcrumbsState(crumbs);
setBreadcrumbsState((current) => (breadcrumbsEqual(current, crumbs) ? current : crumbs));
}, []);
const setMobileToolbar = useCallback((node: ReactNode | null) => {
+102
View File
@@ -318,6 +318,108 @@ describe("LiveUpdatesProvider issue invalidation", () => {
refetchType: "inactive",
});
});
it("refreshes visible issue run queries when the displayed run changes status", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: (key: unknown) => {
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
return {
id: "issue-1",
identifier: "PAP-759",
assigneeAgentId: "agent-1",
};
}
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.activeRun("PAP-759"))) {
return {
id: "run-1",
};
}
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.liveRuns("PAP-759"))) {
return [{ id: "run-1" }];
}
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.runs("PAP-759"))) {
return [{ runId: "run-1" }];
}
return undefined;
},
};
const invalidated = __liveUpdatesTestUtils.invalidateVisibleIssueRunQueries(
queryClient as never,
"/PAP/issues/PAP-759",
{
runId: "run-1",
agentId: "agent-1",
status: "succeeded",
},
{ isForegrounded: true },
);
expect(invalidated).toBe(true);
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.detail("PAP-759"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.activity("PAP-759"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.runs("PAP-759"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.liveRuns("PAP-759"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.activeRun("PAP-759"),
});
});
it("ignores run status events for other issues", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: (key: unknown) => {
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
return {
id: "issue-1",
identifier: "PAP-759",
assigneeAgentId: "agent-1",
};
}
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.activeRun("PAP-759"))) {
return {
id: "run-1",
};
}
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.liveRuns("PAP-759"))) {
return [{ id: "run-1" }];
}
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.runs("PAP-759"))) {
return [{ runId: "run-1" }];
}
return undefined;
},
};
const invalidated = __liveUpdatesTestUtils.invalidateVisibleIssueRunQueries(
queryClient as never,
"/PAP/issues/PAP-759",
{
runId: "run-2",
agentId: "agent-2",
status: "succeeded",
},
{ isForegrounded: true },
);
expect(invalidated).toBe(false);
expect(invalidations).toEqual([]);
});
});
describe("LiveUpdatesProvider visible issue comment hydration", () => {
+26
View File
@@ -259,6 +259,30 @@ function shouldSuppressRunStatusToastForVisibleIssue(
return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId;
}
function invalidateVisibleIssueRunQueries(
queryClient: QueryClient,
pathname: string,
payload: Record<string, unknown>,
options?: VisibleRouteOptions,
): boolean {
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
if (!context) return false;
const runId = readString(payload.runId);
const agentId = readString(payload.agentId);
const matchesVisibleIssue =
(runId !== null && context.runIds.has(runId)) ||
(!!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId);
if (!matchesVisibleIssue) return false;
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(context.routeIssueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(context.routeIssueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(context.routeIssueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(context.routeIssueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(context.routeIssueRef) });
return true;
}
function shouldSuppressAgentStatusToastForVisibleIssue(
queryClient: QueryClient,
pathname: string,
@@ -749,6 +773,7 @@ function handleLiveEvent(
if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status") {
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
invalidateVisibleIssueRunQueries(queryClient, pathname, payload);
if (event.type === "heartbeat.run.status") {
const toast = buildRunStatusToast(payload, nameOf);
if (
@@ -844,6 +869,7 @@ export const __liveUpdatesTestUtils = {
closeSocketQuietly,
hydrateVisibleIssueComment,
invalidateActivityQueries,
invalidateVisibleIssueRunQueries,
resolveLiveCompanyId,
shouldDeferIssueRefetchForVisibleAgentActivity,
shouldDeferVisibleIssueCommentActivity,
+61 -12
View File
@@ -12,6 +12,7 @@ import type {
} from "@paperclipai/shared";
import {
DEFAULT_INBOX_ISSUE_COLUMNS,
buildGroupedInboxSections,
buildInboxKeyboardNavEntries,
buildInboxDismissedAtByKey,
computeInboxBadgeData,
@@ -772,10 +773,11 @@ describe("inbox helpers", () => {
statuses: ["in_progress"],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
showRoutineExecutions: false,
hideRoutineExecutions: true,
},
}).map((issue) => issue.id),
).toEqual(["remote-match"]);
@@ -790,10 +792,11 @@ describe("inbox helpers", () => {
statuses: [],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
showRoutineExecutions: false,
hideRoutineExecutions: true,
},
}),
).toEqual([]);
@@ -808,15 +811,55 @@ describe("inbox helpers", () => {
statuses: [],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
showRoutineExecutions: false,
hideRoutineExecutions: true,
},
}),
).toEqual([]);
});
it("keeps inbox search matches ahead of archived and other result sections", () => {
const inboxIssue = makeIssue("inbox", false);
inboxIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
const archivedIssue = makeIssue("archived", false);
archivedIssue.lastActivityAt = new Date("2026-03-11T03:00:00.000Z");
const otherIssue = makeIssue("other", false);
otherIssue.lastActivityAt = new Date("2026-03-11T05:00:00.000Z");
const sections = [
...buildGroupedInboxSections(
getInboxWorkItems({ issues: [inboxIssue], approvals: [] }),
"none",
{},
),
...buildGroupedInboxSections(
getInboxWorkItems({ issues: [archivedIssue], approvals: [] }),
"none",
{},
{ keyPrefix: "archived-search:", searchSection: "archived" },
),
...buildGroupedInboxSections(
getInboxWorkItems({ issues: [otherIssue], approvals: [] }),
"none",
{},
{ keyPrefix: "other-search:", searchSection: "other" },
),
];
expect(sections.map((section) => section.searchSection)).toEqual(["none", "archived", "other"]);
expect(
sections.map((section) => {
const [item] = section.displayItems;
return item?.kind === "issue" ? item.issue.id : null;
}),
).toEqual(["inbox", "archived", "other"]);
});
it("defaults the remembered inbox tab to mine and persists all", () => {
localStorage.clear();
expect(loadLastInboxTab()).toBe("mine");
@@ -833,10 +876,11 @@ describe("inbox helpers", () => {
statuses: ["todo"],
priorities: ["high"],
assignees: ["agent-1"],
creators: ["user:user-1"],
labels: ["label-1"],
projects: ["project-1"],
workspaces: ["workspace-1"],
showRoutineExecutions: true,
hideRoutineExecutions: false,
},
});
saveInboxFilterPreferences("company-2", {
@@ -846,10 +890,11 @@ describe("inbox helpers", () => {
statuses: ["done"],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
showRoutineExecutions: false,
hideRoutineExecutions: true,
},
});
@@ -860,10 +905,11 @@ describe("inbox helpers", () => {
statuses: ["todo"],
priorities: ["high"],
assignees: ["agent-1"],
creators: ["user:user-1"],
labels: ["label-1"],
projects: ["project-1"],
workspaces: ["workspace-1"],
showRoutineExecutions: true,
hideRoutineExecutions: false,
},
});
expect(loadInboxFilterPreferences("company-2")).toEqual({
@@ -873,10 +919,11 @@ describe("inbox helpers", () => {
statuses: ["done"],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
showRoutineExecutions: false,
hideRoutineExecutions: true,
},
});
});
@@ -889,10 +936,11 @@ describe("inbox helpers", () => {
statuses: ["todo", 123],
priorities: "high",
assignees: ["agent-1"],
creators: ["user:user-1", 42],
labels: null,
projects: ["project-1"],
workspaces: ["workspace-1", false],
showRoutineExecutions: "yes",
hideRoutineExecutions: "yes",
},
}));
@@ -903,10 +951,11 @@ describe("inbox helpers", () => {
statuses: ["todo"],
priorities: [],
assignees: ["agent-1"],
creators: ["user:user-1"],
labels: [],
projects: ["project-1"],
workspaces: ["workspace-1"],
showRoutineExecutions: false,
hideRoutineExecutions: false,
},
});
});
@@ -1060,12 +1109,12 @@ describe("inbox helpers", () => {
expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0);
});
it("hides routine execution issues until the toggle is enabled", () => {
it("hides routine execution issues when the hide toggle is enabled", () => {
const manualIssue = { ...makeIssue("manual", true), originKind: "manual" as const };
const routineIssue = { ...makeIssue("routine", true), originKind: "routine_execution" as const };
expect(filterInboxIssues([manualIssue, routineIssue], false)).toEqual([manualIssue]);
expect(filterInboxIssues([manualIssue, routineIssue], true)).toEqual([manualIssue, routineIssue]);
expect(filterInboxIssues([manualIssue, routineIssue], false)).toEqual([manualIssue, routineIssue]);
expect(filterInboxIssues([manualIssue, routineIssue], true)).toEqual([manualIssue]);
});
it("groups mixed inbox items by type while preserving item order within each group", () => {
+42 -25
View File
@@ -9,6 +9,7 @@ import type {
import {
applyIssueFilters,
defaultIssueFilterState,
normalizeIssueFilterState,
type IssueFilterState,
} from "./issue-filters";
@@ -87,6 +88,16 @@ export interface InboxWorkItemGroup {
items: InboxWorkItem[];
}
export type InboxSearchSection = "none" | "archived" | "other";
export interface InboxGroupedSection {
key: string;
label: string | null;
displayItems: InboxWorkItem[];
childrenByIssueId: Map<string, Issue[]>;
searchSection: InboxSearchSection;
}
export interface InboxKeyboardGroupSection {
key: string;
displayItems: InboxWorkItem[];
@@ -127,25 +138,6 @@ const defaultInboxFilterPreferences: InboxFilterPreferences = {
issueFilters: defaultIssueFilterState,
};
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === "string");
}
function normalizeIssueFilterState(value: unknown): IssueFilterState {
if (!value || typeof value !== "object") return { ...defaultIssueFilterState };
const candidate = value as Partial<Record<keyof IssueFilterState, unknown>>;
return {
statuses: normalizeStringArray(candidate.statuses),
priorities: normalizeStringArray(candidate.priorities),
assignees: normalizeStringArray(candidate.assignees),
labels: normalizeStringArray(candidate.labels),
projects: normalizeStringArray(candidate.projects),
workspaces: normalizeStringArray(candidate.workspaces),
showRoutineExecutions: candidate.showRoutineExecutions === true,
};
}
function normalizeInboxCategoryFilter(value: unknown): InboxCategoryFilter {
return value === "issues_i_touched"
|| value === "join_requests"
@@ -234,7 +226,7 @@ export function loadCollapsedInboxGroupKeys(
const raw = localStorage.getItem(storageKey);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
return new Set(normalizeStringArray(parsed));
return new Set(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []);
} catch {
return new Set();
}
@@ -367,14 +359,14 @@ export function shouldResetInboxWorkspaceGrouping(
export function shouldIncludeRoutineExecutionIssue(
issue: Pick<Issue, "originKind">,
showRoutineExecutions: boolean,
hideRoutineExecutions: boolean,
): boolean {
return showRoutineExecutions || issue.originKind !== "routine_execution";
return !hideRoutineExecutions || issue.originKind !== "routine_execution";
}
export function filterInboxIssues(issues: Issue[], showRoutineExecutions: boolean): Issue[] {
if (showRoutineExecutions) return issues;
return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, showRoutineExecutions));
export function filterInboxIssues(issues: Issue[], hideRoutineExecutions: boolean): Issue[] {
if (!hideRoutineExecutions) return issues;
return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, hideRoutineExecutions));
}
export function matchesInboxIssueSearch(
@@ -928,6 +920,31 @@ export function buildInboxNesting(items: InboxWorkItem[]): {
return { displayItems, childrenByIssueId };
}
export function buildGroupedInboxSections(
items: InboxWorkItem[],
groupBy: InboxWorkItemGroupBy,
workspaceGrouping: InboxWorkspaceGroupingOptions,
options?: { keyPrefix?: string; searchSection?: InboxSearchSection; nestingEnabled?: boolean },
): InboxGroupedSection[] {
const keyPrefix = options?.keyPrefix ?? "";
const searchSection = options?.searchSection ?? "none";
const nestingEnabled = options?.nestingEnabled ?? false;
return groupInboxWorkItems(items, groupBy, workspaceGrouping).map((group) => {
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
? buildInboxNesting(group.items)
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() };
return {
key: `${keyPrefix}${group.key}`,
label: group.label,
displayItems: nestedGroup.displayItems,
childrenByIssueId: nestedGroup.childrenByIssueId,
searchSection,
};
});
}
export function getInboxWorkItemKey(item: InboxWorkItem): string {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
+69
View File
@@ -0,0 +1,69 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState } from "./issue-filters";
function makeIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: overrides.id ?? "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Issue",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labels: [],
labelIds: [],
createdAt: new Date("2026-04-15T00:00:00.000Z"),
updatedAt: new Date("2026-04-15T00:00:00.000Z"),
...overrides,
};
}
describe("issue filters", () => {
it("filters issues by creator across agents and users", () => {
const issues = [
makeIssue({ id: "agent-match", createdByAgentId: "agent-1" }),
makeIssue({ id: "user-match", createdByUserId: "user-1" }),
makeIssue({ id: "excluded", createdByAgentId: "agent-2", createdByUserId: "user-2" }),
];
const filtered = applyIssueFilters(issues, {
...defaultIssueFilterState,
creators: ["agent:agent-1", "user:user-1"],
});
expect(filtered.map((issue) => issue.id)).toEqual(["agent-match", "user-match"]);
});
it("counts creator filters as an active filter group", () => {
expect(countActiveIssueFilters({
...defaultIssueFilterState,
creators: ["user:user-1"],
})).toBe(1);
});
});
+36 -4
View File
@@ -4,20 +4,22 @@ export type IssueFilterState = {
statuses: string[];
priorities: string[];
assignees: string[];
creators: string[];
labels: string[];
projects: string[];
workspaces: string[];
showRoutineExecutions: boolean;
hideRoutineExecutions: boolean;
};
export const defaultIssueFilterState: IssueFilterState = {
statuses: [],
priorities: [],
assignees: [],
creators: [],
labels: [],
projects: [],
workspaces: [],
showRoutineExecutions: false,
hideRoutineExecutions: false,
};
export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
@@ -41,6 +43,26 @@ export function issueFilterArraysEqual(a: string[], b: string[]): boolean {
return sortedA.every((value, index) => value === sortedB[index]);
}
function normalizeIssueFilterValueArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === "string");
}
export function normalizeIssueFilterState(value: unknown): IssueFilterState {
if (!value || typeof value !== "object") return { ...defaultIssueFilterState };
const candidate = value as Partial<Record<keyof IssueFilterState, unknown>>;
return {
statuses: normalizeIssueFilterValueArray(candidate.statuses),
priorities: normalizeIssueFilterValueArray(candidate.priorities),
assignees: normalizeIssueFilterValueArray(candidate.assignees),
creators: normalizeIssueFilterValueArray(candidate.creators),
labels: normalizeIssueFilterValueArray(candidate.labels),
projects: normalizeIssueFilterValueArray(candidate.projects),
workspaces: normalizeIssueFilterValueArray(candidate.workspaces),
hideRoutineExecutions: candidate.hideRoutineExecutions === true,
};
}
export function toggleIssueFilterValue(values: string[], value: string): string[] {
return values.includes(value) ? values.filter((existing) => existing !== value) : [...values, value];
}
@@ -58,7 +80,7 @@ export function applyIssueFilters(
enableRoutineVisibilityFilter = false,
): Issue[] {
let result = issues;
if (enableRoutineVisibilityFilter && !state.showRoutineExecutions) {
if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) {
result = result.filter((issue) => issue.originKind !== "routine_execution");
}
if (state.statuses.length > 0) result = result.filter((issue) => state.statuses.includes(issue.status));
@@ -73,6 +95,15 @@ export function applyIssueFilters(
return false;
});
}
if (state.creators.length > 0) {
result = result.filter((issue) => {
for (const creator of state.creators) {
if (creator.startsWith("agent:") && issue.createdByAgentId === creator.slice("agent:".length)) return true;
if (creator.startsWith("user:") && issue.createdByUserId === creator.slice("user:".length)) return true;
}
return false;
});
}
if (state.labels.length > 0) {
result = result.filter((issue) => (issue.labelIds ?? []).some((id) => state.labels.includes(id)));
}
@@ -96,9 +127,10 @@ export function countActiveIssueFilters(
if (state.statuses.length > 0) count += 1;
if (state.priorities.length > 0) count += 1;
if (state.assignees.length > 0) count += 1;
if (state.creators.length > 0) count += 1;
if (state.labels.length > 0) count += 1;
if (state.projects.length > 0) count += 1;
if (state.workspaces.length > 0) count += 1;
if (enableRoutineVisibilityFilter && state.showRoutineExecutions) count += 1;
if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) count += 1;
return count;
}
+12
View File
@@ -5,12 +5,19 @@ describe("issue-reference", () => {
it("extracts issue ids from company-scoped issue paths", () => {
expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271");
expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179");
expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull();
});
it("extracts issue ids from full issue URLs", () => {
expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179");
});
it("ignores placeholder issue paths", () => {
expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull();
expect(parseIssuePathIdFromPath("http://localhost:3100/issues/:id")).toBeNull();
expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull();
});
it("normalizes bare identifiers, issue URLs, and issue scheme links into internal links", () => {
expect(parseIssueReferenceFromHref("pap-1271")).toEqual({
issuePathId: "PAP-1271",
@@ -36,4 +43,9 @@ describe("issue-reference", () => {
href: "/issues/PAP-1271",
});
});
it("ignores literal route placeholder paths", () => {
expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull();
expect(parseIssueReferenceFromHref("http://localhost:3100/api/issues/:id")).toBeNull();
});
});
+3 -1
View File
@@ -25,7 +25,9 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined):
const segments = pathname.split("/").filter(Boolean);
const issueIndex = segments.findIndex((segment) => segment === "issues");
if (issueIndex === -1 || issueIndex === segments.length - 1) return null;
return decodeURIComponent(segments[issueIndex + 1] ?? "");
const issuePathId = decodeURIComponent(segments[issueIndex + 1] ?? "");
if (!issuePathId || issuePathId.startsWith(":")) return null;
return issuePathId;
}
export function parseIssueReferenceFromHref(href: string | null | undefined) {
+14
View File
@@ -84,6 +84,20 @@ export async function fetchIssueDetail(
return seedIssueDetailCache(queryClient, issue, { issueRef });
}
export function getIssueDetailQueryOptions(
queryClient: QueryClient,
issueRef: string,
options?: {
placeholderIssue?: Pick<Issue, "id" | "identifier"> | null;
},
) {
return {
queryKey: queryKeys.issues.detail(issueRef),
queryFn: () => fetchIssueDetail(queryClient, issueRef),
placeholderData: getCachedIssueDetail(queryClient, issueRef, options?.placeholderIssue ?? undefined),
};
}
export function prefetchIssueDetail(
queryClient: QueryClient,
issueRef: string,
+129
View File
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { getIssueDetailQueryOptions } from "./issueDetailCache";
vi.mock("@/api/issues", () => ({
issuesApi: {
get: vi.fn(),
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function makeIssue(overrides: Partial<Issue> = {}): Issue {
const now = new Date("2026-04-13T20:00:00.000Z");
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Issue title",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1442,
identifier: "PAP-1442",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
function IssueDetailQueryHarness({
issueRef,
placeholderIssue,
}: {
issueRef: string;
placeholderIssue?: Pick<Issue, "id" | "identifier"> | null;
}) {
const queryClient = useQueryClient();
const query = useQuery({
...getIssueDetailQueryOptions(queryClient, issueRef, { placeholderIssue }),
});
return <div>{query.data?.description ?? "EMPTY"}</div>;
}
async function flush() {
// Multiple act cycles to allow React Query to process the async queryFn
for (let i = 0; i < 5; i++) {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
}
describe("getIssueDetailQueryOptions", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("treats cached issue data as placeholder and still fetches full detail", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const partialIssue = makeIssue({ description: null });
const fullIssue = makeIssue({ description: "GitHub Security Advisory body" });
queryClient.setQueryData(queryKeys.issues.detail("issue-1"), partialIssue);
queryClient.setQueryData(queryKeys.issues.detail("PAP-1442"), partialIssue);
vi.mocked(issuesApi.get).mockResolvedValue(fullIssue);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetailQueryHarness
issueRef="PAP-1442"
placeholderIssue={{ id: partialIssue.id, identifier: partialIssue.identifier }}
/>
</QueryClientProvider>,
);
});
await flush();
expect(issuesApi.get).toHaveBeenCalledWith("PAP-1442");
expect(container.textContent).toContain("GitHub Security Advisory body");
await act(async () => {
root.unmount();
});
queryClient.clear();
container.remove();
});
});
+2
View File
@@ -65,6 +65,8 @@ export const queryKeys = {
executionWorkspaces: {
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
["execution-workspaces", companyId, filters ?? {}] as const,
summaryList: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
["execution-workspaces", companyId, "summary", filters ?? {}] as const,
detail: (id: string) => ["execution-workspaces", "detail", id] as const,
closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const,
workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const,
+1 -6
View File
@@ -9,12 +9,7 @@ import {
extractCompanyPrefixFromPath,
normalizeCompanyPrefix,
} from "@/lib/company-routes";
function parseIssuePathIdFromPath(pathname: string | null | undefined): string | null {
if (!pathname) return null;
const match = pathname.match(/(?:^|\/)issues\/([^/?#]+)/);
return match?.[1] ?? null;
}
import { parseIssuePathIdFromPath } from "@/lib/issue-reference";
function resolveTo(to: To, companyPrefix: string | null): To {
if (typeof to === "string") {
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { createUiDevWatchOptions, shouldIgnoreUiDevWatchPath } from "./vite-watch";
describe("shouldIgnoreUiDevWatchPath", () => {
it("ignores test-only files and folders", () => {
expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/components/IssuesList.test.tsx")).toBe(true);
expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/lib/issue-tree.spec.ts")).toBe(true);
expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/__tests__/helpers.ts")).toBe(true);
expect(shouldIgnoreUiDevWatchPath("/repo/ui/tests/helpers.ts")).toBe(true);
});
it("keeps runtime source files watchable", () => {
expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/components/IssuesList.tsx")).toBe(false);
expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/pages/IssueDetail.tsx")).toBe(false);
});
});
describe("createUiDevWatchOptions", () => {
it("preserves the WSL /mnt polling fallback", () => {
expect(createUiDevWatchOptions("/mnt/c/paperclip")).toMatchObject({
usePolling: true,
interval: 1000,
});
});
it("always includes the ignored-path predicate", () => {
expect(createUiDevWatchOptions("/Users/dotta/paperclip")).toHaveProperty("ignored");
});
});
+29
View File
@@ -0,0 +1,29 @@
const TEST_DIRECTORY_NAMES = new Set([
"__tests__",
"_tests",
"test",
"tests",
]);
const TEST_FILE_BASENAME_RE = /\.(test|spec)\.[^/]+$/i;
export function shouldIgnoreUiDevWatchPath(watchedPath: string): boolean {
const normalizedPath = String(watchedPath).replaceAll("\\", "/");
if (normalizedPath.length === 0) return false;
const segments = normalizedPath.split("/");
const basename = segments.at(-1) ?? normalizedPath;
return segments.some((segment) => TEST_DIRECTORY_NAMES.has(segment))
|| TEST_FILE_BASENAME_RE.test(basename);
}
export function createUiDevWatchOptions(currentWorkingDirectory: string) {
return {
ignored: shouldIgnoreUiDevWatchPath,
// WSL2 /mnt/ drives don't support inotify — fall back to polling so HMR works.
...(currentWorkingDirectory.startsWith("/mnt/")
? { usePolling: true, interval: 1000 }
: {}),
};
}
+6
View File
@@ -610,6 +610,12 @@ export function AdapterManager() {
modelsCount: 0,
loaded: true,
disabled: virtual.menuDisabled,
capabilities: {
supportsInstructionsBundle: false,
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
},
}}
canRemove={false}
onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })}
+3 -7
View File
@@ -26,6 +26,7 @@ import { AgentConfigForm } from "../components/AgentConfigForm";
import { PageTabBar } from "../components/PageTabBar";
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities";
import { MarkdownEditor } from "../components/MarkdownEditor";
import { assetsApi } from "../api/assets";
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
@@ -1719,13 +1720,8 @@ function PromptsTab({
externalBundleRef.current = null;
}, [agent.id]);
const isLocal =
agent.adapterType === "claude_local" ||
agent.adapterType === "codex_local" ||
agent.adapterType === "opencode_local" ||
agent.adapterType === "pi_local" ||
agent.adapterType === "hermes_local" ||
agent.adapterType === "cursor";
const getCapabilities = useAdapterCapabilities();
const isLocal = getCapabilities(agent.adapterType).supportsInstructionsBundle;
const { data: bundle, isLoading: bundleLoading } = useQuery({
queryKey: queryKeys.agents.instructionsBundle(agent.id),
+101 -58
View File
@@ -99,8 +99,8 @@ import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/sh
import {
ACTIONABLE_APPROVAL_STATUSES,
DEFAULT_INBOX_ISSUE_COLUMNS,
buildGroupedInboxSections,
buildInboxKeyboardNavEntries,
buildInboxNesting,
getAvailableInboxIssueColumns,
getInboxWorkItemKey,
getApprovalsForTab,
@@ -111,7 +111,6 @@ import {
getLatestFailedRunsByAgent,
matchesInboxIssueSearch,
getRecentTouchedIssues,
groupInboxWorkItems,
isInboxEntityDismissed,
isMineInboxTab,
loadCollapsedInboxGroupKeys,
@@ -138,6 +137,7 @@ import {
saveLastInboxTab,
shouldShowCompanyAlerts,
shouldShowInboxSection,
type InboxGroupedSection,
type InboxTab,
type InboxWorkItem,
type InboxWorkItemGroupBy,
@@ -152,39 +152,13 @@ type SectionKey =
/** A flat navigation entry for keyboard j/k traversal that includes expanded children. */
type NavEntry = InboxKeyboardNavEntry;
type InboxGroupedSection = {
key: string;
label: string | null;
displayItems: InboxWorkItem[];
childrenByIssueId: Map<string, Issue[]>;
isArchivedSearch: boolean;
type CreatorOption = {
id: string;
label: string;
kind: "agent" | "user";
searchText?: string;
};
function buildGroupedInboxSections(
items: InboxWorkItem[],
groupBy: InboxWorkItemGroupBy,
nestingEnabled: boolean,
workspaceGrouping: InboxWorkspaceGroupingOptions,
options?: { keyPrefix?: string; isArchivedSearch?: boolean },
): InboxGroupedSection[] {
const keyPrefix = options?.keyPrefix ?? "";
const isArchivedSearch = options?.isArchivedSearch ?? false;
return groupInboxWorkItems(items, groupBy, workspaceGrouping).map((group) => {
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
? buildInboxNesting(group.items)
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() };
return {
key: `${keyPrefix}${group.key}`,
label: group.label,
displayItems: nestedGroup.displayItems,
childrenByIssueId: nestedGroup.childrenByIssueId,
isArchivedSearch,
};
});
}
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@@ -875,6 +849,66 @@ export function Inbox() {
() => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe),
[visibleTouchedIssues],
);
const creatorOptions = useMemo<CreatorOption[]>(() => {
const options = new Map<string, CreatorOption>();
const sourceIssues = [...mineIssues, ...touchedIssues];
if (currentUserId) {
options.set(`user:${currentUserId}`, {
id: `user:${currentUserId}`,
label: currentUserId === "local-board" ? "Board" : "Me",
kind: "user",
searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`,
});
}
for (const issue of sourceIssues) {
if (issue.createdByUserId) {
const id = `user:${issue.createdByUserId}`;
if (!options.has(id)) {
options.set(id, {
id,
label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5),
kind: "user",
searchText: `${issue.createdByUserId} board user human`,
});
}
}
}
const knownAgentIds = new Set<string>();
for (const agent of agents ?? []) {
knownAgentIds.add(agent.id);
const id = `agent:${agent.id}`;
if (!options.has(id)) {
options.set(id, {
id,
label: agent.name,
kind: "agent",
searchText: `${agent.name} ${agent.id} agent`,
});
}
}
for (const issue of sourceIssues) {
if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) {
const id = `agent:${issue.createdByAgentId}`;
if (!options.has(id)) {
options.set(id, {
id,
label: issue.createdByAgentId.slice(0, 8),
kind: "agent",
searchText: `${issue.createdByAgentId} agent`,
});
}
}
}
return [...options.values()].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1;
return a.label.localeCompare(b.label);
});
}, [agents, currentUserId, mineIssues, touchedIssues]);
const issuesToRender = useMemo(
() => {
if (tab === "mine") return visibleMineIssues;
@@ -1128,19 +1162,12 @@ export function Inbox() {
remoteIssueSearchResults,
],
);
const effectiveWorkItems = useMemo(
() =>
issueSearchSupplementResults.length > 0
? [
...filteredWorkItems,
...getInboxWorkItems({ issues: issueSearchSupplementResults, approvals: [] }),
]
: filteredWorkItems,
[filteredWorkItems, issueSearchSupplementResults],
);
const archivedSearchIssueIds = useMemo(
() => new Set(archivedSearchIssues.map((issue) => issue.id)),
[archivedSearchIssues],
const nonInboxSearchIssueIds = useMemo(
() => new Set([
...archivedSearchIssues.map((issue) => issue.id),
...issueSearchSupplementResults.map((issue) => issue.id),
]),
[archivedSearchIssues, issueSearchSupplementResults],
);
// --- Parent-child nesting for inbox issues ---
@@ -1170,15 +1197,27 @@ export function Inbox() {
});
}, [selectedCompanyId]);
const groupedSections = useMemo<InboxGroupedSection[]>(() => [
...buildGroupedInboxSections(effectiveWorkItems, groupBy, nestingEnabled, inboxWorkspaceGrouping),
...buildGroupedInboxSections(filteredWorkItems, groupBy, inboxWorkspaceGrouping, { nestingEnabled }),
...buildGroupedInboxSections(
getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }),
groupBy,
nestingEnabled,
inboxWorkspaceGrouping,
{ keyPrefix: "archived-search:", isArchivedSearch: true },
{ keyPrefix: "archived-search:", searchSection: "archived", nestingEnabled },
),
], [archivedSearchIssues, effectiveWorkItems, groupBy, inboxWorkspaceGrouping, nestingEnabled]);
...buildGroupedInboxSections(
getInboxWorkItems({ issues: issueSearchSupplementResults, approvals: [] }),
groupBy,
inboxWorkspaceGrouping,
{ keyPrefix: "other-search:", searchSection: "other", nestingEnabled },
),
], [
archivedSearchIssues,
filteredWorkItems,
groupBy,
inboxWorkspaceGrouping,
issueSearchSupplementResults,
nestingEnabled,
]);
const totalVisibleWorkItems = useMemo(
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
[groupedSections],
@@ -1547,7 +1586,7 @@ export function Inbox() {
flatNavItems,
selectedIndex,
canArchive: canArchiveFromTab,
archivedSearchIssueIds,
nonInboxSearchIssueIds,
archivingIssueIds,
undoableArchiveIssueIds,
unarchivingIssueIds,
@@ -1560,7 +1599,7 @@ export function Inbox() {
flatNavItems,
selectedIndex,
canArchive: canArchiveFromTab,
archivedSearchIssueIds,
nonInboxSearchIssueIds,
archivingIssueIds,
undoableArchiveIssueIds,
unarchivingIssueIds,
@@ -1663,10 +1702,10 @@ export function Inbox() {
e.preventDefault();
const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) {
if (!st.archivedSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
if (!st.nonInboxSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
} else if (item) {
if (item.kind === "issue") {
if (!st.archivedSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) {
if (!st.nonInboxSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) {
act.archiveIssue(item.issue.id);
}
} else {
@@ -1891,6 +1930,7 @@ export function Inbox() {
onChange={updateIssueFilters}
activeFilterCount={activeIssueFilterCount}
agents={agents}
creators={creatorOptions}
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
@@ -2172,15 +2212,18 @@ export function Inbox() {
return groupedSections.flatMap((group, groupIndex) => {
const elements: ReactNode[] = [];
const isGroupCollapsed = collapsedGroupKeys.has(group.key);
if (group.isArchivedSearch && (groupIndex === 0 || !groupedSections[groupIndex - 1]?.isArchivedSearch)) {
if (
group.searchSection !== "none"
&& group.searchSection !== groupedSections[groupIndex - 1]?.searchSection
) {
elements.push(
<div
key="archived-search-divider"
key={`${group.searchSection}-search-divider`}
className="flex items-center gap-3 border-y border-border/70 bg-muted/30 px-4 py-2"
>
<div className="h-px flex-1 bg-border/80" />
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Archived
{group.searchSection === "archived" ? "Archived" : "Other results"}
</span>
<div className="h-px flex-1 bg-border/80" />
</div>,
@@ -2351,7 +2394,7 @@ export function Inbox() {
const childIssues = group.childrenByIssueId.get(issue.id) ?? [];
const hasChildren = childIssues.length > 0;
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
const canArchiveIssue = canArchiveFromTab && !group.isArchivedSearch;
const canArchiveIssue = canArchiveFromTab && group.searchSection === "none";
const parentRow = renderInboxIssue({
issue,
depth: 0,
+120 -90
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type Ref } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type Ref } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query";
@@ -30,7 +30,8 @@ import {
readIssueDetailHeaderSeed,
rememberIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb";
import { fetchIssueDetail, getCachedIssueDetail } from "../lib/issueDetailCache";
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
import { getIssueDetailQueryOptions } from "../lib/issueDetailCache";
import {
hasBlockingShortcutDialog,
resolveIssueDetailGoKeyAction,
@@ -492,7 +493,10 @@ function InboxMobileToolbar({
type IssueDetailChatTabProps = {
issueId: string;
issue: Issue;
companyId: string;
projectId: string | null;
issueStatus: Issue["status"];
executionRunId: string | null;
comments: IssueDetailComment[];
hasOlderComments: boolean;
commentsLoadingOlder: boolean;
@@ -525,9 +529,12 @@ type IssueDetailChatTabProps = {
onImageClick: (src: string) => void;
};
function IssueDetailChatTab({
const IssueDetailChatTab = memo(function IssueDetailChatTab({
issueId,
issue,
companyId,
projectId,
issueStatus,
executionRunId,
comments,
hasOlderComments,
commentsLoadingOlder,
@@ -555,59 +562,66 @@ function IssueDetailChatTab({
interruptingQueuedRunId,
onImageClick,
}: IssueDetailChatTabProps) {
const { data: activity, isLoading: activityLoading } = useQuery({
const { data: activity } = useQuery({
queryKey: queryKeys.issues.activity(issueId),
queryFn: () => activityApi.forIssue(issueId),
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueId),
});
const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({
const { data: liveRuns } = useQuery({
queryKey: queryKeys.issues.liveRuns(issueId),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
refetchInterval: 3000,
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
});
const liveRunCount = liveRuns?.length ?? 0;
const { data: activeRun, isLoading: activeRunLoading } = useQuery({
const resolvedLiveRuns = liveRuns ?? [];
const liveRunCount = resolvedLiveRuns.length;
const { data: activeRun = null } = useQuery({
queryKey: queryKeys.issues.activeRun(issueId),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
enabled: !!issue.executionRunId || issue.status === "in_progress",
enabled: !!executionRunId || issueStatus === "in_progress",
refetchInterval: liveRunCount > 0 ? false : 3000,
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId),
});
const hasLiveRuns = liveRunCount > 0 || !!activeRun;
const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({
const resolvedActiveRun = useMemo(
() => resolveIssueActiveRun({ status: issueStatus, executionRunId }, activeRun),
[activeRun, executionRunId, issueStatus],
);
const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun;
const { data: linkedRuns } = useQuery({
queryKey: queryKeys.issues.runs(issueId),
queryFn: () => activityApi.runsForIssue(issueId),
refetchInterval: hasLiveRuns ? 5000 : false,
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
});
const resolvedActivity = activity ?? [];
const resolvedLinkedRuns = linkedRuns ?? [];
const runningIssueRun = useMemo(
() => resolveRunningIssueRun(activeRun, liveRuns),
[activeRun, liveRuns],
() => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns),
[resolvedActiveRun, resolvedLiveRuns],
);
const timelineRuns = useMemo(() => {
const liveIds = new Set<string>();
for (const run of liveRuns ?? []) liveIds.add(run.id);
for (const run of resolvedLiveRuns) liveIds.add(run.id);
if (activeRun) liveIds.add(activeRun.id);
const historicalRuns = liveIds.size === 0
? (linkedRuns ?? [])
: (linkedRuns ?? []).filter((run) => !liveIds.has(run.runId));
? resolvedLinkedRuns
: resolvedLinkedRuns.filter((run) => !liveIds.has(run.runId));
return historicalRuns.map((run) => ({
...run,
adapterType: run.adapterType,
hasStoredOutput: (run.logBytes ?? 0) > 0,
}));
}, [activeRun, linkedRuns, liveRuns]);
}, [activeRun, resolvedLinkedRuns, resolvedLiveRuns]);
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
const agentIdByRunId = new Map<string, string>();
for (const run of linkedRuns ?? []) {
for (const run of resolvedLinkedRuns) {
agentIdByRunId.set(run.runId, run.agentId);
}
for (const evt of activity ?? []) {
for (const evt of resolvedActivity) {
if (evt.action !== "issue.comment_added" || !evt.runId) continue;
const details = evt.details ?? {};
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
@@ -641,20 +655,11 @@ function IssueDetailChatTab({
}
return nextComment;
});
}, [activity, comments, linkedRuns, runningIssueRun]);
}, [comments, resolvedActivity, resolvedLinkedRuns, runningIssueRun]);
const timelineEvents = useMemo(
() => extractIssueTimelineEvents(activity),
[activity],
() => extractIssueTimelineEvents(resolvedActivity),
[resolvedActivity],
);
const initialLoading =
(activityLoading && activity === undefined)
|| (linkedRunsLoading && linkedRuns === undefined)
|| (liveRunsLoading && liveRuns === undefined)
|| (activeRunLoading && activeRun === undefined);
if (initialLoading) {
return <IssueChatSkeleton />;
}
return (
<div className="space-y-3">
@@ -679,11 +684,11 @@ function IssueDetailChatTab({
feedbackTermsUrl={feedbackTermsUrl}
linkedRuns={timelineRuns}
timelineEvents={timelineEvents}
liveRuns={liveRuns}
activeRun={activeRun}
companyId={issue.companyId}
projectId={issue.projectId}
issueStatus={issue.status}
liveRuns={resolvedLiveRuns}
activeRun={resolvedActiveRun}
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
@@ -713,7 +718,7 @@ function IssueDetailChatTab({
/>
</div>
);
}
});
type IssueDetailActivityTabProps = {
issueId: string;
@@ -895,22 +900,15 @@ export function IssueDetail() {
() => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState),
[location.state, resolvedIssueDetailState],
);
const cachedIssue = useMemo(
() =>
issueId
? getCachedIssueDetail(queryClient, issueId, issueHeaderSeed ? {
id: issueHeaderSeed.id,
identifier: issueHeaderSeed.identifier,
} : null)
: undefined,
[issueHeaderSeed, issueId, queryClient],
);
const { data: issue, isLoading, error } = useQuery({
queryKey: queryKeys.issues.detail(issueId!),
queryFn: () => fetchIssueDetail(queryClient, issueId!),
...getIssueDetailQueryOptions(queryClient, issueId!, {
placeholderIssue: issueHeaderSeed ? {
id: issueHeaderSeed.id,
identifier: issueHeaderSeed.identifier,
} : null,
}),
enabled: !!issueId,
initialData: () => cachedIssue,
});
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
const commentComposerDisabledReason = useMemo(() => {
@@ -969,7 +967,8 @@ export function IssueDetail() {
select: (run) => !!run,
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId ?? "pending"),
});
const hasLiveRuns = liveRunCount > 0 || hasActiveRun;
const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun;
const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun;
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
[issueId, location.state, location.search],
@@ -1074,6 +1073,14 @@ export function IssueDetail() {
() => buildIssuePropertiesPanelKey(issue ?? null, childIssues),
[childIssues, issue],
);
const panelIssue = useMemo(
() => issue ?? null,
[issue?.id, issuePanelKey],
);
const panelChildIssues = useMemo(
() => childIssues,
[issuePanelKey],
);
const showRichSubIssuesSection = shouldRenderRichSubIssuesSection(childIssuesLoading, childIssues.length);
const openNewSubIssue = useCallback(() => {
if (!issue) return;
@@ -1118,6 +1125,7 @@ export function IssueDetail() {
() => mergeIssueComments(comments ?? [], optimisticComments),
[comments, optimisticComments],
);
const breadcrumbTitle = issue?.title ?? issueId ?? "Issue";
const invalidateIssueDetail = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
@@ -1758,12 +1766,17 @@ export function IssueDetail() {
});
useEffect(() => {
const titleLabel = issue?.title ?? issueId ?? "Issue";
setBreadcrumbs([
sourceBreadcrumb,
{ label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel },
{ label: hasLiveRuns ? `🔵 ${breadcrumbTitle}` : breadcrumbTitle },
]);
}, [setBreadcrumbs, sourceBreadcrumb, issue, issueId, hasLiveRuns]);
}, [
breadcrumbTitle,
hasLiveRuns,
setBreadcrumbs,
sourceBreadcrumb.href,
sourceBreadcrumb.label,
]);
const isFromInbox = resolvedIssueDetailState?.issueDetailSource === "inbox";
@@ -1805,20 +1818,28 @@ export function IssueDetail() {
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!issue) {
if (!panelIssue) {
closePanel();
return;
}
openPanel(
<IssueProperties
issue={issue}
childIssues={childIssues}
issue={panelIssue}
childIssues={panelChildIssues}
onAddSubIssue={openNewSubIssue}
onUpdate={handleIssuePropertiesUpdate}
/>
);
return () => closePanel();
}, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]);
}, [
closePanel,
handleIssuePropertiesUpdate,
issuePanelKey,
openNewSubIssue,
openPanel,
panelChildIssues,
panelIssue,
]);
const goToInboxShortcutArmedRef = useRef(false);
const goToInboxShortcutTimeoutRef = useRef<number | null>(null);
@@ -2047,6 +2068,36 @@ export function IssueDetail() {
}, [showInboxToolbar, backHref, issue?.id, issueHidden, archivePending, setMobileToolbar]);
const attachmentsInitialLoading = attachmentsLoading && attachments === undefined;
const loadOlderComments = useCallback(() => {
void fetchOlderComments();
}, [fetchOlderComments]);
const handleCommentVote = useCallback(async (commentId: string, vote: "up" | "down", options?: { allowSharing?: boolean; reason?: string }) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_comment",
targetId: commentId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}, [feedbackDataSharingPreference, feedbackVoteMutation]);
const handleChatAdd = useCallback(async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
return;
}
await addComment.mutateAsync({ body, reopen });
}, [addComment, addCommentAndReassign]);
const handleCommentImageUpload = useCallback(async (file: File) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}, [uploadAttachment]);
const handleCommentAttachImage = useCallback(async (file: File) => {
await uploadAttachment.mutateAsync(file);
}, [uploadAttachment]);
const handleInterruptQueuedRun = useCallback(async (runId: string) => {
await interruptQueuedComment.mutateAsync(runId);
}, [interruptQueuedComment]);
if (isLoading) return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
@@ -2572,13 +2623,14 @@ export function IssueDetail() {
{detailTab === "chat" ? (
<IssueDetailChatTab
issueId={issue.id}
issue={issue}
companyId={issue.companyId}
projectId={issue.projectId ?? null}
issueStatus={issue.status}
executionRunId={issue.executionRunId ?? null}
comments={threadComments}
hasOlderComments={hasOlderComments}
commentsLoadingOlder={commentsLoadingOlder}
onLoadOlderComments={() => {
void fetchOlderComments();
}}
onLoadOlderComments={loadOlderComments}
composerRef={commentComposerRef}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
@@ -2593,33 +2645,11 @@ export function IssueDetail() {
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
composerDisabledReason={commentComposerDisabledReason}
onVote={async (commentId, vote, options) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_comment",
targetId: commentId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}}
onAdd={async (body, reopen, reassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
return;
}
await addComment.mutateAsync({ body, reopen });
}}
onImageUpload={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onAttachImage={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
onInterruptQueued={async (runId) => {
await interruptQueuedComment.mutateAsync(runId);
}}
onVote={handleCommentVote}
onAdd={handleChatAdd}
onImageUpload={handleCommentImageUpload}
onAttachImage={handleCommentAttachImage}
onInterruptQueued={handleInterruptQueuedRun}
onCancelQueued={handleCancelQueuedComment}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
onImageClick={handleChatImageClick}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { buildIssuesSearchUrl } from "./Issues";
describe("buildIssuesSearchUrl", () => {
it("preserves trailing spaces in the synced search param", () => {
expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug", "bug ")).toBe("/issues?q=bug+");
});
it("removes the search param when the input is cleared", () => {
expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug#details", "")).toBe("/issues#details");
});
it("returns null when the URL already matches the current search", () => {
expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug+", "bug ")).toBeNull();
});
});
+16 -12
View File
@@ -13,6 +13,20 @@ import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { CircleDot } from "lucide-react";
export function buildIssuesSearchUrl(currentHref: string, search: string): string | null {
const url = new URL(currentHref);
const currentSearch = url.searchParams.get("q") ?? "";
if (currentSearch === search) return null;
if (search.length > 0) {
url.searchParams.set("q", search);
} else {
url.searchParams.delete("q");
}
return `${url.pathname}${url.search}${url.hash}`;
}
export function Issues() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -23,18 +37,8 @@ export function Issues() {
const initialSearch = searchParams.get("q") ?? "";
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
const handleSearchChange = useCallback((search: string) => {
const trimmedSearch = search.trim();
const currentSearch = new URLSearchParams(window.location.search).get("q") ?? "";
if (currentSearch === trimmedSearch) return;
const url = new URL(window.location.href);
if (trimmedSearch) {
url.searchParams.set("q", trimmedSearch);
} else {
url.searchParams.delete("q");
}
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
const nextUrl = buildIssuesSearchUrl(window.location.href, search);
if (!nextUrl) return;
window.history.replaceState(window.history.state, "", nextUrl);
}, []);
+1
View File
@@ -1114,6 +1114,7 @@ export function RoutineDetail() {
open={runVariablesOpen}
onOpenChange={setRunVariablesOpen}
companyId={routine.companyId}
routineName={routine.title}
agents={agents ?? []}
projects={projects ?? []}
defaultProjectId={routine.projectId}
+1
View File
@@ -963,6 +963,7 @@ export function Routines() {
if (!next) setRunDialogRoutine(null);
}}
companyId={selectedCompanyId}
routineName={runDialogRoutine?.title ?? null}
agents={agents ?? []}
projects={projects ?? []}
defaultProjectId={runDialogRoutine?.projectId ?? null}
+14 -4
View File
@@ -2,9 +2,20 @@ import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { createUiDevWatchOptions } from "./src/lib/vite-watch";
export default defineConfig({
export default defineConfig(({ mode }) => ({
plugins: [react(), tailwindcss()],
build: {
minify: "esbuild",
},
esbuild:
mode === "production"
? {
drop: ["console", "debugger"],
legalComments: "none",
}
: undefined,
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
@@ -13,8 +24,7 @@ export default defineConfig({
},
server: {
port: 5173,
// WSL2 /mnt/ drives don't support inotify — fall back to polling so HMR works
watch: process.cwd().startsWith("/mnt/") ? { usePolling: true, interval: 1000 } : undefined,
watch: createUiDevWatchOptions(process.cwd()),
proxy: {
"/api": {
target: "http://localhost:3100",
@@ -22,4 +32,4 @@ export default defineConfig({
},
},
},
});
}));