mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[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:
parent
7463479fc8
commit
d4c3899ca4
34 changed files with 1035 additions and 241 deletions
|
|
@ -224,6 +224,7 @@ export type {
|
||||||
ProjectGoalRef,
|
ProjectGoalRef,
|
||||||
ProjectWorkspace,
|
ProjectWorkspace,
|
||||||
ExecutionWorkspace,
|
ExecutionWorkspace,
|
||||||
|
ExecutionWorkspaceSummary,
|
||||||
ExecutionWorkspaceConfig,
|
ExecutionWorkspaceConfig,
|
||||||
ExecutionWorkspaceCloseAction,
|
ExecutionWorkspaceCloseAction,
|
||||||
ExecutionWorkspaceCloseActionKind,
|
ExecutionWorkspaceCloseActionKind,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ export type { AssetImage } from "./asset.js";
|
||||||
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
||||||
export type {
|
export type {
|
||||||
ExecutionWorkspace,
|
ExecutionWorkspace,
|
||||||
|
ExecutionWorkspaceSummary,
|
||||||
ExecutionWorkspaceConfig,
|
ExecutionWorkspaceConfig,
|
||||||
ExecutionWorkspaceCloseAction,
|
ExecutionWorkspaceCloseAction,
|
||||||
ExecutionWorkspaceCloseActionKind,
|
ExecutionWorkspaceCloseActionKind,
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,13 @@ export interface IssueExecutionWorkspaceSettings {
|
||||||
workspaceRuntime?: Record<string, unknown> | null;
|
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 {
|
export interface ExecutionWorkspace {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
|
|
||||||
90
server/src/__tests__/execution-workspaces-routes.test.ts
Normal file
90
server/src/__tests__/execution-workspaces-routes.test.ts
Normal file
|
|
@ -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) => {
|
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const workspaces = await svc.list(companyId, {
|
const filters = {
|
||||||
projectId: req.query.projectId as string | undefined,
|
projectId: req.query.projectId as string | undefined,
|
||||||
projectWorkspaceId: req.query.projectWorkspaceId as string | undefined,
|
projectWorkspaceId: req.query.projectWorkspaceId as string | undefined,
|
||||||
issueId: req.query.issueId as string | undefined,
|
issueId: req.query.issueId as string | undefined,
|
||||||
status: req.query.status as string | undefined,
|
status: req.query.status as string | undefined,
|
||||||
reuseEligible: req.query.reuseEligible === "true",
|
reuseEligible: req.query.reuseEligible === "true",
|
||||||
});
|
};
|
||||||
|
const workspaces = req.query.summary === "true"
|
||||||
|
? await svc.listSummaries(companyId, filters)
|
||||||
|
: await svc.list(companyId, filters);
|
||||||
res.json(workspaces);
|
res.json(workspaces);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -656,6 +656,8 @@ export function issueRoutes(
|
||||||
originId: req.query.originId as string | undefined,
|
originId: req.query.originId as string | undefined,
|
||||||
includeRoutineExecutions:
|
includeRoutineExecutions:
|
||||||
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
||||||
|
excludeRoutineExecutions:
|
||||||
|
req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1",
|
||||||
q: req.query.q as string | undefined,
|
q: req.query.q as string | undefined,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Db } from "@paperclipai/db";
|
||||||
import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||||
import type {
|
import type {
|
||||||
ExecutionWorkspace,
|
ExecutionWorkspace,
|
||||||
|
ExecutionWorkspaceSummary,
|
||||||
ExecutionWorkspaceCloseAction,
|
ExecutionWorkspaceCloseAction,
|
||||||
ExecutionWorkspaceCloseGitReadiness,
|
ExecutionWorkspaceCloseGitReadiness,
|
||||||
ExecutionWorkspaceCloseReadiness,
|
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) {
|
function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) {
|
||||||
if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false;
|
if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false;
|
||||||
return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime;
|
return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime;
|
||||||
|
|
@ -372,6 +382,33 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function executionWorkspaceService(db: Db) {
|
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 {
|
return {
|
||||||
list: async (companyId: string, filters?: {
|
list: async (companyId: string, filters?: {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
|
@ -380,21 +417,7 @@ export function executionWorkspaceService(db: Db) {
|
||||||
status?: string;
|
status?: string;
|
||||||
reuseEligible?: boolean;
|
reuseEligible?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const conditions = [eq(executionWorkspaces.companyId, companyId)];
|
const conditions = buildListConditions(companyId, filters);
|
||||||
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 rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(executionWorkspaces)
|
.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) => {
|
getById: async (id: string) => {
|
||||||
const row = await db
|
const row = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ export interface IssueFilters {
|
||||||
originKind?: string;
|
originKind?: string;
|
||||||
originId?: string;
|
originId?: string;
|
||||||
includeRoutineExecutions?: boolean;
|
includeRoutineExecutions?: boolean;
|
||||||
|
excludeRoutineExecutions?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
limit?: number;
|
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(ne(issues.originKind, "routine_execution"));
|
||||||
}
|
}
|
||||||
conditions.push(isNull(issues.hiddenAt));
|
conditions.push(isNull(issues.hiddenAt));
|
||||||
|
|
@ -1162,7 +1163,6 @@ export function issueService(db: Db) {
|
||||||
eq(issues.companyId, companyId),
|
eq(issues.companyId, companyId),
|
||||||
isNull(issues.hiddenAt),
|
isNull(issues.hiddenAt),
|
||||||
unreadForUserCondition(companyId, userId),
|
unreadForUserCondition(companyId, userId),
|
||||||
ne(issues.originKind, "routine_execution"),
|
|
||||||
];
|
];
|
||||||
if (status) {
|
if (status) {
|
||||||
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
|
const statuses = status.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
|
|
||||||
29
ui/src/api/execution-workspaces.test.ts
Normal file
29
ui/src/api/execution-workspaces.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockApi = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./client", () => ({
|
||||||
|
api: mockApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { executionWorkspacesApi } from "./execution-workspaces";
|
||||||
|
|
||||||
|
describe("executionWorkspacesApi.listSummaries", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApi.get.mockReset();
|
||||||
|
mockApi.get.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests the lightweight summary payload", async () => {
|
||||||
|
await executionWorkspacesApi.listSummaries("company-1", {
|
||||||
|
projectId: "project-1",
|
||||||
|
reuseEligible: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
|
"/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
ExecutionWorkspace,
|
ExecutionWorkspace,
|
||||||
|
ExecutionWorkspaceSummary,
|
||||||
ExecutionWorkspaceCloseReadiness,
|
ExecutionWorkspaceCloseReadiness,
|
||||||
WorkspaceOperation,
|
WorkspaceOperation,
|
||||||
WorkspaceRuntimeControlTarget,
|
WorkspaceRuntimeControlTarget,
|
||||||
|
|
@ -8,6 +9,28 @@ import { api } from "./client";
|
||||||
import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control";
|
import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control";
|
||||||
|
|
||||||
export const executionWorkspacesApi = {
|
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: (
|
list: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
filters?: {
|
filters?: {
|
||||||
|
|
|
||||||
|
|
@ -922,7 +922,7 @@ function IssueChatUserMessage() {
|
||||||
<div className="flex min-w-0 max-w-[85%] flex-col items-end">
|
<div className="flex min-w-0 max-w-[85%] flex-col items-end">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
queued
|
||||||
? "bg-amber-50/80 dark:bg-amber-500/10"
|
? "bg-amber-50/80 dark:bg-amber-500/10"
|
||||||
: "bg-muted",
|
: "bg-muted",
|
||||||
|
|
@ -957,7 +957,7 @@ function IssueChatUserMessage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="space-y-3">
|
<div className="min-w-0 max-w-full space-y-3">
|
||||||
<MessagePrimitive.Parts
|
<MessagePrimitive.Parts
|
||||||
components={{
|
components={{
|
||||||
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
||||||
|
|
|
||||||
|
|
@ -251,10 +251,10 @@ export function IssueFiltersPopover({
|
||||||
<span className="text-xs text-muted-foreground">Visibility</span>
|
<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">
|
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={state.showRoutineExecutions}
|
checked={state.hideRoutineExecutions}
|
||||||
onCheckedChange={(checked) => onChange({ showRoutineExecutions: checked === true })}
|
onCheckedChange={(checked) => onChange({ hideRoutineExecutions: checked === true })}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">Show routine runs</span>
|
<span className="text-sm">Hide routine runs</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,9 @@ describe("IssueProperties", () => {
|
||||||
const selectedParentTrigger = Array.from(container.querySelectorAll("button"))
|
const selectedParentTrigger = Array.from(container.querySelectorAll("button"))
|
||||||
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
|
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
|
||||||
expect(selectedParentTrigger).not.toBeUndefined();
|
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 () => {
|
await act(async () => {
|
||||||
selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
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";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
|
|
||||||
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
|
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 (
|
return (
|
||||||
<div className="flex items-start gap-1.5 min-w-0 flex-1">
|
<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" />
|
<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
|
<button
|
||||||
type="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}
|
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>
|
</button>
|
||||||
|
{copied && <Check className="h-3 w-3 text-green-500 shrink-0 mt-0.5" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -704,16 +702,25 @@ export function IssueProperties({
|
||||||
if (!issue.parentId) return null;
|
if (!issue.parentId) return null;
|
||||||
return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null;
|
return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null;
|
||||||
}, [allIssues, issue.parentId]);
|
}, [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 ? (
|
const parentTrigger = issue.parentId ? (
|
||||||
<span className="text-sm break-words min-w-0">
|
<span className="text-sm break-words min-w-0 inline">
|
||||||
{issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier
|
{parentIdentifier ? `${parentIdentifier} ` : ""}
|
||||||
? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} `
|
{parentTitle}
|
||||||
: ""}
|
|
||||||
{issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)}
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-muted-foreground">No parent</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 ?? [])
|
const parentOptions = (allIssues ?? [])
|
||||||
.filter((candidate) => candidate.id !== issue.id)
|
.filter((candidate) => candidate.id !== issue.id)
|
||||||
.filter((candidate) => !descendantIssueIds.has(candidate.id))
|
.filter((candidate) => !descendantIssueIds.has(candidate.id))
|
||||||
|
|
@ -939,15 +946,7 @@ export function IssueProperties({
|
||||||
triggerContent={parentTrigger}
|
triggerContent={parentTrigger}
|
||||||
triggerClassName="min-w-0 max-w-full"
|
triggerClassName="min-w-0 max-w-full"
|
||||||
popoverClassName="w-72"
|
popoverClassName="w-72"
|
||||||
extra={issue.parentId ? (
|
extra={parentLink}
|
||||||
<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}
|
|
||||||
>
|
>
|
||||||
{parentContent}
|
{parentContent}
|
||||||
</PropertyPicker>
|
</PropertyPicker>
|
||||||
|
|
@ -1060,10 +1059,21 @@ export function IssueProperties({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd ? (
|
{issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-1">
|
<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 && (
|
{issue.currentExecutionWorkspace?.branchName && (
|
||||||
<PropertyRow label="Branch">
|
<PropertyRow label="Branch">
|
||||||
<TruncatedCopyable
|
<TruncatedCopyable
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const mockAuthApi = vi.hoisted(() => ({
|
||||||
|
|
||||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
|
listSummaries: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||||
|
|
@ -183,11 +184,13 @@ describe("IssuesList", () => {
|
||||||
mockIssuesApi.listLabels.mockReset();
|
mockIssuesApi.listLabels.mockReset();
|
||||||
mockAuthApi.getSession.mockReset();
|
mockAuthApi.getSession.mockReset();
|
||||||
mockExecutionWorkspacesApi.list.mockReset();
|
mockExecutionWorkspacesApi.list.mockReset();
|
||||||
|
mockExecutionWorkspacesApi.listSummaries.mockReset();
|
||||||
mockInstanceSettingsApi.getExperimental.mockReset();
|
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||||
mockIssuesApi.list.mockResolvedValue([]);
|
mockIssuesApi.list.mockResolvedValue([]);
|
||||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||||
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||||
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
@ -216,7 +219,11 @@ describe("IssuesList", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitForAssertion(() => {
|
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).toContain("Server result");
|
||||||
expect(container.textContent).not.toContain("Local issue");
|
expect(container.textContent).not.toContain("Local issue");
|
||||||
});
|
});
|
||||||
|
|
@ -250,6 +257,7 @@ describe("IssuesList", () => {
|
||||||
q: "server",
|
q: "server",
|
||||||
projectId: undefined,
|
projectId: undefined,
|
||||||
parentId: "parent-1",
|
parentId: "parent-1",
|
||||||
|
limit: 200,
|
||||||
});
|
});
|
||||||
expect(container.textContent).toContain("Server result");
|
expect(container.textContent).toContain("Server result");
|
||||||
expect(container.textContent).not.toContain("Local issue");
|
expect(container.textContent).not.toContain("Local issue");
|
||||||
|
|
@ -333,7 +341,7 @@ describe("IssuesList", () => {
|
||||||
expect(onSearchChange).not.toHaveBeenCalled();
|
expect(onSearchChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(149);
|
vi.advanceTimersByTime(249);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onSearchChange).not.toHaveBeenCalled();
|
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 () => {
|
it("uses context-scoped persisted column visibility", async () => {
|
||||||
localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "assignee"]));
|
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 () => {
|
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"]));
|
localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "workspace"]));
|
||||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||||
mockExecutionWorkspacesApi.list.mockResolvedValue([
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: "workspace-alpha",
|
id: "workspace-alpha",
|
||||||
name: "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({
|
const manualIssue = createIssue({
|
||||||
id: "issue-manual",
|
id: "issue-manual",
|
||||||
identifier: "PAP-10",
|
identifier: "PAP-10",
|
||||||
|
|
@ -519,7 +630,7 @@ describe("IssuesList", () => {
|
||||||
|
|
||||||
await waitForAssertion(() => {
|
await waitForAssertion(() => {
|
||||||
expect(container.textContent).toContain("Manual issue");
|
expect(container.textContent).toContain("Manual issue");
|
||||||
expect(container.textContent).not.toContain("Routine issue");
|
expect(container.textContent).toContain("Routine issue");
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
@ -532,21 +643,21 @@ describe("IssuesList", () => {
|
||||||
|
|
||||||
await waitForAssertion(() => {
|
await waitForAssertion(() => {
|
||||||
const toggle = Array.from(document.body.querySelectorAll("label")).find(
|
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();
|
expect(toggle).not.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const toggle = Array.from(document.body.querySelectorAll("label")).find(
|
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 }));
|
toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForAssertion(() => {
|
await waitForAssertion(() => {
|
||||||
expect(container.textContent).toContain("Routine issue");
|
expect(container.textContent).not.toContain("Routine issue");
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|
@ -624,4 +735,29 @@ describe("IssuesList", () => {
|
||||||
root.unmount();
|
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 { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||||
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
||||||
import type { Issue, Project } from "@paperclipai/shared";
|
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 ── */
|
/* ── View state ── */
|
||||||
|
|
||||||
|
|
@ -283,6 +287,7 @@ export function IssuesList({
|
||||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||||
|
const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT);
|
||||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
|
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
|
||||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||||
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
||||||
|
|
@ -333,12 +338,14 @@ export function IssuesList({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
||||||
searchFilters ?? {},
|
searchFilters ?? {},
|
||||||
|
ISSUE_SEARCH_RESULT_LIMIT,
|
||||||
enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
|
enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
issuesApi.list(selectedCompanyId!, {
|
issuesApi.list(selectedCompanyId!, {
|
||||||
q: normalizedIssueSearch,
|
q: normalizedIssueSearch,
|
||||||
projectId,
|
projectId,
|
||||||
|
limit: ISSUE_SEARCH_RESULT_LIMIT,
|
||||||
...searchFilters,
|
...searchFilters,
|
||||||
...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}),
|
...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}),
|
||||||
}),
|
}),
|
||||||
|
|
@ -347,9 +354,9 @@ export function IssuesList({
|
||||||
});
|
});
|
||||||
const { data: executionWorkspaces = [] } = useQuery({
|
const { data: executionWorkspaces = [] } = useQuery({
|
||||||
queryKey: selectedCompanyId
|
queryKey: selectedCompanyId
|
||||||
? queryKeys.executionWorkspaces.list(selectedCompanyId)
|
? queryKeys.executionWorkspaces.summaryList(selectedCompanyId)
|
||||||
: ["execution-workspaces", "__disabled__"],
|
: ["execution-workspaces", "__disabled__"],
|
||||||
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
|
queryFn: () => executionWorkspacesApi.listSummaries(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId && isolatedWorkspacesEnabled,
|
enabled: !!selectedCompanyId && isolatedWorkspacesEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -529,6 +536,26 @@ export function IssuesList({
|
||||||
}));
|
}));
|
||||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
}, [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 newIssueDefaults = useCallback((groupKey?: string) => {
|
||||||
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
||||||
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
|
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
|
||||||
|
|
@ -578,6 +605,7 @@ export function IssuesList({
|
||||||
setAssigneeSearch("");
|
setAssigneeSearch("");
|
||||||
}, [onUpdateIssue]);
|
}, [onUpdateIssue]);
|
||||||
|
|
||||||
|
let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -732,7 +760,11 @@ export function IssuesList({
|
||||||
|
|
||||||
{isLoading && <PageSkeleton variant="issues-list" />}
|
{isLoading && <PageSkeleton variant="issues-list" />}
|
||||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
{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" && (
|
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={CircleDot}
|
icon={CircleDot}
|
||||||
|
|
@ -750,7 +782,10 @@ export function IssuesList({
|
||||||
onUpdateIssue={onUpdateIssue}
|
onUpdateIssue={onUpdateIssue}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
groupedContent.map((group) => (
|
<>
|
||||||
|
{groupedContent.map((group) => {
|
||||||
|
if (remainingRowsToRender <= 0) return null;
|
||||||
|
return (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
key={group.key}
|
key={group.key}
|
||||||
open={!viewState.collapsedGroups.includes(group.key)}
|
open={!viewState.collapsedGroups.includes(group.key)}
|
||||||
|
|
@ -793,10 +828,14 @@ export function IssuesList({
|
||||||
: { roots: group.items, childMap: new Map<string, Issue[]>() };
|
: { roots: group.items, childMap: new Map<string, Issue[]>() };
|
||||||
|
|
||||||
const renderIssueRow = (issue: Issue, depth: number) => {
|
const renderIssueRow = (issue: Issue, depth: number) => {
|
||||||
|
if (remainingRowsToRender <= 0) return null;
|
||||||
|
remainingRowsToRender -= 1;
|
||||||
|
|
||||||
const children = childMap.get(issue.id) ?? [];
|
const children = childMap.get(issue.id) ?? [];
|
||||||
const hasChildren = children.length > 0;
|
const hasChildren = children.length > 0;
|
||||||
const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0;
|
const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0;
|
||||||
const isExpanded = !viewState.collapsedParents.includes(issue.id);
|
const isExpanded = !viewState.collapsedParents.includes(issue.id);
|
||||||
|
const useDeferredRowRendering = !(hasChildren && isExpanded);
|
||||||
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
||||||
const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null;
|
const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null;
|
||||||
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||||
|
|
@ -810,7 +849,18 @@ export function IssuesList({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<IssueRow
|
||||||
issue={issue}
|
issue={issue}
|
||||||
issueLinkState={issueLinkState}
|
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>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
))
|
);
|
||||||
|
})}
|
||||||
|
{remainingIssueRowCount > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Rendering {Math.min(renderedIssueRowLimit, filtered.length)} of {filtered.length} issues
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,8 @@ describe("MarkdownBody", () => {
|
||||||
it("sanitizes unsafe javascript markdown links", () => {
|
it("sanitizes unsafe javascript markdown links", () => {
|
||||||
const html = renderMarkdown("[click me](javascript:alert(document.cookie))");
|
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:");
|
expect(html).not.toContain("javascript:");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -173,7 +174,7 @@ describe("MarkdownBody", () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(html).toContain('href="/issues/PAP-1271"');
|
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");
|
expect(html).toContain("text-green-600");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -192,4 +193,26 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain("Depends on PAP-1271");
|
expect(html).toContain("Depends on PAP-1271");
|
||||||
expect(html).toContain('href="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;
|
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 {
|
function flattenText(value: ReactNode): string {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
if (typeof value === "string" || typeof value === "number") return String(value);
|
if (typeof value === "string" || typeof value === "number") return String(value);
|
||||||
|
|
@ -148,14 +172,44 @@ export function MarkdownBody({
|
||||||
remarkPlugins.push(remarkSoftBreaks);
|
remarkPlugins.push(remarkSoftBreaks);
|
||||||
}
|
}
|
||||||
const components: Components = {
|
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 }) => {
|
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||||
const mermaidSource = extractMermaidSource(preChildren);
|
const mermaidSource = extractMermaidSource(preChildren);
|
||||||
if (mermaidSource) {
|
if (mermaidSource) {
|
||||||
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
|
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;
|
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
|
||||||
if (issueRef) {
|
if (issueRef) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -181,14 +235,14 @@ export function MarkdownBody({
|
||||||
parsed.kind === "project" && "paperclip-project-mention-chip",
|
parsed.kind === "project" && "paperclip-project-mention-chip",
|
||||||
)}
|
)}
|
||||||
data-mention-kind={parsed.kind}
|
data-mention-kind={parsed.kind}
|
||||||
style={mentionChipInlineStyle(parsed)}
|
style={{ ...mergeWrapStyle(linkStyle as React.CSSProperties | undefined), ...mentionChipInlineStyle(parsed) }}
|
||||||
>
|
>
|
||||||
{linkChildren}
|
{linkChildren}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<a href={href} rel="noreferrer">
|
<a href={href} rel="noreferrer" style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}>
|
||||||
{linkChildren}
|
{linkChildren}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
@ -213,11 +267,11 @@ export function MarkdownBody({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
theme === "dark" && "prose-invert",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={mergeWrapStyle(style)}
|
||||||
>
|
>
|
||||||
<Markdown
|
<Markdown
|
||||||
remarkPlugins={remarkPlugins}
|
remarkPlugins={remarkPlugins}
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ export function RoutineRunVariablesDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
companyId,
|
companyId,
|
||||||
|
routineName,
|
||||||
projects,
|
projects,
|
||||||
agents,
|
agents,
|
||||||
defaultProjectId,
|
defaultProjectId,
|
||||||
|
|
@ -142,6 +143,7 @@ export function RoutineRunVariablesDialog({
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
companyId: string | null | undefined;
|
companyId: string | null | undefined;
|
||||||
|
routineName?: string | null;
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
defaultProjectId?: string | null;
|
defaultProjectId?: string | null;
|
||||||
|
|
@ -253,6 +255,9 @@ export function RoutineRunVariablesDialog({
|
||||||
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
||||||
<DialogContent className="max-w-xl">
|
<DialogContent className="max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
{routineName && (
|
||||||
|
<p className="text-muted-foreground text-sm">{routineName}</p>
|
||||||
|
)}
|
||||||
<DialogTitle>Run routine</DialogTitle>
|
<DialogTitle>Run routine</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Choose the agent and optional project for this one run. Routine defaults are prefilled and won't be changed.
|
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("<strong>world</strong>");
|
||||||
expect(html).toContain("<li>first</li>");
|
expect(html).toMatch(/<li[^>]*>first<\/li>/);
|
||||||
expect(html).toContain("<li>second</li>");
|
expect(html).toMatch(/<li[^>]*>second<\/li>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides saved-session resume skip stderr from nice mode normalization", () => {
|
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("<h2>Summary</h2>");
|
||||||
expect(html).toContain("<li>fixed deploy config</li>");
|
expect(html).toMatch(/<li[^>]*>fixed deploy config<\/li>/);
|
||||||
expect(html).toContain("<li>posted issue update</li>");
|
expect(html).toMatch(/<li[^>]*>posted issue update<\/li>/);
|
||||||
expect(html).not.toContain("result");
|
expect(html).not.toContain("result");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
61
ui/src/context/BreadcrumbContext.test.tsx
Normal file
61
ui/src/context/BreadcrumbContext.test.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { BreadcrumbProvider, useBreadcrumbs } from "./BreadcrumbContext";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("BreadcrumbContext", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: ReturnType<typeof createRoot>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not rerender consumers when breadcrumbs are set to the same values", () => {
|
||||||
|
const renderCounts: number[] = [];
|
||||||
|
let updateBreadcrumbs: ((crumbs: Array<{ label: string; href?: string }>) => void) | null = null;
|
||||||
|
|
||||||
|
function TestConsumer() {
|
||||||
|
const { breadcrumbs, setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
renderCounts.push(breadcrumbs.length);
|
||||||
|
updateBreadcrumbs = setBreadcrumbs;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<BreadcrumbProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</BreadcrumbProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(renderCounts).toHaveLength(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
updateBreadcrumbs?.([{ label: "Issues", href: "/issues" }, { label: "PAP-1488" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(renderCounts).toHaveLength(2);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
updateBreadcrumbs?.([{ label: "Issues", href: "/issues" }, { label: "PAP-1488" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(renderCounts).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,12 +14,23 @@ interface BreadcrumbContextValue {
|
||||||
|
|
||||||
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
|
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 }) {
|
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||||
const [breadcrumbs, setBreadcrumbsState] = useState<Breadcrumb[]>([]);
|
const [breadcrumbs, setBreadcrumbsState] = useState<Breadcrumb[]>([]);
|
||||||
const [mobileToolbar, setMobileToolbarState] = useState<ReactNode | null>(null);
|
const [mobileToolbar, setMobileToolbarState] = useState<ReactNode | null>(null);
|
||||||
|
|
||||||
const setBreadcrumbs = useCallback((crumbs: Breadcrumb[]) => {
|
const setBreadcrumbs = useCallback((crumbs: Breadcrumb[]) => {
|
||||||
setBreadcrumbsState(crumbs);
|
setBreadcrumbsState((current) => (breadcrumbsEqual(current, crumbs) ? current : crumbs));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setMobileToolbar = useCallback((node: ReactNode | null) => {
|
const setMobileToolbar = useCallback((node: ReactNode | null) => {
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,108 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||||
refetchType: "inactive",
|
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", () => {
|
describe("LiveUpdatesProvider visible issue comment hydration", () => {
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,30 @@ function shouldSuppressRunStatusToastForVisibleIssue(
|
||||||
return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId;
|
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(
|
function shouldSuppressAgentStatusToastForVisibleIssue(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
pathname: string,
|
pathname: string,
|
||||||
|
|
@ -735,6 +759,7 @@ function handleLiveEvent(
|
||||||
|
|
||||||
if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status") {
|
if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status") {
|
||||||
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
||||||
|
invalidateVisibleIssueRunQueries(queryClient, pathname, payload);
|
||||||
if (event.type === "heartbeat.run.status") {
|
if (event.type === "heartbeat.run.status") {
|
||||||
const toast = buildRunStatusToast(payload, nameOf);
|
const toast = buildRunStatusToast(payload, nameOf);
|
||||||
if (
|
if (
|
||||||
|
|
@ -830,6 +855,7 @@ export const __liveUpdatesTestUtils = {
|
||||||
closeSocketQuietly,
|
closeSocketQuietly,
|
||||||
hydrateVisibleIssueComment,
|
hydrateVisibleIssueComment,
|
||||||
invalidateActivityQueries,
|
invalidateActivityQueries,
|
||||||
|
invalidateVisibleIssueRunQueries,
|
||||||
resolveLiveCompanyId,
|
resolveLiveCompanyId,
|
||||||
shouldDeferIssueRefetchForVisibleAgentActivity,
|
shouldDeferIssueRefetchForVisibleAgentActivity,
|
||||||
shouldDeferVisibleIssueCommentActivity,
|
shouldDeferVisibleIssueCommentActivity,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||||
|
buildGroupedInboxSections,
|
||||||
buildInboxKeyboardNavEntries,
|
buildInboxKeyboardNavEntries,
|
||||||
buildInboxDismissedAtByKey,
|
buildInboxDismissedAtByKey,
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
|
|
@ -718,7 +719,7 @@ describe("inbox helpers", () => {
|
||||||
labels: [],
|
labels: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
showRoutineExecutions: false,
|
hideRoutineExecutions: true,
|
||||||
},
|
},
|
||||||
}).map((issue) => issue.id),
|
}).map((issue) => issue.id),
|
||||||
).toEqual(["remote-match"]);
|
).toEqual(["remote-match"]);
|
||||||
|
|
@ -736,7 +737,7 @@ describe("inbox helpers", () => {
|
||||||
labels: [],
|
labels: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
showRoutineExecutions: false,
|
hideRoutineExecutions: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
|
|
@ -754,12 +755,51 @@ describe("inbox helpers", () => {
|
||||||
labels: [],
|
labels: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
showRoutineExecutions: false,
|
hideRoutineExecutions: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toEqual([]);
|
).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", () => {
|
it("defaults the remembered inbox tab to mine and persists all", () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
expect(loadLastInboxTab()).toBe("mine");
|
expect(loadLastInboxTab()).toBe("mine");
|
||||||
|
|
@ -779,7 +819,7 @@ describe("inbox helpers", () => {
|
||||||
labels: ["label-1"],
|
labels: ["label-1"],
|
||||||
projects: ["project-1"],
|
projects: ["project-1"],
|
||||||
workspaces: ["workspace-1"],
|
workspaces: ["workspace-1"],
|
||||||
showRoutineExecutions: true,
|
hideRoutineExecutions: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
saveInboxFilterPreferences("company-2", {
|
saveInboxFilterPreferences("company-2", {
|
||||||
|
|
@ -792,7 +832,7 @@ describe("inbox helpers", () => {
|
||||||
labels: [],
|
labels: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
showRoutineExecutions: false,
|
hideRoutineExecutions: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -806,7 +846,7 @@ describe("inbox helpers", () => {
|
||||||
labels: ["label-1"],
|
labels: ["label-1"],
|
||||||
projects: ["project-1"],
|
projects: ["project-1"],
|
||||||
workspaces: ["workspace-1"],
|
workspaces: ["workspace-1"],
|
||||||
showRoutineExecutions: true,
|
hideRoutineExecutions: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(loadInboxFilterPreferences("company-2")).toEqual({
|
expect(loadInboxFilterPreferences("company-2")).toEqual({
|
||||||
|
|
@ -819,7 +859,7 @@ describe("inbox helpers", () => {
|
||||||
labels: [],
|
labels: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
showRoutineExecutions: false,
|
hideRoutineExecutions: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -835,7 +875,7 @@ describe("inbox helpers", () => {
|
||||||
labels: null,
|
labels: null,
|
||||||
projects: ["project-1"],
|
projects: ["project-1"],
|
||||||
workspaces: ["workspace-1", false],
|
workspaces: ["workspace-1", false],
|
||||||
showRoutineExecutions: "yes",
|
hideRoutineExecutions: "yes",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -849,7 +889,7 @@ describe("inbox helpers", () => {
|
||||||
labels: [],
|
labels: [],
|
||||||
projects: ["project-1"],
|
projects: ["project-1"],
|
||||||
workspaces: ["workspace-1"],
|
workspaces: ["workspace-1"],
|
||||||
showRoutineExecutions: false,
|
hideRoutineExecutions: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1003,12 +1043,12 @@ describe("inbox helpers", () => {
|
||||||
expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0);
|
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 manualIssue = { ...makeIssue("manual", true), originKind: "manual" as const };
|
||||||
const routineIssue = { ...makeIssue("routine", true), originKind: "routine_execution" as const };
|
const routineIssue = { ...makeIssue("routine", true), originKind: "routine_execution" as const };
|
||||||
|
|
||||||
expect(filterInboxIssues([manualIssue, routineIssue], false)).toEqual([manualIssue]);
|
expect(filterInboxIssues([manualIssue, routineIssue], false)).toEqual([manualIssue, routineIssue]);
|
||||||
expect(filterInboxIssues([manualIssue, routineIssue], true)).toEqual([manualIssue, routineIssue]);
|
expect(filterInboxIssues([manualIssue, routineIssue], true)).toEqual([manualIssue]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("groups mixed inbox items by type while preserving item order within each group", () => {
|
it("groups mixed inbox items by type while preserving item order within each group", () => {
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,16 @@ export interface InboxWorkItemGroup {
|
||||||
items: InboxWorkItem[];
|
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 {
|
export interface InboxKeyboardGroupSection {
|
||||||
key: string;
|
key: string;
|
||||||
displayItems: InboxWorkItem[];
|
displayItems: InboxWorkItem[];
|
||||||
|
|
@ -142,7 +152,7 @@ function normalizeIssueFilterState(value: unknown): IssueFilterState {
|
||||||
labels: normalizeStringArray(candidate.labels),
|
labels: normalizeStringArray(candidate.labels),
|
||||||
projects: normalizeStringArray(candidate.projects),
|
projects: normalizeStringArray(candidate.projects),
|
||||||
workspaces: normalizeStringArray(candidate.workspaces),
|
workspaces: normalizeStringArray(candidate.workspaces),
|
||||||
showRoutineExecutions: candidate.showRoutineExecutions === true,
|
hideRoutineExecutions: candidate.hideRoutineExecutions === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,14 +377,14 @@ export function shouldResetInboxWorkspaceGrouping(
|
||||||
|
|
||||||
export function shouldIncludeRoutineExecutionIssue(
|
export function shouldIncludeRoutineExecutionIssue(
|
||||||
issue: Pick<Issue, "originKind">,
|
issue: Pick<Issue, "originKind">,
|
||||||
showRoutineExecutions: boolean,
|
hideRoutineExecutions: boolean,
|
||||||
): boolean {
|
): boolean {
|
||||||
return showRoutineExecutions || issue.originKind !== "routine_execution";
|
return !hideRoutineExecutions || issue.originKind !== "routine_execution";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterInboxIssues(issues: Issue[], showRoutineExecutions: boolean): Issue[] {
|
export function filterInboxIssues(issues: Issue[], hideRoutineExecutions: boolean): Issue[] {
|
||||||
if (showRoutineExecutions) return issues;
|
if (!hideRoutineExecutions) return issues;
|
||||||
return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, showRoutineExecutions));
|
return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, hideRoutineExecutions));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchesInboxIssueSearch(
|
export function matchesInboxIssueSearch(
|
||||||
|
|
@ -916,6 +926,31 @@ export function buildInboxNesting(items: InboxWorkItem[]): {
|
||||||
return { displayItems, childrenByIssueId };
|
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 {
|
export function getInboxWorkItemKey(item: InboxWorkItem): string {
|
||||||
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
||||||
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export type IssueFilterState = {
|
||||||
labels: string[];
|
labels: string[];
|
||||||
projects: string[];
|
projects: string[];
|
||||||
workspaces: string[];
|
workspaces: string[];
|
||||||
showRoutineExecutions: boolean;
|
hideRoutineExecutions: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultIssueFilterState: IssueFilterState = {
|
export const defaultIssueFilterState: IssueFilterState = {
|
||||||
|
|
@ -17,7 +17,7 @@ export const defaultIssueFilterState: IssueFilterState = {
|
||||||
labels: [],
|
labels: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
showRoutineExecutions: false,
|
hideRoutineExecutions: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||||
|
|
@ -58,7 +58,7 @@ export function applyIssueFilters(
|
||||||
enableRoutineVisibilityFilter = false,
|
enableRoutineVisibilityFilter = false,
|
||||||
): Issue[] {
|
): Issue[] {
|
||||||
let result = issues;
|
let result = issues;
|
||||||
if (enableRoutineVisibilityFilter && !state.showRoutineExecutions) {
|
if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) {
|
||||||
result = result.filter((issue) => issue.originKind !== "routine_execution");
|
result = result.filter((issue) => issue.originKind !== "routine_execution");
|
||||||
}
|
}
|
||||||
if (state.statuses.length > 0) result = result.filter((issue) => state.statuses.includes(issue.status));
|
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.labels.length > 0) count += 1;
|
||||||
if (state.projects.length > 0) count += 1;
|
if (state.projects.length > 0) count += 1;
|
||||||
if (state.workspaces.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;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ export const queryKeys = {
|
||||||
executionWorkspaces: {
|
executionWorkspaces: {
|
||||||
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
|
||||||
["execution-workspaces", companyId, filters ?? {}] as const,
|
["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,
|
detail: (id: string) => ["execution-workspaces", "detail", id] as const,
|
||||||
closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const,
|
closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const,
|
||||||
workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const,
|
workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const,
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,8 @@ import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/sh
|
||||||
import {
|
import {
|
||||||
ACTIONABLE_APPROVAL_STATUSES,
|
ACTIONABLE_APPROVAL_STATUSES,
|
||||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||||
|
buildGroupedInboxSections,
|
||||||
buildInboxKeyboardNavEntries,
|
buildInboxKeyboardNavEntries,
|
||||||
buildInboxNesting,
|
|
||||||
getAvailableInboxIssueColumns,
|
getAvailableInboxIssueColumns,
|
||||||
getInboxWorkItemKey,
|
getInboxWorkItemKey,
|
||||||
getApprovalsForTab,
|
getApprovalsForTab,
|
||||||
|
|
@ -109,7 +109,6 @@ import {
|
||||||
getLatestFailedRunsByAgent,
|
getLatestFailedRunsByAgent,
|
||||||
matchesInboxIssueSearch,
|
matchesInboxIssueSearch,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
groupInboxWorkItems,
|
|
||||||
isInboxEntityDismissed,
|
isInboxEntityDismissed,
|
||||||
isMineInboxTab,
|
isMineInboxTab,
|
||||||
loadCollapsedInboxGroupKeys,
|
loadCollapsedInboxGroupKeys,
|
||||||
|
|
@ -135,6 +134,7 @@ import {
|
||||||
type InboxKeyboardNavEntry,
|
type InboxKeyboardNavEntry,
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
shouldShowInboxSection,
|
shouldShowInboxSection,
|
||||||
|
type InboxGroupedSection,
|
||||||
type InboxTab,
|
type InboxTab,
|
||||||
type InboxWorkItem,
|
type InboxWorkItem,
|
||||||
type InboxWorkItemGroupBy,
|
type InboxWorkItemGroupBy,
|
||||||
|
|
@ -150,38 +150,6 @@ type SectionKey =
|
||||||
/** A flat navigation entry for keyboard j/k traversal that includes expanded children. */
|
/** A flat navigation entry for keyboard j/k traversal that includes expanded children. */
|
||||||
type NavEntry = InboxKeyboardNavEntry;
|
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 {
|
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||||
|
|
@ -1081,19 +1049,12 @@ export function Inbox() {
|
||||||
remoteIssueSearchResults,
|
remoteIssueSearchResults,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const effectiveWorkItems = useMemo(
|
const nonInboxSearchIssueIds = useMemo(
|
||||||
() =>
|
() => new Set([
|
||||||
issueSearchSupplementResults.length > 0
|
...archivedSearchIssues.map((issue) => issue.id),
|
||||||
? [
|
...issueSearchSupplementResults.map((issue) => issue.id),
|
||||||
...filteredWorkItems,
|
]),
|
||||||
...getInboxWorkItems({ issues: issueSearchSupplementResults, approvals: [] }),
|
[archivedSearchIssues, issueSearchSupplementResults],
|
||||||
]
|
|
||||||
: filteredWorkItems,
|
|
||||||
[filteredWorkItems, issueSearchSupplementResults],
|
|
||||||
);
|
|
||||||
const archivedSearchIssueIds = useMemo(
|
|
||||||
() => new Set(archivedSearchIssues.map((issue) => issue.id)),
|
|
||||||
[archivedSearchIssues],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Parent-child nesting for inbox issues ---
|
// --- Parent-child nesting for inbox issues ---
|
||||||
|
|
@ -1123,15 +1084,27 @@ export function Inbox() {
|
||||||
});
|
});
|
||||||
}, [selectedCompanyId]);
|
}, [selectedCompanyId]);
|
||||||
const groupedSections = useMemo<InboxGroupedSection[]>(() => [
|
const groupedSections = useMemo<InboxGroupedSection[]>(() => [
|
||||||
...buildGroupedInboxSections(effectiveWorkItems, groupBy, nestingEnabled, inboxWorkspaceGrouping),
|
...buildGroupedInboxSections(filteredWorkItems, groupBy, inboxWorkspaceGrouping, { nestingEnabled }),
|
||||||
...buildGroupedInboxSections(
|
...buildGroupedInboxSections(
|
||||||
getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }),
|
getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }),
|
||||||
groupBy,
|
groupBy,
|
||||||
nestingEnabled,
|
|
||||||
inboxWorkspaceGrouping,
|
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(
|
const totalVisibleWorkItems = useMemo(
|
||||||
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
|
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
|
||||||
[groupedSections],
|
[groupedSections],
|
||||||
|
|
@ -1500,7 +1473,7 @@ export function Inbox() {
|
||||||
flatNavItems,
|
flatNavItems,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
canArchive: canArchiveFromTab,
|
canArchive: canArchiveFromTab,
|
||||||
archivedSearchIssueIds,
|
nonInboxSearchIssueIds,
|
||||||
archivingIssueIds,
|
archivingIssueIds,
|
||||||
undoableArchiveIssueIds,
|
undoableArchiveIssueIds,
|
||||||
unarchivingIssueIds,
|
unarchivingIssueIds,
|
||||||
|
|
@ -1513,7 +1486,7 @@ export function Inbox() {
|
||||||
flatNavItems,
|
flatNavItems,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
canArchive: canArchiveFromTab,
|
canArchive: canArchiveFromTab,
|
||||||
archivedSearchIssueIds,
|
nonInboxSearchIssueIds,
|
||||||
archivingIssueIds,
|
archivingIssueIds,
|
||||||
undoableArchiveIssueIds,
|
undoableArchiveIssueIds,
|
||||||
unarchivingIssueIds,
|
unarchivingIssueIds,
|
||||||
|
|
@ -1616,10 +1589,10 @@ export function Inbox() {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { issue, item } = resolveNavEntry(st.selectedIndex);
|
const { issue, item } = resolveNavEntry(st.selectedIndex);
|
||||||
if (issue) {
|
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) {
|
} else if (item) {
|
||||||
if (item.kind === "issue") {
|
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);
|
act.archiveIssue(item.issue.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2113,15 +2086,18 @@ export function Inbox() {
|
||||||
return groupedSections.flatMap((group, groupIndex) => {
|
return groupedSections.flatMap((group, groupIndex) => {
|
||||||
const elements: ReactNode[] = [];
|
const elements: ReactNode[] = [];
|
||||||
const isGroupCollapsed = collapsedGroupKeys.has(group.key);
|
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(
|
elements.push(
|
||||||
<div
|
<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"
|
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" />
|
<div className="h-px flex-1 bg-border/80" />
|
||||||
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Archived
|
{group.searchSection === "archived" ? "Archived" : "Other results"}
|
||||||
</span>
|
</span>
|
||||||
<div className="h-px flex-1 bg-border/80" />
|
<div className="h-px flex-1 bg-border/80" />
|
||||||
</div>,
|
</div>,
|
||||||
|
|
@ -2292,7 +2268,7 @@ export function Inbox() {
|
||||||
const childIssues = group.childrenByIssueId.get(issue.id) ?? [];
|
const childIssues = group.childrenByIssueId.get(issue.id) ?? [];
|
||||||
const hasChildren = childIssues.length > 0;
|
const hasChildren = childIssues.length > 0;
|
||||||
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
|
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
|
||||||
const canArchiveIssue = canArchiveFromTab && !group.isArchivedSearch;
|
const canArchiveIssue = canArchiveFromTab && group.searchSection === "none";
|
||||||
const parentRow = renderInboxIssue({
|
const parentRow = renderInboxIssue({
|
||||||
issue,
|
issue,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
|
|
|
||||||
|
|
@ -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 { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||||
import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -488,7 +488,10 @@ function InboxMobileToolbar({
|
||||||
|
|
||||||
type IssueDetailChatTabProps = {
|
type IssueDetailChatTabProps = {
|
||||||
issueId: string;
|
issueId: string;
|
||||||
issue: Issue;
|
companyId: string;
|
||||||
|
projectId: string | null;
|
||||||
|
issueStatus: Issue["status"];
|
||||||
|
executionRunId: string | null;
|
||||||
comments: IssueDetailComment[];
|
comments: IssueDetailComment[];
|
||||||
hasOlderComments: boolean;
|
hasOlderComments: boolean;
|
||||||
commentsLoadingOlder: boolean;
|
commentsLoadingOlder: boolean;
|
||||||
|
|
@ -519,9 +522,12 @@ type IssueDetailChatTabProps = {
|
||||||
onImageClick: (src: string) => void;
|
onImageClick: (src: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function IssueDetailChatTab({
|
const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||||
issueId,
|
issueId,
|
||||||
issue,
|
companyId,
|
||||||
|
projectId,
|
||||||
|
issueStatus,
|
||||||
|
executionRunId,
|
||||||
comments,
|
comments,
|
||||||
hasOlderComments,
|
hasOlderComments,
|
||||||
commentsLoadingOlder,
|
commentsLoadingOlder,
|
||||||
|
|
@ -547,59 +553,62 @@ function IssueDetailChatTab({
|
||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
onImageClick,
|
onImageClick,
|
||||||
}: IssueDetailChatTabProps) {
|
}: IssueDetailChatTabProps) {
|
||||||
const { data: activity, isLoading: activityLoading } = useQuery({
|
const { data: activity } = useQuery({
|
||||||
queryKey: queryKeys.issues.activity(issueId),
|
queryKey: queryKeys.issues.activity(issueId),
|
||||||
queryFn: () => activityApi.forIssue(issueId),
|
queryFn: () => activityApi.forIssue(issueId),
|
||||||
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueId),
|
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueId),
|
||||||
});
|
});
|
||||||
const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({
|
const { data: liveRuns } = useQuery({
|
||||||
queryKey: queryKeys.issues.liveRuns(issueId),
|
queryKey: queryKeys.issues.liveRuns(issueId),
|
||||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
|
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
|
||||||
refetchInterval: 3000,
|
refetchInterval: 3000,
|
||||||
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
|
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
|
||||||
});
|
});
|
||||||
const liveRunCount = liveRuns?.length ?? 0;
|
const resolvedLiveRuns = liveRuns ?? [];
|
||||||
const { data: activeRun, isLoading: activeRunLoading } = useQuery({
|
const liveRunCount = resolvedLiveRuns.length;
|
||||||
|
const { data: activeRun = null } = useQuery({
|
||||||
queryKey: queryKeys.issues.activeRun(issueId),
|
queryKey: queryKeys.issues.activeRun(issueId),
|
||||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
|
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
|
||||||
enabled: !!issue.executionRunId || issue.status === "in_progress",
|
enabled: !!executionRunId || issueStatus === "in_progress",
|
||||||
refetchInterval: liveRunCount > 0 ? false : 3000,
|
refetchInterval: liveRunCount > 0 ? false : 3000,
|
||||||
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId),
|
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId),
|
||||||
});
|
});
|
||||||
const hasLiveRuns = liveRunCount > 0 || !!activeRun;
|
const hasLiveRuns = liveRunCount > 0 || !!activeRun;
|
||||||
const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({
|
const { data: linkedRuns } = useQuery({
|
||||||
queryKey: queryKeys.issues.runs(issueId),
|
queryKey: queryKeys.issues.runs(issueId),
|
||||||
queryFn: () => activityApi.runsForIssue(issueId),
|
queryFn: () => activityApi.runsForIssue(issueId),
|
||||||
refetchInterval: hasLiveRuns ? 5000 : false,
|
refetchInterval: hasLiveRuns ? 5000 : false,
|
||||||
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
|
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
|
||||||
});
|
});
|
||||||
|
const resolvedActivity = activity ?? [];
|
||||||
|
const resolvedLinkedRuns = linkedRuns ?? [];
|
||||||
|
|
||||||
const runningIssueRun = useMemo(
|
const runningIssueRun = useMemo(
|
||||||
() => resolveRunningIssueRun(activeRun, liveRuns),
|
() => resolveRunningIssueRun(activeRun, resolvedLiveRuns),
|
||||||
[activeRun, liveRuns],
|
[activeRun, resolvedLiveRuns],
|
||||||
);
|
);
|
||||||
const timelineRuns = useMemo(() => {
|
const timelineRuns = useMemo(() => {
|
||||||
const liveIds = new Set<string>();
|
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);
|
if (activeRun) liveIds.add(activeRun.id);
|
||||||
const historicalRuns = liveIds.size === 0
|
const historicalRuns = liveIds.size === 0
|
||||||
? (linkedRuns ?? [])
|
? resolvedLinkedRuns
|
||||||
: (linkedRuns ?? []).filter((run) => !liveIds.has(run.runId));
|
: resolvedLinkedRuns.filter((run) => !liveIds.has(run.runId));
|
||||||
return historicalRuns.map((run) => ({
|
return historicalRuns.map((run) => ({
|
||||||
...run,
|
...run,
|
||||||
adapterType: run.adapterType,
|
adapterType: run.adapterType,
|
||||||
hasStoredOutput: (run.logBytes ?? 0) > 0,
|
hasStoredOutput: (run.logBytes ?? 0) > 0,
|
||||||
}));
|
}));
|
||||||
}, [activeRun, linkedRuns, liveRuns]);
|
}, [activeRun, resolvedLinkedRuns, resolvedLiveRuns]);
|
||||||
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
|
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
|
||||||
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
|
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
|
||||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
|
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
|
||||||
const agentIdByRunId = new Map<string, string>();
|
const agentIdByRunId = new Map<string, string>();
|
||||||
|
|
||||||
for (const run of linkedRuns ?? []) {
|
for (const run of resolvedLinkedRuns) {
|
||||||
agentIdByRunId.set(run.runId, run.agentId);
|
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;
|
if (evt.action !== "issue.comment_added" || !evt.runId) continue;
|
||||||
const details = evt.details ?? {};
|
const details = evt.details ?? {};
|
||||||
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
|
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
|
||||||
|
|
@ -633,20 +642,11 @@ function IssueDetailChatTab({
|
||||||
}
|
}
|
||||||
return nextComment;
|
return nextComment;
|
||||||
});
|
});
|
||||||
}, [activity, comments, linkedRuns, runningIssueRun]);
|
}, [comments, resolvedActivity, resolvedLinkedRuns, runningIssueRun]);
|
||||||
const timelineEvents = useMemo(
|
const timelineEvents = useMemo(
|
||||||
() => extractIssueTimelineEvents(activity),
|
() => extractIssueTimelineEvents(resolvedActivity),
|
||||||
[activity],
|
[resolvedActivity],
|
||||||
);
|
);
|
||||||
const initialLoading =
|
|
||||||
(activityLoading && activity === undefined)
|
|
||||||
|| (linkedRunsLoading && linkedRuns === undefined)
|
|
||||||
|| (liveRunsLoading && liveRuns === undefined)
|
|
||||||
|| (activeRunLoading && activeRun === undefined);
|
|
||||||
|
|
||||||
if (initialLoading) {
|
|
||||||
return <IssueChatSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -671,11 +671,11 @@ function IssueDetailChatTab({
|
||||||
feedbackTermsUrl={feedbackTermsUrl}
|
feedbackTermsUrl={feedbackTermsUrl}
|
||||||
linkedRuns={timelineRuns}
|
linkedRuns={timelineRuns}
|
||||||
timelineEvents={timelineEvents}
|
timelineEvents={timelineEvents}
|
||||||
liveRuns={liveRuns}
|
liveRuns={resolvedLiveRuns}
|
||||||
activeRun={activeRun}
|
activeRun={activeRun}
|
||||||
companyId={issue.companyId}
|
companyId={companyId}
|
||||||
projectId={issue.projectId}
|
projectId={projectId}
|
||||||
issueStatus={issue.status}
|
issueStatus={issueStatus}
|
||||||
agentMap={agentMap}
|
agentMap={agentMap}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
draftKey={draftKey}
|
draftKey={draftKey}
|
||||||
|
|
@ -703,7 +703,7 @@ function IssueDetailChatTab({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
type IssueDetailActivityTabProps = {
|
type IssueDetailActivityTabProps = {
|
||||||
issueId: string;
|
issueId: string;
|
||||||
|
|
@ -1060,6 +1060,14 @@ export function IssueDetail() {
|
||||||
() => buildIssuePropertiesPanelKey(issue ?? null, childIssues),
|
() => buildIssuePropertiesPanelKey(issue ?? null, childIssues),
|
||||||
[childIssues, issue],
|
[childIssues, issue],
|
||||||
);
|
);
|
||||||
|
const panelIssue = useMemo(
|
||||||
|
() => issue ?? null,
|
||||||
|
[issue?.id, issuePanelKey],
|
||||||
|
);
|
||||||
|
const panelChildIssues = useMemo(
|
||||||
|
() => childIssues,
|
||||||
|
[issuePanelKey],
|
||||||
|
);
|
||||||
const showRichSubIssuesSection = shouldRenderRichSubIssuesSection(childIssuesLoading, childIssues.length);
|
const showRichSubIssuesSection = shouldRenderRichSubIssuesSection(childIssuesLoading, childIssues.length);
|
||||||
const openNewSubIssue = useCallback(() => {
|
const openNewSubIssue = useCallback(() => {
|
||||||
if (!issue) return;
|
if (!issue) return;
|
||||||
|
|
@ -1103,6 +1111,7 @@ export function IssueDetail() {
|
||||||
() => mergeIssueComments(comments ?? [], optimisticComments),
|
() => mergeIssueComments(comments ?? [], optimisticComments),
|
||||||
[comments, optimisticComments],
|
[comments, optimisticComments],
|
||||||
);
|
);
|
||||||
|
const breadcrumbTitle = issue?.title ?? issueId ?? "Issue";
|
||||||
|
|
||||||
const invalidateIssueDetail = useCallback(() => {
|
const invalidateIssueDetail = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||||
|
|
@ -1743,12 +1752,17 @@ export function IssueDetail() {
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const titleLabel = issue?.title ?? issueId ?? "Issue";
|
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
sourceBreadcrumb,
|
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";
|
const isFromInbox = resolvedIssueDetailState?.issueDetailSource === "inbox";
|
||||||
|
|
||||||
|
|
@ -1790,20 +1804,28 @@ export function IssueDetail() {
|
||||||
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!issue) {
|
if (!panelIssue) {
|
||||||
closePanel();
|
closePanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openPanel(
|
openPanel(
|
||||||
<IssueProperties
|
<IssueProperties
|
||||||
issue={issue}
|
issue={panelIssue}
|
||||||
childIssues={childIssues}
|
childIssues={panelChildIssues}
|
||||||
onAddSubIssue={openNewSubIssue}
|
onAddSubIssue={openNewSubIssue}
|
||||||
onUpdate={handleIssuePropertiesUpdate}
|
onUpdate={handleIssuePropertiesUpdate}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return () => closePanel();
|
return () => closePanel();
|
||||||
}, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]);
|
}, [
|
||||||
|
closePanel,
|
||||||
|
handleIssuePropertiesUpdate,
|
||||||
|
issuePanelKey,
|
||||||
|
openNewSubIssue,
|
||||||
|
openPanel,
|
||||||
|
panelChildIssues,
|
||||||
|
panelIssue,
|
||||||
|
]);
|
||||||
|
|
||||||
const goToInboxShortcutArmedRef = useRef(false);
|
const goToInboxShortcutArmedRef = useRef(false);
|
||||||
const goToInboxShortcutTimeoutRef = useRef<number | null>(null);
|
const goToInboxShortcutTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
@ -2032,6 +2054,36 @@ export function IssueDetail() {
|
||||||
}, [showInboxToolbar, backHref, issue?.id, issueHidden, archivePending, setMobileToolbar]);
|
}, [showInboxToolbar, backHref, issue?.id, issueHidden, archivePending, setMobileToolbar]);
|
||||||
|
|
||||||
const attachmentsInitialLoading = attachmentsLoading && attachments === undefined;
|
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 (isLoading) return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
|
||||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||||
|
|
@ -2557,13 +2609,14 @@ export function IssueDetail() {
|
||||||
{detailTab === "chat" ? (
|
{detailTab === "chat" ? (
|
||||||
<IssueDetailChatTab
|
<IssueDetailChatTab
|
||||||
issueId={issue.id}
|
issueId={issue.id}
|
||||||
issue={issue}
|
companyId={issue.companyId}
|
||||||
|
projectId={issue.projectId ?? null}
|
||||||
|
issueStatus={issue.status}
|
||||||
|
executionRunId={issue.executionRunId ?? null}
|
||||||
comments={threadComments}
|
comments={threadComments}
|
||||||
hasOlderComments={hasOlderComments}
|
hasOlderComments={hasOlderComments}
|
||||||
commentsLoadingOlder={commentsLoadingOlder}
|
commentsLoadingOlder={commentsLoadingOlder}
|
||||||
onLoadOlderComments={() => {
|
onLoadOlderComments={loadOlderComments}
|
||||||
void fetchOlderComments();
|
|
||||||
}}
|
|
||||||
composerRef={commentComposerRef}
|
composerRef={commentComposerRef}
|
||||||
feedbackVotes={feedbackVotes}
|
feedbackVotes={feedbackVotes}
|
||||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||||
|
|
@ -2576,33 +2629,11 @@ export function IssueDetail() {
|
||||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||||
mentions={mentionOptions}
|
mentions={mentionOptions}
|
||||||
composerDisabledReason={commentComposerDisabledReason}
|
composerDisabledReason={commentComposerDisabledReason}
|
||||||
onVote={async (commentId, vote, options) => {
|
onVote={handleCommentVote}
|
||||||
await feedbackVoteMutation.mutateAsync({
|
onAdd={handleChatAdd}
|
||||||
targetType: "issue_comment",
|
onImageUpload={handleCommentImageUpload}
|
||||||
targetId: commentId,
|
onAttachImage={handleCommentAttachImage}
|
||||||
vote,
|
onInterruptQueued={handleInterruptQueuedRun}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
onCancelQueued={handleCancelQueuedComment}
|
onCancelQueued={handleCancelQueuedComment}
|
||||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
|
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
|
||||||
onImageClick={handleChatImageClick}
|
onImageClick={handleChatImageClick}
|
||||||
|
|
|
||||||
16
ui/src/pages/Issues.test.tsx
Normal file
16
ui/src/pages/Issues.test.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildIssuesSearchUrl } from "./Issues";
|
||||||
|
|
||||||
|
describe("buildIssuesSearchUrl", () => {
|
||||||
|
it("preserves trailing spaces in the synced search param", () => {
|
||||||
|
expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug", "bug ")).toBe("/issues?q=bug+");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the search param when the input is cleared", () => {
|
||||||
|
expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug#details", "")).toBe("/issues#details");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the URL already matches the current search", () => {
|
||||||
|
expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug+", "bug ")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,6 +13,20 @@ import { EmptyState } from "../components/EmptyState";
|
||||||
import { IssuesList } from "../components/IssuesList";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { CircleDot } from "lucide-react";
|
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() {
|
export function Issues() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
|
@ -23,18 +37,8 @@ export function Issues() {
|
||||||
const initialSearch = searchParams.get("q") ?? "";
|
const initialSearch = searchParams.get("q") ?? "";
|
||||||
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
||||||
const handleSearchChange = useCallback((search: string) => {
|
const handleSearchChange = useCallback((search: string) => {
|
||||||
const trimmedSearch = search.trim();
|
const nextUrl = buildIssuesSearchUrl(window.location.href, search);
|
||||||
const currentSearch = new URLSearchParams(window.location.search).get("q") ?? "";
|
if (!nextUrl) return;
|
||||||
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}`;
|
|
||||||
window.history.replaceState(window.history.state, "", nextUrl);
|
window.history.replaceState(window.history.state, "", nextUrl);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1114,6 +1114,7 @@ export function RoutineDetail() {
|
||||||
open={runVariablesOpen}
|
open={runVariablesOpen}
|
||||||
onOpenChange={setRunVariablesOpen}
|
onOpenChange={setRunVariablesOpen}
|
||||||
companyId={routine.companyId}
|
companyId={routine.companyId}
|
||||||
|
routineName={routine.title}
|
||||||
agents={agents ?? []}
|
agents={agents ?? []}
|
||||||
projects={projects ?? []}
|
projects={projects ?? []}
|
||||||
defaultProjectId={routine.projectId}
|
defaultProjectId={routine.projectId}
|
||||||
|
|
|
||||||
|
|
@ -972,6 +972,7 @@ export function Routines() {
|
||||||
if (!next) setRunDialogRoutine(null);
|
if (!next) setRunDialogRoutine(null);
|
||||||
}}
|
}}
|
||||||
companyId={selectedCompanyId}
|
companyId={selectedCompanyId}
|
||||||
|
routineName={runDialogRoutine?.title ?? null}
|
||||||
agents={agents ?? []}
|
agents={agents ?? []}
|
||||||
projects={projects ?? []}
|
projects={projects ?? []}
|
||||||
defaultProjectId={runDialogRoutine?.projectId ?? null}
|
defaultProjectId={runDialogRoutine?.projectId ?? null}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue