[codex] improve issue and routine UI responsiveness (#3744)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Operators rely on issue, inbox, and routine views to understand what the company is doing in real time > - Those views need to stay fast and readable even when issue lists, markdown comments, and run metadata get large > - The current branch had a coherent set of UI and live-update improvements spread across issue search, issue detail rendering, routine affordances, and workspace lookups > - This pull request groups those board-facing changes into one standalone branch that can merge independently of the heartbeat/runtime work > - The benefit is a faster, clearer issue and routine workflow without changing the underlying task model ## What Changed - Show routine execution issues by default and rename the filter to `Hide routine runs` so the default state no longer looks like an active filter. - Show the routine name in the run dialog and tighten the issue properties pane with a workspace link, copy-on-click behavior, and an inline parent arrow. - Reduce issue detail rerenders, keep queued issue chat mounted, improve issues page search responsiveness, and speed up issues first paint. - Add inbox "other search results", refresh visible issue runs after status updates, and optimize workspace lookups through summary-mode execution workspace queries. - Improve markdown wrapping and scrolling behavior for long strings and self-comment code blocks. - Relax the markdown sanitizer assertion so the test still validates safety after the new wrap-friendly inline styles. ## Verification - `pnpm vitest run ui/src/components/IssuesList.test.tsx ui/src/lib/inbox.test.ts ui/src/pages/Issues.test.tsx ui/src/context/BreadcrumbContext.test.tsx ui/src/context/LiveUpdatesProvider.test.ts ui/src/components/MarkdownBody.test.tsx ui/src/api/execution-workspaces.test.ts server/src/__tests__/execution-workspaces-routes.test.ts` ## Risks - This touches several issue-facing UI surfaces at once, so regressions would most likely show up as stale rendering, search result mismatches, or small markdown presentation differences. - The workspace lookup optimization depends on the summary-mode route shape staying aligned between server and UI. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment. Exact backend model deployment ID was not exposed in-session. Tool-assisted editing and shell execution were used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -224,6 +224,7 @@ export type {
|
||||
ProjectGoalRef,
|
||||
ProjectWorkspace,
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
ExecutionWorkspaceConfig,
|
||||
ExecutionWorkspaceCloseAction,
|
||||
ExecutionWorkspaceCloseActionKind,
|
||||
|
||||
@@ -63,6 +63,7 @@ export type { AssetImage } from "./asset.js";
|
||||
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
ExecutionWorkspaceConfig,
|
||||
ExecutionWorkspaceCloseAction,
|
||||
ExecutionWorkspaceCloseActionKind,
|
||||
|
||||
@@ -161,6 +161,13 @@ export interface IssueExecutionWorkspaceSettings {
|
||||
workspaceRuntime?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing" | "agent_default"> | "adapter_managed" | "cloud_sandbox";
|
||||
projectWorkspaceId: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspace {
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listSummaries: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getCloseReadiness: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({
|
||||
listForExecutionWorkspace: vi.fn(),
|
||||
createRecorder: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerServiceMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
const [{ executionWorkspaceRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/execution-workspaces.js")>("../routes/execution-workspaces.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", executionWorkspaceRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("execution workspace routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/execution-workspaces.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerServiceMocks();
|
||||
vi.resetAllMocks();
|
||||
mockExecutionWorkspaceService.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspaceService.listSummaries.mockResolvedValue([
|
||||
{
|
||||
id: "workspace-1",
|
||||
name: "Alpha",
|
||||
mode: "isolated_workspace",
|
||||
projectWorkspaceId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses summary mode for lightweight workspace lookups", async () => {
|
||||
const res = await request(await createApp())
|
||||
.get("/api/companies/company-1/execution-workspaces?summary=true&reuseEligible=true");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: "workspace-1",
|
||||
name: "Alpha",
|
||||
mode: "isolated_workspace",
|
||||
projectWorkspaceId: null,
|
||||
},
|
||||
]);
|
||||
expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", {
|
||||
projectId: undefined,
|
||||
projectWorkspaceId: undefined,
|
||||
issueId: undefined,
|
||||
status: undefined,
|
||||
reuseEligible: true,
|
||||
});
|
||||
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -37,13 +37,16 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const workspaces = await svc.list(companyId, {
|
||||
const filters = {
|
||||
projectId: req.query.projectId as string | undefined,
|
||||
projectWorkspaceId: req.query.projectWorkspaceId as string | undefined,
|
||||
issueId: req.query.issueId as string | undefined,
|
||||
status: req.query.status as string | undefined,
|
||||
reuseEligible: req.query.reuseEligible === "true",
|
||||
});
|
||||
};
|
||||
const workspaces = req.query.summary === "true"
|
||||
? await svc.listSummaries(companyId, filters)
|
||||
: await svc.list(companyId, filters);
|
||||
res.json(workspaces);
|
||||
});
|
||||
|
||||
|
||||
@@ -656,6 +656,8 @@ export function issueRoutes(
|
||||
originId: req.query.originId as string | undefined,
|
||||
includeRoutineExecutions:
|
||||
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
||||
excludeRoutineExecutions:
|
||||
req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1",
|
||||
q: req.query.q as string | undefined,
|
||||
limit,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Db } from "@paperclipai/db";
|
||||
import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import type {
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
ExecutionWorkspaceCloseAction,
|
||||
ExecutionWorkspaceCloseGitReadiness,
|
||||
ExecutionWorkspaceCloseReadiness,
|
||||
@@ -336,6 +337,15 @@ function toExecutionWorkspace(
|
||||
};
|
||||
}
|
||||
|
||||
function toExecutionWorkspaceSummary(row: Pick<ExecutionWorkspaceRow, "id" | "name" | "mode" | "projectWorkspaceId">): ExecutionWorkspaceSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
mode: row.mode as ExecutionWorkspaceSummary["mode"],
|
||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) {
|
||||
if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false;
|
||||
return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime;
|
||||
@@ -372,6 +382,33 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace(
|
||||
}
|
||||
|
||||
export function executionWorkspaceService(db: Db) {
|
||||
function buildListConditions(
|
||||
companyId: string,
|
||||
filters?: {
|
||||
projectId?: string;
|
||||
projectWorkspaceId?: string;
|
||||
issueId?: string;
|
||||
status?: string;
|
||||
reuseEligible?: boolean;
|
||||
},
|
||||
) {
|
||||
const conditions = [eq(executionWorkspaces.companyId, companyId)];
|
||||
if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId));
|
||||
if (filters?.projectWorkspaceId) {
|
||||
conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId));
|
||||
}
|
||||
if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId));
|
||||
if (filters?.status) {
|
||||
const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean);
|
||||
if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!));
|
||||
else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses));
|
||||
}
|
||||
if (filters?.reuseEligible) {
|
||||
conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"]));
|
||||
}
|
||||
return conditions;
|
||||
}
|
||||
|
||||
return {
|
||||
list: async (companyId: string, filters?: {
|
||||
projectId?: string;
|
||||
@@ -380,21 +417,7 @@ export function executionWorkspaceService(db: Db) {
|
||||
status?: string;
|
||||
reuseEligible?: boolean;
|
||||
}) => {
|
||||
const conditions = [eq(executionWorkspaces.companyId, companyId)];
|
||||
if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId));
|
||||
if (filters?.projectWorkspaceId) {
|
||||
conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId));
|
||||
}
|
||||
if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId));
|
||||
if (filters?.status) {
|
||||
const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean);
|
||||
if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!));
|
||||
else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses));
|
||||
}
|
||||
if (filters?.reuseEligible) {
|
||||
conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"]));
|
||||
}
|
||||
|
||||
const conditions = buildListConditions(companyId, filters);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(executionWorkspaces)
|
||||
@@ -409,6 +432,27 @@ export function executionWorkspaceService(db: Db) {
|
||||
);
|
||||
},
|
||||
|
||||
listSummaries: async (companyId: string, filters?: {
|
||||
projectId?: string;
|
||||
projectWorkspaceId?: string;
|
||||
issueId?: string;
|
||||
status?: string;
|
||||
reuseEligible?: boolean;
|
||||
}) => {
|
||||
const conditions = buildListConditions(companyId, filters);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: executionWorkspaces.id,
|
||||
name: executionWorkspaces.name,
|
||||
mode: executionWorkspaces.mode,
|
||||
projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
|
||||
})
|
||||
.from(executionWorkspaces)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
|
||||
return rows.map((row) => toExecutionWorkspaceSummary(row));
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
const row = await db
|
||||
.select()
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface IssueFilters {
|
||||
originKind?: string;
|
||||
originId?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
excludeRoutineExecutions?: boolean;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
}
|
||||
@@ -985,7 +986,7 @@ export function issueService(db: Db) {
|
||||
)!,
|
||||
);
|
||||
}
|
||||
if (!filters?.includeRoutineExecutions && !filters?.originKind && !filters?.originId) {
|
||||
if (filters?.excludeRoutineExecutions && !filters?.originKind && !filters?.originId) {
|
||||
conditions.push(ne(issues.originKind, "routine_execution"));
|
||||
}
|
||||
conditions.push(isNull(issues.hiddenAt));
|
||||
@@ -1162,7 +1163,6 @@ export function issueService(db: Db) {
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
unreadForUserCondition(companyId, userId),
|
||||
ne(issues.originKind, "routine_execution"),
|
||||
];
|
||||
if (status) {
|
||||
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -922,7 +922,7 @@ function IssueChatUserMessage() {
|
||||
<div className="flex min-w-0 max-w-[85%] flex-col items-end">
|
||||
<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",
|
||||
@@ -957,7 +957,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} />,
|
||||
|
||||
@@ -251,10 +251,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}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -20,7 +20,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 }> }) {
|
||||
@@ -39,17 +39,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>
|
||||
);
|
||||
}
|
||||
@@ -704,16 +702,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))
|
||||
@@ -939,15 +946,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>
|
||||
@@ -1060,10 +1059,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
|
||||
|
||||
@@ -28,6 +28,7 @@ const mockAuthApi = vi.hoisted(() => ({
|
||||
|
||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listSummaries: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
@@ -183,11 +184,13 @@ describe("IssuesList", () => {
|
||||
mockIssuesApi.listLabels.mockReset();
|
||||
mockAuthApi.getSession.mockReset();
|
||||
mockExecutionWorkspacesApi.list.mockReset();
|
||||
mockExecutionWorkspacesApi.listSummaries.mockReset();
|
||||
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -216,7 +219,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");
|
||||
});
|
||||
@@ -250,6 +257,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");
|
||||
@@ -333,7 +341,7 @@ describe("IssuesList", () => {
|
||||
expect(onSearchChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(149);
|
||||
vi.advanceTimersByTime(249);
|
||||
});
|
||||
|
||||
expect(onSearchChange).not.toHaveBeenCalled();
|
||||
@@ -351,6 +359,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"]));
|
||||
|
||||
@@ -423,7 +534,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",
|
||||
@@ -491,7 +602,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",
|
||||
@@ -519,7 +630,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 () => {
|
||||
@@ -532,21 +643,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(() => {
|
||||
@@ -624,4 +735,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,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 ── */
|
||||
|
||||
@@ -283,6 +287,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();
|
||||
@@ -333,12 +338,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 } : {}),
|
||||
}),
|
||||
@@ -347,9 +354,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,
|
||||
});
|
||||
|
||||
@@ -529,6 +536,26 @@ export function IssuesList({
|
||||
}));
|
||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
||||
|
||||
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;
|
||||
@@ -578,6 +605,7 @@ export function IssuesList({
|
||||
setAssigneeSearch("");
|
||||
}, [onUpdateIssue]);
|
||||
|
||||
let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -732,7 +760,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}
|
||||
@@ -750,7 +782,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)}
|
||||
@@ -793,10 +828,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 toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
@@ -810,7 +849,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}
|
||||
@@ -984,11 +1034,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>
|
||||
);
|
||||
|
||||
@@ -99,7 +99,8 @@ describe("MarkdownBody", () => {
|
||||
it("sanitizes unsafe javascript markdown links", () => {
|
||||
const html = renderMarkdown("[click me](javascript:alert(document.cookie))");
|
||||
|
||||
expect(html).toContain('<a href="" rel="noreferrer">click me</a>');
|
||||
expect(html).toContain('<a href="" rel="noreferrer"');
|
||||
expect(html).toContain(">click me</a>");
|
||||
expect(html).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
@@ -173,7 +174,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");
|
||||
});
|
||||
|
||||
@@ -192,4 +193,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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -148,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 (
|
||||
@@ -181,14 +235,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>
|
||||
);
|
||||
@@ -213,11 +267,11 @@ 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}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -245,6 +245,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,
|
||||
@@ -735,6 +759,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 (
|
||||
@@ -830,6 +855,7 @@ export const __liveUpdatesTestUtils = {
|
||||
closeSocketQuietly,
|
||||
hydrateVisibleIssueComment,
|
||||
invalidateActivityQueries,
|
||||
invalidateVisibleIssueRunQueries,
|
||||
resolveLiveCompanyId,
|
||||
shouldDeferIssueRefetchForVisibleAgentActivity,
|
||||
shouldDeferVisibleIssueCommentActivity,
|
||||
|
||||
+52
-12
@@ -12,6 +12,7 @@ import type {
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||
buildGroupedInboxSections,
|
||||
buildInboxKeyboardNavEntries,
|
||||
buildInboxDismissedAtByKey,
|
||||
computeInboxBadgeData,
|
||||
@@ -718,7 +719,7 @@ describe("inbox helpers", () => {
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
showRoutineExecutions: false,
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
}).map((issue) => issue.id),
|
||||
).toEqual(["remote-match"]);
|
||||
@@ -736,7 +737,7 @@ describe("inbox helpers", () => {
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
showRoutineExecutions: false,
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
@@ -754,12 +755,51 @@ describe("inbox helpers", () => {
|
||||
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");
|
||||
@@ -779,7 +819,7 @@ describe("inbox helpers", () => {
|
||||
labels: ["label-1"],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
showRoutineExecutions: true,
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
saveInboxFilterPreferences("company-2", {
|
||||
@@ -792,7 +832,7 @@ describe("inbox helpers", () => {
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
showRoutineExecutions: false,
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -806,7 +846,7 @@ describe("inbox helpers", () => {
|
||||
labels: ["label-1"],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
showRoutineExecutions: true,
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
expect(loadInboxFilterPreferences("company-2")).toEqual({
|
||||
@@ -819,7 +859,7 @@ describe("inbox helpers", () => {
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
showRoutineExecutions: false,
|
||||
hideRoutineExecutions: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -835,7 +875,7 @@ describe("inbox helpers", () => {
|
||||
labels: null,
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1", false],
|
||||
showRoutineExecutions: "yes",
|
||||
hideRoutineExecutions: "yes",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -849,7 +889,7 @@ describe("inbox helpers", () => {
|
||||
labels: [],
|
||||
projects: ["project-1"],
|
||||
workspaces: ["workspace-1"],
|
||||
showRoutineExecutions: false,
|
||||
hideRoutineExecutions: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1003,12 +1043,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", () => {
|
||||
|
||||
+41
-6
@@ -87,6 +87,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[];
|
||||
@@ -142,7 +152,7 @@ function normalizeIssueFilterState(value: unknown): IssueFilterState {
|
||||
labels: normalizeStringArray(candidate.labels),
|
||||
projects: normalizeStringArray(candidate.projects),
|
||||
workspaces: normalizeStringArray(candidate.workspaces),
|
||||
showRoutineExecutions: candidate.showRoutineExecutions === true,
|
||||
hideRoutineExecutions: candidate.hideRoutineExecutions === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,14 +377,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(
|
||||
@@ -916,6 +926,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}`;
|
||||
|
||||
@@ -7,7 +7,7 @@ export type IssueFilterState = {
|
||||
labels: string[];
|
||||
projects: string[];
|
||||
workspaces: string[];
|
||||
showRoutineExecutions: boolean;
|
||||
hideRoutineExecutions: boolean;
|
||||
};
|
||||
|
||||
export const defaultIssueFilterState: IssueFilterState = {
|
||||
@@ -17,7 +17,7 @@ export const defaultIssueFilterState: IssueFilterState = {
|
||||
labels: [],
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
showRoutineExecutions: false,
|
||||
hideRoutineExecutions: false,
|
||||
};
|
||||
|
||||
export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||
@@ -58,7 +58,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));
|
||||
@@ -99,6 +99,6 @@ export function countActiveIssueFilters(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+35
-59
@@ -97,8 +97,8 @@ import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/sh
|
||||
import {
|
||||
ACTIONABLE_APPROVAL_STATUSES,
|
||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||
buildGroupedInboxSections,
|
||||
buildInboxKeyboardNavEntries,
|
||||
buildInboxNesting,
|
||||
getAvailableInboxIssueColumns,
|
||||
getInboxWorkItemKey,
|
||||
getApprovalsForTab,
|
||||
@@ -109,7 +109,6 @@ import {
|
||||
getLatestFailedRunsByAgent,
|
||||
matchesInboxIssueSearch,
|
||||
getRecentTouchedIssues,
|
||||
groupInboxWorkItems,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadCollapsedInboxGroupKeys,
|
||||
@@ -135,6 +134,7 @@ import {
|
||||
type InboxKeyboardNavEntry,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
type InboxGroupedSection,
|
||||
type InboxTab,
|
||||
type InboxWorkItem,
|
||||
type InboxWorkItemGroupBy,
|
||||
@@ -150,38 +150,6 @@ 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;
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -1081,19 +1049,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 ---
|
||||
@@ -1123,15 +1084,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],
|
||||
@@ -1500,7 +1473,7 @@ export function Inbox() {
|
||||
flatNavItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
archivedSearchIssueIds,
|
||||
nonInboxSearchIssueIds,
|
||||
archivingIssueIds,
|
||||
undoableArchiveIssueIds,
|
||||
unarchivingIssueIds,
|
||||
@@ -1513,7 +1486,7 @@ export function Inbox() {
|
||||
flatNavItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
archivedSearchIssueIds,
|
||||
nonInboxSearchIssueIds,
|
||||
archivingIssueIds,
|
||||
undoableArchiveIssueIds,
|
||||
unarchivingIssueIds,
|
||||
@@ -1616,10 +1589,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 {
|
||||
@@ -2113,15 +2086,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>,
|
||||
@@ -2292,7 +2268,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,
|
||||
|
||||
+104
-73
@@ -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";
|
||||
@@ -488,7 +488,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;
|
||||
@@ -519,9 +522,12 @@ type IssueDetailChatTabProps = {
|
||||
onImageClick: (src: string) => void;
|
||||
};
|
||||
|
||||
function IssueDetailChatTab({
|
||||
const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
issueId,
|
||||
issue,
|
||||
companyId,
|
||||
projectId,
|
||||
issueStatus,
|
||||
executionRunId,
|
||||
comments,
|
||||
hasOlderComments,
|
||||
commentsLoadingOlder,
|
||||
@@ -547,59 +553,62 @@ 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 { 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(activeRun, resolvedLiveRuns),
|
||||
[activeRun, 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;
|
||||
@@ -633,20 +642,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">
|
||||
@@ -671,11 +671,11 @@ function IssueDetailChatTab({
|
||||
feedbackTermsUrl={feedbackTermsUrl}
|
||||
linkedRuns={timelineRuns}
|
||||
timelineEvents={timelineEvents}
|
||||
liveRuns={liveRuns}
|
||||
liveRuns={resolvedLiveRuns}
|
||||
activeRun={activeRun}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId}
|
||||
issueStatus={issue.status}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
issueStatus={issueStatus}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
draftKey={draftKey}
|
||||
@@ -703,7 +703,7 @@ function IssueDetailChatTab({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
type IssueDetailActivityTabProps = {
|
||||
issueId: string;
|
||||
@@ -1060,6 +1060,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;
|
||||
@@ -1103,6 +1111,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!) });
|
||||
@@ -1743,12 +1752,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";
|
||||
|
||||
@@ -1790,20 +1804,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);
|
||||
@@ -2032,6 +2054,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>;
|
||||
@@ -2557,13 +2609,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}
|
||||
@@ -2576,33 +2629,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}
|
||||
|
||||
@@ -972,6 +972,7 @@ export function Routines() {
|
||||
if (!next) setRunDialogRoutine(null);
|
||||
}}
|
||||
companyId={selectedCompanyId}
|
||||
routineName={runDialogRoutine?.title ?? null}
|
||||
agents={agents ?? []}
|
||||
projects={projects ?? []}
|
||||
defaultProjectId={runDialogRoutine?.projectId ?? null}
|
||||
|
||||
Reference in New Issue
Block a user