mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-20 04:20:38 +09:00
## Thinking Path > - Paperclip orchestrates AI agents through a company-scoped control plane. > - The affected surface is the board UI for issue threads, issue lists, routines, dialogs, navigation, and issue review indicators. > - Closed PR #4692 bundled backend, schema, docs, workflow, and UI/QoL work into one oversized change set. > - Greptile could not keep reviewing that broad PR because it exceeded the 100-file review limit and mixed unrelated concerns. > - This pull request extracts the UI/QoL slice into a fresh branch under the review limit while leaving workflow and lockfile churn out. > - The benefit is a focused review path for the board UI performance and workflow improvements without reopening the oversized PR. ## What Changed - Added long issue-thread virtualization, scroll-container binding, anchor preservation, latest-comment jump targeting, and related regression/perf fixtures. - Improved issue list scalability with scroll-based loading, server offset parameters, and pagination-focused UI tests. - Reduced new issue dialog typing churn and split dialog action subscriptions so broad layout/nav surfaces avoid unnecessary renders. - Added routine variables help and routine description mention options for users, agents, and projects. - Added productivity review badge/link UI and fixed the badge to use Paperclip's company-prefixed router link. - Kept the split PR below Greptile's review limit and excluded `.github/workflows/pr.yml` and `pnpm-lock.yaml`. ## Verification - `pnpm install --no-frozen-lockfile` in the clean worktree to install `@tanstack/react-virtual` locally without committing lockfile churn. - `pnpm --filter @paperclipai/ui exec vitest run --config vitest.config.ts src/components/IssueChatThread.test.tsx src/components/IssuesList.test.tsx src/components/NewIssueDialog.test.tsx src/pages/Routines.test.tsx src/pages/Issues.test.tsx` passed: 5 files, 83 tests. - `pnpm --filter @paperclipai/ui typecheck` passed. - `git diff --check origin/master..HEAD` passed. - Split-scope checks: 53 changed files; no `.github/workflows/pr.yml`; no `pnpm-lock.yaml`. - Screenshots were not captured in this heartbeat; the changes are primarily virtualization, routing, pagination, and editor behavior covered by focused regression tests. ## Risks - Moderate UI risk because issue-thread virtualization changes scroll behavior on long conversations; regression tests cover anchor jumps, latest-comment targeting, row metadata, and short-thread fallback. - Moderate integration risk because the issue-list offset parameter and productivity review field depend on matching API behavior. - Dependency risk: the UI package adds `@tanstack/react-virtual` while repository policy keeps `pnpm-lock.yaml` out of PRs, so CI must resolve dependency changes through the repo's normal lockfile policy. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled local repository and GitHub workflow. Exact runtime context window was not exposed by the harness. ## 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 checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [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
1991ec9d6f
commit
6b7f6ce4b8
48 changed files with 3388 additions and 260 deletions
|
|
@ -671,7 +671,6 @@ export function issueRoutes(
|
||||||
details: {
|
details: {
|
||||||
issueId: issue.id,
|
issueId: issue.id,
|
||||||
status: issue.status,
|
status: issue.status,
|
||||||
securityPrinciples: ["Complete Mediation", "Fail Securely"],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -694,7 +693,6 @@ export function issueRoutes(
|
||||||
holdId: activePauseHold.holdId,
|
holdId: activePauseHold.holdId,
|
||||||
rootIssueId: activePauseHold.rootIssueId,
|
rootIssueId: activePauseHold.rootIssueId,
|
||||||
mode: activePauseHold.mode,
|
mode: activePauseHold.mode,
|
||||||
securityPrinciples: ["Complete Mediation", "Fail Securely", "Secure Defaults"],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -928,7 +926,6 @@ export function issueRoutes(
|
||||||
const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
|
const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
|
||||||
? Number.parseInt(rawOffset, 10)
|
? Number.parseInt(rawOffset, 10)
|
||||||
: null;
|
: null;
|
||||||
const offset = parsedOffset ?? 0;
|
|
||||||
|
|
||||||
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
||||||
|
|
@ -954,6 +951,7 @@ export function issueRoutes(
|
||||||
res.status(400).json({ error: "offset must be a non-negative integer" });
|
res.status(400).json({ error: "offset must be a non-negative integer" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const offset = parsedOffset ?? 0;
|
||||||
|
|
||||||
const result = await svc.list(companyId, {
|
const result = await svc.list(companyId, {
|
||||||
status: req.query.status as string | undefined,
|
status: req.query.status as string | undefined,
|
||||||
|
|
|
||||||
|
|
@ -942,7 +942,8 @@ async function listIssueProductivityReviewMap(
|
||||||
isNull(issues.hiddenAt),
|
isNull(issues.hiddenAt),
|
||||||
notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES),
|
notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.orderBy(desc(issues.createdAt), desc(issues.id));
|
||||||
reviewRows.push(...rows);
|
reviewRows.push(...rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -985,6 +986,7 @@ async function listIssueProductivityReviewMap(
|
||||||
|
|
||||||
for (const row of reviewRows) {
|
for (const row of reviewRows) {
|
||||||
if (!row.sourceIssueId) continue;
|
if (!row.sourceIssueId) continue;
|
||||||
|
if (map.has(row.sourceIssueId)) continue;
|
||||||
const detail = triggerByReviewIssueId.get(row.reviewIssueId);
|
const detail = triggerByReviewIssueId.get(row.reviewIssueId);
|
||||||
map.set(row.sourceIssueId, {
|
map.set(row.sourceIssueId, {
|
||||||
reviewIssueId: row.reviewIssueId,
|
reviewIssueId: row.reviewIssueId,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
|
||||||
import { Workspaces } from "./pages/Workspaces";
|
import { Workspaces } from "./pages/Workspaces";
|
||||||
import { Issues } from "./pages/Issues";
|
import { Issues } from "./pages/Issues";
|
||||||
import { IssueDetail } from "./pages/IssueDetail";
|
import { IssueDetail } from "./pages/IssueDetail";
|
||||||
|
import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf";
|
||||||
import { Routines } from "./pages/Routines";
|
import { Routines } from "./pages/Routines";
|
||||||
import { RoutineDetail } from "./pages/RoutineDetail";
|
import { RoutineDetail } from "./pages/RoutineDetail";
|
||||||
import { UserProfile } from "./pages/UserProfile";
|
import { UserProfile } from "./pages/UserProfile";
|
||||||
|
|
@ -50,7 +51,7 @@ import { InviteLandingPage } from "./pages/InviteLanding";
|
||||||
import { JoinRequestQueue } from "./pages/JoinRequestQueue";
|
import { JoinRequestQueue } from "./pages/JoinRequestQueue";
|
||||||
import { NotFoundPage } from "./pages/NotFound";
|
import { NotFoundPage } from "./pages/NotFound";
|
||||||
import { useCompany } from "./context/CompanyContext";
|
import { useCompany } from "./context/CompanyContext";
|
||||||
import { useDialog } from "./context/DialogContext";
|
import { useDialogActions } from "./context/DialogContext";
|
||||||
import { loadLastInboxTab } from "./lib/inbox";
|
import { loadLastInboxTab } from "./lib/inbox";
|
||||||
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
|
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
|
||||||
|
|
||||||
|
|
@ -98,6 +99,9 @@ function boardRoutes() {
|
||||||
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
||||||
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
|
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
|
||||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||||
|
{import.meta.env.DEV ? (
|
||||||
|
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||||
|
) : null}
|
||||||
<Route path="routines" element={<Routines />} />
|
<Route path="routines" element={<Routines />} />
|
||||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||||
|
|
@ -139,7 +143,7 @@ function LegacySettingsRedirect() {
|
||||||
|
|
||||||
function OnboardingRoutePage() {
|
function OnboardingRoutePage() {
|
||||||
const { companies } = useCompany();
|
const { companies } = useCompany();
|
||||||
const { openOnboarding } = useDialog();
|
const { openOnboarding } = useDialogActions();
|
||||||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||||
const matchedCompany = companyPrefix
|
const matchedCompany = companyPrefix
|
||||||
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
|
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
|
||||||
|
|
@ -231,7 +235,7 @@ function UnprefixedBoardRedirect() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoCompaniesStartPage() {
|
function NoCompaniesStartPage() {
|
||||||
const { openOnboarding } = useDialog();
|
const { openOnboarding } = useDialogActions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl py-10">
|
<div className="mx-auto max-w-xl py-10">
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,12 @@ describe("issuesApi.list", () => {
|
||||||
"/companies/company-1/issues?workspaceId=workspace-1&limit=1000",
|
"/companies/company-1/issues?workspaceId=workspace-1&limit=1000",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes pagination offsets through to the company issues endpoint", async () => {
|
||||||
|
await issuesApi.list("company-1", { limit: 500, offset: 1500 });
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
|
"/companies/company-1/issues?limit=500&offset=1500",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export const issuesApi = {
|
||||||
includeBlockedBy?: boolean;
|
includeBlockedBy?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
@ -70,6 +71,7 @@ export const issuesApi = {
|
||||||
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
|
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
|
||||||
if (filters?.q) params.set("q", filters.q);
|
if (filters?.q) params.set("q", filters.q);
|
||||||
if (filters?.limit) params.set("limit", String(filters.limit));
|
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||||
|
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ vi.mock("../context/CompanyContext", () => ({
|
||||||
|
|
||||||
vi.mock("../context/DialogContext", () => ({
|
vi.mock("../context/DialogContext", () => ({
|
||||||
useDialog: () => dialogState,
|
useDialog: () => dialogState,
|
||||||
|
useDialogActions: () => dialogState,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../context/SidebarContext", () => ({
|
vi.mock("../context/SidebarContext", () => ({
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from "react";
|
||||||
import { useNavigate } from "@/lib/router";
|
import { useNavigate } from "@/lib/router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
|
@ -37,7 +37,7 @@ export function CommandPalette() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewIssue, openNewAgent } = useDialog();
|
const { openNewIssue, openNewAgent } = useDialogActions();
|
||||||
const { isMobile, setSidebarOpen } = useSidebar();
|
const { isMobile, setSidebarOpen } = useSidebar();
|
||||||
const searchQuery = query.trim();
|
const searchQuery = query.trim();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||||
|
|
@ -125,7 +125,7 @@ function SortableCompanyItem({
|
||||||
|
|
||||||
export function CompanyRail() {
|
export function CompanyRail() {
|
||||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||||
const { openOnboarding } = useDialog();
|
const { openOnboarding } = useDialogActions();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isInstanceRoute = location.pathname.startsWith("/instance/");
|
const isInstanceRoute = location.pathname.startsWith("/instance/");
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,40 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Agent } from "@paperclipai/shared";
|
import type { Agent } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
IssueChatThread,
|
IssueChatThread,
|
||||||
|
VIRTUALIZED_THREAD_ROW_THRESHOLD,
|
||||||
canStopIssueChatRun,
|
canStopIssueChatRun,
|
||||||
|
findLatestCommentMessageIndex,
|
||||||
resolveAssistantMessageFoldedState,
|
resolveAssistantMessageFoldedState,
|
||||||
resolveIssueChatHumanAuthor,
|
resolveIssueChatHumanAuthor,
|
||||||
} from "./IssueChatThread";
|
} from "./IssueChatThread";
|
||||||
|
import { ToastProvider } from "../context/ToastContext";
|
||||||
|
import { ToastViewport } from "./ToastViewport";
|
||||||
import type {
|
import type {
|
||||||
AskUserQuestionsInteraction,
|
AskUserQuestionsInteraction,
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
SuggestTasksInteraction,
|
SuggestTasksInteraction,
|
||||||
} from "../lib/issue-thread-interactions";
|
} from "../lib/issue-thread-interactions";
|
||||||
|
import {
|
||||||
|
issueChatLongThreadAgentMap,
|
||||||
|
issueChatLongThreadComments,
|
||||||
|
issueChatLongThreadEvents,
|
||||||
|
issueChatLongThreadLinkedRuns,
|
||||||
|
issueChatLongThreadTranscriptsByRunId,
|
||||||
|
} from "../fixtures/issueChatLongThreadFixture";
|
||||||
|
import type {
|
||||||
|
IssueChatLinkedRun,
|
||||||
|
IssueChatTranscriptEntry,
|
||||||
|
} from "../lib/issue-chat-messages";
|
||||||
|
|
||||||
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
function hasSmoothScrollBehavior(arg: unknown) {
|
||||||
|
return typeof arg === "object"
|
||||||
|
&& arg !== null
|
||||||
|
&& "behavior" in arg
|
||||||
|
&& (arg as ScrollToOptions).behavior === "smooth";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { markdownBodyRenderMock, markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||||
|
markdownBodyRenderMock: vi.fn(),
|
||||||
markdownEditorFocusMock: vi.fn(),
|
markdownEditorFocusMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -59,7 +82,10 @@ vi.mock("../lib/issue-chat-scroll", async (importOriginal) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("./MarkdownBody", () => ({
|
vi.mock("./MarkdownBody", () => ({
|
||||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
MarkdownBody: ({ children }: { children: ReactNode }) => {
|
||||||
|
markdownBodyRenderMock(children);
|
||||||
|
return <div>{children}</div>;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./MarkdownEditor", () => ({
|
vi.mock("./MarkdownEditor", () => ({
|
||||||
|
|
@ -273,6 +299,7 @@ describe("IssueChatThread", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
window.scrollTo = vi.fn();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -284,6 +311,7 @@ describe("IssueChatThread", () => {
|
||||||
captureComposerViewportSnapshotMock.mockClear();
|
captureComposerViewportSnapshotMock.mockClear();
|
||||||
restoreComposerViewportSnapshotMock.mockClear();
|
restoreComposerViewportSnapshotMock.mockClear();
|
||||||
shouldPreserveComposerViewportMock.mockClear();
|
shouldPreserveComposerViewportMock.mockClear();
|
||||||
|
markdownBodyRenderMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops the count heading and does not use an internal scrollbox", () => {
|
it("drops the count heading and does not use an internal scrollbox", () => {
|
||||||
|
|
@ -318,6 +346,644 @@ describe("IssueChatThread", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("virtualizes long merged threads so only a windowed slice mounts", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const totalMergedRows =
|
||||||
|
issueChatLongThreadComments.length
|
||||||
|
+ issueChatLongThreadEvents.length
|
||||||
|
+ issueChatLongThreadLinkedRuns.length;
|
||||||
|
expect(totalMergedRows).toBeGreaterThanOrEqual(VIRTUALIZED_THREAD_ROW_THRESHOLD);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={issueChatLongThreadComments}
|
||||||
|
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||||
|
timelineEvents={issueChatLongThreadEvents}
|
||||||
|
liveRuns={[]}
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={async () => {}}
|
||||||
|
showComposer={false}
|
||||||
|
showJumpToLatest={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||||
|
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualizer = container.querySelector(
|
||||||
|
'[data-testid="issue-chat-thread-virtualizer"]',
|
||||||
|
) as HTMLDivElement | null;
|
||||||
|
expect(virtualizer).not.toBeNull();
|
||||||
|
expect(virtualizer?.dataset.virtualCount).toBe(String(totalMergedRows));
|
||||||
|
|
||||||
|
const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]');
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
expect(rows.length).toBeLessThan(totalMergedRows);
|
||||||
|
|
||||||
|
const virtualRows = container.querySelectorAll(
|
||||||
|
'[data-testid="issue-chat-thread-virtual-row"]',
|
||||||
|
);
|
||||||
|
expect(virtualRows.length).toBe(rows.length);
|
||||||
|
for (const row of Array.from(virtualRows)) {
|
||||||
|
const transform = (row as HTMLDivElement).style.transform;
|
||||||
|
expect(transform).toMatch(/translateY\(/);
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("measures tall virtual rows before positioning following rows", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const requestAnimationFrameMock = vi
|
||||||
|
.spyOn(window, "requestAnimationFrame")
|
||||||
|
.mockImplementation((callback) => {
|
||||||
|
callback(0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={issueChatLongThreadComments}
|
||||||
|
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||||
|
timelineEvents={issueChatLongThreadEvents}
|
||||||
|
liveRuns={[]}
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={async () => {}}
|
||||||
|
showComposer={false}
|
||||||
|
showJumpToLatest={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||||
|
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualRows = container.querySelectorAll<HTMLDivElement>(
|
||||||
|
'[data-testid="issue-chat-thread-virtual-row"]',
|
||||||
|
);
|
||||||
|
expect(virtualRows.length).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
Object.defineProperty(virtualRows[0], "getBoundingClientRect", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => ({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 700,
|
||||||
|
height: 800,
|
||||||
|
top: 0,
|
||||||
|
right: 700,
|
||||||
|
bottom: 800,
|
||||||
|
left: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
virtualRows[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextTransform = virtualRows[1].style.transform;
|
||||||
|
const translateY = Number(nextTransform.match(/translateY\(([-\d.]+)px\)/)?.[1] ?? "0");
|
||||||
|
expect(translateY).toBeGreaterThanOrEqual(800);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
requestAnimationFrameMock.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scrolls loaded hash targets through the virtualized message index", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const targetComment = issueChatLongThreadComments.at(-1);
|
||||||
|
expect(targetComment).toBeDefined();
|
||||||
|
const scrollToMock = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter initialEntries={[`/issues/PAP-1#comment-${targetComment!.id}`]}>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={issueChatLongThreadComments}
|
||||||
|
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||||
|
timelineEvents={issueChatLongThreadEvents}
|
||||||
|
liveRuns={[]}
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={async () => {}}
|
||||||
|
showComposer={false}
|
||||||
|
showJumpToLatest={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||||
|
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(true);
|
||||||
|
|
||||||
|
scrollToMock.mockRestore();
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the virtualizer when jumping to the latest long-thread row", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const scrollToMock = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={issueChatLongThreadComments}
|
||||||
|
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||||
|
timelineEvents={issueChatLongThreadEvents}
|
||||||
|
liveRuns={[]}
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={async () => {}}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||||
|
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const jump = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent === "Jump to latest",
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
expect(jump).toBeDefined();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jump?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(true);
|
||||||
|
|
||||||
|
scrollToMock.mockRestore();
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression for PAP-2660: on the real issue page the chat thread is wrapped
|
||||||
|
// in `<main id="main-content" overflow-auto>`, so the virtualizer must bind
|
||||||
|
// to that ancestor's scroll instead of `window` (which never moves on
|
||||||
|
// desktop). When mounted inside an overflow-auto ancestor the jump-to-latest
|
||||||
|
// action must drive that element's scrollTo, not window.scrollTo.
|
||||||
|
it("targets an overflow-auto ancestor instead of window scroll on jump-to-latest", () => {
|
||||||
|
container.remove();
|
||||||
|
const scrollHost = document.createElement("main");
|
||||||
|
scrollHost.id = "main-content";
|
||||||
|
scrollHost.style.overflowY = "auto";
|
||||||
|
scrollHost.style.overflow = "auto";
|
||||||
|
scrollHost.style.height = "640px";
|
||||||
|
document.body.appendChild(scrollHost);
|
||||||
|
container = document.createElement("div");
|
||||||
|
scrollHost.appendChild(container);
|
||||||
|
|
||||||
|
const root = createRoot(container);
|
||||||
|
const windowScrollToMock = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||||
|
const elementScrollToMock = vi.fn();
|
||||||
|
scrollHost.scrollTo = elementScrollToMock as unknown as typeof scrollHost.scrollTo;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={issueChatLongThreadComments}
|
||||||
|
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||||
|
timelineEvents={issueChatLongThreadEvents}
|
||||||
|
liveRuns={[]}
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={async () => {}}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||||
|
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const jump = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent === "Jump to latest",
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
expect(jump).toBeDefined();
|
||||||
|
|
||||||
|
windowScrollToMock.mockClear();
|
||||||
|
elementScrollToMock.mockClear();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jump?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(elementScrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(true);
|
||||||
|
expect(windowScrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(false);
|
||||||
|
|
||||||
|
windowScrollToMock.mockRestore();
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
scrollHost.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression for PAP-2672: when the merged feed ends with a non-comment row
|
||||||
|
// (run/timeline/embedded output) we still want Jump to latest to land on the
|
||||||
|
// last comment, not whichever activity row sorts last.
|
||||||
|
it("targets the latest comment row when trailing rows are non-comments (PAP-2672)", () => {
|
||||||
|
const lastComment = issueChatLongThreadComments.at(-1);
|
||||||
|
expect(lastComment).toBeDefined();
|
||||||
|
const trailingRunStart = new Date(new Date(lastComment!.createdAt).getTime() + 60_000);
|
||||||
|
const trailingRun: IssueChatLinkedRun = {
|
||||||
|
runId: "trailing-run-pap-2672",
|
||||||
|
status: "failed",
|
||||||
|
agentId: "agent-perf-codex",
|
||||||
|
agentName: "TrailingRunner",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
createdAt: trailingRunStart,
|
||||||
|
startedAt: trailingRunStart,
|
||||||
|
finishedAt: trailingRunStart,
|
||||||
|
hasStoredOutput: true,
|
||||||
|
};
|
||||||
|
const trailingTranscriptEntries: readonly IssueChatTranscriptEntry[] = [
|
||||||
|
{
|
||||||
|
kind: "assistant",
|
||||||
|
ts: trailingRunStart.toISOString(),
|
||||||
|
text: "Trailing run posted after the latest comment.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const transcriptsByRunId = new Map(issueChatLongThreadTranscriptsByRunId);
|
||||||
|
transcriptsByRunId.set(trailingRun.runId, trailingTranscriptEntries);
|
||||||
|
const linkedRuns: IssueChatLinkedRun[] = [
|
||||||
|
...issueChatLongThreadLinkedRuns,
|
||||||
|
trailingRun,
|
||||||
|
];
|
||||||
|
|
||||||
|
container.remove();
|
||||||
|
const scrollHost = document.createElement("main");
|
||||||
|
scrollHost.id = "main-content";
|
||||||
|
scrollHost.style.overflowY = "auto";
|
||||||
|
scrollHost.style.overflow = "auto";
|
||||||
|
scrollHost.style.height = "800px";
|
||||||
|
Object.defineProperty(scrollHost, "scrollHeight", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 200_000,
|
||||||
|
});
|
||||||
|
Object.defineProperty(scrollHost, "clientHeight", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 800,
|
||||||
|
});
|
||||||
|
document.body.appendChild(scrollHost);
|
||||||
|
container = document.createElement("div");
|
||||||
|
scrollHost.appendChild(container);
|
||||||
|
|
||||||
|
const elementScrollToMock = vi.fn();
|
||||||
|
scrollHost.scrollTo = elementScrollToMock as unknown as typeof scrollHost.scrollTo;
|
||||||
|
|
||||||
|
const root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={issueChatLongThreadComments}
|
||||||
|
linkedRuns={linkedRuns}
|
||||||
|
timelineEvents={issueChatLongThreadEvents}
|
||||||
|
liveRuns={[]}
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={async () => {}}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
transcriptsByRunId={transcriptsByRunId}
|
||||||
|
hasOutputForRun={(runId) => transcriptsByRunId.has(runId)}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualizerEl = container.querySelector<HTMLDivElement>(
|
||||||
|
'[data-testid="issue-chat-thread-virtualizer"]',
|
||||||
|
);
|
||||||
|
expect(virtualizerEl).not.toBeNull();
|
||||||
|
const totalMergedRows = Number(virtualizerEl?.dataset.virtualCount ?? "0");
|
||||||
|
expect(totalMergedRows).toBeGreaterThan(VIRTUALIZED_THREAD_ROW_THRESHOLD);
|
||||||
|
|
||||||
|
elementScrollToMock.mockClear();
|
||||||
|
|
||||||
|
const jump = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent === "Jump to latest",
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
expect(jump).toBeDefined();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jump?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
const smoothCalls = elementScrollToMock.mock.calls
|
||||||
|
.map((call) => call[0] as ScrollToOptions)
|
||||||
|
.filter(hasSmoothScrollBehavior);
|
||||||
|
expect(smoothCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// For align="end" with the very last index, tanstack-virtual short-circuits
|
||||||
|
// to getMaxScrollOffset() (= scrollHeight - clientHeight = 199_200 here).
|
||||||
|
// A jump to the latest comment row (one slot earlier) lands at item.end -
|
||||||
|
// clientHeight, which is strictly less. Asserting top < maxScrollOffset
|
||||||
|
// proves the button isn't routing to the trailing run row.
|
||||||
|
const maxScrollOffset = 200_000 - 800;
|
||||||
|
const lastTop = smoothCalls[smoothCalls.length - 1]?.top;
|
||||||
|
expect(typeof lastTop).toBe("number");
|
||||||
|
expect(lastTop as number).toBeLessThan(maxScrollOffset);
|
||||||
|
expect(lastTop as number).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
scrollHost.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression for PAP-2672 follow-up: clicking Jump to latest must refresh
|
||||||
|
// the comments page so a comment that arrived after the initial load is
|
||||||
|
// present before we scroll. Otherwise the user lands on the latest *loaded*
|
||||||
|
// comment but not the absolute newest.
|
||||||
|
it("invokes onRefreshLatestComments before scrolling on Jump to latest", async () => {
|
||||||
|
const refreshMock = vi.fn(async () => undefined);
|
||||||
|
const directComments = issueChatLongThreadComments.slice(0, 8);
|
||||||
|
|
||||||
|
const root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={directComments}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={async () => {}}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
onRefreshLatestComments={refreshMock}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const jump = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent === "Jump to latest",
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
expect(jump).toBeDefined();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jump?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findLatestCommentMessageIndex prefers the last comment-anchored row (PAP-2672)", () => {
|
||||||
|
const messages = [
|
||||||
|
{ metadata: { custom: { anchorId: "comment-a" } } },
|
||||||
|
{ metadata: { custom: { anchorId: "run-1" } } },
|
||||||
|
{ metadata: { custom: { anchorId: "comment-b" } } },
|
||||||
|
{ metadata: { custom: { anchorId: "run-2" } } },
|
||||||
|
{ metadata: { custom: { anchorId: "activity-3" } } },
|
||||||
|
];
|
||||||
|
expect(findLatestCommentMessageIndex(messages as never)).toBe(2);
|
||||||
|
expect(
|
||||||
|
findLatestCommentMessageIndex([
|
||||||
|
{ metadata: { custom: { anchorId: "run-only" } } },
|
||||||
|
] as never),
|
||||||
|
).toBe(-1);
|
||||||
|
expect(findLatestCommentMessageIndex([] as never)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the direct render path for short threads under the virtualization threshold", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const directComments = issueChatLongThreadComments.slice(0, 12);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={directComments}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={async () => {}}
|
||||||
|
showComposer={false}
|
||||||
|
showJumpToLatest={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="issue-chat-thread-virtualizer"]'),
|
||||||
|
).toBeNull();
|
||||||
|
const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]');
|
||||||
|
expect(rows.length).toBe(directComments.length);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders virtualized rows with the same role/kind metadata as the direct path", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={issueChatLongThreadComments}
|
||||||
|
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||||
|
timelineEvents={issueChatLongThreadEvents}
|
||||||
|
liveRuns={[]}
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={async () => {}}
|
||||||
|
showComposer={false}
|
||||||
|
showJumpToLatest={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||||
|
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]');
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
const roles = new Set<string>();
|
||||||
|
const kinds = new Set<string>();
|
||||||
|
for (const row of Array.from(rows)) {
|
||||||
|
const element = row as HTMLDivElement;
|
||||||
|
const role = element.dataset.messageRole;
|
||||||
|
const kind = element.dataset.messageKind;
|
||||||
|
if (role) roles.add(role);
|
||||||
|
if (kind) kinds.add(kind);
|
||||||
|
}
|
||||||
|
expect(roles.size).toBeGreaterThan(0);
|
||||||
|
expect(kinds.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-render long-thread markdown rows for unrelated layout updates", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const onAdd = async () => {};
|
||||||
|
const hasOutputForRun = (runId: string) => issueChatLongThreadTranscriptsByRunId.has(runId);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={issueChatLongThreadComments}
|
||||||
|
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||||
|
timelineEvents={issueChatLongThreadEvents}
|
||||||
|
liveRuns={[]}
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={onAdd}
|
||||||
|
showComposer={false}
|
||||||
|
showJumpToLatest={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||||
|
hasOutputForRun={hasOutputForRun}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(markdownBodyRenderMock).toHaveBeenCalled();
|
||||||
|
markdownBodyRenderMock.mockClear();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={issueChatLongThreadComments}
|
||||||
|
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||||
|
timelineEvents={issueChatLongThreadEvents}
|
||||||
|
liveRuns={[]}
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={onAdd}
|
||||||
|
showComposer={false}
|
||||||
|
showJumpToLatest
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||||
|
hasOutputForRun={hasOutputForRun}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(markdownBodyRenderMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-render unchanged markdown when feedback votes change", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const onAdd = async () => {};
|
||||||
|
const onVote = async () => {};
|
||||||
|
const comments = [{
|
||||||
|
id: "comment-agent-feedback",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorAgentId: "agent-1",
|
||||||
|
authorUserId: null,
|
||||||
|
body: "Agent summary with **markdown**",
|
||||||
|
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
}];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={comments}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onVote={onVote}
|
||||||
|
feedbackVotes={[]}
|
||||||
|
showComposer={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(markdownBodyRenderMock).toHaveBeenCalled();
|
||||||
|
markdownBodyRenderMock.mockClear();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={comments}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onVote={onVote}
|
||||||
|
feedbackVotes={[{
|
||||||
|
id: "feedback-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
targetType: "issue_comment",
|
||||||
|
targetId: "comment-agent-feedback",
|
||||||
|
authorUserId: "user-1",
|
||||||
|
vote: "up",
|
||||||
|
reason: null,
|
||||||
|
sharedWithLabs: false,
|
||||||
|
sharedAt: null,
|
||||||
|
consentVersion: null,
|
||||||
|
redactionSummary: null,
|
||||||
|
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||||
|
}]}
|
||||||
|
showComposer={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(markdownBodyRenderMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("shows explicit follow-up badges and event copy", () => {
|
it("shows explicit follow-up badges and event copy", () => {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
|
@ -1258,6 +1924,127 @@ describe("IssueChatThread", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("warns once before sending a reply with no assignee selected", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<ToastProvider>
|
||||||
|
<ToastViewport />
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
enableReassign
|
||||||
|
reassignOptions={[
|
||||||
|
{ id: "", label: "No assignee" },
|
||||||
|
{ id: "agent:agent-1", label: "Agent 1" },
|
||||||
|
]}
|
||||||
|
currentAssigneeValue=""
|
||||||
|
suggestedAssigneeValue=""
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||||
|
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(element) => element.textContent === "Send",
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
expect(editor).not.toBeNull();
|
||||||
|
expect(submitButton).toBeDefined();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLTextAreaElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
valueSetter?.call(editor, "Reply without assignee");
|
||||||
|
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
submitButton?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(appendMock).not.toHaveBeenCalled();
|
||||||
|
expect(document.body.textContent).toContain("No assignee selected");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
submitButton?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(appendMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(appendMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
content: [{ type: "text", text: "Reply without assignee" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn when sending a reply with an assignee selected", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<ToastProvider>
|
||||||
|
<ToastViewport />
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
enableReassign
|
||||||
|
reassignOptions={[
|
||||||
|
{ id: "", label: "No assignee" },
|
||||||
|
{ id: "agent:agent-1", label: "Agent 1" },
|
||||||
|
]}
|
||||||
|
currentAssigneeValue="agent:agent-1"
|
||||||
|
suggestedAssigneeValue="agent:agent-1"
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||||
|
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(element) => element.textContent === "Send",
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLTextAreaElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
valueSetter?.call(editor, "Reply with assignee");
|
||||||
|
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
submitButton?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(appendMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(document.body.textContent).not.toContain("No assignee selected");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("exposes a composer focus handle that forwards to the editor", () => {
|
it("exposes a composer focus handle that forwards to the editor", () => {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>();
|
const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>();
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { X } from "lucide-react";
|
import { Eye, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
createIssueDetailPath,
|
createIssueDetailPath,
|
||||||
rememberIssueDetailLocationState,
|
rememberIssueDetailLocationState,
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from "../lib/issueDetailBreadcrumb";
|
} from "../lib/issueDetailBreadcrumb";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
|
||||||
|
|
||||||
type UnreadState = "hidden" | "visible" | "fading";
|
type UnreadState = "hidden" | "visible" | "fading";
|
||||||
|
|
||||||
|
|
@ -63,6 +64,19 @@ export function IssueRow({
|
||||||
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
||||||
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
|
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
|
||||||
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
|
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
|
||||||
|
const productivityReview = issue.productivityReview ?? null;
|
||||||
|
const productivityReviewIndicator = productivityReview ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-300",
|
||||||
|
selected ? "border-muted-foreground text-muted-foreground" : null,
|
||||||
|
)}
|
||||||
|
title={`Productivity review: ${productivityReviewTriggerLabel(productivityReview.trigger)}`}
|
||||||
|
aria-label="Productivity review open"
|
||||||
|
>
|
||||||
|
<Eye className="h-2.5 w-2.5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
const hasChecklistStep = checklistStepNumber !== null;
|
const hasChecklistStep = checklistStepNumber !== null;
|
||||||
const checklistStep = hasChecklistStep ? (
|
const checklistStep = hasChecklistStep ? (
|
||||||
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
|
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
|
||||||
|
|
@ -87,8 +101,9 @@ export function IssueRow({
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 pt-px sm:hidden">
|
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
|
||||||
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
|
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
|
||||||
|
{productivityReviewIndicator}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||||
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
|
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
|
||||||
|
|
@ -105,8 +120,9 @@ export function IssueRow({
|
||||||
) : null}
|
) : null}
|
||||||
{desktopMetaLeading ?? (
|
{desktopMetaLeading ?? (
|
||||||
<>
|
<>
|
||||||
<span className="hidden shrink-0 sm:inline-flex">
|
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
|
||||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />
|
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />
|
||||||
|
{productivityReviewIndicator}
|
||||||
</span>
|
</span>
|
||||||
{checklistStep}
|
{checklistStep}
|
||||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ vi.mock("../context/CompanyContext", () => ({
|
||||||
|
|
||||||
vi.mock("../context/DialogContext", () => ({
|
vi.mock("../context/DialogContext", () => ({
|
||||||
useDialog: () => dialogState,
|
useDialog: () => dialogState,
|
||||||
|
useDialogActions: () => dialogState,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/router", () => ({
|
vi.mock("@/lib/router", () => ({
|
||||||
|
|
@ -221,6 +222,20 @@ async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) {
|
||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDocumentScrollMetrics({
|
||||||
|
innerHeight,
|
||||||
|
scrollY,
|
||||||
|
scrollHeight,
|
||||||
|
}: {
|
||||||
|
innerHeight: number;
|
||||||
|
scrollY: number;
|
||||||
|
scrollHeight: number;
|
||||||
|
}) {
|
||||||
|
Object.defineProperty(window, "innerHeight", { configurable: true, value: innerHeight });
|
||||||
|
Object.defineProperty(window, "scrollY", { configurable: true, value: scrollY });
|
||||||
|
Object.defineProperty(document.documentElement, "scrollHeight", { configurable: true, value: scrollHeight });
|
||||||
|
}
|
||||||
|
|
||||||
function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
|
function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|
@ -268,6 +283,7 @@ describe("IssuesList", () => {
|
||||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||||
|
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 });
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -853,6 +869,142 @@ describe("IssuesList", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps rendering local issue batches while the user stays near the bottom", async () => {
|
||||||
|
const manyIssues = Array.from({ length: 420 }, (_, 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(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 });
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForAssertion(() => {
|
||||||
|
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(250);
|
||||||
|
expect(container.textContent).toContain("Rendering 250 of 420 issues");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the desktop main scroll container before rendering more local rows", async () => {
|
||||||
|
const manyIssues = Array.from({ length: 420 }, (_, index) =>
|
||||||
|
createIssue({
|
||||||
|
id: `issue-${index + 1}`,
|
||||||
|
identifier: `PAP-${index + 1}`,
|
||||||
|
title: `Issue ${index + 1}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const main = document.createElement("main");
|
||||||
|
main.id = "main-content";
|
||||||
|
main.style.overflowY = "auto";
|
||||||
|
document.body.appendChild(main);
|
||||||
|
main.appendChild(container);
|
||||||
|
Object.defineProperty(main, "clientHeight", { configurable: true, value: 600 });
|
||||||
|
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 2000 });
|
||||||
|
Object.defineProperty(main, "scrollTop", { configurable: true, writable: true, value: 0 });
|
||||||
|
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 600 });
|
||||||
|
|
||||||
|
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(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
main.scrollTop = 1500;
|
||||||
|
main.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForAssertion(() => {
|
||||||
|
expect(container.querySelectorAll('[data-testid="issue-row"]').length).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests more server issues after scrolling past the rendered rows", async () => {
|
||||||
|
const visibleIssues = Array.from({ length: 100 }, (_, index) =>
|
||||||
|
createIssue({
|
||||||
|
id: `issue-${index + 1}`,
|
||||||
|
identifier: `PAP-${index + 1}`,
|
||||||
|
title: `Issue ${index + 1}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const onLoadMoreIssues = vi.fn();
|
||||||
|
setDocumentScrollMetrics({ innerHeight: 2000, scrollY: 0, scrollHeight: 1000 });
|
||||||
|
|
||||||
|
const { root } = renderWithQueryClient(
|
||||||
|
<IssuesList
|
||||||
|
issues={visibleIssues}
|
||||||
|
agents={[]}
|
||||||
|
projects={[]}
|
||||||
|
viewStateKey="paperclip:test-issues"
|
||||||
|
hasMoreIssues
|
||||||
|
onLoadMoreIssues={onLoadMoreIssues}
|
||||||
|
onUpdateIssue={() => undefined}
|
||||||
|
/>,
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForAssertion(() => {
|
||||||
|
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(onLoadMoreIssues).toHaveBeenCalledTimes(1);
|
||||||
|
await flush();
|
||||||
|
expect(onLoadMoreIssues).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 });
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForAssertion(() => {
|
||||||
|
expect(onLoadMoreIssues).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("skips deferred row sizing for expanded parent rows with visible children", async () => {
|
it("skips deferred row sizing for expanded parent rows with visible children", async () => {
|
||||||
const parentIssue = createIssue({
|
const parentIssue = createIssue({
|
||||||
id: "issue-parent",
|
id: "issue-parent",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
|
|
@ -72,7 +72,20 @@ const ISSUE_SEARCH_RESULT_LIMIT = 200;
|
||||||
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
|
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
|
||||||
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100;
|
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100;
|
||||||
const ISSUE_ROW_RENDER_BATCH_SIZE = 150;
|
const ISSUE_ROW_RENDER_BATCH_SIZE = 150;
|
||||||
const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0;
|
const ISSUE_SCROLL_LOAD_THRESHOLD_PX = 320;
|
||||||
|
|
||||||
|
function findIssuesScrollContainer(element: HTMLElement | null): HTMLElement | null {
|
||||||
|
if (!element || typeof window === "undefined") return null;
|
||||||
|
let current = element.parentElement;
|
||||||
|
while (current && current !== document.body && current !== document.documentElement) {
|
||||||
|
const overflowY = window.getComputedStyle(current).overflowY;
|
||||||
|
if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const boardIssueStatuses = ISSUE_STATUSES;
|
const boardIssueStatuses = ISSUE_STATUSES;
|
||||||
const issueStatusLabels: Record<IssueStatus, string> = {
|
const issueStatusLabels: Record<IssueStatus, string> = {
|
||||||
backlog: "Backlog",
|
backlog: "Backlog",
|
||||||
|
|
@ -306,8 +319,11 @@ interface IssuesListProps {
|
||||||
defaultSortField?: IssueSortField;
|
defaultSortField?: IssueSortField;
|
||||||
showProgressSummary?: boolean;
|
showProgressSummary?: boolean;
|
||||||
enableRoutineVisibilityFilter?: boolean;
|
enableRoutineVisibilityFilter?: boolean;
|
||||||
|
hasMoreIssues?: boolean;
|
||||||
|
isLoadingMoreIssues?: boolean;
|
||||||
mutedIssueIds?: Set<string>;
|
mutedIssueIds?: Set<string>;
|
||||||
issueBadgeById?: Map<string, string>;
|
issueBadgeById?: Map<string, string>;
|
||||||
|
onLoadMoreIssues?: () => void;
|
||||||
onSearchChange?: (search: string) => void;
|
onSearchChange?: (search: string) => void;
|
||||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -475,13 +491,17 @@ export function IssuesList({
|
||||||
defaultSortField,
|
defaultSortField,
|
||||||
showProgressSummary = false,
|
showProgressSummary = false,
|
||||||
enableRoutineVisibilityFilter = false,
|
enableRoutineVisibilityFilter = false,
|
||||||
|
hasMoreIssues = false,
|
||||||
|
isLoadingMoreIssues = false,
|
||||||
mutedIssueIds,
|
mutedIssueIds,
|
||||||
issueBadgeById,
|
issueBadgeById,
|
||||||
|
onLoadMoreIssues,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onUpdateIssue,
|
onUpdateIssue,
|
||||||
}: IssuesListProps) {
|
}: IssuesListProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialogActions();
|
||||||
const { data: session } = useQuery({
|
const { data: session } = useQuery({
|
||||||
queryKey: queryKeys.auth.session,
|
queryKey: queryKeys.auth.session,
|
||||||
queryFn: () => authApi.getSession(),
|
queryFn: () => authApi.getSession(),
|
||||||
|
|
@ -512,6 +532,8 @@ export function IssuesList({
|
||||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||||
const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT);
|
const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT);
|
||||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
|
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
|
||||||
|
const renderedIssueIdsRef = useRef("");
|
||||||
|
const initialServerFillRequestedRef = useRef(false);
|
||||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||||
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
||||||
|
|
||||||
|
|
@ -966,23 +988,86 @@ export function IssuesList({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewState.viewMode !== "list") return;
|
if (viewState.viewMode !== "list") return;
|
||||||
setRenderedIssueRowLimit(Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT));
|
const nextIssueIds = filtered.map((issue) => issue.id).join("|");
|
||||||
|
const previousIssueIds = renderedIssueIdsRef.current;
|
||||||
|
renderedIssueIdsRef.current = nextIssueIds;
|
||||||
|
|
||||||
|
setRenderedIssueRowLimit((current) => {
|
||||||
|
const nextInitialLimit = Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT);
|
||||||
|
const listAppended = previousIssueIds.length > 0
|
||||||
|
&& nextIssueIds.startsWith(previousIssueIds)
|
||||||
|
&& filtered.length >= current;
|
||||||
|
if (listAppended) return Math.min(filtered.length, Math.max(current, nextInitialLimit));
|
||||||
|
return nextInitialLimit;
|
||||||
|
});
|
||||||
}, [filtered, viewState.viewMode]);
|
}, [filtered, viewState.viewMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
const hasMoreRenderedRows = viewState.viewMode === "list" && renderedIssueRowLimit < filtered.length;
|
||||||
|
const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0);
|
||||||
|
const loadMoreIssueRows = useCallback(() => {
|
||||||
if (viewState.viewMode !== "list") return;
|
if (viewState.viewMode !== "list") return;
|
||||||
if (renderedIssueRowLimit >= filtered.length) return;
|
if (hasMoreRenderedRows) {
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setRenderedIssueRowLimit((current) => Math.min(filtered.length, current + ISSUE_ROW_RENDER_BATCH_SIZE));
|
setRenderedIssueRowLimit((current) => Math.min(filtered.length, current + ISSUE_ROW_RENDER_BATCH_SIZE));
|
||||||
});
|
});
|
||||||
}, ISSUE_ROW_RENDER_BATCH_DELAY_MS);
|
return;
|
||||||
|
}
|
||||||
|
if (hasMoreIssues && !isLoadingMoreIssues) {
|
||||||
|
onLoadMoreIssues?.();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
filtered.length,
|
||||||
|
hasMoreIssues,
|
||||||
|
hasMoreRenderedRows,
|
||||||
|
isLoadingMoreIssues,
|
||||||
|
onLoadMoreIssues,
|
||||||
|
viewState.viewMode,
|
||||||
|
]);
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
const canLoadMoreIssues = viewState.viewMode === "list"
|
||||||
}, [filtered.length, renderedIssueRowLimit, viewState.viewMode]);
|
&& !isLoading
|
||||||
|
&& (hasMoreRenderedRows || (hasMoreIssues && !isLoadingMoreIssues));
|
||||||
|
|
||||||
const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0);
|
useEffect(() => {
|
||||||
|
if (!canLoadMoreIssues) return;
|
||||||
|
let animationFrameId: number | null = null;
|
||||||
|
const scrollContainer = findIssuesScrollContainer(rootRef.current);
|
||||||
|
const scrollTarget: Window | HTMLElement = scrollContainer ?? window;
|
||||||
|
|
||||||
|
const checkScrollPosition = (trigger: "initial" | "scroll" | "resize" = "scroll") => {
|
||||||
|
if (animationFrameId !== null) return;
|
||||||
|
animationFrameId = window.requestAnimationFrame(() => {
|
||||||
|
animationFrameId = null;
|
||||||
|
const scrollHeight = scrollContainer?.scrollHeight ?? document.documentElement.scrollHeight;
|
||||||
|
if (scrollHeight === 0) return;
|
||||||
|
const viewportHeight = scrollContainer?.clientHeight ?? window.innerHeight;
|
||||||
|
const scrollBottom = scrollContainer
|
||||||
|
? scrollContainer.scrollTop + scrollContainer.clientHeight
|
||||||
|
: window.scrollY + window.innerHeight;
|
||||||
|
const hasScrollableOverflow = scrollHeight > viewportHeight + 1;
|
||||||
|
const threshold = scrollHeight - ISSUE_SCROLL_LOAD_THRESHOLD_PX;
|
||||||
|
if (scrollBottom >= threshold) {
|
||||||
|
if (trigger === "initial" && !hasMoreRenderedRows && hasMoreIssues && !hasScrollableOverflow) {
|
||||||
|
if (initialServerFillRequestedRef.current) return;
|
||||||
|
initialServerFillRequestedRef.current = true;
|
||||||
|
}
|
||||||
|
loadMoreIssueRows();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => checkScrollPosition("scroll");
|
||||||
|
const handleResize = () => checkScrollPosition("resize");
|
||||||
|
scrollTarget.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
checkScrollPosition("initial");
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollTarget.removeEventListener("scroll", handleScroll);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
}, [canLoadMoreIssues, hasMoreIssues, hasMoreRenderedRows, loadMoreIssueRows]);
|
||||||
|
|
||||||
const newIssueDefaults = useCallback((groupKey?: string) => {
|
const newIssueDefaults = useCallback((groupKey?: string) => {
|
||||||
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
||||||
|
|
@ -1036,7 +1121,7 @@ export function IssuesList({
|
||||||
let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY;
|
let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div ref={rootRef} className="space-y-4">
|
||||||
{progressSummary ? (
|
{progressSummary ? (
|
||||||
<SubIssueProgressSummaryStrip summary={progressSummary} issueLinkState={issueLinkState} />
|
<SubIssueProgressSummaryStrip summary={progressSummary} issueLinkState={issueLinkState} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -1556,10 +1641,16 @@ export function IssuesList({
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{remainingIssueRowCount > 0 && (
|
{(remainingIssueRowCount > 0 || hasMoreIssues || isLoadingMoreIssues) && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="py-2" data-testid="issues-load-more-sentinel">
|
||||||
Rendering {Math.min(renderedIssueRowLimit, filtered.length)} of {filtered.length} issues
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
{isLoadingMoreIssues
|
||||||
|
? "Loading more issues..."
|
||||||
|
: remainingIssueRowCount > 0
|
||||||
|
? `Rendering ${Math.min(renderedIssueRowLimit, filtered.length)} of ${filtered.length} issues`
|
||||||
|
: "Scroll to load more issues"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,10 @@ vi.mock("../context/DialogContext", () => ({
|
||||||
openNewIssue: vi.fn(),
|
openNewIssue: vi.fn(),
|
||||||
openOnboarding: vi.fn(),
|
openOnboarding: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useDialogActions: () => ({
|
||||||
|
openNewIssue: vi.fn(),
|
||||||
|
openOnboarding: vi.fn(),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../context/PanelContext", () => ({
|
vi.mock("../context/PanelContext", () => ({
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { MobileBottomNav } from "./MobileBottomNav";
|
||||||
import { WorktreeBanner } from "./WorktreeBanner";
|
import { WorktreeBanner } from "./WorktreeBanner";
|
||||||
import { DevRestartBanner } from "./DevRestartBanner";
|
import { DevRestartBanner } from "./DevRestartBanner";
|
||||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
|
@ -54,7 +54,7 @@ function readRememberedInstanceSettingsPath(): string {
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||||
const { openNewIssue, openOnboarding } = useDialog();
|
const { openNewIssue, openOnboarding } = useDialogActions();
|
||||||
const { togglePanelVisible } = usePanel();
|
const { togglePanelVisible } = usePanel();
|
||||||
const {
|
const {
|
||||||
companies,
|
companies,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
Inbox,
|
Inbox,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||||
|
|
@ -37,7 +37,7 @@ type MobileNavItem = MobileNavLinkItem | MobileNavActionItem;
|
||||||
export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialogActions();
|
||||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||||
|
|
||||||
const items = useMemo<MobileNavItem[]>(
|
const items = useMemo<MobileNavItem[]>(
|
||||||
|
|
|
||||||
|
|
@ -372,6 +372,56 @@ describe("NewIssueDialog", () => {
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("submits the latest locally typed title and description", async () => {
|
||||||
|
const { root } = renderDialog(container);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const titleInput = container.querySelector('textarea[placeholder="Issue title"]') as HTMLTextAreaElement | null;
|
||||||
|
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null;
|
||||||
|
expect(titleInput).not.toBeNull();
|
||||||
|
expect(descriptionInput).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLTextAreaElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
valueSetter?.call(titleInput, "Typed issue");
|
||||||
|
titleInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLTextAreaElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
valueSetter?.call(descriptionInput, "Typed description");
|
||||||
|
descriptionInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||||
|
.find((button) => button.textContent?.includes("Create Issue"));
|
||||||
|
expect(submitButton).not.toBeUndefined();
|
||||||
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Typed issue",
|
||||||
|
description: "Typed description",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => {
|
it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => {
|
||||||
dialogState.newIssueDefaults = {
|
dialogState.newIssueDefaults = {
|
||||||
parentId: "issue-1",
|
parentId: "issue-1",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
|
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent, type RefObject } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
|
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
|
|
@ -269,6 +269,111 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
||||||
return "shared_workspace";
|
return "shared_workspace";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IssueTitleTextarea = memo(function IssueTitleTextarea({
|
||||||
|
value,
|
||||||
|
pending,
|
||||||
|
assigneeValue,
|
||||||
|
projectId,
|
||||||
|
descriptionEditorRef,
|
||||||
|
assigneeSelectorRef,
|
||||||
|
projectSelectorRef,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
pending: boolean;
|
||||||
|
assigneeValue: string;
|
||||||
|
projectId: string;
|
||||||
|
descriptionEditorRef: RefObject<MarkdownEditorRef | null>;
|
||||||
|
assigneeSelectorRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
projectSelectorRef: RefObject<HTMLButtonElement | null>;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [draftValue, setDraftValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
|
||||||
|
placeholder="Issue title"
|
||||||
|
rows={1}
|
||||||
|
value={draftValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextValue = e.target.value;
|
||||||
|
setDraftValue(nextValue);
|
||||||
|
onChange(nextValue);
|
||||||
|
e.target.style.height = "auto";
|
||||||
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||||
|
}}
|
||||||
|
readOnly={pending}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (
|
||||||
|
e.key === "Enter" &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.nativeEvent.isComposing
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
descriptionEditorRef.current?.focus();
|
||||||
|
}
|
||||||
|
if (e.key === "Tab" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (assigneeValue) {
|
||||||
|
if (projectId) {
|
||||||
|
descriptionEditorRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
projectSelectorRef.current?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assigneeSelectorRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const IssueDescriptionEditor = memo(function IssueDescriptionEditor({
|
||||||
|
value,
|
||||||
|
expanded,
|
||||||
|
mentions,
|
||||||
|
descriptionEditorRef,
|
||||||
|
imageUploadHandler,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
expanded: boolean;
|
||||||
|
mentions: MentionOption[];
|
||||||
|
descriptionEditorRef: RefObject<MarkdownEditorRef | null>;
|
||||||
|
imageUploadHandler: (file: File) => Promise<string>;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [draftValue, setDraftValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MarkdownEditor
|
||||||
|
ref={descriptionEditorRef}
|
||||||
|
value={draftValue}
|
||||||
|
onChange={(nextValue) => {
|
||||||
|
setDraftValue(nextValue);
|
||||||
|
onChange(nextValue);
|
||||||
|
}}
|
||||||
|
placeholder="Add description..."
|
||||||
|
bordered={false}
|
||||||
|
mentions={mentions}
|
||||||
|
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||||
|
imageUploadHandler={imageUploadHandler}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function issueExecutionWorkspaceModeForExistingWorkspace(mode: string | null | undefined) {
|
function issueExecutionWorkspaceModeForExistingWorkspace(mode: string | null | undefined) {
|
||||||
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
|
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
|
||||||
return mode;
|
return mode;
|
||||||
|
|
@ -286,6 +391,10 @@ export function NewIssueDialog() {
|
||||||
const { pushToast } = useToastActions();
|
const { pushToast } = useToastActions();
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
const titleRef = useRef("");
|
||||||
|
const descriptionRef = useRef("");
|
||||||
|
const [titleHasText, setTitleHasText] = useState(false);
|
||||||
|
const [draftHasText, setDraftHasText] = useState(false);
|
||||||
const [status, setStatus] = useState("todo");
|
const [status, setStatus] = useState("todo");
|
||||||
const [priority, setPriority] = useState("");
|
const [priority, setPriority] = useState("");
|
||||||
const [assigneeValue, setAssigneeValue] = useState("");
|
const [assigneeValue, setAssigneeValue] = useState("");
|
||||||
|
|
@ -463,6 +572,10 @@ export function NewIssueDialog() {
|
||||||
return assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts");
|
return assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const uploadDescriptionImageHandler = useCallback(async (file: File) => {
|
||||||
|
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||||
|
return asset.contentPath;
|
||||||
|
}, [uploadDescriptionImage.mutateAsync]);
|
||||||
|
|
||||||
// Debounced draft saving
|
// Debounced draft saving
|
||||||
const scheduleSave = useCallback(
|
const scheduleSave = useCallback(
|
||||||
|
|
@ -475,12 +588,22 @@ export function NewIssueDialog() {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save draft on meaningful changes
|
const setIssueText = useCallback((nextTitle: string, nextDescription: string) => {
|
||||||
useEffect(() => {
|
titleRef.current = nextTitle;
|
||||||
|
descriptionRef.current = nextDescription;
|
||||||
|
setTitle(nextTitle);
|
||||||
|
setDescription(nextDescription);
|
||||||
|
setTitleHasText(nextTitle.trim().length > 0);
|
||||||
|
setDraftHasText(nextTitle.trim().length > 0 || nextDescription.trim().length > 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const queueDraftSave = useCallback((overrides: { title?: string; description?: string } = {}) => {
|
||||||
if (!newIssueOpen) return;
|
if (!newIssueOpen) return;
|
||||||
|
const nextTitle = overrides.title ?? titleRef.current;
|
||||||
|
const nextDescription = overrides.description ?? descriptionRef.current;
|
||||||
scheduleSave({
|
scheduleSave({
|
||||||
title,
|
title: nextTitle,
|
||||||
description,
|
description: nextDescription,
|
||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
assigneeValue,
|
assigneeValue,
|
||||||
|
|
@ -495,8 +618,43 @@ export function NewIssueDialog() {
|
||||||
selectedExecutionWorkspaceId,
|
selectedExecutionWorkspaceId,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
title,
|
newIssueOpen,
|
||||||
description,
|
scheduleSave,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
assigneeValue,
|
||||||
|
reviewerValue,
|
||||||
|
approverValue,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
assigneeModelOverride,
|
||||||
|
assigneeThinkingEffort,
|
||||||
|
assigneeChrome,
|
||||||
|
executionWorkspaceMode,
|
||||||
|
selectedExecutionWorkspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleTitleChange = useCallback((nextTitle: string) => {
|
||||||
|
titleRef.current = nextTitle;
|
||||||
|
const nextTitleHasText = nextTitle.trim().length > 0;
|
||||||
|
const nextDraftHasText = nextTitleHasText || descriptionRef.current.trim().length > 0;
|
||||||
|
setTitleHasText((current) => current === nextTitleHasText ? current : nextTitleHasText);
|
||||||
|
setDraftHasText((current) => current === nextDraftHasText ? current : nextDraftHasText);
|
||||||
|
queueDraftSave({ title: nextTitle });
|
||||||
|
}, [queueDraftSave]);
|
||||||
|
|
||||||
|
const handleDescriptionChange = useCallback((nextDescription: string) => {
|
||||||
|
descriptionRef.current = nextDescription;
|
||||||
|
const nextDraftHasText = titleRef.current.trim().length > 0 || nextDescription.trim().length > 0;
|
||||||
|
setDraftHasText((current) => current === nextDraftHasText ? current : nextDraftHasText);
|
||||||
|
queueDraftSave({ description: nextDescription });
|
||||||
|
}, [queueDraftSave]);
|
||||||
|
|
||||||
|
// Save draft on meaningful changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!newIssueOpen) return;
|
||||||
|
queueDraftSave();
|
||||||
|
}, [
|
||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
assigneeValue,
|
assigneeValue,
|
||||||
|
|
@ -510,7 +668,7 @@ export function NewIssueDialog() {
|
||||||
executionWorkspaceMode,
|
executionWorkspaceMode,
|
||||||
selectedExecutionWorkspaceId,
|
selectedExecutionWorkspaceId,
|
||||||
newIssueOpen,
|
newIssueOpen,
|
||||||
scheduleSave,
|
queueDraftSave,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Restore draft or apply defaults when dialog opens
|
// Restore draft or apply defaults when dialog opens
|
||||||
|
|
@ -528,8 +686,7 @@ export function NewIssueDialog() {
|
||||||
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
|
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
|
||||||
? "reuse_existing"
|
? "reuse_existing"
|
||||||
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
|
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||||
setTitle(newIssueDefaults.title ?? "");
|
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
|
||||||
setDescription(newIssueDefaults.description ?? "");
|
|
||||||
setStatus(newIssueDefaults.status ?? "todo");
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
setPriority(newIssueDefaults.priority ?? "");
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
setProjectId(defaultProjectId);
|
setProjectId(defaultProjectId);
|
||||||
|
|
@ -542,8 +699,7 @@ export function NewIssueDialog() {
|
||||||
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||||
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
||||||
} else if (newIssueDefaults.title) {
|
} else if (newIssueDefaults.title) {
|
||||||
setTitle(newIssueDefaults.title);
|
setIssueText(newIssueDefaults.title, newIssueDefaults.description ?? "");
|
||||||
setDescription(newIssueDefaults.description ?? "");
|
|
||||||
setStatus(newIssueDefaults.status ?? "todo");
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
setPriority(newIssueDefaults.priority ?? "");
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||||
|
|
@ -564,8 +720,7 @@ export function NewIssueDialog() {
|
||||||
} else if (draft && draft.title.trim()) {
|
} else if (draft && draft.title.trim()) {
|
||||||
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
|
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
|
||||||
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
|
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
|
||||||
setTitle(draft.title);
|
setIssueText(draft.title, draft.description);
|
||||||
setDescription(draft.description);
|
|
||||||
setStatus(draft.status || "todo");
|
setStatus(draft.status || "todo");
|
||||||
setPriority(draft.priority);
|
setPriority(draft.priority);
|
||||||
setAssigneeValue(
|
setAssigneeValue(
|
||||||
|
|
@ -591,6 +746,7 @@ export function NewIssueDialog() {
|
||||||
} else {
|
} else {
|
||||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||||
|
setIssueText("", "");
|
||||||
setStatus(newIssueDefaults.status ?? "todo");
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
setPriority(newIssueDefaults.priority ?? "");
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
setProjectId(defaultProjectId);
|
setProjectId(defaultProjectId);
|
||||||
|
|
@ -607,7 +763,7 @@ export function NewIssueDialog() {
|
||||||
setSelectedExecutionWorkspaceId("");
|
setSelectedExecutionWorkspaceId("");
|
||||||
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
||||||
}
|
}
|
||||||
}, [newIssueOpen, newIssueDefaults, orderedProjects]);
|
}, [newIssueOpen, newIssueDefaults, orderedProjects, setIssueText]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!supportsAssigneeOverrides) {
|
if (!supportsAssigneeOverrides) {
|
||||||
|
|
@ -637,8 +793,7 @@ export function NewIssueDialog() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setTitle("");
|
setIssueText("", "");
|
||||||
setDescription("");
|
|
||||||
setStatus("todo");
|
setStatus("todo");
|
||||||
setPriority("");
|
setPriority("");
|
||||||
setAssigneeValue("");
|
setAssigneeValue("");
|
||||||
|
|
@ -687,7 +842,9 @@ export function NewIssueDialog() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!effectiveCompanyId || !title.trim() || createIssue.isPending) return;
|
const currentTitle = titleRef.current.trim();
|
||||||
|
const currentDescription = descriptionRef.current.trim();
|
||||||
|
if (!effectiveCompanyId || !currentTitle || createIssue.isPending) return;
|
||||||
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
|
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
|
||||||
adapterType: assigneeAdapterType,
|
adapterType: assigneeAdapterType,
|
||||||
modelOverride: assigneeModelOverride,
|
modelOverride: assigneeModelOverride,
|
||||||
|
|
@ -716,8 +873,8 @@ export function NewIssueDialog() {
|
||||||
createIssue.mutate({
|
createIssue.mutate({
|
||||||
companyId: effectiveCompanyId,
|
companyId: effectiveCompanyId,
|
||||||
stagedFiles,
|
stagedFiles,
|
||||||
title: title.trim(),
|
title: currentTitle,
|
||||||
description: description.trim() || undefined,
|
description: currentDescription || undefined,
|
||||||
status,
|
status,
|
||||||
priority: priority || "medium",
|
priority: priority || "medium",
|
||||||
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
||||||
|
|
@ -806,7 +963,7 @@ export function NewIssueDialog() {
|
||||||
setStagedFiles((current) => current.filter((file) => file.id !== id));
|
setStagedFiles((current) => current.filter((file) => file.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDraft = title.trim().length > 0 || description.trim().length > 0 || stagedFiles.length > 0;
|
const hasDraft = draftHasText || stagedFiles.length > 0;
|
||||||
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
||||||
const currentPriority = priorities.find((p) => p.value === priority);
|
const currentPriority = priorities.find((p) => p.value === priority);
|
||||||
const currentAssignee = selectedAssigneeAgentId
|
const currentAssignee = selectedAssigneeAgentId
|
||||||
|
|
@ -884,7 +1041,7 @@ export function NewIssueDialog() {
|
||||||
})),
|
})),
|
||||||
[orderedProjects],
|
[orderedProjects],
|
||||||
);
|
);
|
||||||
const savedDraft = loadDraft();
|
const savedDraft = useMemo(() => newIssueOpen ? loadDraft() : null, [newIssueOpen]);
|
||||||
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
|
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
|
||||||
const canDiscardDraft = hasDraft || hasSavedDraft;
|
const canDiscardDraft = hasDraft || hasSavedDraft;
|
||||||
const createIssueErrorMessage =
|
const createIssueErrorMessage =
|
||||||
|
|
@ -1056,42 +1213,15 @@ export function NewIssueDialog() {
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="px-4 pt-4 pb-2">
|
<div className="px-4 pt-4 pb-2">
|
||||||
<textarea
|
<IssueTitleTextarea
|
||||||
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
|
value={title}
|
||||||
placeholder="Issue title"
|
pending={createIssue.isPending}
|
||||||
rows={1}
|
assigneeValue={assigneeValue}
|
||||||
value={title}
|
projectId={projectId}
|
||||||
onChange={(e) => {
|
descriptionEditorRef={descriptionEditorRef}
|
||||||
setTitle(e.target.value);
|
assigneeSelectorRef={assigneeSelectorRef}
|
||||||
e.target.style.height = "auto";
|
projectSelectorRef={projectSelectorRef}
|
||||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
onChange={handleTitleChange}
|
||||||
}}
|
|
||||||
readOnly={createIssue.isPending}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (
|
|
||||||
e.key === "Enter" &&
|
|
||||||
!e.metaKey &&
|
|
||||||
!e.ctrlKey &&
|
|
||||||
!e.nativeEvent.isComposing
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
descriptionEditorRef.current?.focus();
|
|
||||||
}
|
|
||||||
if (e.key === "Tab" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (assigneeValue) {
|
|
||||||
// Assignee already set — skip to project or description
|
|
||||||
if (projectId) {
|
|
||||||
descriptionEditorRef.current?.focus();
|
|
||||||
} else {
|
|
||||||
projectSelectorRef.current?.focus();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
assigneeSelectorRef.current?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1466,18 +1596,13 @@ export function NewIssueDialog() {
|
||||||
isFileDragOver && "bg-accent/20",
|
isFileDragOver && "bg-accent/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MarkdownEditor
|
<IssueDescriptionEditor
|
||||||
ref={descriptionEditorRef}
|
|
||||||
value={description}
|
value={description}
|
||||||
onChange={setDescription}
|
expanded={expanded}
|
||||||
placeholder="Add description..."
|
|
||||||
bordered={false}
|
|
||||||
mentions={mentionOptions}
|
mentions={mentionOptions}
|
||||||
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
descriptionEditorRef={descriptionEditorRef}
|
||||||
imageUploadHandler={async (file) => {
|
imageUploadHandler={uploadDescriptionImageHandler}
|
||||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
onChange={handleDescriptionChange}
|
||||||
return asset.contentPath;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{stagedFiles.length > 0 ? (
|
{stagedFiles.length > 0 ? (
|
||||||
|
|
@ -1682,7 +1807,7 @@ export function NewIssueDialog() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="min-w-[8.5rem] disabled:opacity-100"
|
className="min-w-[8.5rem] disabled:opacity-100"
|
||||||
disabled={!title.trim() || createIssue.isPending}
|
disabled={!titleHasText || createIssue.isPending}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
aria-busy={createIssue.isPending}
|
aria-busy={createIssue.isPending}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
77
ui/src/components/ProductivityReviewBadge.tsx
Normal file
77
ui/src/components/ProductivityReviewBadge.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { Eye } from "lucide-react";
|
||||||
|
import type { IssueProductivityReview } from "@paperclipai/shared";
|
||||||
|
import { Link } from "../lib/router";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
|
|
||||||
|
const TRIGGER_LABELS: Record<string, string> = {
|
||||||
|
no_comment_streak: "No-comment streak",
|
||||||
|
long_active_duration: "Long active duration",
|
||||||
|
high_churn: "High churn",
|
||||||
|
};
|
||||||
|
|
||||||
|
const REVIEW_STATUS_LABELS: Record<string, string> = {
|
||||||
|
todo: "Open",
|
||||||
|
in_progress: "In progress",
|
||||||
|
in_review: "In review",
|
||||||
|
blocked: "Blocked",
|
||||||
|
backlog: "Open",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function productivityReviewTriggerLabel(
|
||||||
|
trigger: IssueProductivityReview["trigger"],
|
||||||
|
): string {
|
||||||
|
if (!trigger) return "Productivity review";
|
||||||
|
return TRIGGER_LABELS[trigger] ?? "Productivity review";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductivityReviewBadge({
|
||||||
|
review,
|
||||||
|
className,
|
||||||
|
hideLabel = false,
|
||||||
|
}: {
|
||||||
|
review: IssueProductivityReview;
|
||||||
|
className?: string;
|
||||||
|
hideLabel?: boolean;
|
||||||
|
}) {
|
||||||
|
const label = productivityReviewTriggerLabel(review.trigger);
|
||||||
|
const reviewIdentifier = review.reviewIdentifier ?? review.reviewIssueId.slice(0, 8);
|
||||||
|
const reviewPath = createIssueDetailPath(review.reviewIdentifier ?? review.reviewIssueId);
|
||||||
|
const statusLabel = REVIEW_STATUS_LABELS[review.status] ?? review.status.replace(/_/g, " ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
to={reviewPath}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0 hover:bg-amber-500/20 transition-colors",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={`Under review · productivity review ${reviewIdentifier} (${label})`}
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3" aria-hidden />
|
||||||
|
{hideLabel ? null : <span>Under review</span>}
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="font-semibold">Productivity review open</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Trigger:</span> {label}
|
||||||
|
</div>
|
||||||
|
{typeof review.noCommentStreak === "number" && review.noCommentStreak > 0 ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">No-comment streak:</span>{" "}
|
||||||
|
{review.noCommentStreak} runs
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Review:</span> {reviewIdentifier} ({statusLabel})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
import { ChevronDown, ChevronRight, HelpCircle } from "lucide-react";
|
||||||
import { syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared";
|
import { syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
|
|
@ -65,8 +72,8 @@ export function RoutineVariablesEditor({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={open} onOpenChange={setOpen}>
|
<Collapsible open={open} onOpenChange={setOpen} className="overflow-hidden rounded-lg border border-border/70">
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-border/70 px-3 py-2 text-left">
|
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Variables</p>
|
<p className="text-sm font-medium">Variables</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|
@ -75,9 +82,9 @@ export function RoutineVariablesEditor({
|
||||||
</div>
|
</div>
|
||||||
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="space-y-3 pt-3">
|
<CollapsibleContent className="divide-y divide-border/70 border-t border-border/70">
|
||||||
{syncedVariables.map((variable) => (
|
{syncedVariables.map((variable) => (
|
||||||
<div key={variable.name} className="rounded-lg border border-border/70 p-4">
|
<div key={variable.name} className="p-4">
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
{`{{${variable.name}}}`}
|
{`{{${variable.name}}}`}
|
||||||
|
|
@ -225,10 +232,108 @@ export function RoutineVariablesEditor({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BuiltinVariableDoc = {
|
||||||
|
name: string;
|
||||||
|
example: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUILTIN_VARIABLE_DOCS: BuiltinVariableDoc[] = [
|
||||||
|
{
|
||||||
|
name: "date",
|
||||||
|
example: "2026-04-28",
|
||||||
|
description: "Current date in YYYY-MM-DD format (UTC) at the time the routine runs.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "timestamp",
|
||||||
|
example: "April 28, 2026 at 12:17 PM UTC",
|
||||||
|
description: "Human-readable date and time (UTC) at the time the routine runs.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function RoutineVariablesHint() {
|
export function RoutineVariablesHint() {
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground">
|
<>
|
||||||
Use `{"{{variable_name}}"}` placeholders in the instructions to prompt for inputs when the routine runs.
|
<div className="flex items-center justify-between gap-2 rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground">
|
||||||
</div>
|
<span>
|
||||||
|
Use `{"{{variable_name}}"}` placeholders in the instructions to prompt for inputs when the routine runs.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHelpOpen(true)}
|
||||||
|
className="shrink-0 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
aria-label="Show variable help"
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={helpOpen} onOpenChange={setHelpOpen}>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Routine variables</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
How to prompt for inputs and which variables Paperclip fills in automatically.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5 text-sm">
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Custom variables
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Type{" "}
|
||||||
|
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground">
|
||||||
|
{"{{variable_name}}"}
|
||||||
|
</code>{" "}
|
||||||
|
anywhere in the title or instructions. Paperclip detects each placeholder, lists it
|
||||||
|
under <span className="font-medium text-foreground">Variables</span>, and prompts
|
||||||
|
for a value before each run.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc space-y-1 pl-5 text-muted-foreground">
|
||||||
|
<li>Names must start with a letter and may use letters, numbers, and underscores.</li>
|
||||||
|
<li>Pick a type (text, textarea, number, boolean, select), default value, and whether it is required.</li>
|
||||||
|
<li>The same name reused across the title and instructions is treated as one variable.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Built-in variables
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
These are filled in automatically — no setup needed and they will not appear in the
|
||||||
|
Variables list.
|
||||||
|
</p>
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border/70">
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead className="bg-muted/40 text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 font-medium">Placeholder</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Example</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/70">
|
||||||
|
{BUILTIN_VARIABLE_DOCS.map((entry) => (
|
||||||
|
<tr key={entry.name} className="align-top">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">{`{{${entry.name}}}`}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-muted-foreground">{entry.example}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{entry.description}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ vi.mock("../context/DialogContext", () => ({
|
||||||
useDialog: () => ({
|
useDialog: () => ({
|
||||||
openNewIssue: vi.fn(),
|
openNewIssue: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useDialogActions: () => ({
|
||||||
|
openNewIssue: vi.fn(),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../context/CompanyContext", () => ({
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { SidebarSection } from "./SidebarSection";
|
||||||
import { SidebarNavItem } from "./SidebarNavItem";
|
import { SidebarNavItem } from "./SidebarNavItem";
|
||||||
import { SidebarProjects } from "./SidebarProjects";
|
import { SidebarProjects } from "./SidebarProjects";
|
||||||
import { SidebarAgents } from "./SidebarAgents";
|
import { SidebarAgents } from "./SidebarAgents";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
|
|
@ -29,7 +29,7 @@ import { PluginSlotOutlet } from "@/plugins/slots";
|
||||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialogActions();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||||
const { data: experimentalSettings } = useQuery({
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ vi.mock("../context/DialogContext", () => ({
|
||||||
useDialog: () => ({
|
useDialog: () => ({
|
||||||
openNewAgent: mockOpenNewAgent,
|
openNewAgent: mockOpenNewAgent,
|
||||||
}),
|
}),
|
||||||
|
useDialogActions: () => ({
|
||||||
|
openNewAgent: mockOpenNewAgent,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../context/SidebarContext", () => ({
|
vi.mock("../context/SidebarContext", () => ({
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { useToastActions } from "../context/ToastContext";
|
import { useToastActions } from "../context/ToastContext";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
|
@ -158,7 +158,7 @@ export function SidebarAgents() {
|
||||||
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewAgent } = useDialog();
|
const { openNewAgent } = useDialogActions();
|
||||||
const { isMobile, setSidebarOpen } = useSidebar();
|
const { isMobile, setSidebarOpen } = useSidebar();
|
||||||
const { pushToast } = useToastActions();
|
const { pushToast } = useToastActions();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
|
@ -124,7 +124,7 @@ function SortableProjectItem({
|
||||||
export function SidebarProjects() {
|
export function SidebarProjects() {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||||
const { openNewProject } = useDialog();
|
const { openNewProject } = useDialogActions();
|
||||||
const { isMobile, setSidebarOpen } = useSidebar();
|
const { isMobile, setSidebarOpen } = useSidebar();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
|
|
||||||
58
ui/src/context/DialogContext.test.tsx
Normal file
58
ui/src/context/DialogContext.test.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { DialogProvider, useDialogActions, useDialogState } from "./DialogContext";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("DialogContext", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps action-only consumers from rerendering when dialog state changes", () => {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
let actionRenderCount = 0;
|
||||||
|
let stateRenderCount = 0;
|
||||||
|
|
||||||
|
function ActionOnlyConsumer() {
|
||||||
|
actionRenderCount += 1;
|
||||||
|
const { openNewIssue } = useDialogActions();
|
||||||
|
return <button onClick={() => openNewIssue()}>Open issue</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StateConsumer() {
|
||||||
|
stateRenderCount += 1;
|
||||||
|
const { newIssueOpen } = useDialogState();
|
||||||
|
return <span>{newIssueOpen ? "open" : "closed"}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<DialogProvider>
|
||||||
|
<ActionOnlyConsumer />
|
||||||
|
<StateConsumer />
|
||||||
|
</DialogProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(actionRenderCount).toBe(1);
|
||||||
|
expect(stateRenderCount).toBe(1);
|
||||||
|
|
||||||
|
const button = host.querySelector("button");
|
||||||
|
act(() => {
|
||||||
|
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain("open");
|
||||||
|
expect(actionRenderCount).toBe(1);
|
||||||
|
expect(stateRenderCount).toBe(2);
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
interface NewIssueDefaults {
|
interface NewIssueDefaults {
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
@ -48,7 +48,22 @@ interface DialogContextValue {
|
||||||
closeOnboarding: () => void;
|
closeOnboarding: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DialogContext = createContext<DialogContextValue | null>(null);
|
type DialogStateValue = Pick<
|
||||||
|
DialogContextValue,
|
||||||
|
| "newIssueOpen"
|
||||||
|
| "newIssueDefaults"
|
||||||
|
| "newProjectOpen"
|
||||||
|
| "newGoalOpen"
|
||||||
|
| "newGoalDefaults"
|
||||||
|
| "newAgentOpen"
|
||||||
|
| "onboardingOpen"
|
||||||
|
| "onboardingOptions"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type DialogActionsValue = Omit<DialogContextValue, keyof DialogStateValue>;
|
||||||
|
|
||||||
|
const DialogStateContext = createContext<DialogStateValue | null>(null);
|
||||||
|
const DialogActionsContext = createContext<DialogActionsValue | null>(null);
|
||||||
|
|
||||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||||
const [newIssueOpen, setNewIssueOpen] = useState(false);
|
const [newIssueOpen, setNewIssueOpen] = useState(false);
|
||||||
|
|
@ -106,38 +121,84 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||||
setOnboardingOptions({});
|
setOnboardingOptions({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const stateValue = useMemo<DialogStateValue>(
|
||||||
|
() => ({
|
||||||
|
newIssueOpen,
|
||||||
|
newIssueDefaults,
|
||||||
|
newProjectOpen,
|
||||||
|
newGoalOpen,
|
||||||
|
newGoalDefaults,
|
||||||
|
newAgentOpen,
|
||||||
|
onboardingOpen,
|
||||||
|
onboardingOptions,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
newIssueOpen,
|
||||||
|
newIssueDefaults,
|
||||||
|
newProjectOpen,
|
||||||
|
newGoalOpen,
|
||||||
|
newGoalDefaults,
|
||||||
|
newAgentOpen,
|
||||||
|
onboardingOpen,
|
||||||
|
onboardingOptions,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionsValue = useMemo<DialogActionsValue>(
|
||||||
|
() => ({
|
||||||
|
openNewIssue,
|
||||||
|
closeNewIssue,
|
||||||
|
openNewProject,
|
||||||
|
closeNewProject,
|
||||||
|
openNewGoal,
|
||||||
|
closeNewGoal,
|
||||||
|
openNewAgent,
|
||||||
|
closeNewAgent,
|
||||||
|
openOnboarding,
|
||||||
|
closeOnboarding,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
openNewIssue,
|
||||||
|
closeNewIssue,
|
||||||
|
openNewProject,
|
||||||
|
closeNewProject,
|
||||||
|
openNewGoal,
|
||||||
|
closeNewGoal,
|
||||||
|
openNewAgent,
|
||||||
|
closeNewAgent,
|
||||||
|
openOnboarding,
|
||||||
|
closeOnboarding,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider
|
<DialogActionsContext.Provider value={actionsValue}>
|
||||||
value={{
|
<DialogStateContext.Provider value={stateValue}>
|
||||||
newIssueOpen,
|
{children}
|
||||||
newIssueDefaults,
|
</DialogStateContext.Provider>
|
||||||
openNewIssue,
|
</DialogActionsContext.Provider>
|
||||||
closeNewIssue,
|
|
||||||
newProjectOpen,
|
|
||||||
openNewProject,
|
|
||||||
closeNewProject,
|
|
||||||
newGoalOpen,
|
|
||||||
newGoalDefaults,
|
|
||||||
openNewGoal,
|
|
||||||
closeNewGoal,
|
|
||||||
newAgentOpen,
|
|
||||||
openNewAgent,
|
|
||||||
closeNewAgent,
|
|
||||||
onboardingOpen,
|
|
||||||
onboardingOptions,
|
|
||||||
openOnboarding,
|
|
||||||
closeOnboarding,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DialogContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDialog() {
|
export function useDialogActions() {
|
||||||
const ctx = useContext(DialogContext);
|
const ctx = useContext(DialogActionsContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error("useDialog must be used within DialogProvider");
|
throw new Error("useDialogActions must be used within DialogProvider");
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDialogState() {
|
||||||
|
const ctx = useContext(DialogStateContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useDialogState must be used within DialogProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDialog() {
|
||||||
|
return {
|
||||||
|
...useDialogState(),
|
||||||
|
...useDialogActions(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,10 @@ export function useToastActions() {
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useOptionalToastActions() {
|
||||||
|
return useContext(ToastActionsContext);
|
||||||
|
}
|
||||||
|
|
||||||
export function useToast() {
|
export function useToast() {
|
||||||
const toasts = useToastState();
|
const toasts = useToastState();
|
||||||
const actions = useToastActions();
|
const actions = useToastActions();
|
||||||
|
|
|
||||||
47
ui/src/fixtures/issueChatLongThreadFixture.test.ts
Normal file
47
ui/src/fixtures/issueChatLongThreadFixture.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
issueChatLongThreadAgentMap,
|
||||||
|
issueChatLongThreadComments,
|
||||||
|
issueChatLongThreadEvents,
|
||||||
|
issueChatLongThreadLinkedRuns,
|
||||||
|
issueChatLongThreadMarkdownCommentIds,
|
||||||
|
issueChatLongThreadTranscriptsByRunId,
|
||||||
|
LONG_THREAD_COMMENT_COUNT,
|
||||||
|
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
|
||||||
|
} from "./issueChatLongThreadFixture";
|
||||||
|
import { buildIssueChatMessages } from "../lib/issue-chat-messages";
|
||||||
|
|
||||||
|
describe("issueChatLongThreadFixture", () => {
|
||||||
|
it("builds a deterministic long issue-thread shape", () => {
|
||||||
|
const messages = buildIssueChatMessages({
|
||||||
|
comments: issueChatLongThreadComments,
|
||||||
|
timelineEvents: issueChatLongThreadEvents,
|
||||||
|
linkedRuns: issueChatLongThreadLinkedRuns,
|
||||||
|
liveRuns: [],
|
||||||
|
agentMap: issueChatLongThreadAgentMap,
|
||||||
|
currentUserId: "user-board",
|
||||||
|
transcriptsByRunId: issueChatLongThreadTranscriptsByRunId,
|
||||||
|
hasOutputForRun: (runId) => issueChatLongThreadTranscriptsByRunId.has(runId),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issueChatLongThreadComments).toHaveLength(LONG_THREAD_COMMENT_COUNT);
|
||||||
|
expect(issueChatLongThreadMarkdownCommentIds.size).toBe(LONG_THREAD_MARKDOWN_COMMENT_COUNT);
|
||||||
|
expect(messages.length).toBeGreaterThanOrEqual(450);
|
||||||
|
expect(messages.filter((message) => message.role === "assistant").length).toBeGreaterThanOrEqual(
|
||||||
|
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps markdown rows markdown-heavy enough to exercise MarkdownBody", () => {
|
||||||
|
const markdownComments = issueChatLongThreadComments.filter((comment) =>
|
||||||
|
issueChatLongThreadMarkdownCommentIds.has(comment.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markdownComments).toHaveLength(LONG_THREAD_MARKDOWN_COMMENT_COUNT);
|
||||||
|
for (const comment of markdownComments.slice(0, 5)) {
|
||||||
|
expect(comment.body).toContain("## Baseline note");
|
||||||
|
expect(comment.body).toContain("```ts");
|
||||||
|
expect(comment.body).toContain("| Metric | Value |");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
214
ui/src/fixtures/issueChatLongThreadFixture.ts
Normal file
214
ui/src/fixtures/issueChatLongThreadFixture.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
import type { LiveRunForIssue } from "../api/heartbeats";
|
||||||
|
import type {
|
||||||
|
IssueChatComment,
|
||||||
|
IssueChatLinkedRun,
|
||||||
|
IssueChatTranscriptEntry,
|
||||||
|
} from "../lib/issue-chat-messages";
|
||||||
|
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||||
|
|
||||||
|
export const LONG_THREAD_COMMENT_COUNT = 469;
|
||||||
|
export const LONG_THREAD_MARKDOWN_COMMENT_COUNT = 150;
|
||||||
|
export const LONG_THREAD_EVENT_COUNT = 12;
|
||||||
|
export const LONG_THREAD_LINKED_RUN_COUNT = 6;
|
||||||
|
|
||||||
|
const baseTime = new Date("2026-04-28T14:00:00.000Z").getTime();
|
||||||
|
|
||||||
|
function atMinute(offset: number) {
|
||||||
|
return new Date(baseTime + offset * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgent(id: string, name: string, icon: string, urlKey: string): Agent {
|
||||||
|
const now = new Date("2026-04-28T14:00:00.000Z");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
companyId: "company-long-thread",
|
||||||
|
name,
|
||||||
|
urlKey,
|
||||||
|
role: "engineer",
|
||||||
|
title: null,
|
||||||
|
icon,
|
||||||
|
status: "active",
|
||||||
|
reportsTo: null,
|
||||||
|
capabilities: null,
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
spentMonthlyCents: 0,
|
||||||
|
lastHeartbeatAt: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
permissions: { canCreateAgents: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryAgent = createAgent("agent-perf-codex", "CodexCoder", "code", "codexcoder");
|
||||||
|
const reviewerAgent = createAgent("agent-perf-reviewer", "ReviewBot", "sparkles", "reviewbot");
|
||||||
|
|
||||||
|
export const issueChatLongThreadAgentMap = new Map<string, Agent>([
|
||||||
|
[primaryAgent.id, primaryAgent],
|
||||||
|
[reviewerAgent.id, reviewerAgent],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function markdownBody(index: number) {
|
||||||
|
return [
|
||||||
|
`## Baseline note ${index}`,
|
||||||
|
"",
|
||||||
|
`This assistant update captures a deterministic markdown-heavy row for long-thread rendering. It references [PAP-${2600 + index}](/PAP/issues/PAP-${2600 + index}) and includes enough structure to exercise markdown parsing.`,
|
||||||
|
"",
|
||||||
|
"- Parsed checklist item one with inline `code`",
|
||||||
|
"- Parsed checklist item two with **bold** and _italic_ text",
|
||||||
|
"- Parsed checklist item three with a link to [Paperclip](/PAP/dashboard)",
|
||||||
|
"",
|
||||||
|
"| Metric | Value |",
|
||||||
|
"| --- | ---: |",
|
||||||
|
`| Fixture row | ${index} |`,
|
||||||
|
`| Synthetic tokens | ${1200 + index} |`,
|
||||||
|
"",
|
||||||
|
"```ts",
|
||||||
|
`const fixtureRow${index} = { markdown: true, deterministic: true };`,
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainUserBody(index: number) {
|
||||||
|
return `Board checkpoint ${index}: keep the issue-detail page responsive while the thread is full of historical comments.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainAssistantBody(index: number) {
|
||||||
|
return `Processed checkpoint ${index}. The current direct-render path should keep this row mounted with the rest of the thread.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createComment(index: number): IssueChatComment {
|
||||||
|
const isMarkdown = index < LONG_THREAD_MARKDOWN_COMMENT_COUNT;
|
||||||
|
const isAssistant = isMarkdown || index % 4 === 1 || index % 4 === 2;
|
||||||
|
const authorAgentId = isAssistant
|
||||||
|
? (index % 7 === 0 ? reviewerAgent.id : primaryAgent.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `long-thread-comment-${String(index + 1).padStart(3, "0")}`,
|
||||||
|
companyId: "company-long-thread",
|
||||||
|
issueId: "issue-long-thread",
|
||||||
|
authorAgentId,
|
||||||
|
authorUserId: authorAgentId ? null : "user-board",
|
||||||
|
body: isMarkdown
|
||||||
|
? markdownBody(index + 1)
|
||||||
|
: authorAgentId
|
||||||
|
? plainAssistantBody(index + 1)
|
||||||
|
: plainUserBody(index + 1),
|
||||||
|
createdAt: atMinute(index),
|
||||||
|
updatedAt: atMinute(index),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const issueChatLongThreadComments: IssueChatComment[] = Array.from(
|
||||||
|
{ length: LONG_THREAD_COMMENT_COUNT },
|
||||||
|
(_, index) => createComment(index),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const issueChatLongThreadMarkdownCommentIds = new Set(
|
||||||
|
issueChatLongThreadComments
|
||||||
|
.slice(0, LONG_THREAD_MARKDOWN_COMMENT_COUNT)
|
||||||
|
.map((comment) => comment.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const issueChatLongThreadEvents: IssueTimelineEvent[] = Array.from(
|
||||||
|
{ length: LONG_THREAD_EVENT_COUNT },
|
||||||
|
(_, index) => ({
|
||||||
|
id: `long-thread-event-${index + 1}`,
|
||||||
|
createdAt: atMinute(index * 36 + 18),
|
||||||
|
actorType: index % 3 === 0 ? "user" : "agent",
|
||||||
|
actorId: index % 3 === 0 ? "user-board" : primaryAgent.id,
|
||||||
|
statusChange: index % 2 === 0
|
||||||
|
? { from: index === 0 ? "todo" : "in_progress", to: "in_progress" }
|
||||||
|
: undefined,
|
||||||
|
assigneeChange: index % 2 === 1
|
||||||
|
? {
|
||||||
|
from: { agentId: null, userId: null },
|
||||||
|
to: { agentId: index % 4 === 1 ? primaryAgent.id : reviewerAgent.id, userId: null },
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const issueChatLongThreadLinkedRuns: IssueChatLinkedRun[] = Array.from(
|
||||||
|
{ length: LONG_THREAD_LINKED_RUN_COUNT },
|
||||||
|
(_, index) => ({
|
||||||
|
runId: `long-thread-run-${index + 1}`,
|
||||||
|
status: index % 3 === 0 ? "failed" : index % 3 === 1 ? "timed_out" : "succeeded",
|
||||||
|
agentId: index % 2 === 0 ? primaryAgent.id : reviewerAgent.id,
|
||||||
|
agentName: index % 2 === 0 ? primaryAgent.name : reviewerAgent.name,
|
||||||
|
adapterType: "codex_local",
|
||||||
|
createdAt: atMinute(index * 72 + 12),
|
||||||
|
startedAt: atMinute(index * 72 + 12),
|
||||||
|
finishedAt: atMinute(index * 72 + 16),
|
||||||
|
hasStoredOutput: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const issueChatLongThreadLiveRuns: LiveRunForIssue[] = [];
|
||||||
|
|
||||||
|
export const issueChatLongThreadTranscriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>(
|
||||||
|
issueChatLongThreadLinkedRuns.map((run, index) => [
|
||||||
|
run.runId,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: "thinking",
|
||||||
|
ts: atMinute(index * 72 + 13).toISOString(),
|
||||||
|
text: `Inspecting long-thread segment ${index + 1}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts: atMinute(index * 72 + 14).toISOString(),
|
||||||
|
name: "read_file",
|
||||||
|
toolUseId: `long-thread-tool-${index + 1}`,
|
||||||
|
input: { path: "ui/src/components/IssueChatThread.tsx" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: atMinute(index * 72 + 15).toISOString(),
|
||||||
|
toolUseId: `long-thread-tool-${index + 1}`,
|
||||||
|
content: "Confirmed the direct-render fixture keeps the full message subtree mounted.",
|
||||||
|
isError: run.status !== "succeeded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "assistant",
|
||||||
|
ts: atMinute(index * 72 + 16).toISOString(),
|
||||||
|
text: `Run ${index + 1} produced a compact transcript row for adjacent run context.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const issueChatLongThreadFixtureContext = {
|
||||||
|
issue: {
|
||||||
|
identifier: "PAP-PERF",
|
||||||
|
title: "Long-thread rendering baseline fixture",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "medium",
|
||||||
|
projectName: "Paperclip App",
|
||||||
|
},
|
||||||
|
documents: [
|
||||||
|
"Implementation Plan",
|
||||||
|
"Profiler Notes",
|
||||||
|
"Release Checklist",
|
||||||
|
"QA Readout",
|
||||||
|
],
|
||||||
|
subIssues: [
|
||||||
|
"Phase 1: Add long-thread perf fixture and baseline",
|
||||||
|
"Phase 2: Isolate issue-thread row rendering and Markdown work",
|
||||||
|
"Phase 3: Apply virtualization and guard scroll behavior",
|
||||||
|
"Phase 4: Verify production issue profile improvement",
|
||||||
|
],
|
||||||
|
sidebarStats: [
|
||||||
|
["Comments", String(LONG_THREAD_COMMENT_COUNT)],
|
||||||
|
["Markdown bodies", String(LONG_THREAD_MARKDOWN_COMMENT_COUNT)],
|
||||||
|
["Timeline events", String(LONG_THREAD_EVENT_COUNT)],
|
||||||
|
["Linked runs", String(LONG_THREAD_LINKED_RUN_COUNT)],
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
@ -442,13 +442,15 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
margin: 0 0.1rem;
|
margin: 0 0.1rem;
|
||||||
padding: 0.05rem 0.45rem;
|
padding: 0 0.45rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.3;
|
line-height: 1.25;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
vertical-align: baseline;
|
vertical-align: middle;
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
@ -860,15 +862,19 @@ span.paperclip-project-mention-chip {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
margin: 0 0.1rem;
|
margin: 0 0.1rem;
|
||||||
padding: 0.05rem 0.45rem;
|
padding: 0 0.45rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.3;
|
line-height: 1.25;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
/* Align the pill relative to the surrounding text baseline instead of its
|
/* Center the pill on the surrounding x-height so it sits on the text line
|
||||||
x-height midpoint so it sits on the text line rather than floating above. */
|
instead of hanging below it. inline-flex baseline alignment is unreliable
|
||||||
vertical-align: baseline;
|
across browsers, so use vertical-align: middle for a predictable result.
|
||||||
|
Nudge up 1px so it visually centers with the cap height of the text. */
|
||||||
|
vertical-align: middle;
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { usePanel } from "../context/PanelContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useToastActions } from "../context/ToastContext";
|
import { useToastActions } from "../context/ToastContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||||
|
|
@ -626,7 +626,7 @@ export function AgentDetail() {
|
||||||
}>();
|
}>();
|
||||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||||
const { closePanel } = usePanel();
|
const { closePanel } = usePanel();
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialogActions();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
import { agentsApi, type OrgNode } from "../api/agents";
|
import { agentsApi, type OrgNode } from "../api/agents";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
@ -55,7 +55,7 @@ function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean
|
||||||
|
|
||||||
export function Agents() {
|
export function Agents() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewAgent } = useDialog();
|
const { openNewAgent } = useDialogActions();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
@ -36,7 +36,7 @@ export function Companies() {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
} = useCompany();
|
} = useCompany();
|
||||||
const { openOnboarding } = useDialog();
|
const { openOnboarding } = useDialogActions();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { buildCompanyUserProfileMap } from "../lib/company-members";
|
import { buildCompanyUserProfileMap } from "../lib/company-members";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { MetricCard } from "../components/MetricCard";
|
import { MetricCard } from "../components/MetricCard";
|
||||||
|
|
@ -36,7 +36,7 @@ function getRecentIssues(issues: Issue[]): Issue[] {
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { selectedCompanyId, companies } = useCompany();
|
const { selectedCompanyId, companies } = useCompany();
|
||||||
const { openOnboarding } = useDialog();
|
const { openOnboarding } = useDialogActions();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
|
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
|
||||||
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { projectsApi } from "../api/projects";
|
||||||
import { assetsApi } from "../api/assets";
|
import { assetsApi } from "../api/assets";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { GoalProperties } from "../components/GoalProperties";
|
import { GoalProperties } from "../components/GoalProperties";
|
||||||
|
|
@ -49,7 +49,7 @@ export function GoalPropertiesToggleButton({
|
||||||
export function GoalDetail() {
|
export function GoalDetail() {
|
||||||
const { goalId } = useParams<{ goalId: string }>();
|
const { goalId } = useParams<{ goalId: string }>();
|
||||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||||
const { openNewGoal } = useDialog();
|
const { openNewGoal } = useDialogActions();
|
||||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { GoalTree } from "../components/GoalTree";
|
import { GoalTree } from "../components/GoalTree";
|
||||||
|
|
@ -13,7 +13,7 @@ import { Target, Plus } from "lucide-react";
|
||||||
|
|
||||||
export function Goals() {
|
export function Goals() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewGoal } = useDialog();
|
const { openNewGoal } = useDialogActions();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
213
ui/src/pages/IssueChatLongThreadPerf.tsx
Normal file
213
ui/src/pages/IssueChatLongThreadPerf.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
import { Profiler, useEffect, useLayoutEffect, useMemo, useRef, useState, type ProfilerOnRenderCallback } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { IssueChatThread } from "../components/IssueChatThread";
|
||||||
|
import {
|
||||||
|
issueChatLongThreadAgentMap,
|
||||||
|
issueChatLongThreadComments,
|
||||||
|
issueChatLongThreadEvents,
|
||||||
|
issueChatLongThreadFixtureContext,
|
||||||
|
issueChatLongThreadLinkedRuns,
|
||||||
|
issueChatLongThreadLiveRuns,
|
||||||
|
issueChatLongThreadMarkdownCommentIds,
|
||||||
|
issueChatLongThreadTranscriptsByRunId,
|
||||||
|
LONG_THREAD_COMMENT_COUNT,
|
||||||
|
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
|
||||||
|
} from "../fixtures/issueChatLongThreadFixture";
|
||||||
|
|
||||||
|
const noop = async () => {};
|
||||||
|
|
||||||
|
type RenderMetrics = {
|
||||||
|
commitCount: number;
|
||||||
|
mountActualDuration: number | null;
|
||||||
|
latestActualDuration: number | null;
|
||||||
|
maxActualDuration: number;
|
||||||
|
totalActualDuration: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialMetrics: RenderMetrics = {
|
||||||
|
commitCount: 0,
|
||||||
|
mountActualDuration: null,
|
||||||
|
latestActualDuration: null,
|
||||||
|
maxActualDuration: 0,
|
||||||
|
totalActualDuration: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatMs(value: number | null) {
|
||||||
|
if (value === null || !Number.isFinite(value)) return "pending";
|
||||||
|
return `${value.toFixed(1)} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricTile({ label, value, testId }: { label: string; value: string; testId: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-background px-3 py-2">
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div data-testid={testId} className="mt-1 font-mono text-sm text-foreground">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueChatLongThreadPerf() {
|
||||||
|
const [metrics, setMetrics] = useState<RenderMetrics>(initialMetrics);
|
||||||
|
const metricsRef = useRef<RenderMetrics>(initialMetrics);
|
||||||
|
const renderStartedAtRef = useRef(performance.now());
|
||||||
|
const publishTimerRef = useRef<number | null>(null);
|
||||||
|
const publishedRef = useRef(false);
|
||||||
|
const fixture = issueChatLongThreadFixtureContext;
|
||||||
|
const rowTarget = useMemo(
|
||||||
|
() => LONG_THREAD_COMMENT_COUNT + issueChatLongThreadEvents.length + issueChatLongThreadLinkedRuns.length,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (publishTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(publishTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (publishedRef.current || metricsRef.current.commitCount > 0) return;
|
||||||
|
const mountDuration = performance.now() - renderStartedAtRef.current;
|
||||||
|
const next = {
|
||||||
|
commitCount: 1,
|
||||||
|
mountActualDuration: mountDuration,
|
||||||
|
latestActualDuration: mountDuration,
|
||||||
|
maxActualDuration: mountDuration,
|
||||||
|
totalActualDuration: mountDuration,
|
||||||
|
};
|
||||||
|
metricsRef.current = next;
|
||||||
|
publishedRef.current = true;
|
||||||
|
setMetrics(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRender: ProfilerOnRenderCallback = (_id, phase, actualDuration) => {
|
||||||
|
const current = metricsRef.current;
|
||||||
|
metricsRef.current = {
|
||||||
|
commitCount: current.commitCount + 1,
|
||||||
|
mountActualDuration: phase === "mount" && current.mountActualDuration === null
|
||||||
|
? actualDuration
|
||||||
|
: current.mountActualDuration,
|
||||||
|
latestActualDuration: actualDuration,
|
||||||
|
maxActualDuration: Math.max(current.maxActualDuration, actualDuration),
|
||||||
|
totalActualDuration: current.totalActualDuration + actualDuration,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (publishedRef.current || publishTimerRef.current !== null) return;
|
||||||
|
publishTimerRef.current = window.setTimeout(() => {
|
||||||
|
publishTimerRef.current = null;
|
||||||
|
publishedRef.current = true;
|
||||||
|
setMetrics(metricsRef.current);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="issue-chat-long-thread-perf" className="space-y-5">
|
||||||
|
<div className="flex flex-col gap-3 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono text-[11px]">
|
||||||
|
{fixture.issue.identifier}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">{fixture.issue.status.replace(/_/g, " ")}</Badge>
|
||||||
|
<Badge variant="outline">{fixture.issue.projectName}</Badge>
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-2xl font-semibold tracking-tight">{fixture.issue.title}</h1>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||||
|
Deterministic local fixture for measuring the current direct-render issue chat path with
|
||||||
|
hundreds of merged thread rows, markdown-heavy assistant bodies, linked runs, documents,
|
||||||
|
sub-issues, and sidebar context.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid min-w-[280px] grid-cols-2 gap-2">
|
||||||
|
<MetricTile label="Fixture rows" value={String(rowTarget)} testId="perf-fixture-row-target" />
|
||||||
|
<MetricTile label="Markdown rows" value={String(LONG_THREAD_MARKDOWN_COMMENT_COUNT)} testId="perf-fixture-markdown-rows" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<main className="min-w-0 space-y-4">
|
||||||
|
<Card className="border-border/70">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Issue documents</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{fixture.documents.map((document) => (
|
||||||
|
<div key={document} className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||||
|
{document}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/70">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Sub-issues</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{fixture.subIssues.map((subIssue, index) => (
|
||||||
|
<div key={subIssue} className="flex items-center gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">#{index + 1}</span>
|
||||||
|
<span>{subIssue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Profiler id="issue-chat-long-thread" onRender={handleRender}>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={issueChatLongThreadComments}
|
||||||
|
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||||
|
timelineEvents={issueChatLongThreadEvents}
|
||||||
|
liveRuns={issueChatLongThreadLiveRuns}
|
||||||
|
issueStatus="in_progress"
|
||||||
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
|
currentUserId="user-board"
|
||||||
|
onAdd={noop}
|
||||||
|
showComposer={false}
|
||||||
|
showJumpToLatest={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||||
|
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||||
|
/>
|
||||||
|
</Profiler>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside className="space-y-4 xl:sticky xl:top-4 xl:self-start">
|
||||||
|
<Card className="border-border/70">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Baseline metrics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2">
|
||||||
|
<MetricTile label="Profiler commits" value={String(metrics.commitCount)} testId="perf-commit-count" />
|
||||||
|
<MetricTile label="Mount duration" value={formatMs(metrics.mountActualDuration)} testId="perf-mount-duration" />
|
||||||
|
<MetricTile label="Latest duration" value={formatMs(metrics.latestActualDuration)} testId="perf-latest-duration" />
|
||||||
|
<MetricTile label="Max duration" value={formatMs(metrics.maxActualDuration)} testId="perf-max-duration" />
|
||||||
|
<MetricTile label="Total duration" value={formatMs(metrics.totalActualDuration)} testId="perf-total-duration" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/70">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Fixture shape</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{fixture.sidebarStats.map(([label, value]) => (
|
||||||
|
<div key={label} className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-mono">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="hidden" data-testid="perf-markdown-comment-id-sample">
|
||||||
|
{[...issueChatLongThreadMarkdownCommentIds].slice(0, 3).join(",")}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -134,6 +134,9 @@ vi.mock("../context/DialogContext", () => ({
|
||||||
useDialog: () => ({
|
useDialog: () => ({
|
||||||
openNewIssue: vi.fn(),
|
openNewIssue: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useDialogActions: () => ({
|
||||||
|
openNewIssue: vi.fn(),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../context/PanelContext", () => ({
|
vi.mock("../context/PanelContext", () => ({
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { agentsApi } from "../api/agents";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { useToastActions } from "../context/ToastContext";
|
import { useToastActions } from "../context/ToastContext";
|
||||||
|
|
@ -77,6 +77,7 @@ import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
||||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
|
import { ProductivityReviewBadge } from "../components/ProductivityReviewBadge";
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||||
|
|
@ -108,6 +109,7 @@ import {
|
||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Hexagon,
|
Hexagon,
|
||||||
ListTree,
|
ListTree,
|
||||||
|
|
@ -558,6 +560,7 @@ type IssueDetailChatTabProps = {
|
||||||
hasOlderComments: boolean;
|
hasOlderComments: boolean;
|
||||||
commentsLoadingOlder: boolean;
|
commentsLoadingOlder: boolean;
|
||||||
onLoadOlderComments: () => void;
|
onLoadOlderComments: () => void;
|
||||||
|
onRefreshLatestComments: () => Promise<unknown> | void;
|
||||||
composerRef: Ref<IssueChatComposerHandle>;
|
composerRef: Ref<IssueChatComposerHandle>;
|
||||||
feedbackVotes?: FeedbackVote[];
|
feedbackVotes?: FeedbackVote[];
|
||||||
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
|
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
|
||||||
|
|
@ -611,6 +614,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||||
hasOlderComments,
|
hasOlderComments,
|
||||||
commentsLoadingOlder,
|
commentsLoadingOlder,
|
||||||
onLoadOlderComments,
|
onLoadOlderComments,
|
||||||
|
onRefreshLatestComments,
|
||||||
composerRef,
|
composerRef,
|
||||||
feedbackVotes,
|
feedbackVotes,
|
||||||
feedbackDataSharingPreference,
|
feedbackDataSharingPreference,
|
||||||
|
|
@ -835,6 +839,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||||
}
|
}
|
||||||
: undefined}
|
: undefined}
|
||||||
onImageClick={onImageClick}
|
onImageClick={onImageClick}
|
||||||
|
onRefreshLatestComments={onRefreshLatestComments}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1023,7 +1028,7 @@ function IssueDetailActivityTab({
|
||||||
export function IssueDetail() {
|
export function IssueDetail() {
|
||||||
const { issueId } = useParams<{ issueId: string }>();
|
const { issueId } = useParams<{ issueId: string }>();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialogActions();
|
||||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||||
const { setBreadcrumbs, setMobileToolbar } = useBreadcrumbs();
|
const { setBreadcrumbs, setMobileToolbar } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -1090,6 +1095,7 @@ export function IssueDetail() {
|
||||||
isFetchingNextPage: commentsLoadingOlder,
|
isFetchingNextPage: commentsLoadingOlder,
|
||||||
hasNextPage: hasOlderComments,
|
hasNextPage: hasOlderComments,
|
||||||
fetchNextPage: fetchOlderComments,
|
fetchNextPage: fetchOlderComments,
|
||||||
|
refetch: refetchComments,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
queryKey: queryKeys.issues.comments(issueId!),
|
queryKey: queryKeys.issues.comments(issueId!),
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
|
|
@ -2554,6 +2560,13 @@ export function IssueDetail() {
|
||||||
const loadOlderComments = useCallback(() => {
|
const loadOlderComments = useCallback(() => {
|
||||||
void fetchOlderComments();
|
void fetchOlderComments();
|
||||||
}, [fetchOlderComments]);
|
}, [fetchOlderComments]);
|
||||||
|
const refetchLatestComments = useCallback(async () => {
|
||||||
|
// Refetch the entire infinite-query (page 0 first), so any comments that
|
||||||
|
// arrived after the initial load — including ones live updates may have
|
||||||
|
// missed during reconnects — are present before we scroll the user to
|
||||||
|
// the absolute newest.
|
||||||
|
await refetchComments();
|
||||||
|
}, [refetchComments]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldPrefetchOlderComments) return;
|
if (!shouldPrefetchOlderComments) return;
|
||||||
void fetchOlderComments();
|
void fetchOlderComments();
|
||||||
|
|
@ -2906,6 +2919,20 @@ export function IssueDetail() {
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{issue.productivityReview ? (
|
||||||
|
<ProductivityReviewBadge review={issue.productivityReview} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{issue.originKind === "issue_productivity_review" ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
|
||||||
|
title="This task is a productivity review."
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
Productivity review
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{issue.projectId ? (
|
{issue.projectId ? (
|
||||||
<Link
|
<Link
|
||||||
to={`/projects/${issue.projectId}`}
|
to={`/projects/${issue.projectId}`}
|
||||||
|
|
@ -3402,6 +3429,7 @@ export function IssueDetail() {
|
||||||
hasOlderComments={hasOlderComments}
|
hasOlderComments={hasOlderComments}
|
||||||
commentsLoadingOlder={commentsLoadingOlder}
|
commentsLoadingOlder={commentsLoadingOlder}
|
||||||
onLoadOlderComments={loadOlderComments}
|
onLoadOlderComments={loadOlderComments}
|
||||||
|
onRefreshLatestComments={refetchLatestComments}
|
||||||
composerRef={commentComposerRef}
|
composerRef={commentComposerRef}
|
||||||
feedbackVotes={feedbackVotes}
|
feedbackVotes={feedbackVotes}
|
||||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildIssuesSearchUrl } from "./Issues";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
import { buildIssuesSearchUrl, getNextIssuesPageOffset, mergeIssuePagesStable } from "./Issues";
|
||||||
|
|
||||||
|
function createIssue(id: string, title: string): Issue {
|
||||||
|
return { id, title } as Issue;
|
||||||
|
}
|
||||||
|
|
||||||
describe("buildIssuesSearchUrl", () => {
|
describe("buildIssuesSearchUrl", () => {
|
||||||
it("preserves trailing spaces in the synced search param", () => {
|
it("preserves trailing spaces in the synced search param", () => {
|
||||||
|
|
@ -14,3 +19,29 @@ describe("buildIssuesSearchUrl", () => {
|
||||||
expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug+", "bug ")).toBeNull();
|
expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug+", "bug ")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("issues page pagination helpers", () => {
|
||||||
|
it("advances to the next offset when the current page is full", () => {
|
||||||
|
expect(getNextIssuesPageOffset(500, 0)).toBe(500);
|
||||||
|
expect(getNextIssuesPageOffset(500, 500)).toBe(1000);
|
||||||
|
expect(getNextIssuesPageOffset(1000, 2000, 1000)).toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops requesting issue pages when the current page is partial", () => {
|
||||||
|
expect(getNextIssuesPageOffset(499, 0)).toBeUndefined();
|
||||||
|
expect(getNextIssuesPageOffset(999, 2000, 1000)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes overlapping pages without moving the original issue position", () => {
|
||||||
|
const first = createIssue("issue-1", "Original first");
|
||||||
|
const second = createIssue("issue-2", "Second");
|
||||||
|
const duplicateFirst = createIssue("issue-1", "Duplicate first");
|
||||||
|
const third = createIssue("issue-3", "Third");
|
||||||
|
|
||||||
|
expect(mergeIssuePagesStable([[first, second], [duplicateFirst, third]])).toEqual([
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
third,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useMemo, useCallback } from "react";
|
import { useEffect, useMemo, useCallback, useRef, useState } from "react";
|
||||||
import { useLocation, useSearchParams } from "@/lib/router";
|
import { useLocation, useSearchParams } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
|
@ -13,8 +13,33 @@ import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
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";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
|
||||||
const WORKSPACE_FILTER_ISSUE_LIMIT = 1000;
|
const WORKSPACE_FILTER_ISSUE_LIMIT = 1000;
|
||||||
|
const ISSUES_PAGE_SIZE = 500;
|
||||||
|
|
||||||
|
export function getNextIssuesPageOffset(
|
||||||
|
loadedPageSize: number,
|
||||||
|
currentOffset: number,
|
||||||
|
pageSize: number = ISSUES_PAGE_SIZE,
|
||||||
|
): number | undefined {
|
||||||
|
return loadedPageSize >= pageSize ? currentOffset + pageSize : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeIssuePagesStable(pages: Issue[][]): Issue[] {
|
||||||
|
const seenIssueIds = new Set<string>();
|
||||||
|
const merged: Issue[] = [];
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
for (const issue of page) {
|
||||||
|
if (seenIssueIds.has(issue.id)) continue;
|
||||||
|
seenIssueIds.add(issue.id);
|
||||||
|
merged.push(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildIssuesSearchUrl(currentHref: string, search: string): string | null {
|
export function buildIssuesSearchUrl(currentHref: string, search: string): string | null {
|
||||||
const url = new URL(currentHref);
|
const url = new URL(currentHref);
|
||||||
|
|
@ -36,15 +61,27 @@ export function Issues() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const fetchNextPageInFlightRef = useRef(false);
|
||||||
|
|
||||||
const initialSearch = searchParams.get("q") ?? "";
|
const urlSearch = searchParams.get("q") ?? "";
|
||||||
|
const [searchOverride, setSearchOverride] = useState<{ search: string; locationSearch: string } | null>(null);
|
||||||
|
const syncedSearch = useMemo(() => {
|
||||||
|
if (typeof window !== "undefined" && searchOverride?.locationSearch === window.location.search) {
|
||||||
|
return searchOverride.search;
|
||||||
|
}
|
||||||
|
return urlSearch;
|
||||||
|
}, [searchOverride, urlSearch, location.search]);
|
||||||
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
||||||
const initialWorkspaces = searchParams.getAll("workspace").filter((workspaceId) => workspaceId.length > 0);
|
const initialWorkspaces = searchParams.getAll("workspace").filter((workspaceId) => workspaceId.length > 0);
|
||||||
const workspaceIdFilter = initialWorkspaces.length === 1 ? initialWorkspaces[0] : undefined;
|
const workspaceIdFilter = initialWorkspaces.length === 1 ? initialWorkspaces[0] : undefined;
|
||||||
const handleSearchChange = useCallback((search: string) => {
|
const handleSearchChange = useCallback((search: string) => {
|
||||||
const nextUrl = buildIssuesSearchUrl(window.location.href, search);
|
const nextUrl = buildIssuesSearchUrl(window.location.href, search);
|
||||||
if (!nextUrl) return;
|
if (!nextUrl) {
|
||||||
|
setSearchOverride(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.history.replaceState(window.history.state, "", nextUrl);
|
window.history.replaceState(window.history.state, "", nextUrl);
|
||||||
|
setSearchOverride({ search, locationSearch: window.location.search });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data: agents } = useQuery({
|
const { data: agents } = useQuery({
|
||||||
|
|
@ -82,7 +119,16 @@ export function Issues() {
|
||||||
setBreadcrumbs([{ label: "Issues" }]);
|
setBreadcrumbs([{ label: "Issues" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
const { data: issues, isLoading, error } = useQuery({
|
const issuePageSize = workspaceIdFilter ? WORKSPACE_FILTER_ISSUE_LIMIT : ISSUES_PAGE_SIZE;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: issuePages,
|
||||||
|
isLoading,
|
||||||
|
isFetchingNextPage,
|
||||||
|
error,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
...queryKeys.issues.list(selectedCompanyId!),
|
...queryKeys.issues.list(selectedCompanyId!),
|
||||||
"participant-agent",
|
"participant-agent",
|
||||||
|
|
@ -90,16 +136,34 @@ export function Issues() {
|
||||||
"workspace",
|
"workspace",
|
||||||
workspaceIdFilter ?? "__all__",
|
workspaceIdFilter ?? "__all__",
|
||||||
"with-routine-executions",
|
"with-routine-executions",
|
||||||
|
"infinite",
|
||||||
|
issuePageSize,
|
||||||
],
|
],
|
||||||
queryFn: () => issuesApi.list(selectedCompanyId!, {
|
queryFn: ({ pageParam }) => issuesApi.list(selectedCompanyId!, {
|
||||||
participantAgentId,
|
participantAgentId,
|
||||||
workspaceId: workspaceIdFilter,
|
workspaceId: workspaceIdFilter,
|
||||||
includeRoutineExecutions: true,
|
includeRoutineExecutions: true,
|
||||||
...(workspaceIdFilter ? { limit: WORKSPACE_FILTER_ISSUE_LIMIT } : {}),
|
limit: issuePageSize,
|
||||||
|
offset: pageParam,
|
||||||
}),
|
}),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage, _allPages, lastPageParam) =>
|
||||||
|
getNextIssuesPageOffset(lastPage.length, lastPageParam, issuePageSize),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const issues = useMemo(() => mergeIssuePagesStable(issuePages?.pages ?? []), [issuePages]);
|
||||||
|
const hasMoreServerIssues = syncedSearch.trim().length === 0
|
||||||
|
&& hasNextPage === true;
|
||||||
|
const loadMoreServerIssues = useCallback(() => {
|
||||||
|
if (!hasNextPage || isFetchingNextPage || fetchNextPageInFlightRef.current) return;
|
||||||
|
fetchNextPageInFlightRef.current = true;
|
||||||
|
void fetchNextPage({ cancelRefetch: false }).finally(() => {
|
||||||
|
fetchNextPageInFlightRef.current = false;
|
||||||
|
});
|
||||||
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
const updateIssue = useMutation({
|
const updateIssue = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
issuesApi.update(id, data),
|
issuesApi.update(id, data),
|
||||||
|
|
@ -116,6 +180,7 @@ export function Issues() {
|
||||||
<IssuesList
|
<IssuesList
|
||||||
issues={issues ?? []}
|
issues={issues ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
isLoadingMoreIssues={isFetchingNextPage}
|
||||||
error={error as Error | null}
|
error={error as Error | null}
|
||||||
agents={agents}
|
agents={agents}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
|
@ -124,9 +189,11 @@ export function Issues() {
|
||||||
issueLinkState={issueLinkState}
|
issueLinkState={issueLinkState}
|
||||||
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
||||||
initialWorkspaces={initialWorkspaces.length > 0 ? initialWorkspaces : undefined}
|
initialWorkspaces={initialWorkspaces.length > 0 ? initialWorkspaces : undefined}
|
||||||
initialSearch={initialSearch}
|
initialSearch={syncedSearch}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
enableRoutineVisibilityFilter
|
enableRoutineVisibilityFilter
|
||||||
|
hasMoreIssues={hasMoreServerIssues}
|
||||||
|
onLoadMoreIssues={loadMoreServerIssues}
|
||||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||||
searchFilters={participantAgentId || workspaceIdFilter ? { participantAgentId, workspaceId: workspaceIdFilter } : undefined}
|
searchFilters={participantAgentId || workspaceIdFilter ? { participantAgentId, workspaceId: workspaceIdFilter } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
|
|
@ -15,7 +15,7 @@ import { Hexagon, Plus } from "lucide-react";
|
||||||
|
|
||||||
export function Projects() {
|
export function Projects() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewProject } = useDialog();
|
const { openNewProject } = useDialogActions();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -20,18 +20,20 @@ import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { accessApi } from "../api/access";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToastActions } from "../context/ToastContext";
|
import { useToastActions } from "../context/ToastContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
|
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
|
||||||
|
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
|
||||||
import {
|
import {
|
||||||
RoutineRunVariablesDialog,
|
RoutineRunVariablesDialog,
|
||||||
type RoutineRunDialogSubmitData,
|
type RoutineRunDialogSubmitData,
|
||||||
|
|
@ -350,6 +352,11 @@ export function RoutineDetail() {
|
||||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
const { data: companyMembers } = useQuery({
|
||||||
|
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||||
|
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const routineDefaults = useMemo(
|
const routineDefaults = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -649,6 +656,13 @@ export function RoutineDetail() {
|
||||||
})),
|
})),
|
||||||
[projects],
|
[projects],
|
||||||
);
|
);
|
||||||
|
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||||
|
return buildMarkdownMentionOptions({
|
||||||
|
agents,
|
||||||
|
projects,
|
||||||
|
members: companyMembers?.users,
|
||||||
|
});
|
||||||
|
}, [agents, companyMembers?.users, projects]);
|
||||||
const currentAssignee = editDraft.assigneeAgentId ? agentById.get(editDraft.assigneeAgentId) ?? null : null;
|
const currentAssignee = editDraft.assigneeAgentId ? agentById.get(editDraft.assigneeAgentId) ?? null : null;
|
||||||
const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null;
|
const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null;
|
||||||
|
|
||||||
|
|
@ -882,6 +896,7 @@ export function RoutineDetail() {
|
||||||
placeholder="Add instructions..."
|
placeholder="Add instructions..."
|
||||||
bordered={false}
|
bordered={false}
|
||||||
contentClassName="min-h-[120px] text-[15px] leading-7"
|
contentClassName="min-h-[120px] text-[15px] leading-7"
|
||||||
|
mentions={mentionOptions}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
||||||
saveRoutine.mutate();
|
saveRoutine.mutate();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ let currentSearch = "";
|
||||||
const navigateMock = vi.fn();
|
const navigateMock = vi.fn();
|
||||||
const routinesListMock = vi.fn<(companyId: string) => Promise<RoutineListItem[]>>();
|
const routinesListMock = vi.fn<(companyId: string) => Promise<RoutineListItem[]>>();
|
||||||
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
|
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
|
||||||
|
const markdownEditorRenderMock = vi.fn((props: { mentions?: Array<{ id: string; name: string }> }) => props);
|
||||||
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
|
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
|
||||||
<div data-testid="issues-list">{issues.map((issue) => issue.title).join(", ")}</div>
|
<div data-testid="issues-list">{issues.map((issue) => issue.title).join(", ")}</div>
|
||||||
));
|
));
|
||||||
|
|
@ -158,6 +159,24 @@ vi.mock("../api/projects", () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/access", () => ({
|
||||||
|
accessApi: {
|
||||||
|
listUserDirectory: vi.fn(async () => ({
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
principalId: "user-1",
|
||||||
|
status: "active",
|
||||||
|
user: {
|
||||||
|
name: "Taylor",
|
||||||
|
email: "taylor@example.com",
|
||||||
|
image: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../api/instanceSettings", () => ({
|
vi.mock("../api/instanceSettings", () => ({
|
||||||
instanceSettingsApi: {
|
instanceSettingsApi: {
|
||||||
getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: false })),
|
getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: false })),
|
||||||
|
|
@ -186,7 +205,10 @@ vi.mock("@/components/ui/tabs", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../components/MarkdownEditor", () => ({
|
vi.mock("../components/MarkdownEditor", () => ({
|
||||||
MarkdownEditor: () => <div />,
|
MarkdownEditor: (props: { mentions?: Array<{ id: string; name: string }> }) => {
|
||||||
|
markdownEditorRenderMock(props);
|
||||||
|
return <div data-testid="markdown-editor" />;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../components/InlineEntitySelector", () => ({
|
vi.mock("../components/InlineEntitySelector", () => ({
|
||||||
|
|
@ -303,6 +325,7 @@ describe("Routines page", () => {
|
||||||
navigateMock.mockReset();
|
navigateMock.mockReset();
|
||||||
routinesListMock.mockReset();
|
routinesListMock.mockReset();
|
||||||
issuesListMock.mockReset();
|
issuesListMock.mockReset();
|
||||||
|
markdownEditorRenderMock.mockClear();
|
||||||
issuesListRenderMock.mockClear();
|
issuesListRenderMock.mockClear();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
@ -334,6 +357,70 @@ describe("Routines page", () => {
|
||||||
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]);
|
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes company mention options to the routine description editor", async () => {
|
||||||
|
routinesListMock.mockResolvedValue([]);
|
||||||
|
issuesListMock.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Routines />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
let createButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||||
|
button.textContent?.includes("Create routine"),
|
||||||
|
);
|
||||||
|
for (let attempts = 0; attempts < 5 && !createButton; attempts += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
createButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||||
|
button.textContent?.includes("Create routine"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(createButton).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
createButton?.click();
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let attempts = 0; attempts < 5; attempts += 1) {
|
||||||
|
const hasMentionOptions = markdownEditorRenderMock.mock.calls.some(([props]) => (props.mentions ?? []).length > 0);
|
||||||
|
if (hasMentionOptions) break;
|
||||||
|
await act(async () => {
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const callsWithMentions = markdownEditorRenderMock.mock.calls
|
||||||
|
.map(([props]) => props.mentions ?? [])
|
||||||
|
.filter((mentions) => mentions.length > 0);
|
||||||
|
|
||||||
|
expect(callsWithMentions.at(-1)?.map((mention) => mention.id)).toEqual([
|
||||||
|
"user:user-1",
|
||||||
|
"agent:agent-1",
|
||||||
|
"agent:agent-2",
|
||||||
|
"project:project-1",
|
||||||
|
"project:project-2",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("shows recent runs through the issues list scoped to routine execution issues", async () => {
|
it("shows recent runs through the issues list scoped to routine execution issues", async () => {
|
||||||
currentSearch = "tab=runs";
|
currentSearch = "tab=runs";
|
||||||
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]);
|
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]);
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
|
import { accessApi } from "../api/access";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToastActions } from "../context/ToastContext";
|
import { useToastActions } from "../context/ToastContext";
|
||||||
|
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { groupBy } from "../lib/groupBy";
|
import { groupBy } from "../lib/groupBy";
|
||||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
|
|
@ -23,7 +25,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
|
||||||
import {
|
import {
|
||||||
RoutineRunVariablesDialog,
|
RoutineRunVariablesDialog,
|
||||||
type RoutineRunDialogSubmitData,
|
type RoutineRunDialogSubmitData,
|
||||||
|
|
@ -353,6 +355,11 @@ export function Routines() {
|
||||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
const { data: companyMembers } = useQuery({
|
||||||
|
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
||||||
|
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
|
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
|
||||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
|
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
|
||||||
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
|
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
|
||||||
|
|
@ -369,6 +376,14 @@ export function Routines() {
|
||||||
autoResizeTextarea(titleInputRef.current);
|
autoResizeTextarea(titleInputRef.current);
|
||||||
}, [draft.title, composerOpen]);
|
}, [draft.title, composerOpen]);
|
||||||
|
|
||||||
|
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||||
|
return buildMarkdownMentionOptions({
|
||||||
|
agents,
|
||||||
|
projects,
|
||||||
|
members: companyMembers?.users,
|
||||||
|
});
|
||||||
|
}, [agents, companyMembers?.users, projects]);
|
||||||
|
|
||||||
const createRoutine = useMutation({
|
const createRoutine = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
routinesApi.create(selectedCompanyId!, buildRoutineMutationPayload(draft)),
|
routinesApi.create(selectedCompanyId!, buildRoutineMutationPayload(draft)),
|
||||||
|
|
@ -808,6 +823,7 @@ export function Routines() {
|
||||||
placeholder="Add instructions..."
|
placeholder="Add instructions..."
|
||||||
bordered={false}
|
bordered={false}
|
||||||
contentClassName="min-h-[160px] text-sm text-muted-foreground"
|
contentClassName="min-h-[160px] text-sm text-muted-foreground"
|
||||||
|
mentions={mentionOptions}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
|
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
|
||||||
createRoutine.mutate();
|
createRoutine.mutate();
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,8 @@ Ship criteria for the board UI refresh:
|
||||||
- [x] Keep comments and task updates auditable
|
- [x] Keep comments and task updates auditable
|
||||||
- [ ] Attach screenshots after QA
|
- [ ] Attach screenshots after QA
|
||||||
|
|
||||||
|
Tooling: lean on [/react-perf-optimizer](skill://skill-react-perf?s=react-perf-optimizer) and [/vercel-react-best-practices](skill://skill-vercel-react?s=vercel-react-best-practices) so we don't regress render performance on the page it's open to. Inline skill chips like [/release-changelog](skill://skill-release?s=release-changelog) must sit on the surrounding text line, not hang below it.
|
||||||
|
|
||||||
| Surface | Owner | State |
|
| Surface | Owner | State |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Issues | CodexCoder | In progress |
|
| Issues | CodexCoder | In progress |
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { AGENT_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES } from "@paperclipai/shared";
|
import { AGENT_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES } from "@paperclipai/shared";
|
||||||
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
|
import type {
|
||||||
|
IssueBlockerAttention,
|
||||||
|
IssueProductivityReview,
|
||||||
|
IssueRelationIssueSummary,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { Bot, CheckCircle2, Clock3, DollarSign, FolderKanban, Inbox, MessageSquare, Users } from "lucide-react";
|
import { Bot, CheckCircle2, Clock3, DollarSign, FolderKanban, Inbox, MessageSquare, Users } from "lucide-react";
|
||||||
import { CopyText } from "@/components/CopyText";
|
import { CopyText } from "@/components/CopyText";
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
|
@ -10,6 +14,7 @@ import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
|
||||||
import { IssueRow } from "@/components/IssueRow";
|
import { IssueRow } from "@/components/IssueRow";
|
||||||
import { MetricCard } from "@/components/MetricCard";
|
import { MetricCard } from "@/components/MetricCard";
|
||||||
import { PriorityIcon } from "@/components/PriorityIcon";
|
import { PriorityIcon } from "@/components/PriorityIcon";
|
||||||
|
import { ProductivityReviewBadge } from "@/components/ProductivityReviewBadge";
|
||||||
import { QuotaBar } from "@/components/QuotaBar";
|
import { QuotaBar } from "@/components/QuotaBar";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { StatusIcon } from "@/components/StatusIcon";
|
import { StatusIcon } from "@/components/StatusIcon";
|
||||||
|
|
@ -370,6 +375,114 @@ function CoveredBlockedSurface({ mode, size }: { mode: "light" | "dark"; size: "
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProductivityReviewFixture = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
review: IssueProductivityReview;
|
||||||
|
};
|
||||||
|
|
||||||
|
const productivityReviewFixtures: ProductivityReviewFixture[] = [
|
||||||
|
{
|
||||||
|
label: "No-comment streak",
|
||||||
|
description: "Source issue has had 12 completed runs without a run-created comment.",
|
||||||
|
review: {
|
||||||
|
reviewIssueId: "review-issue-1",
|
||||||
|
reviewIdentifier: "PAP-2702",
|
||||||
|
status: "todo",
|
||||||
|
priority: "high",
|
||||||
|
trigger: "no_comment_streak",
|
||||||
|
noCommentStreak: 12,
|
||||||
|
createdAt: new Date("2026-04-28T13:30:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-28T13:55:00.000Z"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Long active duration",
|
||||||
|
description: "Source issue has been actively running for over 6 hours.",
|
||||||
|
review: {
|
||||||
|
reviewIssueId: "review-issue-2",
|
||||||
|
reviewIdentifier: "PAP-2703",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "medium",
|
||||||
|
trigger: "long_active_duration",
|
||||||
|
noCommentStreak: null,
|
||||||
|
createdAt: new Date("2026-04-28T08:30:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-28T13:00:00.000Z"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "High churn",
|
||||||
|
description: "Source issue is producing >10 runs/comments per hour.",
|
||||||
|
review: {
|
||||||
|
reviewIssueId: "review-issue-3",
|
||||||
|
reviewIdentifier: "PAP-2704",
|
||||||
|
status: "todo",
|
||||||
|
priority: "high",
|
||||||
|
trigger: "high_churn",
|
||||||
|
noCommentStreak: 4,
|
||||||
|
createdAt: new Date("2026-04-28T13:45:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-28T13:55:00.000Z"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const productivityReviewIssueRowFixtures = productivityReviewFixtures.map((fixture, index) =>
|
||||||
|
createIssue({
|
||||||
|
id: `issue-productivity-source-${index + 1}`,
|
||||||
|
identifier: `PAP-${2710 + index}`,
|
||||||
|
issueNumber: 2710 + index,
|
||||||
|
title: `Source issue under review · ${fixture.label}`,
|
||||||
|
status: index === 1 ? "in_progress" : "in_progress",
|
||||||
|
priority: fixture.review.priority,
|
||||||
|
productivityReview: fixture.review,
|
||||||
|
lastActivityAt: fixture.review.updatedAt,
|
||||||
|
updatedAt: fixture.review.updatedAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
function ProductivityReviewMatrix() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{productivityReviewFixtures.map((fixture) => (
|
||||||
|
<div
|
||||||
|
key={fixture.label}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border border-border bg-background/70 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{fixture.label}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{fixture.description}</div>
|
||||||
|
</div>
|
||||||
|
<ProductivityReviewBadge review={fixture.review} />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-muted-foreground">
|
||||||
|
Trigger {fixture.review.trigger ?? "unknown"} · review {fixture.review.reviewIdentifier}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border">
|
||||||
|
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
|
IssueRow with productivity-review indicator
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{productivityReviewIssueRowFixtures.map((issue) => (
|
||||||
|
<IssueRow key={issue.id} issue={issue} mobileMeta={<StatusBadge status={issue.status} />} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
On the source issue header the amber pill reads <strong>Under review</strong> and links to the open
|
||||||
|
productivity-review child — describing the state the task is in. The productivity-review issue itself
|
||||||
|
carries a static <strong>Productivity review</strong> pill identifying what kind of issue it is.
|
||||||
|
List rows get a smaller eye glyph next to the status icon so operators can spot yellow tasks without
|
||||||
|
the clickable label.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatusLanguage() {
|
function StatusLanguage() {
|
||||||
const [priority, setPriority] = useState("high");
|
const [priority, setPriority] = useState("high");
|
||||||
|
|
||||||
|
|
@ -466,6 +579,10 @@ function StatusLanguage() {
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section eyebrow="Productivity review" title="Yellow accountability state on source issues">
|
||||||
|
<ProductivityReviewMatrix />
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section eyebrow="Priority" title="Static labels and editable popover trigger">
|
<Section eyebrow="Priority" title="Static labels and editable popover trigger">
|
||||||
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue