From d4c3899ca4c9c2abfbcbeab8056a9036f30daf34 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:54:05 -0500 Subject: [PATCH] [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 --- packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + .../shared/src/types/workspace-runtime.ts | 7 + .../execution-workspaces-routes.test.ts | 90 +++++++++ server/src/routes/execution-workspaces.ts | 7 +- server/src/routes/issues.ts | 2 + server/src/services/execution-workspaces.ts | 74 ++++++-- server/src/services/issues.ts | 4 +- ui/src/api/execution-workspaces.test.ts | 29 +++ ui/src/api/execution-workspaces.ts | 23 +++ ui/src/components/IssueChatThread.tsx | 4 +- ui/src/components/IssueFiltersPopover.tsx | 6 +- ui/src/components/IssueProperties.test.tsx | 3 + ui/src/components/IssueProperties.tsx | 54 +++--- ui/src/components/IssuesList.test.tsx | 152 ++++++++++++++- ui/src/components/IssuesList.tsx | 73 +++++++- ui/src/components/MarkdownBody.test.tsx | 27 ++- ui/src/components/MarkdownBody.tsx | 66 ++++++- .../components/RoutineRunVariablesDialog.tsx | 5 + .../transcript/RunTranscriptView.test.tsx | 8 +- ui/src/context/BreadcrumbContext.test.tsx | 61 ++++++ ui/src/context/BreadcrumbContext.tsx | 13 +- ui/src/context/LiveUpdatesProvider.test.ts | 102 ++++++++++ ui/src/context/LiveUpdatesProvider.tsx | 26 +++ ui/src/lib/inbox.test.ts | 64 +++++-- ui/src/lib/inbox.ts | 47 ++++- ui/src/lib/issue-filters.ts | 8 +- ui/src/lib/queryKeys.ts | 2 + ui/src/pages/Inbox.tsx | 94 ++++------ ui/src/pages/IssueDetail.tsx | 177 ++++++++++-------- ui/src/pages/Issues.test.tsx | 16 ++ ui/src/pages/Issues.tsx | 28 +-- ui/src/pages/RoutineDetail.tsx | 1 + ui/src/pages/Routines.tsx | 1 + 34 files changed, 1035 insertions(+), 241 deletions(-) create mode 100644 server/src/__tests__/execution-workspaces-routes.test.ts create mode 100644 ui/src/api/execution-workspaces.test.ts create mode 100644 ui/src/context/BreadcrumbContext.test.tsx create mode 100644 ui/src/pages/Issues.test.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0cc99826..fab8c1ed 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -224,6 +224,7 @@ export type { ProjectGoalRef, ProjectWorkspace, ExecutionWorkspace, + ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, ExecutionWorkspaceCloseAction, ExecutionWorkspaceCloseActionKind, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 6c38141d..62ca4dde 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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, diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 9e287200..4e6b01cd 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -161,6 +161,13 @@ export interface IssueExecutionWorkspaceSettings { workspaceRuntime?: Record | null; } +export interface ExecutionWorkspaceSummary { + id: string; + name: string; + mode: Exclude | "adapter_managed" | "cloud_sandbox"; + projectWorkspaceId: string | null; +} + export interface ExecutionWorkspace { id: string; companyId: string; diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts new file mode 100644 index 00000000..a5295fe0 --- /dev/null +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -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("../routes/execution-workspaces.js"), + vi.importActual("../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(); + }); +}); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index faabd53c..20e80c53 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -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); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index c5d2d40d..1531441e 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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, }); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index af1c3ee8..1832a1a1 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -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): 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 | 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() diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index db8eb39a..e18cd7c3 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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); diff --git a/ui/src/api/execution-workspaces.test.ts b/ui/src/api/execution-workspaces.test.ts new file mode 100644 index 00000000..b18bd650 --- /dev/null +++ b/ui/src/api/execution-workspaces.test.ts @@ -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", + ); + }); +}); diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index 2c10a4dc..cd5b7341 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -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( + `/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`, + ); + }, list: ( companyId: string, filters?: { diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index a8f48cd9..91fda373 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -922,7 +922,7 @@ function IssueChatUserMessage() {
) : null} -
+
, diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 2d21d49a..321d9b1d 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -251,10 +251,10 @@ export function IssueFiltersPopover({ Visibility
) : null} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 3c92683a..2069d76b 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -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 })); diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index ed7a5c31..0deebe63 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -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 (
- - {value} - + {copied && }
); } @@ -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 ? ( - - {issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier - ? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} ` - : ""} - {issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)} + + {parentIdentifier ? `${parentIdentifier} ` : ""} + {parentTitle} ) : ( No parent ); + const parentLink = issue.parentId ? ( + e.stopPropagation()} + > + + + ) : 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 ? ( - e.stopPropagation()} - > - - - ) : undefined} + extra={parentLink} > {parentContent} @@ -1060,10 +1059,21 @@ export function IssueProperties({ )}
- {issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd ? ( + {issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? ( <>
+ {issue.executionWorkspaceId && ( + + + View workspace + + + + )} {issue.currentExecutionWorkspace?.branchName && ( ({ 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( + 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( + 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( + 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( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1"); + expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled(); + }); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 2ac9b89c..1f8ebcdc 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -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(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); + const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT); const [visibleIssueColumns, setVisibleIssueColumns] = useState(() => 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 = { ...(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 (
@@ -732,7 +760,11 @@ export function IssuesList({ {isLoading && } {error &&

{error.message}

} - + {normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && ( +

+ Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further. +

+ )} {!isLoading && filtered.length === 0 && viewState.viewMode === "list" && ( ) : ( - groupedContent.map((group) => ( + <> + {groupedContent.map((group) => { + if (remainingRowsToRender <= 0) return null; + return ( () }; 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 ( -
0 ? { paddingLeft: `${depth * 16}px` } : undefined}> +
0 ? { paddingLeft: `${depth * 16}px` } : {}), + ...(useDeferredRowRendering + ? { + contentVisibility: "auto", + containIntrinsicSize: "44px", + } + : {}), + }} + > renderIssueRow(issue, 0)); + return roots.map((issue) => renderIssueRow(issue, 0)).filter((node) => node !== null); })()} - )) + ); + })} + {remainingIssueRowCount > 0 && ( +

+ Rendering {Math.min(renderedIssueRowLimit, filtered.length)} of {filtered.length} issues +

+ )} + )}
); diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index ebbf6953..c3b66c69 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -99,7 +99,8 @@ describe("MarkdownBody", () => { it("sanitizes unsafe javascript markdown links", () => { const html = renderMarkdown("[click me](javascript:alert(document.cookie))"); - expect(html).toContain('click me'); + expect(html).toContain('click me"); expect(html).not.toContain("javascript:"); }); @@ -173,7 +174,7 @@ describe("MarkdownBody", () => { ]); expect(html).toContain('href="/issues/PAP-1271"'); - expect(html).toContain("PAP-1271"); + expect(html).toContain('PAP-1271'); 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(" { + const html = renderMarkdown("[link](https://example.com/reallyreallyreallyreallyreallyreallyreallyreallylong)"); + + expect(html).toContain(' { + const html = renderMarkdown("```text\nGET /heartbeat-runs/ca5d23fc-c15b-4826-8ff1-2b6dd11be096/log?offset=2062357&limitBytes=256000\n```"); + + expect(html).toContain(" ( +

+ {paragraphChildren} +

+ ), + li: ({ node: _node, style: listItemStyle, children: listItemChildren, ...listItemProps }) => ( +
  • + {listItemChildren} +
  • + ), + blockquote: ({ node: _node, style: blockquoteStyle, children: blockquoteChildren, ...blockquoteProps }) => ( +
    + {blockquoteChildren} +
    + ), + td: ({ node: _node, style: tableCellStyle, children: tableCellChildren, ...tableCellProps }) => ( + + {tableCellChildren} + + ), + th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => ( + + {tableHeaderChildren} + + ), pre: ({ node: _node, children: preChildren, ...preProps }) => { const mermaidSource = extractMermaidSource(preChildren); if (mermaidSource) { return ; } - return
    {preChildren}
    ; + return
    {preChildren}
    ; }, - a: ({ href, children: linkChildren }) => { + code: ({ node: _node, style: codeStyle, children: codeChildren, ...codeProps }) => ( + + {codeChildren} + + ), + 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}
    ); } return ( - + {linkChildren} ); @@ -213,11 +267,11 @@ export function MarkdownBody({ return (
    void; companyId: string | null | undefined; + routineName?: string | null; projects: Project[]; agents: Agent[]; defaultProjectId?: string | null; @@ -253,6 +255,9 @@ export function RoutineRunVariablesDialog({ !isPending && onOpenChange(next)}> + {routineName && ( +

    {routineName}

    + )} Run routine Choose the agent and optional project for this one run. Routine defaults are prefilled and won't be changed. diff --git a/ui/src/components/transcript/RunTranscriptView.test.tsx b/ui/src/components/transcript/RunTranscriptView.test.tsx index 230ab5a4..41f635d2 100644 --- a/ui/src/components/transcript/RunTranscriptView.test.tsx +++ b/ui/src/components/transcript/RunTranscriptView.test.tsx @@ -54,8 +54,8 @@ describe("RunTranscriptView", () => { ); expect(html).toContain("world"); - expect(html).toContain("
  • first
  • "); - expect(html).toContain("
  • second
  • "); + expect(html).toMatch(/]*>first<\/li>/); + expect(html).toMatch(/]*>second<\/li>/); }); it("hides saved-session resume skip stderr from nice mode normalization", () => { @@ -106,8 +106,8 @@ describe("RunTranscriptView", () => { ); expect(html).toContain("

    Summary

    "); - expect(html).toContain("
  • fixed deploy config
  • "); - expect(html).toContain("
  • posted issue update
  • "); + expect(html).toMatch(/]*>fixed deploy config<\/li>/); + expect(html).toMatch(/]*>posted issue update<\/li>/); expect(html).not.toContain("result"); }); }); diff --git a/ui/src/context/BreadcrumbContext.test.tsx b/ui/src/context/BreadcrumbContext.test.tsx new file mode 100644 index 00000000..fb705dc9 --- /dev/null +++ b/ui/src/context/BreadcrumbContext.test.tsx @@ -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; + + 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( + + + , + ); + }); + + 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); + }); +}); diff --git a/ui/src/context/BreadcrumbContext.tsx b/ui/src/context/BreadcrumbContext.tsx index 11d1db6e..0f156840 100644 --- a/ui/src/context/BreadcrumbContext.tsx +++ b/ui/src/context/BreadcrumbContext.tsx @@ -14,12 +14,23 @@ interface BreadcrumbContextValue { const BreadcrumbContext = createContext(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([]); const [mobileToolbar, setMobileToolbarState] = useState(null); const setBreadcrumbs = useCallback((crumbs: Breadcrumb[]) => { - setBreadcrumbsState(crumbs); + setBreadcrumbsState((current) => (breadcrumbsEqual(current, crumbs) ? current : crumbs)); }, []); const setMobileToolbar = useCallback((node: ReactNode | null) => { diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 559f80df..96c5d3b9 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -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", () => { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 1db342c0..a146ca66 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -245,6 +245,30 @@ function shouldSuppressRunStatusToastForVisibleIssue( return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId; } +function invalidateVisibleIssueRunQueries( + queryClient: QueryClient, + pathname: string, + payload: Record, + 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, diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 37f0fc0f..3c1008b3 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -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", () => { diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 9060d0a2..43f11645 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -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; + 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, - 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() }; + + 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}`; diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts index 46a77e07..c9ec4a5b 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -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; } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index f7c47b45..6c67cbd0 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -65,6 +65,8 @@ export const queryKeys = { executionWorkspaces: { list: (companyId: string, filters?: Record) => ["execution-workspaces", companyId, filters ?? {}] as const, + summaryList: (companyId: string, filters?: Record) => + ["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, diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index ff3d22fa..6cf23a8b 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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; - 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() }; - - 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(() => [ - ...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(
    - Archived + {group.searchSection === "archived" ? "Archived" : "Other results"}
    , @@ -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, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index de2a390d..4a6f2269 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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(issueId), }); - const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({ + const { data: liveRuns } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId), refetchInterval: 3000, placeholderData: keepPreviousDataForSameQueryTail(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(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(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(); - 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(() => { const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null; const runMetaByCommentId = new Map(); const agentIdByRunId = new Map(); - 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 ; - } return (
    @@ -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({ />
    ); -} +}); 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( ); return () => closePanel(); - }, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]); + }, [ + closePanel, + handleIssuePropertiesUpdate, + issuePanelKey, + openNewSubIssue, + openPanel, + panelChildIssues, + panelIssue, + ]); const goToInboxShortcutArmedRef = useRef(false); const goToInboxShortcutTimeoutRef = useRef(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 ; if (error) return

    {error.message}

    ; @@ -2557,13 +2609,14 @@ export function IssueDetail() { {detailTab === "chat" ? ( { - 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} diff --git a/ui/src/pages/Issues.test.tsx b/ui/src/pages/Issues.test.tsx new file mode 100644 index 00000000..1234242b --- /dev/null +++ b/ui/src/pages/Issues.test.tsx @@ -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(); + }); +}); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index e1ecffc4..90db2bae 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -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); }, []); diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 1ff17a25..de6a0099 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -1114,6 +1114,7 @@ export function RoutineDetail() { open={runVariablesOpen} onOpenChange={setRunVariablesOpen} companyId={routine.companyId} + routineName={routine.title} agents={agents ?? []} projects={projects ?? []} defaultProjectId={routine.projectId} diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index f36adeba..d86dceef 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -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}