mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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:
commit
ea7066709b
121 files changed
+6172
-660
No files matched your search
+1
-1
@@ -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;
|
||||
}
|
||||
@@ -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) */
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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("");
|
||||
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(
|
||||
|
||||
@@ -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'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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1114,6 +1114,7 @@ export function RoutineDetail() {
|
||||
open={runVariablesOpen}
|
||||
onOpenChange={setRunVariablesOpen}
|
||||
companyId={routine.companyId}
|
||||
routineName={routine.title}
|
||||
agents={agents ?? []}
|
||||
projects={projects ?? []}
|
||||
defaultProjectId={routine.projectId}
|
||||
|
||||
@@ -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
@@ -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({
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
Reference in new issue
Block a user