mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10: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
|
|
@ -39,6 +39,7 @@ vi.mock("../context/CompanyContext", () => ({
|
|||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => dialogState,
|
||||
useDialogActions: () => dialogState,
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from "react";
|
|||
import { useNavigate } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
|
|
@ -37,7 +37,7 @@ export function CommandPalette() {
|
|||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue, openNewAgent } = useDialog();
|
||||
const { openNewIssue, openNewAgent } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const searchQuery = query.trim();
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||
|
|
@ -125,7 +125,7 @@ function SortableCompanyItem({
|
|||
|
||||
export function CompanyRail() {
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
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 {
|
||||
IssueChatThread,
|
||||
VIRTUALIZED_THREAD_ROW_THRESHOLD,
|
||||
canStopIssueChatRun,
|
||||
findLatestCommentMessageIndex,
|
||||
resolveAssistantMessageFoldedState,
|
||||
resolveIssueChatHumanAuthor,
|
||||
} from "./IssueChatThread";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
import type {
|
||||
AskUserQuestionsInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} 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(),
|
||||
}));
|
||||
|
||||
|
|
@ -59,7 +82,10 @@ vi.mock("../lib/issue-chat-scroll", async (importOriginal) => {
|
|||
});
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => {
|
||||
markdownBodyRenderMock(children);
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownEditor", () => ({
|
||||
|
|
@ -273,6 +299,7 @@ describe("IssueChatThread", () => {
|
|||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
window.scrollTo = vi.fn();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
|
|
@ -284,6 +311,7 @@ describe("IssueChatThread", () => {
|
|||
captureComposerViewportSnapshotMock.mockClear();
|
||||
restoreComposerViewportSnapshotMock.mockClear();
|
||||
shouldPreserveComposerViewportMock.mockClear();
|
||||
markdownBodyRenderMock.mockClear();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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", () => {
|
||||
const root = createRoot(container);
|
||||
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 { Issue } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { X } from "lucide-react";
|
||||
import { Eye, X } from "lucide-react";
|
||||
import {
|
||||
createIssueDetailPath,
|
||||
rememberIssueDetailLocationState,
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { cn } from "../lib/utils";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
|
||||
|
||||
type UnreadState = "hidden" | "visible" | "fading";
|
||||
|
||||
|
|
@ -63,6 +64,19 @@ export function IssueRow({
|
|||
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
||||
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
|
||||
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 checklistStep = hasChecklistStep ? (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
|
||||
|
|
@ -87,8 +101,9 @@ export function IssueRow({
|
|||
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} />}
|
||||
{productivityReviewIndicator}
|
||||
</span>
|
||||
<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)}>
|
||||
|
|
@ -105,8 +120,9 @@ export function IssueRow({
|
|||
) : null}
|
||||
{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} />
|
||||
{productivityReviewIndicator}
|
||||
</span>
|
||||
{checklistStep}
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ vi.mock("../context/CompanyContext", () => ({
|
|||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => dialogState,
|
||||
useDialogActions: () => dialogState,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
|
|
@ -221,6 +222,20 @@ async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) {
|
|||
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) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
|
|
@ -268,6 +283,7 @@ describe("IssuesList", () => {
|
|||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 });
|
||||
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 () => {
|
||||
const parentIssue = createIssue({
|
||||
id: "issue-parent",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { Link } from "@/lib/router";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
|
|
@ -72,7 +72,20 @@ const ISSUE_SEARCH_RESULT_LIMIT = 200;
|
|||
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
|
||||
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100;
|
||||
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 issueStatusLabels: Record<IssueStatus, string> = {
|
||||
backlog: "Backlog",
|
||||
|
|
@ -306,8 +319,11 @@ interface IssuesListProps {
|
|||
defaultSortField?: IssueSortField;
|
||||
showProgressSummary?: boolean;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
hasMoreIssues?: boolean;
|
||||
isLoadingMoreIssues?: boolean;
|
||||
mutedIssueIds?: Set<string>;
|
||||
issueBadgeById?: Map<string, string>;
|
||||
onLoadMoreIssues?: () => void;
|
||||
onSearchChange?: (search: string) => void;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
|
@ -475,13 +491,17 @@ export function IssuesList({
|
|||
defaultSortField,
|
||||
showProgressSummary = false,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
hasMoreIssues = false,
|
||||
isLoadingMoreIssues = false,
|
||||
mutedIssueIds,
|
||||
issueBadgeById,
|
||||
onLoadMoreIssues,
|
||||
onSearchChange,
|
||||
onUpdateIssue,
|
||||
}: IssuesListProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
|
|
@ -512,6 +532,8 @@ export function IssuesList({
|
|||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||
const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT);
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
|
||||
const renderedIssueIdsRef = useRef("");
|
||||
const initialServerFillRequestedRef = useRef(false);
|
||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
||||
|
||||
|
|
@ -966,23 +988,86 @@ export function IssuesList({
|
|||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
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 (renderedIssueRowLimit >= filtered.length) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (hasMoreRenderedRows) {
|
||||
startTransition(() => {
|
||||
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);
|
||||
}, [filtered.length, renderedIssueRowLimit, viewState.viewMode]);
|
||||
const canLoadMoreIssues = viewState.viewMode === "list"
|
||||
&& !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 defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
||||
|
|
@ -1036,7 +1121,7 @@ export function IssuesList({
|
|||
let remainingRowsToRender = viewState.viewMode === "list" ? renderedIssueRowLimit : Number.POSITIVE_INFINITY;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div ref={rootRef} className="space-y-4">
|
||||
{progressSummary ? (
|
||||
<SubIssueProgressSummaryStrip summary={progressSummary} issueLinkState={issueLinkState} />
|
||||
) : null}
|
||||
|
|
@ -1556,10 +1641,16 @@ export function IssuesList({
|
|||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
{remainingIssueRowCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Rendering {Math.min(renderedIssueRowLimit, filtered.length)} of {filtered.length} issues
|
||||
</p>
|
||||
{(remainingIssueRowCount > 0 || hasMoreIssues || isLoadingMoreIssues) && (
|
||||
<div className="py-2" data-testid="issues-load-more-sentinel">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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(),
|
||||
openOnboarding: vi.fn(),
|
||||
}),
|
||||
useDialogActions: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
openOnboarding: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/PanelContext", () => ({
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { MobileBottomNav } from "./MobileBottomNav";
|
|||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
|
|
@ -54,7 +54,7 @@ function readRememberedInstanceSettingsPath(): string {
|
|||
|
||||
export function Layout() {
|
||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||
const { openNewIssue, openOnboarding } = useDialog();
|
||||
const { openNewIssue, openOnboarding } = useDialogActions();
|
||||
const { togglePanelVisible } = usePanel();
|
||||
const {
|
||||
companies,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
Inbox,
|
||||
} from "lucide-react";
|
||||
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 { cn } from "../lib/utils";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
|
|
@ -37,7 +37,7 @@ type MobileNavItem = MobileNavLinkItem | MobileNavActionItem;
|
|||
export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
||||
const location = useLocation();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||
|
||||
const items = useMemo<MobileNavItem[]>(
|
||||
|
|
|
|||
|
|
@ -372,6 +372,56 @@ describe("NewIssueDialog", () => {
|
|||
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 () => {
|
||||
dialogState.newIssueDefaults = {
|
||||
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 { pickTextColorForSolidBg } from "@/lib/color-contrast";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
|
|
@ -269,6 +269,111 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
|||
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) {
|
||||
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
|
||||
return mode;
|
||||
|
|
@ -286,6 +391,10 @@ export function NewIssueDialog() {
|
|||
const { pushToast } = useToastActions();
|
||||
const [title, setTitle] = 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 [priority, setPriority] = useState("");
|
||||
const [assigneeValue, setAssigneeValue] = useState("");
|
||||
|
|
@ -463,6 +572,10 @@ export function NewIssueDialog() {
|
|||
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
|
||||
const scheduleSave = useCallback(
|
||||
|
|
@ -475,12 +588,22 @@ export function NewIssueDialog() {
|
|||
[],
|
||||
);
|
||||
|
||||
// Save draft on meaningful changes
|
||||
useEffect(() => {
|
||||
const setIssueText = useCallback((nextTitle: string, nextDescription: string) => {
|
||||
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;
|
||||
const nextTitle = overrides.title ?? titleRef.current;
|
||||
const nextDescription = overrides.description ?? descriptionRef.current;
|
||||
scheduleSave({
|
||||
title,
|
||||
description,
|
||||
title: nextTitle,
|
||||
description: nextDescription,
|
||||
status,
|
||||
priority,
|
||||
assigneeValue,
|
||||
|
|
@ -495,8 +618,43 @@ export function NewIssueDialog() {
|
|||
selectedExecutionWorkspaceId,
|
||||
});
|
||||
}, [
|
||||
title,
|
||||
description,
|
||||
newIssueOpen,
|
||||
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,
|
||||
priority,
|
||||
assigneeValue,
|
||||
|
|
@ -510,7 +668,7 @@ export function NewIssueDialog() {
|
|||
executionWorkspaceMode,
|
||||
selectedExecutionWorkspaceId,
|
||||
newIssueOpen,
|
||||
scheduleSave,
|
||||
queueDraftSave,
|
||||
]);
|
||||
|
||||
// Restore draft or apply defaults when dialog opens
|
||||
|
|
@ -528,8 +686,7 @@ export function NewIssueDialog() {
|
|||
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
|
||||
? "reuse_existing"
|
||||
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||
setTitle(newIssueDefaults.title ?? "");
|
||||
setDescription(newIssueDefaults.description ?? "");
|
||||
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(defaultProjectId);
|
||||
|
|
@ -542,8 +699,7 @@ export function NewIssueDialog() {
|
|||
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
||||
} else if (newIssueDefaults.title) {
|
||||
setTitle(newIssueDefaults.title);
|
||||
setDescription(newIssueDefaults.description ?? "");
|
||||
setIssueText(newIssueDefaults.title, newIssueDefaults.description ?? "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
|
|
@ -564,8 +720,7 @@ export function NewIssueDialog() {
|
|||
} else if (draft && draft.title.trim()) {
|
||||
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
|
||||
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
|
||||
setTitle(draft.title);
|
||||
setDescription(draft.description);
|
||||
setIssueText(draft.title, draft.description);
|
||||
setStatus(draft.status || "todo");
|
||||
setPriority(draft.priority);
|
||||
setAssigneeValue(
|
||||
|
|
@ -591,6 +746,7 @@ export function NewIssueDialog() {
|
|||
} else {
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||
setIssueText("", "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(defaultProjectId);
|
||||
|
|
@ -607,7 +763,7 @@ export function NewIssueDialog() {
|
|||
setSelectedExecutionWorkspaceId("");
|
||||
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults, orderedProjects]);
|
||||
}, [newIssueOpen, newIssueDefaults, orderedProjects, setIssueText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsAssigneeOverrides) {
|
||||
|
|
@ -637,8 +793,7 @@ export function NewIssueDialog() {
|
|||
}, []);
|
||||
|
||||
function reset() {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setIssueText("", "");
|
||||
setStatus("todo");
|
||||
setPriority("");
|
||||
setAssigneeValue("");
|
||||
|
|
@ -687,7 +842,9 @@ export function NewIssueDialog() {
|
|||
}
|
||||
|
||||
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({
|
||||
adapterType: assigneeAdapterType,
|
||||
modelOverride: assigneeModelOverride,
|
||||
|
|
@ -716,8 +873,8 @@ export function NewIssueDialog() {
|
|||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
stagedFiles,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
title: currentTitle,
|
||||
description: currentDescription || undefined,
|
||||
status,
|
||||
priority: priority || "medium",
|
||||
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
||||
|
|
@ -806,7 +963,7 @@ export function NewIssueDialog() {
|
|||
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 currentPriority = priorities.find((p) => p.value === priority);
|
||||
const currentAssignee = selectedAssigneeAgentId
|
||||
|
|
@ -884,7 +1041,7 @@ export function NewIssueDialog() {
|
|||
})),
|
||||
[orderedProjects],
|
||||
);
|
||||
const savedDraft = loadDraft();
|
||||
const savedDraft = useMemo(() => newIssueOpen ? loadDraft() : null, [newIssueOpen]);
|
||||
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
|
||||
const canDiscardDraft = hasDraft || hasSavedDraft;
|
||||
const createIssueErrorMessage =
|
||||
|
|
@ -1056,42 +1213,15 @@ export function NewIssueDialog() {
|
|||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{/* Title */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<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={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
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
|
||||
<IssueTitleTextarea
|
||||
value={title}
|
||||
pending={createIssue.isPending}
|
||||
assigneeValue={assigneeValue}
|
||||
projectId={projectId}
|
||||
descriptionEditorRef={descriptionEditorRef}
|
||||
assigneeSelectorRef={assigneeSelectorRef}
|
||||
projectSelectorRef={projectSelectorRef}
|
||||
onChange={handleTitleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -1466,18 +1596,13 @@ export function NewIssueDialog() {
|
|||
isFileDragOver && "bg-accent/20",
|
||||
)}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
<IssueDescriptionEditor
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
expanded={expanded}
|
||||
mentions={mentionOptions}
|
||||
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}}
|
||||
descriptionEditorRef={descriptionEditorRef}
|
||||
imageUploadHandler={uploadDescriptionImageHandler}
|
||||
onChange={handleDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
{stagedFiles.length > 0 ? (
|
||||
|
|
@ -1682,7 +1807,7 @@ export function NewIssueDialog() {
|
|||
<Button
|
||||
size="sm"
|
||||
className="min-w-[8.5rem] disabled:opacity-100"
|
||||
disabled={!title.trim() || createIssue.isPending}
|
||||
disabled={!titleHasText || createIssue.isPending}
|
||||
onClick={handleSubmit}
|
||||
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 { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, HelpCircle } from "lucide-react";
|
||||
import { syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import {
|
||||
|
|
@ -65,8 +72,8 @@ export function RoutineVariablesEditor({
|
|||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-border/70 px-3 py-2 text-left">
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="overflow-hidden rounded-lg border border-border/70">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Variables</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
@ -75,9 +82,9 @@ export function RoutineVariablesEditor({
|
|||
</div>
|
||||
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
<CollapsibleContent className="divide-y divide-border/70 border-t border-border/70">
|
||||
{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">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{`{{${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() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
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>
|
||||
<>
|
||||
<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">
|
||||
<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: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
}),
|
||||
useDialogActions: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { SidebarSection } from "./SidebarSection";
|
|||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { SidebarAgents } from "./SidebarAgents";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
|
|
@ -29,7 +29,7 @@ import { PluginSlotOutlet } from "@/plugins/slots";
|
|||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
|
||||
export function Sidebar() {
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ vi.mock("../context/DialogContext", () => ({
|
|||
useDialog: () => ({
|
||||
openNewAgent: mockOpenNewAgent,
|
||||
}),
|
||||
useDialogActions: () => ({
|
||||
openNewAgent: mockOpenNewAgent,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
Plus,
|
||||
} from "lucide-react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
|
|
@ -158,7 +158,7 @@ export function SidebarAgents() {
|
|||
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewAgent } = useDialog();
|
||||
const { openNewAgent } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { pushToast } = useToastActions();
|
||||
const location = useLocation();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
|
|
@ -124,7 +124,7 @@ function SortableProjectItem({
|
|||
export function SidebarProjects() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { openNewProject } = useDialog();
|
||||
const { openNewProject } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const location = useLocation();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue