Merge remote-tracking branch 'upstream/master' into dev
# Conflicts: # packages/shared/src/validators/company-skill.ts # packages/shared/src/validators/index.ts # server/src/__tests__/company-skills-routes.test.ts # server/src/routes/company-skills.ts # server/src/services/company-skills.ts # ui/src/pages/CompanySkills.tsx
This commit is contained in:
+125
-42
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, type ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -16,6 +17,7 @@ const mockAuthApi = vi.hoisted(() => ({
|
||||
|
||||
const mockAccessApi = vi.hoisted(() => ({
|
||||
getCurrentBoardAccess: vi.fn(),
|
||||
claimBootstrapAdmin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api/health", () => ({
|
||||
@@ -31,6 +33,7 @@ vi.mock("./api/access", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children?: ReactNode }) => <a href={to}>{children}</a>,
|
||||
Navigate: ({ to }: { to: string }) => <div>Navigate:{to}</div>,
|
||||
Outlet: () => <div>Outlet content</div>,
|
||||
Route: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
@@ -39,13 +42,39 @@ vi.mock("@/lib/router", () => ({
|
||||
useParams: () => ({}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitForText(container: HTMLElement, text: string) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (container.textContent?.includes(text)) return;
|
||||
await flushReact();
|
||||
}
|
||||
expect(container.textContent).toContain(text);
|
||||
}
|
||||
|
||||
function renderGate(container: HTMLElement) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudAccessGate />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function unmountRoot(root: ReturnType<typeof createRoot>) {
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,6 +87,7 @@ describe("CloudAccessGate", () => {
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bootstrapStatus: "ready",
|
||||
});
|
||||
});
|
||||
@@ -82,28 +112,13 @@ describe("CloudAccessGate", () => {
|
||||
keyId: null,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudAccessGate />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
const root = renderGate(container);
|
||||
await waitForText(container, "No company access");
|
||||
|
||||
expect(container.textContent).toContain("No company access");
|
||||
expect(container.textContent).not.toContain("Outlet content");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
unmountRoot(root);
|
||||
});
|
||||
|
||||
it("allows authenticated users with company access through to the board", async () => {
|
||||
@@ -120,27 +135,95 @@ describe("CloudAccessGate", () => {
|
||||
keyId: null,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudAccessGate />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
const root = renderGate(container);
|
||||
await waitForText(container, "Outlet content");
|
||||
|
||||
expect(container.textContent).toContain("Outlet content");
|
||||
expect(container.textContent).not.toContain("No company access");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
unmountRoot(root);
|
||||
});
|
||||
|
||||
it("shows browser sign-in setup for signed-out private bootstrap-pending instances", async () => {
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bootstrapStatus: "bootstrap_pending",
|
||||
bootstrapInviteActive: false,
|
||||
});
|
||||
mockAuthApi.getSession.mockResolvedValue(null);
|
||||
|
||||
const root = renderGate(container);
|
||||
await waitForText(container, "Finish setting up this Paperclip");
|
||||
|
||||
expect(container.textContent).toContain("Finish setting up this Paperclip");
|
||||
expect(container.textContent).toContain("Sign in / Create account");
|
||||
expect(container.textContent).toContain("pnpm paperclipai auth bootstrap-ceo");
|
||||
expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled();
|
||||
|
||||
unmountRoot(root);
|
||||
});
|
||||
|
||||
it("shows the claim action for signed-in private bootstrap-pending instances", async () => {
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bootstrapStatus: "bootstrap_pending",
|
||||
bootstrapInviteActive: false,
|
||||
});
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
||||
});
|
||||
mockAccessApi.claimBootstrapAdmin.mockResolvedValue({ claimed: true, userId: "user-1" });
|
||||
|
||||
const root = renderGate(container);
|
||||
await waitForText(container, "Claim this instance");
|
||||
|
||||
expect(container.textContent).toContain("Claim this instance");
|
||||
expect(container.textContent).toContain("Signed in as user@example.com");
|
||||
expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled();
|
||||
|
||||
const button = Array.from(container.querySelectorAll("button")).find((candidate) =>
|
||||
candidate.textContent?.includes("Claim this instance"),
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
flushSync(() => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await waitForText(container, "You're the instance admin");
|
||||
|
||||
expect(mockAccessApi.claimBootstrapAdmin).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("You're the instance admin");
|
||||
expect(container.textContent).toContain("Continue to dashboard");
|
||||
|
||||
unmountRoot(root);
|
||||
});
|
||||
|
||||
it("keeps public bootstrap-pending instances invite-only", async () => {
|
||||
mockHealthApi.get.mockResolvedValue({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "public",
|
||||
bootstrapStatus: "bootstrap_pending",
|
||||
bootstrapInviteActive: true,
|
||||
});
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: { id: "user-1", email: "user@example.com", name: "User", image: null },
|
||||
});
|
||||
|
||||
const root = renderGate(container);
|
||||
await waitForText(container, "This Paperclip is waiting on its first admin");
|
||||
|
||||
expect(container.textContent).toContain("This Paperclip is waiting on its first admin");
|
||||
expect(container.textContent).toContain("invite-only mode");
|
||||
expect(container.textContent).not.toContain("Claim this instance");
|
||||
expect(container.textContent).not.toContain("Sign in / Create account");
|
||||
expect(mockAccessApi.claimBootstrapAdmin).not.toHaveBeenCalled();
|
||||
|
||||
unmountRoot(root);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
|
||||
import { CloudUpstream } from "./pages/CloudUpstream";
|
||||
import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab";
|
||||
import { BootstrapSetupUxLab } from "./pages/BootstrapSetupUxLab";
|
||||
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
|
||||
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
@@ -284,6 +285,7 @@ export function App() {
|
||||
<Route path="invite/:token" element={<InviteLandingPage />} />
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
<Route path="ux-lab/cloud-upstream" element={<CloudUpstreamUxLab />} />
|
||||
<Route path="ux-lab/bootstrap-setup" element={<BootstrapSetupUxLab />} />
|
||||
|
||||
<Route element={<CloudAccessGate />}>
|
||||
<Route index element={<CompanyRootRedirect />} />
|
||||
|
||||
@@ -384,6 +384,9 @@ export const accessApi = {
|
||||
claimBoard: (token: string, code: string) =>
|
||||
api.post<{ claimed: true; userId: string }>(`/board-claim/${token}/claim`, { code }),
|
||||
|
||||
claimBootstrapAdmin: () =>
|
||||
api.post<{ claimed: true; userId: string }>("/bootstrap/claim", {}),
|
||||
|
||||
getCliAuthChallenge: (id: string, token: string) =>
|
||||
api.get<CliAuthChallengeStatus>(`/cli-auth/challenges/${id}?token=${encodeURIComponent(token)}`),
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type {
|
||||
CatalogSkill,
|
||||
CatalogSkillFileDetail,
|
||||
CatalogSkillKind,
|
||||
CompanySkill,
|
||||
CompanySkillCreateRequest,
|
||||
CompanySkillDetail,
|
||||
CompanySkillFileDetail,
|
||||
CompanySkillImportResult,
|
||||
CompanySkillInstallCatalogRequest,
|
||||
CompanySkillInstallCatalogResult,
|
||||
CompanySkillListItem,
|
||||
CompanySkillProjectScanRequest,
|
||||
CompanySkillProjectScanResult,
|
||||
@@ -11,6 +16,12 @@ import type {
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface CatalogListQuery {
|
||||
kind?: CatalogSkillKind;
|
||||
category?: string;
|
||||
q?: string;
|
||||
}
|
||||
|
||||
export const companySkillsApi = {
|
||||
list: (companyId: string) =>
|
||||
api.get<CompanySkillListItem[]>(`/companies/${encodeURIComponent(companyId)}/skills`),
|
||||
@@ -60,4 +71,23 @@ export const companySkillsApi = {
|
||||
api.delete<CompanySkill>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
|
||||
),
|
||||
catalogList: (query: CatalogListQuery = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (query.kind) params.set("kind", query.kind);
|
||||
if (query.category) params.set("category", query.category);
|
||||
if (query.q) params.set("q", query.q);
|
||||
const search = params.toString();
|
||||
return api.get<CatalogSkill[]>(`/skills/catalog${search ? `?${search}` : ""}`);
|
||||
},
|
||||
catalogDetail: (catalogRef: string) =>
|
||||
api.get<CatalogSkill>(`/skills/catalog/${encodeURIComponent(catalogRef)}`),
|
||||
catalogFile: (catalogRef: string, relativePath: string = "SKILL.md") =>
|
||||
api.get<CatalogSkillFileDetail>(
|
||||
`/skills/catalog/${encodeURIComponent(catalogRef)}/files?path=${encodeURIComponent(relativePath)}`,
|
||||
),
|
||||
installCatalog: (companyId: string, payload: CompanySkillInstallCatalogRequest) =>
|
||||
api.post<CompanySkillInstallCatalogResult>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/install-catalog`,
|
||||
payload,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
CreateDocumentAnnotationCommentRequest,
|
||||
CreateDocumentAnnotationThreadRequest,
|
||||
DocumentAnnotationComment,
|
||||
DocumentAnnotationThread,
|
||||
DocumentAnnotationThreadStatus,
|
||||
DocumentAnnotationThreadWithComments,
|
||||
UpdateDocumentAnnotationThreadRequest,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export type DocumentAnnotationListFilter = "open" | "resolved" | "all";
|
||||
|
||||
export const documentAnnotationsApi = {
|
||||
list: (
|
||||
issueId: string,
|
||||
key: string,
|
||||
options: { status?: DocumentAnnotationListFilter; includeComments?: boolean } = {},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.status) params.set("status", options.status);
|
||||
if (options.includeComments) params.set("includeComments", "true");
|
||||
const qs = params.toString();
|
||||
return api.get<DocumentAnnotationThreadWithComments[]>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
get: (issueId: string, key: string, threadId: string) =>
|
||||
api.get<DocumentAnnotationThreadWithComments>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
|
||||
),
|
||||
create: (issueId: string, key: string, data: CreateDocumentAnnotationThreadRequest) =>
|
||||
api.post<DocumentAnnotationThreadWithComments>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations`,
|
||||
data,
|
||||
),
|
||||
addComment: (
|
||||
issueId: string,
|
||||
key: string,
|
||||
threadId: string,
|
||||
data: CreateDocumentAnnotationCommentRequest,
|
||||
) =>
|
||||
api.post<DocumentAnnotationComment>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}/comments`,
|
||||
data,
|
||||
),
|
||||
updateStatus: (
|
||||
issueId: string,
|
||||
key: string,
|
||||
threadId: string,
|
||||
status: DocumentAnnotationThreadStatus,
|
||||
) => {
|
||||
const payload: UpdateDocumentAnnotationThreadRequest = { status };
|
||||
return api.patch<DocumentAnnotationThread>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/annotations/${threadId}`,
|
||||
payload,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AcceptedPlanDecompositionSummary,
|
||||
AskUserQuestionsAnswer,
|
||||
Approval,
|
||||
CreateIssueTreeHold,
|
||||
@@ -201,6 +202,8 @@ export const issuesApi = {
|
||||
},
|
||||
listInteractions: (id: string) =>
|
||||
api.get<IssueThreadInteraction[]>(`/issues/${id}/interactions`),
|
||||
listAcceptedPlanDecompositions: (id: string) =>
|
||||
api.get<AcceptedPlanDecompositionSummary[]>(`/issues/${id}/accepted-plan-decompositions`),
|
||||
createInteraction: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<IssueThreadInteraction>(`/issues/${id}/interactions`, data),
|
||||
acceptInteraction: (
|
||||
|
||||
@@ -132,13 +132,14 @@ export interface PluginDashboardData {
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface AvailablePluginExample {
|
||||
export interface AvailableBundledPlugin {
|
||||
packageName: string;
|
||||
pluginKey: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
localPath: string;
|
||||
tag: "example" | "first-party";
|
||||
experimental: boolean;
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderProblem {
|
||||
@@ -215,10 +216,10 @@ export const pluginsApi = {
|
||||
api.get<PluginRecord[]>(`/plugins${status ? `?status=${status}` : ""}`),
|
||||
|
||||
/**
|
||||
* List bundled example plugins available from the current repo checkout.
|
||||
* List bundled plugin packages available from the current repo checkout.
|
||||
*/
|
||||
listExamples: () =>
|
||||
api.get<AvailablePluginExample[]>("/plugins/examples"),
|
||||
listBundled: () =>
|
||||
api.get<AvailableBundledPlugin[]>("/plugins/examples"),
|
||||
|
||||
/**
|
||||
* Fetch a single plugin record by its UUID or plugin key.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const BOOTSTRAP_FALLBACK_COMMAND = "pnpm paperclipai auth bootstrap-ceo";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { supportsAdapterModelRefresh } from "./AgentConfigForm";
|
||||
|
||||
describe("supportsAdapterModelRefresh", () => {
|
||||
it("enables the model refresh action for Claude, Codex, and ACPX adapters", () => {
|
||||
expect(supportsAdapterModelRefresh("claude_local")).toBe(true);
|
||||
expect(supportsAdapterModelRefresh("codex_local")).toBe(true);
|
||||
expect(supportsAdapterModelRefresh("acpx_local")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the refresh action hidden for adapters without a live refresh hook", () => {
|
||||
expect(supportsAdapterModelRefresh("opencode_local")).toBe(false);
|
||||
expect(supportsAdapterModelRefresh("process")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -114,6 +114,10 @@ const emptyOverlay: AgentConfigOverlay = {
|
||||
/** Stable empty object used as fallback for missing env config to avoid new-object-per-render. */
|
||||
const EMPTY_ENV: Record<string, EnvBinding> = {};
|
||||
|
||||
export function supportsAdapterModelRefresh(adapterType: string): boolean {
|
||||
return adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "acpx_local";
|
||||
}
|
||||
|
||||
function isOverlayDirty(o: AgentConfigOverlay): boolean {
|
||||
return (
|
||||
Object.keys(o.identity).length > 0 ||
|
||||
@@ -1006,7 +1010,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
return result.data?.model ?? null;
|
||||
}}
|
||||
onRefreshModels={
|
||||
adapterType === "codex_local" || adapterType === "acpx_local"
|
||||
supportsAdapterModelRefresh(adapterType)
|
||||
? handleRefreshModels
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Loader2, ShieldCheck, Terminal, TriangleAlert } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BOOTSTRAP_FALLBACK_COMMAND } from "@/bootstrapSetup";
|
||||
import type { AuthSession } from "@paperclipai/shared";
|
||||
|
||||
type BootstrapPendingPageProps = {
|
||||
claimAvailable: boolean;
|
||||
hasActiveInvite?: boolean;
|
||||
session: AuthSession | null | undefined;
|
||||
claimState: "idle" | "claiming" | "success";
|
||||
claimError?: { status?: number; message?: string } | null;
|
||||
onClaim: () => void;
|
||||
};
|
||||
|
||||
function CliFallback({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||
return (
|
||||
<div className="mt-6 border-t border-border pt-5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Terminal className="size-4 text-muted-foreground" aria-hidden />
|
||||
<span>Prefer to finish setup from the host?</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasActiveInvite
|
||||
? "A bootstrap invite is already active. Check your Paperclip startup logs for the first-admin URL, or run this command on the host to rotate it:"
|
||||
: "Run this command on the host that runs Paperclip to print a one-time first-admin invite URL:"}
|
||||
</p>
|
||||
<pre className="mt-3 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 font-mono text-xs">
|
||||
{BOOTSTRAP_FALLBACK_COMMAND}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function displayIdentity(session: AuthSession) {
|
||||
return session.user.email || session.user.name || session.user.id;
|
||||
}
|
||||
|
||||
function claimErrorCopy(error: BootstrapPendingPageProps["claimError"]) {
|
||||
if (error?.status === 409) {
|
||||
return {
|
||||
title: "Someone else has already claimed this instance.",
|
||||
body: "Refresh to sign in, or ask the existing admin to invite you from Instance settings -> Access.",
|
||||
};
|
||||
}
|
||||
if (error?.status === 401) {
|
||||
return {
|
||||
title: "Your session expired. Sign in again to claim this instance.",
|
||||
body: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "We couldn't reach the server. Try again in a moment.",
|
||||
body: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function BootstrapPendingPage({
|
||||
claimAvailable,
|
||||
hasActiveInvite = false,
|
||||
session,
|
||||
claimState,
|
||||
claimError,
|
||||
onClaim,
|
||||
}: BootstrapPendingPageProps) {
|
||||
if (!claimAvailable) {
|
||||
return (
|
||||
<StateChrome>
|
||||
<h1 className="text-xl font-semibold">This Paperclip is waiting on its first admin</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This instance runs in invite-only mode. The operator must generate a one-time first-admin invite URL
|
||||
from the host. Once you have the link, open it from this browser to finish setup.
|
||||
</p>
|
||||
<CliFallback hasActiveInvite={hasActiveInvite} />
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Browser-based claim is intentionally disabled in public mode so anyone on the network can't promote
|
||||
themselves.
|
||||
</p>
|
||||
</StateChrome>
|
||||
);
|
||||
}
|
||||
|
||||
if (claimState === "success") {
|
||||
return (
|
||||
<StateChrome>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-9 flex-shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
||||
<ShieldCheck className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">You're the instance admin</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Setup is complete. Taking you to onboarding to create your first company...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" aria-hidden />
|
||||
<span className="text-sm text-muted-foreground">Redirecting...</span>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Button asChild variant="outline">
|
||||
<a href="/">Continue to dashboard</a>
|
||||
</Button>
|
||||
</div>
|
||||
</StateChrome>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<StateChrome>
|
||||
<h1 className="text-xl font-semibold">Finish setting up this Paperclip</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No admin has claimed this instance yet. Sign in or create your Paperclip account to become the first
|
||||
admin from this browser.
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
<Button asChild>
|
||||
<Link to="/auth?next=/">Sign in / Create account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<CliFallback hasActiveInvite={hasActiveInvite} />
|
||||
</StateChrome>
|
||||
);
|
||||
}
|
||||
|
||||
const errorCopy = claimErrorCopy(claimError);
|
||||
const isClaiming = claimState === "claiming";
|
||||
return (
|
||||
<StateChrome>
|
||||
<h1 className="text-xl font-semibold">Finish setting up this Paperclip</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No admin has claimed this instance yet. Claim it now to become the first admin and start onboarding.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<Button onClick={onClaim} disabled={isClaiming}>
|
||||
{isClaiming && <Loader2 className="mr-2 size-4 animate-spin" aria-hidden />}
|
||||
{isClaiming ? "Claiming..." : "Claim this instance"}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Signed in as <span className="font-medium text-foreground">{displayIdentity(session)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Wrong account?{" "}
|
||||
<Link to="/auth?next=/" className="underline underline-offset-2">
|
||||
Switch account
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{claimError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
<TriangleAlert className="mt-0.5 size-4 flex-shrink-0" aria-hidden />
|
||||
<div>
|
||||
<p className="font-medium">{errorCopy.title}</p>
|
||||
{errorCopy.body && <p className="mt-1 text-destructive/90">{errorCopy.body}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CliFallback hasActiveInvite={hasActiveInvite} />
|
||||
</StateChrome>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,11 @@
|
||||
import { Navigate, Outlet, useLocation } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { healthApi } from "@/api/health";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">Instance setup required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasActiveInvite
|
||||
? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:"
|
||||
: "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}
|
||||
</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{`pnpm paperclipai auth bootstrap-ceo`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { BootstrapPendingPage } from "@/components/BootstrapPendingPage";
|
||||
|
||||
function NoBoardAccessPage() {
|
||||
return (
|
||||
@@ -42,6 +26,7 @@ function NoBoardAccessPage() {
|
||||
|
||||
export function CloudAccessGate() {
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const healthQuery = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
@@ -58,6 +43,7 @@ export function CloudAccessGate() {
|
||||
});
|
||||
|
||||
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
|
||||
const isBootstrapPending = isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending";
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
@@ -68,14 +54,24 @@ export function CloudAccessGate() {
|
||||
const boardAccessQuery = useQuery({
|
||||
queryKey: queryKeys.access.currentBoardAccess,
|
||||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||||
enabled: isAuthenticatedMode && !!sessionQuery.data,
|
||||
enabled: isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data,
|
||||
retry: false,
|
||||
});
|
||||
const claimMutation = useMutation({
|
||||
mutationFn: () => accessApi.claimBootstrapAdmin(),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess });
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
healthQuery.isLoading ||
|
||||
(isAuthenticatedMode && sessionQuery.isLoading) ||
|
||||
(isAuthenticatedMode && !!sessionQuery.data && boardAccessQuery.isLoading)
|
||||
(isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data && boardAccessQuery.isLoading)
|
||||
) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
@@ -92,8 +88,26 @@ export function CloudAccessGate() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
|
||||
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
|
||||
if (isBootstrapPending) {
|
||||
const health = healthQuery.data;
|
||||
if (!health) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
const claimError = claimMutation.error instanceof ApiError
|
||||
? { status: claimMutation.error.status, message: claimMutation.error.message }
|
||||
: claimMutation.error instanceof Error
|
||||
? { message: claimMutation.error.message }
|
||||
: null;
|
||||
return (
|
||||
<BootstrapPendingPage
|
||||
claimAvailable={health.deploymentExposure === "private"}
|
||||
hasActiveInvite={health.bootstrapInviteActive}
|
||||
session={sessionQuery.data}
|
||||
claimState={claimMutation.isSuccess ? "success" : claimMutation.isPending ? "claiming" : "idle"}
|
||||
claimError={claimError}
|
||||
onClaim={() => claimMutation.mutate()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && !sessionQuery.data) {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DocumentAnnotationLayer } from "./DocumentAnnotationLayer";
|
||||
|
||||
const mockRangesForNormalizedSpan = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/document-annotation-selection", () => ({
|
||||
buildAnchorFromContainerSelection: vi.fn(),
|
||||
getContainerTextOffset: vi.fn(),
|
||||
rangesForNormalizedSpan: mockRangesForNormalizedSpan,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
await callback();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function makeRect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
width,
|
||||
height,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function makeRange(rects: DOMRect[], commonAncestorContainer: Node = document.createTextNode("")): Range {
|
||||
return {
|
||||
commonAncestorContainer,
|
||||
getClientRects: () => rects,
|
||||
} as unknown as Range;
|
||||
}
|
||||
|
||||
describe("DocumentAnnotationLayer", () => {
|
||||
let container: HTMLDivElement;
|
||||
let rectSpy: ReturnType<typeof vi.spyOn>;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockRangesForNormalizedSpan.mockReturnValue([makeRange([makeRect(8, 12, 80, 18)])]);
|
||||
rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(makeRect(0, 0, 400, 300));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(() => root?.unmount());
|
||||
root = null;
|
||||
}
|
||||
rectSpy.mockRestore();
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses solid yellow backgrounds for annotation highlights in light and dark themes", async () => {
|
||||
const body = document.createElement("div");
|
||||
body.textContent = "Annotated body text.";
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={{ current: body }}
|
||||
markdown="Annotated body text."
|
||||
threads={[
|
||||
{ id: "active", selectedText: "Annotated", status: "open", anchorState: "active" },
|
||||
{ id: "focused", selectedText: "body", status: "open", anchorState: "active" },
|
||||
{ id: "stale", selectedText: "text", status: "open", anchorState: "stale" },
|
||||
{ id: "resolved", selectedText: "body text", status: "resolved", anchorState: "active" },
|
||||
]}
|
||||
focusedThreadId="focused"
|
||||
onThreadFocus={vi.fn()}
|
||||
pendingAnchor={null}
|
||||
onPendingAnchorChange={vi.fn()}
|
||||
onRequestComment={vi.fn()}
|
||||
hideResolved={false}
|
||||
/>,
|
||||
);
|
||||
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
const highlights = Array.from(container.querySelectorAll(".paperclip-doc-annotation-highlight"));
|
||||
expect(highlights).toHaveLength(4);
|
||||
|
||||
for (const highlight of highlights) {
|
||||
const backgroundClasses = Array.from(highlight.classList).filter((className) =>
|
||||
/^(dark:|hover:|dark:hover:)?bg-yellow-\d+$/.test(className)
|
||||
|| /^(dark:|hover:|dark:hover:)?bg-yellow-\d+\//.test(className),
|
||||
);
|
||||
expect(backgroundClasses.some((className) => className.includes("/"))).toBe(false);
|
||||
expect(backgroundClasses.some((className) => className.startsWith("bg-yellow-"))).toBe(true);
|
||||
expect(backgroundClasses.some((className) => className.startsWith("dark:bg-yellow-"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not render highlights for text clipped by folded document content", async () => {
|
||||
const body = document.createElement("div");
|
||||
const clippedContent = document.createElement("div");
|
||||
clippedContent.className = "fold-curtain__content";
|
||||
const hiddenText = document.createTextNode("Hidden folded text");
|
||||
clippedContent.appendChild(hiddenText);
|
||||
body.appendChild(clippedContent);
|
||||
mockRangesForNormalizedSpan.mockReturnValue([makeRange([makeRect(8, 60, 80, 18)], hiddenText)]);
|
||||
rectSpy.mockImplementation(function (this: HTMLElement) {
|
||||
if (this === clippedContent) return makeRect(0, 0, 400, 40);
|
||||
return makeRect(0, 0, 400, 120);
|
||||
});
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={{ current: body }}
|
||||
markdown="Hidden folded text"
|
||||
threads={[
|
||||
{ id: "hidden", selectedText: "Hidden folded text", status: "open", anchorState: "active" },
|
||||
]}
|
||||
focusedThreadId={null}
|
||||
onThreadFocus={vi.fn()}
|
||||
pendingAnchor={null}
|
||||
onPendingAnchorChange={vi.fn()}
|
||||
onRequestComment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(container.querySelector(".paperclip-doc-annotation-highlight")).toBeNull();
|
||||
expect(container.querySelector(".paperclip-doc-annotation-hit-target")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses native CSS highlights for visual paint when the browser supports them", async () => {
|
||||
const originalCss = globalThis.CSS;
|
||||
const originalHighlight = (globalThis as { Highlight?: unknown }).Highlight;
|
||||
const setHighlight = vi.fn();
|
||||
const deleteHighlight = vi.fn();
|
||||
class MockHighlight {
|
||||
ranges: Range[];
|
||||
|
||||
constructor(...ranges: Range[]) {
|
||||
this.ranges = ranges;
|
||||
}
|
||||
}
|
||||
(globalThis as { CSS?: unknown }).CSS = {
|
||||
...(originalCss ?? {}),
|
||||
highlights: {
|
||||
set: setHighlight,
|
||||
delete: deleteHighlight,
|
||||
},
|
||||
};
|
||||
(globalThis as { Highlight?: unknown }).Highlight = MockHighlight;
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.textContent = "Annotated body text.";
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={{ current: body }}
|
||||
markdown="Annotated body text."
|
||||
threads={[
|
||||
{ id: "active", selectedText: "Annotated", status: "open", anchorState: "active" },
|
||||
]}
|
||||
focusedThreadId={null}
|
||||
onThreadFocus={vi.fn()}
|
||||
pendingAnchor={null}
|
||||
onPendingAnchorChange={vi.fn()}
|
||||
onRequestComment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(container.querySelector(".paperclip-doc-annotation-highlight")).toBeNull();
|
||||
expect(container.querySelector(".paperclip-doc-annotation-hit-target")).not.toBeNull();
|
||||
const openHighlightCall = setHighlight.mock.calls.find(([name]) => name === "paperclip-doc-annotation-open");
|
||||
expect(openHighlightCall).toBeTruthy();
|
||||
expect((openHighlightCall?.[1] as MockHighlight).ranges).toHaveLength(1);
|
||||
|
||||
await act(async () => root?.unmount());
|
||||
root = null;
|
||||
expect(deleteHighlight).toHaveBeenCalledWith("paperclip-doc-annotation-open");
|
||||
|
||||
(globalThis as { CSS?: unknown }).CSS = originalCss;
|
||||
(globalThis as { Highlight?: unknown }).Highlight = originalHighlight;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, MessageSquarePlus } from "lucide-react";
|
||||
import type {
|
||||
DocumentAnnotationAnchorState,
|
||||
DocumentAnnotationThreadStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
buildAnchorFromContainerSelection,
|
||||
getContainerTextOffset,
|
||||
rangesForNormalizedSpan,
|
||||
} from "@/lib/document-annotation-selection";
|
||||
import type { DocumentAnnotationAnchorSelector } from "@paperclipai/shared";
|
||||
|
||||
export interface AnnotationOverlayThread {
|
||||
id: string;
|
||||
selectedText: string;
|
||||
status: DocumentAnnotationThreadStatus;
|
||||
anchorState: DocumentAnnotationAnchorState;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
export interface PendingAnchor {
|
||||
selector: DocumentAnnotationAnchorSelector;
|
||||
selectedText: string;
|
||||
}
|
||||
|
||||
export interface AnnotationLayerProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
markdown: string;
|
||||
threads: AnnotationOverlayThread[];
|
||||
focusedThreadId: string | null;
|
||||
onThreadFocus: (threadId: string) => void;
|
||||
/** Tracks the most recently captured pending selection. */
|
||||
pendingAnchor: PendingAnchor | null;
|
||||
onPendingAnchorChange: (anchor: PendingAnchor | null) => void;
|
||||
onRequestComment: (anchor: PendingAnchor) => void;
|
||||
/** Disables the "add comment" affordance when set. */
|
||||
newCommentDisabled?: boolean;
|
||||
newCommentDisabledReason?: string | null;
|
||||
/** Hide resolved highlights even when included in the threads list. */
|
||||
hideResolved?: boolean;
|
||||
/** Test-only: override window object for layout calculations. */
|
||||
testWindow?: { innerWidth: number; innerHeight: number };
|
||||
/**
|
||||
* When this number changes, re-read the current document selection and emit a
|
||||
* pending anchor for the keyboard shortcut path.
|
||||
*/
|
||||
captureSelectionRequestId?: number;
|
||||
}
|
||||
|
||||
interface HighlightRect {
|
||||
threadId: string;
|
||||
status: DocumentAnnotationThreadStatus;
|
||||
anchorState: DocumentAnnotationAnchorState;
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** True for the last rect of this thread (used to anchor a glyph at the run end). */
|
||||
isTail: boolean;
|
||||
}
|
||||
|
||||
interface ToolbarPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
type NativeHighlightKind = "open" | "focused" | "stale" | "resolved";
|
||||
|
||||
type NativeHighlightRanges = Record<NativeHighlightKind, Range[]>;
|
||||
|
||||
type CssHighlight = object;
|
||||
|
||||
type HighlightConstructor = new (...ranges: Range[]) => CssHighlight;
|
||||
|
||||
type HighlightRegistry = {
|
||||
set: (name: string, highlight: CssHighlight) => void;
|
||||
delete: (name: string) => void;
|
||||
};
|
||||
|
||||
const NATIVE_HIGHLIGHT_NAMES: Record<NativeHighlightKind, string> = {
|
||||
open: "paperclip-doc-annotation-open",
|
||||
focused: "paperclip-doc-annotation-focused",
|
||||
stale: "paperclip-doc-annotation-stale",
|
||||
resolved: "paperclip-doc-annotation-resolved",
|
||||
};
|
||||
|
||||
const nativeHighlightInstances = new Map<string, NativeHighlightRanges>();
|
||||
|
||||
function getNativeHighlightApi(): { registry: HighlightRegistry; HighlightCtor: HighlightConstructor } | null {
|
||||
const css = (globalThis as { CSS?: { highlights?: HighlightRegistry } }).CSS;
|
||||
const HighlightCtor = (globalThis as { Highlight?: HighlightConstructor }).Highlight;
|
||||
if (!css?.highlights || typeof HighlightCtor !== "function") return null;
|
||||
return { registry: css.highlights, HighlightCtor };
|
||||
}
|
||||
|
||||
function emptyNativeHighlightRanges(): NativeHighlightRanges {
|
||||
return {
|
||||
open: [],
|
||||
focused: [],
|
||||
stale: [],
|
||||
resolved: [],
|
||||
};
|
||||
}
|
||||
|
||||
function syncNativeHighlights(api = getNativeHighlightApi()) {
|
||||
if (!api) return;
|
||||
for (const kind of Object.keys(NATIVE_HIGHLIGHT_NAMES) as NativeHighlightKind[]) {
|
||||
const ranges = Array.from(nativeHighlightInstances.values()).flatMap((entry) => entry[kind]);
|
||||
const name = NATIVE_HIGHLIGHT_NAMES[kind];
|
||||
if (ranges.length === 0) {
|
||||
api.registry.delete(name);
|
||||
} else {
|
||||
api.registry.set(name, new api.HighlightCtor(...ranges));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setNativeHighlightRanges(instanceId: string, ranges: NativeHighlightRanges) {
|
||||
if (!getNativeHighlightApi()) return;
|
||||
nativeHighlightInstances.set(instanceId, ranges);
|
||||
syncNativeHighlights();
|
||||
}
|
||||
|
||||
function clearNativeHighlightRanges(instanceId: string) {
|
||||
if (!nativeHighlightInstances.delete(instanceId)) return;
|
||||
syncNativeHighlights();
|
||||
}
|
||||
|
||||
function elementFromNode(node: Node | null | undefined): HTMLElement | null {
|
||||
if (!node) return null;
|
||||
if (node instanceof HTMLElement) return node;
|
||||
const parent = node.parentElement;
|
||||
return parent instanceof HTMLElement ? parent : null;
|
||||
}
|
||||
|
||||
function intersectRects(a: DOMRect, b: DOMRect): DOMRect | null {
|
||||
const left = Math.max(a.left, b.left);
|
||||
const top = Math.max(a.top, b.top);
|
||||
const right = Math.min(a.right, b.right);
|
||||
const bottom = Math.min(a.bottom, b.bottom);
|
||||
if (right <= left || bottom <= top) return null;
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function clipsOverflow(element: HTMLElement) {
|
||||
if (element.classList.contains("fold-curtain__content")) return true;
|
||||
if (typeof window === "undefined" || typeof window.getComputedStyle !== "function") return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
return [style.overflow, style.overflowX, style.overflowY].some((value) =>
|
||||
value === "hidden" || value === "clip" || value === "auto" || value === "scroll",
|
||||
);
|
||||
}
|
||||
|
||||
function visibleClipRectForRange(range: Range, container: HTMLElement): DOMRect | null {
|
||||
let clipRect = container.getBoundingClientRect();
|
||||
let element = elementFromNode(range.commonAncestorContainer);
|
||||
while (element) {
|
||||
if (clipsOverflow(element)) {
|
||||
const nextClipRect = intersectRects(clipRect, element.getBoundingClientRect());
|
||||
if (!nextClipRect) return null;
|
||||
clipRect = nextClipRect;
|
||||
}
|
||||
if (element === container) break;
|
||||
element = element.parentElement;
|
||||
}
|
||||
return clipRect;
|
||||
}
|
||||
|
||||
function nativeHighlightKind(input: {
|
||||
focused: boolean;
|
||||
stale: boolean;
|
||||
resolved: boolean;
|
||||
}): NativeHighlightKind {
|
||||
if (input.resolved) return "resolved";
|
||||
if (input.stale) return "stale";
|
||||
if (input.focused) return "focused";
|
||||
return "open";
|
||||
}
|
||||
|
||||
export function DocumentAnnotationLayer({
|
||||
containerRef,
|
||||
markdown,
|
||||
threads,
|
||||
focusedThreadId,
|
||||
onThreadFocus,
|
||||
pendingAnchor,
|
||||
onPendingAnchorChange,
|
||||
onRequestComment,
|
||||
newCommentDisabled = false,
|
||||
newCommentDisabledReason = null,
|
||||
hideResolved = true,
|
||||
captureSelectionRequestId,
|
||||
}: AnnotationLayerProps) {
|
||||
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([]);
|
||||
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastCaptureSelectionRequestIdRef = useRef<number>(0);
|
||||
const reactId = useId();
|
||||
const nativeHighlightInstanceId = useMemo(
|
||||
() => `document-annotation-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
|
||||
[reactId],
|
||||
);
|
||||
const nativeHighlightsSupported = getNativeHighlightApi() !== null;
|
||||
|
||||
const visibleThreads = useMemo(() => {
|
||||
if (!hideResolved) return threads;
|
||||
return threads.filter((thread) => thread.status !== "resolved" || thread.anchorState === "orphaned" || thread.id === focusedThreadId);
|
||||
}, [threads, hideResolved, focusedThreadId]);
|
||||
|
||||
const computeHighlightRects = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
if (!container || !overlay) {
|
||||
clearNativeHighlightRanges(nativeHighlightInstanceId);
|
||||
setHighlightRects([]);
|
||||
return;
|
||||
}
|
||||
const overlayRect = overlay.getBoundingClientRect();
|
||||
const next: HighlightRect[] = [];
|
||||
const nativeRanges = emptyNativeHighlightRanges();
|
||||
for (const thread of visibleThreads) {
|
||||
if (thread.anchorState === "orphaned") continue;
|
||||
const isFocused = thread.id === focusedThreadId;
|
||||
const isStale = thread.anchorState === "stale";
|
||||
const isResolved = thread.status === "resolved";
|
||||
const nativeKind = nativeHighlightKind({
|
||||
focused: isFocused,
|
||||
stale: isStale,
|
||||
resolved: isResolved,
|
||||
});
|
||||
const ranges = rangesForNormalizedSpan({
|
||||
container,
|
||||
selectedText: thread.selectedText,
|
||||
});
|
||||
const startIndex = next.length;
|
||||
for (const range of ranges) {
|
||||
const visibleClipRect = visibleClipRectForRange(range, container);
|
||||
if (!visibleClipRect) continue;
|
||||
let rangeIsVisible = false;
|
||||
for (const rect of Array.from(range.getClientRects())) {
|
||||
if (rect.width === 0 || rect.height === 0) continue;
|
||||
const visibleRect = intersectRects(rect, visibleClipRect);
|
||||
if (!visibleRect) continue;
|
||||
rangeIsVisible = true;
|
||||
next.push({
|
||||
threadId: thread.id,
|
||||
status: thread.status,
|
||||
anchorState: thread.anchorState,
|
||||
top: visibleRect.top - overlayRect.top,
|
||||
left: visibleRect.left - overlayRect.left,
|
||||
width: visibleRect.width,
|
||||
height: visibleRect.height,
|
||||
isTail: false,
|
||||
});
|
||||
}
|
||||
if (rangeIsVisible) nativeRanges[nativeKind].push(range);
|
||||
}
|
||||
if (next.length > startIndex) {
|
||||
next[next.length - 1].isTail = true;
|
||||
}
|
||||
}
|
||||
setNativeHighlightRanges(nativeHighlightInstanceId, nativeRanges);
|
||||
setHighlightRects(next);
|
||||
}, [containerRef, focusedThreadId, nativeHighlightInstanceId, visibleThreads]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
computeHighlightRects();
|
||||
}, [computeHighlightRects]);
|
||||
|
||||
useEffect(() => () => clearNativeHighlightRanges(nativeHighlightInstanceId), [nativeHighlightInstanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const container = containerRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
let cancelled = false;
|
||||
let frame: number | null = null;
|
||||
|
||||
const schedule = () => {
|
||||
if (cancelled || frame !== null) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
if (!cancelled) computeHighlightRects();
|
||||
});
|
||||
};
|
||||
|
||||
const handleResizeOrScroll = () => schedule();
|
||||
window.addEventListener("resize", handleResizeOrScroll);
|
||||
window.addEventListener("scroll", handleResizeOrScroll, true);
|
||||
|
||||
const resizeObserver = typeof window.ResizeObserver === "function"
|
||||
? new window.ResizeObserver(schedule)
|
||||
: null;
|
||||
if (resizeObserver && container) resizeObserver.observe(container);
|
||||
if (resizeObserver && overlay) resizeObserver.observe(overlay);
|
||||
|
||||
const mutationObserver = typeof window.MutationObserver === "function" && container
|
||||
? new window.MutationObserver((mutations) => {
|
||||
const onlyLayerMutations = mutations.every((mutation) => {
|
||||
const target = elementFromNode(mutation.target);
|
||||
return !!target?.closest(".paperclip-doc-annotation-layer, .paperclip-doc-annotation-visual-layer");
|
||||
});
|
||||
if (!onlyLayerMutations) schedule();
|
||||
})
|
||||
: null;
|
||||
if (mutationObserver && container) {
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-state", "open", "hidden", "aria-expanded"],
|
||||
});
|
||||
}
|
||||
|
||||
schedule();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
window.removeEventListener("resize", handleResizeOrScroll);
|
||||
window.removeEventListener("scroll", handleResizeOrScroll, true);
|
||||
};
|
||||
}, [computeHighlightRects, containerRef]);
|
||||
|
||||
const captureSelection = useCallback((): PendingAnchor | null => {
|
||||
const container = containerRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
if (!container || !overlay) return null;
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!container.contains(range.commonAncestorContainer)) return null;
|
||||
const containerOffset = getContainerTextOffset(container, range);
|
||||
if (!containerOffset) return null;
|
||||
const anchor = buildAnchorFromContainerSelection({ markdown, containerOffset });
|
||||
if (!anchor) return null;
|
||||
const overlayRect = overlay.getBoundingClientRect();
|
||||
const rect = range.getBoundingClientRect();
|
||||
const top = Math.max(0, rect.top - overlayRect.top - 36);
|
||||
const left = Math.max(0, rect.left - overlayRect.left + rect.width / 2 - 80);
|
||||
setToolbarPosition({ top, left });
|
||||
return {
|
||||
selector: anchor.selector,
|
||||
selectedText: containerOffset.selectedText,
|
||||
};
|
||||
}, [containerRef, markdown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const handleSelectionChange = () => {
|
||||
const anchor = captureSelection();
|
||||
if (!anchor) {
|
||||
onPendingAnchorChange(null);
|
||||
setToolbarPosition(null);
|
||||
return;
|
||||
}
|
||||
onPendingAnchorChange(anchor);
|
||||
};
|
||||
document.addEventListener("selectionchange", handleSelectionChange);
|
||||
return () => document.removeEventListener("selectionchange", handleSelectionChange);
|
||||
}, [captureSelection, onPendingAnchorChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (captureSelectionRequestId === undefined) return;
|
||||
if (captureSelectionRequestId === 0) return;
|
||||
if (lastCaptureSelectionRequestIdRef.current === captureSelectionRequestId) return;
|
||||
lastCaptureSelectionRequestIdRef.current = captureSelectionRequestId;
|
||||
const anchor = captureSelection();
|
||||
if (anchor) {
|
||||
onPendingAnchorChange(anchor);
|
||||
onRequestComment(anchor);
|
||||
}
|
||||
}, [captureSelectionRequestId, captureSelection, onPendingAnchorChange, onRequestComment]);
|
||||
|
||||
const handleAddComment = () => {
|
||||
if (pendingAnchor) onRequestComment(pendingAnchor);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!nativeHighlightsSupported ? (
|
||||
<div className="paperclip-doc-annotation-visual-layer pointer-events-none absolute inset-0 z-0" aria-hidden="true">
|
||||
<div className="relative h-full w-full">
|
||||
{highlightRects.map((rect, index) => {
|
||||
const isFocused = rect.threadId === focusedThreadId;
|
||||
const isStale = rect.anchorState === "stale";
|
||||
const isResolved = rect.status === "resolved";
|
||||
return (
|
||||
<span
|
||||
key={`visual-${rect.threadId}-${index}`}
|
||||
data-thread-id={rect.threadId}
|
||||
data-anchor-state={rect.anchorState}
|
||||
data-status={rect.status}
|
||||
data-focused={isFocused || undefined}
|
||||
className={cn(
|
||||
"paperclip-doc-annotation-highlight absolute rounded-none transition-colors",
|
||||
// base box treatment (replaces the previous baseline border)
|
||||
isResolved
|
||||
? "bg-yellow-100 outline outline-1 outline-dashed outline-offset-0 outline-yellow-700/45 dark:bg-yellow-700 dark:outline-yellow-200/45"
|
||||
: isStale
|
||||
? "bg-yellow-200 outline outline-2 outline-dashed outline-offset-0 outline-yellow-700/65 dark:bg-yellow-600 dark:outline-yellow-200/70"
|
||||
: isFocused
|
||||
? "bg-yellow-300 outline outline-2 outline-offset-0 outline-yellow-700/85 shadow-[0_0_0_1px_var(--color-background)] dark:bg-yellow-500 dark:outline-yellow-200/85"
|
||||
: "bg-yellow-200 dark:bg-yellow-600",
|
||||
)}
|
||||
style={{
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="paperclip-doc-annotation-layer pointer-events-none absolute inset-0 z-[2]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div ref={overlayRef} className="relative h-full w-full">
|
||||
{highlightRects.map((rect, index) => {
|
||||
const isFocused = rect.threadId === focusedThreadId;
|
||||
return (
|
||||
<button
|
||||
key={`${rect.threadId}-${index}`}
|
||||
type="button"
|
||||
data-thread-id={rect.threadId}
|
||||
data-anchor-state={rect.anchorState}
|
||||
data-status={rect.status}
|
||||
data-focused={isFocused || undefined}
|
||||
aria-label="Open annotation thread"
|
||||
className={cn(
|
||||
"paperclip-doc-annotation-hit-target pointer-events-auto absolute cursor-pointer rounded-none bg-transparent",
|
||||
isFocused && "ring-1 ring-transparent",
|
||||
)}
|
||||
style={{
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onThreadFocus(rect.threadId);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{highlightRects.map((rect, index) =>
|
||||
rect.isTail && rect.anchorState === "stale" ? (
|
||||
<span
|
||||
key={`tail-${rect.threadId}-${index}`}
|
||||
aria-hidden="true"
|
||||
data-thread-id={rect.threadId}
|
||||
className="paperclip-doc-annotation-tail pointer-events-none absolute inline-flex items-center justify-center rounded-sm bg-amber-500/95 text-amber-50 shadow-sm dark:bg-amber-500/90 dark:text-amber-50"
|
||||
style={{
|
||||
top: rect.top + Math.max(0, rect.height / 2 - 8),
|
||||
left: rect.left + rect.width + 2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
}}
|
||||
title="Anchor moved — needs review"
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
</span>
|
||||
) : null,
|
||||
)}
|
||||
{pendingAnchor && toolbarPosition ? (
|
||||
<div
|
||||
data-testid="document-annotation-selection-toolbar"
|
||||
role="toolbar"
|
||||
aria-label="Selection actions"
|
||||
className="paperclip-doc-annotation-selection-toolbar pointer-events-auto absolute z-10 flex items-center gap-1 rounded-md border border-border bg-popover px-1 py-1 shadow-md"
|
||||
style={{ top: toolbarPosition.top, left: toolbarPosition.left }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={handleAddComment}
|
||||
disabled={newCommentDisabled}
|
||||
title={newCommentDisabled
|
||||
? newCommentDisabledReason ?? undefined
|
||||
: "Add comment on selection (⌘⇧M)"}
|
||||
>
|
||||
<MessageSquarePlus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
DocumentAnnotationComment,
|
||||
DocumentAnnotationThreadStatus,
|
||||
DocumentAnnotationThreadWithComments,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
MoreHorizontal,
|
||||
RotateCcw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn, relativeTime } from "@/lib/utils";
|
||||
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import type { PendingAnchor } from "./DocumentAnnotationLayer";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
|
||||
type AnnotationFilter = "open" | "resolved" | "stale" | "orphan";
|
||||
|
||||
const FILTERS: { id: AnnotationFilter; label: string }[] = [
|
||||
{ id: "open", label: "Open" },
|
||||
{ id: "resolved", label: "Resolved" },
|
||||
{ id: "stale", label: "Stale" },
|
||||
{ id: "orphan", label: "Orphaned" },
|
||||
];
|
||||
|
||||
export interface AnnotationPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
issueId: string;
|
||||
documentKey: string;
|
||||
documentRevisionNumber: number;
|
||||
baseRevisionId: string | null;
|
||||
baseRevisionNumber: number;
|
||||
threads: DocumentAnnotationThreadWithComments[];
|
||||
focusedThreadId: string | null;
|
||||
onFocusThread: (threadId: string | null) => void;
|
||||
focusedCommentId: string | null;
|
||||
/** External pending anchor captured from the layer for the composer. */
|
||||
pendingAnchor: PendingAnchor | null;
|
||||
onClearPendingAnchor: () => void;
|
||||
/** Request the body layer to start a comment from the current text selection (⌘⇧M). */
|
||||
onRequestCommentFromSelection?: () => void;
|
||||
newCommentDisabled?: boolean;
|
||||
newCommentDisabledReason?: string | null;
|
||||
/** When mobile is true, render via shadcn Sheet at the bottom instead of side panel. */
|
||||
isMobile?: boolean;
|
||||
/** Desktop panel width calculated by the document frame. */
|
||||
desktopWidth?: number;
|
||||
className?: string;
|
||||
/** Resolve `<authorAgentId>` to a display name. */
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
/** Resolve `<authorUserId>` to a display name. */
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}
|
||||
|
||||
export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
|
||||
if (props.isMobile) {
|
||||
return (
|
||||
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
className="paperclip-doc-annotation-sheet flex max-h-[88vh] flex-col rounded-none border-t border-border bg-background p-0"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
Comments on {props.documentKey} revision {props.documentRevisionNumber}
|
||||
</SheetTitle>
|
||||
<div className="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-muted-foreground/30" aria-hidden="true" />
|
||||
<AnnotationPanelBody {...props} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
if (!props.open) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
role="complementary"
|
||||
aria-label={`Annotations for ${props.documentKey.toUpperCase()}, revision ${props.documentRevisionNumber}`}
|
||||
data-testid="document-annotation-panel"
|
||||
className={cn(
|
||||
"flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-none border border-border bg-card shadow-md",
|
||||
props.className,
|
||||
)}
|
||||
style={props.desktopWidth ? { width: props.desktopWidth, maxWidth: props.desktopWidth } : undefined}
|
||||
>
|
||||
<AnnotationPanelBody {...props} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState<AnnotationFilter>("open");
|
||||
const [composerValue, setComposerValue] = useState("");
|
||||
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined;
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
return props.threads.filter((thread) => {
|
||||
if (filter === "open") return thread.status === "open" && thread.anchorState !== "orphaned";
|
||||
if (filter === "resolved") return thread.status === "resolved";
|
||||
if (filter === "stale") return thread.anchorState === "stale";
|
||||
if (filter === "orphan") return thread.anchorState === "orphaned";
|
||||
return true;
|
||||
});
|
||||
}, [props.threads, filter]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const result = { open: 0, resolved: 0, stale: 0, orphan: 0 };
|
||||
for (const thread of props.threads) {
|
||||
if (thread.status === "resolved") result.resolved += 1;
|
||||
if (thread.anchorState === "stale") result.stale += 1;
|
||||
if (thread.anchorState === "orphaned") result.orphan += 1;
|
||||
if (thread.status === "open" && thread.anchorState !== "orphaned") result.open += 1;
|
||||
}
|
||||
return result;
|
||||
}, [props.threads]);
|
||||
|
||||
const invalidateAll = useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey)
|
||||
&& query.queryKey[0] === "issues"
|
||||
&& query.queryKey[1] === "document-annotations"
|
||||
&& query.queryKey[2] === props.issueId
|
||||
&& query.queryKey[3] === props.documentKey,
|
||||
});
|
||||
}, [props.documentKey, props.issueId, queryClient]);
|
||||
|
||||
const createThread = useMutation({
|
||||
mutationFn: async (body: string) => {
|
||||
if (!props.pendingAnchor) throw new Error("No selection to anchor to.");
|
||||
if (!props.baseRevisionId) throw new Error("Document has no revision yet.");
|
||||
return documentAnnotationsApi.create(props.issueId, props.documentKey, {
|
||||
baseRevisionId: props.baseRevisionId,
|
||||
baseRevisionNumber: props.baseRevisionNumber,
|
||||
selector: props.pendingAnchor.selector,
|
||||
body,
|
||||
});
|
||||
},
|
||||
onSuccess: (thread) => {
|
||||
props.onClearPendingAnchor();
|
||||
setComposerValue("");
|
||||
props.onFocusThread(thread.id);
|
||||
invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const addReply = useMutation({
|
||||
mutationFn: ({ threadId, body }: { threadId: string; body: string }) =>
|
||||
documentAnnotationsApi.addComment(props.issueId, props.documentKey, threadId, { body }),
|
||||
onSuccess: (_data, variables) => {
|
||||
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
|
||||
invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const updateStatus = useMutation({
|
||||
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) =>
|
||||
documentAnnotationsApi.updateStatus(props.issueId, props.documentKey, threadId, status),
|
||||
onSuccess: () => invalidateAll(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.open) {
|
||||
setComposerValue("");
|
||||
}
|
||||
}, [props.open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.pendingAnchor && props.open) {
|
||||
composerRef.current?.focus();
|
||||
}
|
||||
}, [props.open, props.pendingAnchor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.focusedThreadId) return;
|
||||
const focused = props.threads.find((thread) => thread.id === props.focusedThreadId);
|
||||
if (!focused) return;
|
||||
if (focused.anchorState === "orphaned") setFilter("orphan");
|
||||
else if (focused.anchorState === "stale") setFilter("stale");
|
||||
else if (focused.status === "resolved") setFilter("resolved");
|
||||
else setFilter("open");
|
||||
}, [props.focusedThreadId, props.threads]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
data-testid={bodyTestId}
|
||||
className="flex items-start justify-between gap-2 border-b border-border px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<p className="text-sm font-medium">Comments</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
rev {props.documentRevisionNumber}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
props.onFocusThread(null);
|
||||
props.onOpenChange(false);
|
||||
}}
|
||||
aria-label="Close annotation panel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="flex flex-wrap gap-1 border-b border-border px-3 py-2">
|
||||
{FILTERS.map((entry) => {
|
||||
const count = counts[entry.id];
|
||||
const isActive = filter === entry.id;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => setFilter(entry.id)}
|
||||
data-active={isActive || undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors",
|
||||
isActive
|
||||
? "border-border bg-muted text-foreground"
|
||||
: "border-transparent bg-transparent text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{entry.label}</span>
|
||||
<span className={cn("tabular-nums", isActive ? "text-muted-foreground" : "text-muted-foreground/70")}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{props.newCommentDisabled && props.newCommentDisabledReason ? (
|
||||
<p
|
||||
data-testid="document-annotation-disabled-reason"
|
||||
className="border-b border-border bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{props.newCommentDisabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
{filteredThreads.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
{filter === "open" ? "No open comments yet. Select text to add one." : `No ${filter} comments.`}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{filteredThreads.map((thread) => (
|
||||
<ThreadCard
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
expanded={thread.id === props.focusedThreadId}
|
||||
focusedCommentId={
|
||||
thread.id === props.focusedThreadId ? props.focusedCommentId : null
|
||||
}
|
||||
onFocus={() => props.onFocusThread(thread.id)}
|
||||
replyDraft={replyDrafts[thread.id] ?? ""}
|
||||
onReplyChange={(value) =>
|
||||
setReplyDrafts((current) => ({ ...current, [thread.id]: value }))
|
||||
}
|
||||
onSubmitReply={() => {
|
||||
const body = (replyDrafts[thread.id] ?? "").trim();
|
||||
if (!body) return;
|
||||
addReply.mutate({ threadId: thread.id, body });
|
||||
}}
|
||||
onResolveToggle={() =>
|
||||
updateStatus.mutate({
|
||||
threadId: thread.id,
|
||||
status: thread.status === "resolved" ? "open" : "resolved",
|
||||
})
|
||||
}
|
||||
onCopyLink={() => copyAnnotationLink(props.documentKey, thread.id)}
|
||||
pendingReply={addReply.isPending && addReply.variables?.threadId === thread.id}
|
||||
pendingStatus={updateStatus.isPending && updateStatus.variables?.threadId === thread.id}
|
||||
agentMap={props.agentMap}
|
||||
userProfileMap={props.userProfileMap}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{props.pendingAnchor ? (
|
||||
<div className="border-t border-border bg-muted/20 px-3 py-2">
|
||||
<blockquote className="mb-2 line-clamp-3 overflow-hidden rounded-none bg-background px-2 py-1 text-xs italic text-muted-foreground">
|
||||
{truncate(props.pendingAnchor.selectedText, 160)}
|
||||
</blockquote>
|
||||
<Textarea
|
||||
ref={composerRef}
|
||||
data-testid="document-annotation-composer"
|
||||
rows={3}
|
||||
value={composerValue}
|
||||
onChange={(event) => setComposerValue(event.target.value)}
|
||||
placeholder="Write a comment…"
|
||||
disabled={props.newCommentDisabled}
|
||||
className="resize-y rounded-none text-sm"
|
||||
/>
|
||||
{createThread.isError ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
{(createThread.error as Error).message || "Failed to create comment"}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
props.onClearPendingAnchor();
|
||||
setComposerValue("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={
|
||||
createThread.isPending
|
||||
|| !composerValue.trim()
|
||||
|| props.newCommentDisabled
|
||||
|| !props.baseRevisionId
|
||||
}
|
||||
onClick={() => createThread.mutate(composerValue.trim())}
|
||||
>
|
||||
{createThread.isPending ? "Posting…" : "Comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadCard(props: {
|
||||
thread: DocumentAnnotationThreadWithComments;
|
||||
expanded: boolean;
|
||||
focusedCommentId: string | null;
|
||||
onFocus: () => void;
|
||||
replyDraft: string;
|
||||
onReplyChange: (value: string) => void;
|
||||
onSubmitReply: () => void;
|
||||
onResolveToggle: () => void;
|
||||
onCopyLink: () => void;
|
||||
pendingReply: boolean;
|
||||
pendingStatus: boolean;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}) {
|
||||
const { thread } = props;
|
||||
const statusVariant: { variant: "default" | "outline" | "secondary"; label: string } =
|
||||
thread.status === "resolved"
|
||||
? { variant: "outline", label: "Resolved" }
|
||||
: thread.anchorState === "orphaned"
|
||||
? { variant: "outline", label: "Orphaned" }
|
||||
: thread.anchorState === "stale"
|
||||
? { variant: "outline", label: "Stale" }
|
||||
: { variant: "default", label: "Open" };
|
||||
const latestComment = thread.comments[thread.comments.length - 1];
|
||||
|
||||
return (
|
||||
<li>
|
||||
<article
|
||||
role="article"
|
||||
data-thread-id={thread.id}
|
||||
data-anchor-state={thread.anchorState}
|
||||
data-status={thread.status}
|
||||
data-focused={props.expanded || undefined}
|
||||
aria-labelledby={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"rounded-none border border-border bg-card transition-colors",
|
||||
props.expanded && "ring-1 ring-ring/70",
|
||||
thread.status === "resolved" && "bg-muted/30",
|
||||
)}
|
||||
tabIndex={0}
|
||||
onClick={props.onFocus}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 pt-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant={statusVariant.variant} className="px-1.5 py-0 text-[10px] uppercase tracking-[0.12em]">
|
||||
{statusVariant.label}
|
||||
</Badge>
|
||||
<span>{relativeTime(thread.updatedAt)}</span>
|
||||
</div>
|
||||
<blockquote
|
||||
id={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"mx-3 mt-1 line-clamp-2 overflow-hidden rounded-none bg-muted/40 px-2 py-1 text-xs italic text-muted-foreground",
|
||||
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{truncate(thread.selectedText, 120)}
|
||||
</blockquote>
|
||||
{props.expanded ? (
|
||||
<div className="space-y-2 px-3 py-2">
|
||||
{thread.comments.map((comment) => (
|
||||
<CommentRow
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
focused={props.focusedCommentId === comment.id}
|
||||
agentMap={props.agentMap}
|
||||
userProfileMap={props.userProfileMap}
|
||||
/>
|
||||
))}
|
||||
<Textarea
|
||||
data-testid={`document-annotation-reply-${thread.id}`}
|
||||
rows={2}
|
||||
value={props.replyDraft}
|
||||
onChange={(event) => props.onReplyChange(event.target.value)}
|
||||
placeholder="Reply…"
|
||||
className="resize-y rounded-none text-sm"
|
||||
disabled={props.pendingReply}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={props.onResolveToggle}
|
||||
disabled={props.pendingStatus}
|
||||
className="gap-1"
|
||||
>
|
||||
{thread.status === "resolved" ? (
|
||||
<>
|
||||
<RotateCcw className="h-3 w-3" /> Reopen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-3 w-3" /> Resolve
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!props.replyDraft.trim() || props.pendingReply}
|
||||
onClick={props.onSubmitReply}
|
||||
>
|
||||
{props.pendingReply ? "Sending…" : "Reply"}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
title="More actions"
|
||||
aria-label="More thread actions"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
props.onCopyLink();
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy link
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{thread.comments.length} comment{thread.comments.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
{latestComment ? <span className="ml-1">· {truncate(latestComment.body, 120)}</span> : null}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentRow({
|
||||
comment,
|
||||
focused,
|
||||
agentMap,
|
||||
userProfileMap,
|
||||
}: {
|
||||
comment: DocumentAnnotationComment;
|
||||
focused: boolean;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}) {
|
||||
const author = resolveAuthor(comment, { agentMap, userProfileMap });
|
||||
return (
|
||||
<div
|
||||
id={`comment-${comment.id}`}
|
||||
data-focused={focused || undefined}
|
||||
className={cn(
|
||||
"rounded-none border border-border bg-background px-2 py-1.5",
|
||||
focused && "ring-2 ring-primary/40",
|
||||
)}
|
||||
>
|
||||
<div className="mb-0.5 flex items-center justify-between gap-2 text-[11px]">
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium text-foreground">{author.name}</span>
|
||||
{author.role === "agent" ? (
|
||||
<span className="ml-1 text-muted-foreground">· agent</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{relativeTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm leading-6">{comment.body}</MarkdownBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAuthor(
|
||||
comment: DocumentAnnotationComment,
|
||||
maps: {
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
},
|
||||
): { name: string; role: "board" | "agent" } {
|
||||
if (comment.authorAgentId) {
|
||||
const agent = maps.agentMap?.get(comment.authorAgentId);
|
||||
return {
|
||||
name: agent?.name ?? comment.authorAgentId.slice(0, 8),
|
||||
role: "agent",
|
||||
};
|
||||
}
|
||||
if (comment.authorUserId) {
|
||||
const profile = maps.userProfileMap?.get(comment.authorUserId);
|
||||
return {
|
||||
name: profile?.label ?? comment.authorUserId.slice(0, 8),
|
||||
role: "board",
|
||||
};
|
||||
}
|
||||
return { name: comment.authorType === "agent" ? "Agent" : "Board", role: comment.authorType === "agent" ? "agent" : "board" };
|
||||
}
|
||||
|
||||
function truncate(value: string, limit: number) {
|
||||
if (value.length <= limit) return value;
|
||||
return `${value.slice(0, limit - 1)}…`;
|
||||
}
|
||||
|
||||
async function copyAnnotationLink(documentKey: string, threadId: string) {
|
||||
if (typeof window === "undefined" || !navigator.clipboard) return;
|
||||
const { pathname } = window.location;
|
||||
const hash = `#document-${encodeURIComponent(documentKey)}&thread=${encodeURIComponent(threadId)}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}${pathname}${hash}`);
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
DocumentAnnotationThreadWithComments,
|
||||
IssueDocument,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DocumentAnnotationsCountChip,
|
||||
IssueDocumentAnnotations,
|
||||
} from "./IssueDocumentAnnotations";
|
||||
|
||||
const mockAnnotationsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockPendingAnchor = vi.hoisted(() => ({
|
||||
selector: {
|
||||
quote: { exact: "should keep the editor", prefix: "We ", suffix: "." },
|
||||
position: { normalizedStart: 10, normalizedEnd: 32, markdownStart: 10, markdownEnd: 32 },
|
||||
},
|
||||
selectedText: "should keep the editor",
|
||||
}));
|
||||
|
||||
vi.mock("@/api/document-annotations", () => ({
|
||||
documentAnnotationsApi: mockAnnotationsApi,
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
Sheet: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-slot="sheet">{children}</div> : null,
|
||||
SheetContent: ({
|
||||
children,
|
||||
className,
|
||||
side,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
side?: string;
|
||||
}) => (
|
||||
<div data-slot="sheet-content" data-side={side} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SheetTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-slot="sheet-title" className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./DocumentAnnotationLayer", () => ({
|
||||
DocumentAnnotationLayer: (props: {
|
||||
newCommentDisabled?: boolean;
|
||||
onPendingAnchorChange: (anchor: typeof mockPendingAnchor | null) => void;
|
||||
onRequestComment: (anchor: typeof mockPendingAnchor) => void;
|
||||
}) => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-annotation-selection"
|
||||
disabled={props.newCommentDisabled}
|
||||
onClick={() => {
|
||||
props.onPendingAnchorChange(mockPendingAnchor);
|
||||
props.onRequestComment(mockPendingAnchor);
|
||||
props.onPendingAnchorChange(null);
|
||||
}}
|
||||
>
|
||||
Mock selection
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-annotation-selection-only"
|
||||
disabled={props.newCommentDisabled}
|
||||
onClick={() => {
|
||||
props.onPendingAnchorChange(mockPendingAnchor);
|
||||
}}
|
||||
>
|
||||
Mock captured selection
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
await callback();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(() => {});
|
||||
}
|
||||
|
||||
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||
setter?.call(textarea, value);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeDoc(overrides: Partial<IssueDocument> = {}): IssueDocument {
|
||||
return {
|
||||
id: "doc-1",
|
||||
companyId: "co-1",
|
||||
issueId: "issue-1",
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "# Plan\n\nWe should keep the editor.",
|
||||
latestRevisionId: "rev-4",
|
||||
latestRevisionNumber: 4,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "user-1",
|
||||
lockedAt: null,
|
||||
lockedByAgentId: null,
|
||||
lockedByUserId: null,
|
||||
createdAt: new Date("2026-04-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-04-01T00:01:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeThread(
|
||||
overrides: Partial<DocumentAnnotationThreadWithComments> = {},
|
||||
): DocumentAnnotationThreadWithComments {
|
||||
const id = overrides.id ?? "thread-1";
|
||||
return {
|
||||
id,
|
||||
companyId: "co-1",
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
documentKey: "plan",
|
||||
status: "open",
|
||||
anchorState: "active",
|
||||
anchorConfidence: "exact",
|
||||
originalRevisionId: "rev-4",
|
||||
originalRevisionNumber: 4,
|
||||
currentRevisionId: "rev-4",
|
||||
currentRevisionNumber: 4,
|
||||
selectedText: "should keep the editor",
|
||||
prefixText: "We ",
|
||||
suffixText: ".",
|
||||
normalizedStart: 0,
|
||||
normalizedEnd: 22,
|
||||
markdownStart: 0,
|
||||
markdownEnd: 22,
|
||||
anchorSelector: {
|
||||
quote: { exact: "should keep the editor", prefix: "We ", suffix: "." },
|
||||
position: { normalizedStart: 0, normalizedEnd: 22, markdownStart: 0, markdownEnd: 22 },
|
||||
},
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-04-01T00:01:00Z"),
|
||||
updatedAt: new Date("2026-04-01T00:02:00Z"),
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
companyId: "co-1",
|
||||
threadId: id,
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
body: "Please clarify this assumption.",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-04-01T00:01:00Z"),
|
||||
updatedAt: new Date("2026-04-01T00:01:00Z"),
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({
|
||||
doc,
|
||||
draftDirty = false,
|
||||
draftConflicted = false,
|
||||
historicalPreview = false,
|
||||
locationHash = "",
|
||||
initialPanelOpen = false,
|
||||
}: {
|
||||
doc: IssueDocument;
|
||||
draftDirty?: boolean;
|
||||
draftConflicted?: boolean;
|
||||
historicalPreview?: boolean;
|
||||
locationHash?: string;
|
||||
initialPanelOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(initialPanelOpen);
|
||||
return (
|
||||
<>
|
||||
<DocumentAnnotationsCountChip
|
||||
issueId="issue-1"
|
||||
docKey={doc.key}
|
||||
panelOpen={open}
|
||||
onToggle={() => setOpen((current) => !current)}
|
||||
/>
|
||||
<IssueDocumentAnnotations
|
||||
issueId="issue-1"
|
||||
doc={doc}
|
||||
bodyMarkdown={doc.body}
|
||||
draftDirty={draftDirty}
|
||||
draftConflicted={draftConflicted}
|
||||
historicalPreview={historicalPreview}
|
||||
locationHash={locationHash}
|
||||
panelOpen={open}
|
||||
onPanelOpenChange={setOpen}
|
||||
>
|
||||
<p>Body content</p>
|
||||
</IssueDocumentAnnotations>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("IssueDocumentAnnotations", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders the open count chip and opens the panel on click", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const chip = container.querySelector('[data-testid="document-annotation-count-plan"]');
|
||||
expect(chip).not.toBeNull();
|
||||
expect(chip!.textContent).toContain("1");
|
||||
expect(mockAnnotationsApi.list).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
(chip as HTMLButtonElement).click();
|
||||
});
|
||||
await flush();
|
||||
const panel = container.querySelector('[data-testid="document-annotation-panel"]');
|
||||
expect(panel).not.toBeNull();
|
||||
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]');
|
||||
expect(anchor).not.toBeNull();
|
||||
expect(anchor?.className).toContain("fixed");
|
||||
});
|
||||
|
||||
it("keeps the desktop annotation panel inside the issue content area when properties are visible", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
const rectFor = (left: number, top: number, right: number, bottom: number) => ({
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
if (this instanceof HTMLElement && this.id === "main-content") {
|
||||
return rectFor(0, 0, 900, 800);
|
||||
}
|
||||
if (
|
||||
this instanceof HTMLElement
|
||||
&& this.getAttribute("data-testid") === "document-annotation-body-plan"
|
||||
) {
|
||||
return rectFor(80, 120, 640, 620);
|
||||
}
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<main id="main-content">
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</main>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]') as HTMLElement | null;
|
||||
const panel = container.querySelector('[data-testid="document-annotation-panel"]') as HTMLElement | null;
|
||||
expect(anchor).not.toBeNull();
|
||||
expect(panel).not.toBeNull();
|
||||
expect(anchor!.style.left).toBe("524px");
|
||||
expect(anchor!.style.width).toBe("360px");
|
||||
expect(panel!.style.width).toBe("360px");
|
||||
expect(parseFloat(anchor!.style.left) + parseFloat(anchor!.style.width)).toBeLessThanOrEqual(884);
|
||||
} finally {
|
||||
rectSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-opens the panel and focuses the thread when deep-linked", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread({ id: "thread-99" })]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} locationHash="#document-plan&thread=thread-99" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const panel = container.querySelector('[data-testid="document-annotation-panel"]');
|
||||
expect(panel).not.toBeNull();
|
||||
const focusedThread = container.querySelector('[data-thread-id="thread-99"][data-focused]');
|
||||
expect(focusedThread).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows a disabled reason in the panel when the draft is dirty", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} draftDirty initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const reason = container.querySelector(
|
||||
'[data-testid="document-annotation-disabled-reason"]',
|
||||
);
|
||||
expect(reason).not.toBeNull();
|
||||
expect(reason!.textContent).toMatch(/draft/i);
|
||||
});
|
||||
|
||||
it("filters resolved threads behind their tab", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({ id: "open-1" }),
|
||||
makeThread({ id: "resolved-1", status: "resolved" }),
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
// Open filter shows only open
|
||||
expect(container.querySelector('[data-thread-id="open-1"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).toBeNull();
|
||||
|
||||
// Switch to Resolved
|
||||
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.startsWith("Resolved"),
|
||||
);
|
||||
expect(resolvedTab).not.toBeUndefined();
|
||||
await act(async () => resolvedTab!.click());
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('[data-thread-id="resolved-1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders author name + role from agent and user maps", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({
|
||||
id: "open-1",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-board",
|
||||
companyId: "co-1",
|
||||
threadId: "open-1",
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
body: "From the board.",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-04-01T00:01:00Z"),
|
||||
updatedAt: new Date("2026-04-01T00:01:00Z"),
|
||||
},
|
||||
{
|
||||
id: "comment-agent",
|
||||
companyId: "co-1",
|
||||
threadId: "open-1",
|
||||
issueId: "issue-1",
|
||||
documentId: "doc-1",
|
||||
body: "From the agent.",
|
||||
authorType: "agent",
|
||||
authorAgentId: "agent-uxdesigner",
|
||||
authorUserId: null,
|
||||
createdByRunId: "run-1",
|
||||
createdAt: new Date("2026-04-01T00:02:00Z"),
|
||||
updatedAt: new Date("2026-04-01T00:02:00Z"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
const agentMap = new Map([["agent-uxdesigner", { id: "agent-uxdesigner", name: "UXDesigner" }]]);
|
||||
const userProfileMap = new Map([["user-1", { label: "Dotta", image: null }]]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DocumentAnnotationsCountChip
|
||||
issueId="issue-1"
|
||||
docKey={doc.key}
|
||||
panelOpen
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
<IssueDocumentAnnotations
|
||||
issueId="issue-1"
|
||||
doc={doc}
|
||||
bodyMarkdown={doc.body}
|
||||
draftDirty={false}
|
||||
draftConflicted={false}
|
||||
historicalPreview={false}
|
||||
locationHash=""
|
||||
panelOpen
|
||||
onPanelOpenChange={() => {}}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
>
|
||||
<p>Body</p>
|
||||
</IssueDocumentAnnotations>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
// Click the open thread to expand it.
|
||||
const threadCard = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
|
||||
expect(threadCard).not.toBeNull();
|
||||
await act(async () => threadCard!.click());
|
||||
await flush();
|
||||
|
||||
const expandedText = container.querySelector('[data-thread-id="open-1"]')?.textContent ?? "";
|
||||
expect(expandedText).toContain("Dotta");
|
||||
expect(expandedText).not.toContain("· board");
|
||||
expect(expandedText).toContain("UXDesigner");
|
||||
expect(expandedText).toContain("· agent");
|
||||
});
|
||||
|
||||
it("does not render a persistent New comment on selection hint when panel is open", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const cta = container.querySelector('[data-testid="document-annotation-new-comment-cta"]');
|
||||
expect(cta).toBeNull();
|
||||
expect(container.textContent).not.toMatch(/New comment on selection/i);
|
||||
expect(container.textContent).not.toMatch(/⌘⇧M/);
|
||||
});
|
||||
|
||||
it("keeps a captured selection from opening the composer until the layer requests a comment", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const selectOnlyButton = container.querySelector(
|
||||
'[data-testid="mock-annotation-selection-only"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(selectOnlyButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
selectOnlyButton!.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.querySelector('[data-testid="document-annotation-composer"]')).toBeNull();
|
||||
|
||||
expect(container.querySelector('[data-testid="document-annotation-new-comment-cta"]')).toBeNull();
|
||||
const directRequestButton = container.querySelector(
|
||||
'[data-testid="mock-annotation-selection"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(directRequestButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
directRequestButton!.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
const composer = container.querySelector(
|
||||
'[data-testid="document-annotation-composer"]',
|
||||
) as HTMLTextAreaElement | null;
|
||||
expect(composer).not.toBeNull();
|
||||
expect(container.textContent).toContain(mockPendingAnchor.selectedText);
|
||||
});
|
||||
|
||||
it("creates a thread from a captured selection and refreshes the shared annotations query", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([]);
|
||||
mockAnnotationsApi.create.mockResolvedValue(makeThread({ id: "created-1" }));
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
expect(mockAnnotationsApi.list).toHaveBeenCalledTimes(1);
|
||||
|
||||
const selectButton = container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement | null;
|
||||
expect(selectButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
selectButton!.click();
|
||||
});
|
||||
await flush();
|
||||
|
||||
const composer = container.querySelector('[data-testid="document-annotation-composer"]') as HTMLTextAreaElement | null;
|
||||
expect(composer).not.toBeNull();
|
||||
await act(async () => {
|
||||
setTextareaValue(composer!, "New anchored comment");
|
||||
});
|
||||
await flush();
|
||||
|
||||
const submit = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Comment",
|
||||
);
|
||||
expect(submit).not.toBeUndefined();
|
||||
await act(async () => {
|
||||
submit!.click();
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(mockAnnotationsApi.create).toHaveBeenCalledWith("issue-1", "plan", {
|
||||
baseRevisionId: "rev-4",
|
||||
baseRevisionNumber: 4,
|
||||
selector: mockPendingAnchor.selector,
|
||||
body: "New anchored comment",
|
||||
});
|
||||
expect(mockAnnotationsApi.list.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("shows resolve and reopen actions and updates thread status", async () => {
|
||||
mockAnnotationsApi.list.mockResolvedValue([
|
||||
makeThread({ id: "open-1" }),
|
||||
makeThread({ id: "resolved-1", status: "resolved" }),
|
||||
]);
|
||||
mockAnnotationsApi.updateStatus.mockResolvedValue(makeThread({ id: "open-1", status: "resolved" }));
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const openThread = container.querySelector('[data-thread-id="open-1"]') as HTMLElement | null;
|
||||
expect(openThread).not.toBeNull();
|
||||
await act(async () => openThread!.click());
|
||||
await flush();
|
||||
|
||||
const resolveButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => /\bResolve\b/.test(button.textContent ?? ""),
|
||||
);
|
||||
expect(resolveButton).not.toBeUndefined();
|
||||
await act(async () => resolveButton!.click());
|
||||
await flush();
|
||||
expect(mockAnnotationsApi.updateStatus).toHaveBeenCalledWith("issue-1", "plan", "open-1", "resolved");
|
||||
|
||||
const resolvedTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.startsWith("Resolved"),
|
||||
);
|
||||
expect(resolvedTab).not.toBeUndefined();
|
||||
await act(async () => resolvedTab!.click());
|
||||
await flush();
|
||||
|
||||
const resolvedThread = container.querySelector('[data-thread-id="resolved-1"]') as HTMLElement | null;
|
||||
expect(resolvedThread).not.toBeNull();
|
||||
await act(async () => resolvedThread!.click());
|
||||
await flush();
|
||||
|
||||
const reopenButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Reopen"),
|
||||
);
|
||||
expect(reopenButton).not.toBeUndefined();
|
||||
await act(async () => reopenButton!.click());
|
||||
await flush();
|
||||
expect(mockAnnotationsApi.updateStatus).toHaveBeenCalledWith("issue-1", "plan", "resolved-1", "open");
|
||||
});
|
||||
|
||||
it("renders the mobile annotation panel through the sheet path", async () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = makeQueryClient();
|
||||
const doc = makeDoc();
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness doc={doc} initialPanelOpen />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const sheet = container.querySelector('[data-slot="sheet-content"]');
|
||||
expect(sheet).not.toBeNull();
|
||||
expect(sheet?.getAttribute("data-side")).toBe("bottom");
|
||||
expect(sheet?.className).toContain("paperclip-doc-annotation-sheet");
|
||||
} finally {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: originalMatchMedia,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from "@paperclipai/shared";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { documentAnnotationsApi } from "@/api/document-annotations";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
|
||||
import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer";
|
||||
import { DocumentAnnotationPanel } from "./DocumentAnnotationPanel";
|
||||
import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
|
||||
const DESKTOP_ANNOTATION_PANEL_WIDTH = 360;
|
||||
const DESKTOP_ANNOTATION_PANEL_MIN_WIDTH = 280;
|
||||
const DESKTOP_ANNOTATION_PANEL_GAP = 12;
|
||||
const DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN = 16;
|
||||
|
||||
export interface IssueDocumentAnnotationsProps {
|
||||
issueId: string;
|
||||
doc: IssueDocument;
|
||||
/** The body that is being rendered/edited (current or historical revision). */
|
||||
bodyMarkdown: string;
|
||||
/** True when a draft has unsaved changes or is currently saving. */
|
||||
draftDirty: boolean;
|
||||
/** True when there is a remote conflict that requires user resolution. */
|
||||
draftConflicted: boolean;
|
||||
/** True when the document is being viewed in historical revision preview. */
|
||||
historicalPreview: boolean;
|
||||
/** Render the document body (rendered MarkdownBody or MarkdownEditor) inside the wrapper. */
|
||||
children: ReactNode;
|
||||
/** Current location hash so we can resolve deep-link targets. */
|
||||
locationHash: string;
|
||||
/** Controlled panel state. Caller owns this so the count chip can live in the doc header. */
|
||||
panelOpen: boolean;
|
||||
onPanelOpenChange: (open: boolean) => void;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
/** Seed which thread is focused on mount. Used by Storybook/screenshot harness. */
|
||||
defaultFocusedThreadId?: string;
|
||||
}
|
||||
|
||||
export function IssueDocumentAnnotations({
|
||||
issueId,
|
||||
doc,
|
||||
bodyMarkdown,
|
||||
draftDirty,
|
||||
draftConflicted,
|
||||
historicalPreview,
|
||||
children,
|
||||
locationHash,
|
||||
panelOpen,
|
||||
onPanelOpenChange,
|
||||
agentMap,
|
||||
userProfileMap,
|
||||
defaultFocusedThreadId,
|
||||
}: IssueDocumentAnnotationsProps) {
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(defaultFocusedThreadId ?? null);
|
||||
const [focusedCommentId, setFocusedCommentId] = useState<string | null>(null);
|
||||
const [selectionAnchor, setSelectionAnchor] = useState<PendingAnchor | null>(null);
|
||||
const [composerAnchor, setComposerAnchor] = useState<PendingAnchor | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [desktopPanelFrame, setDesktopPanelFrame] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
maxHeight: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
const hashHandledRef = useRef<string | null>(null);
|
||||
// Bus token to ask the body layer to capture the current selection into a pendingAnchor.
|
||||
const [captureSelectionRequestId, setCaptureSelectionRequestId] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const mediaQuery = window.matchMedia("(max-width: 1023px)");
|
||||
const handler = () => setIsMobile(mediaQuery.matches);
|
||||
handler();
|
||||
if (typeof mediaQuery.addEventListener === "function") {
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
}
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelOpen || isMobile || typeof window === "undefined") {
|
||||
setDesktopPanelFrame(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePanelFrame = () => {
|
||||
const container = containerRef.current;
|
||||
const rect = container?.getBoundingClientRect();
|
||||
if (!container || !rect) {
|
||||
setDesktopPanelFrame(null);
|
||||
return;
|
||||
}
|
||||
const boundaryRect = container.closest("main")?.getBoundingClientRect();
|
||||
const boundaryLeft = boundaryRect?.left ?? 0;
|
||||
const boundaryRight = boundaryRect?.right ?? window.innerWidth;
|
||||
const boundaryWidth = Math.max(0, boundaryRight - boundaryLeft);
|
||||
const maxPanelWidth = Math.max(
|
||||
DESKTOP_ANNOTATION_PANEL_MIN_WIDTH,
|
||||
boundaryWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
|
||||
);
|
||||
const desiredWidth = Math.min(DESKTOP_ANNOTATION_PANEL_WIDTH, maxPanelWidth);
|
||||
const top = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, rect.top);
|
||||
const desiredLeft = rect.right + DESKTOP_ANNOTATION_PANEL_GAP;
|
||||
const spaceRightOfDocument = boundaryRight
|
||||
- desiredLeft
|
||||
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||
const width = spaceRightOfDocument >= DESKTOP_ANNOTATION_PANEL_MIN_WIDTH
|
||||
? Math.min(desiredWidth, spaceRightOfDocument)
|
||||
: desiredWidth;
|
||||
const maxVisibleLeft = boundaryRight
|
||||
- width
|
||||
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
|
||||
setDesktopPanelFrame({
|
||||
left: Math.max(
|
||||
boundaryLeft + DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
|
||||
Math.min(desiredLeft, maxVisibleLeft),
|
||||
),
|
||||
top,
|
||||
width,
|
||||
maxHeight: Math.max(240, window.innerHeight - top - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN),
|
||||
});
|
||||
};
|
||||
|
||||
updatePanelFrame();
|
||||
window.addEventListener("resize", updatePanelFrame);
|
||||
window.addEventListener("scroll", updatePanelFrame, true);
|
||||
const resizeObserver = typeof window.ResizeObserver === "function"
|
||||
? new window.ResizeObserver(updatePanelFrame)
|
||||
: null;
|
||||
const observedContainer = containerRef.current;
|
||||
if (resizeObserver && observedContainer) {
|
||||
resizeObserver.observe(observedContainer);
|
||||
const main = observedContainer.closest("main");
|
||||
if (main) resizeObserver.observe(main);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePanelFrame);
|
||||
window.removeEventListener("scroll", updatePanelFrame, true);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [doc.key, isMobile, panelOpen]);
|
||||
|
||||
const annotationsQuery = useQuery({
|
||||
queryKey: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
|
||||
queryFn: () => documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const allThreads = annotationsQuery.data ?? [];
|
||||
|
||||
// Resolve deep link `#document-<key>&thread=...&comment=...` once per change.
|
||||
useEffect(() => {
|
||||
if (!locationHash) return;
|
||||
if (hashHandledRef.current === locationHash) return;
|
||||
const target = parseDocumentAnnotationHash(locationHash);
|
||||
if (!target || target.documentKey !== doc.key) return;
|
||||
if (!target.threadId) return;
|
||||
hashHandledRef.current = locationHash;
|
||||
onPanelOpenChange(true);
|
||||
setFocusedThreadId(target.threadId);
|
||||
setFocusedCommentId(target.commentId);
|
||||
}, [doc.key, locationHash, onPanelOpenChange]);
|
||||
|
||||
const newCommentDisabled = draftDirty || draftConflicted || historicalPreview || !doc.latestRevisionId;
|
||||
const newCommentDisabledReason = historicalPreview
|
||||
? "New comments are disabled while previewing a historical revision."
|
||||
: draftConflicted
|
||||
? "Resolve the document conflict before adding new comments."
|
||||
: draftDirty
|
||||
? "Save the draft to anchor new comments."
|
||||
: !doc.latestRevisionId
|
||||
? "Document has no saved revision yet."
|
||||
: null;
|
||||
|
||||
const handleSelectionAnchorChange = useCallback((anchor: PendingAnchor | null) => {
|
||||
setSelectionAnchor(anchor);
|
||||
}, []);
|
||||
|
||||
const handleClearComposerAnchor = useCallback(() => {
|
||||
setSelectionAnchor(null);
|
||||
setComposerAnchor(null);
|
||||
}, []);
|
||||
|
||||
const handleRequestComment = useCallback((anchor: PendingAnchor) => {
|
||||
if (newCommentDisabled) return;
|
||||
setSelectionAnchor(null);
|
||||
setComposerAnchor(anchor);
|
||||
onPanelOpenChange(true);
|
||||
}, [newCommentDisabled, onPanelOpenChange]);
|
||||
|
||||
const handleThreadFocus = useCallback((threadId: string | null) => {
|
||||
setFocusedThreadId(threadId);
|
||||
if (threadId) {
|
||||
onPanelOpenChange(true);
|
||||
setFocusedCommentId(null);
|
||||
}
|
||||
}, [onPanelOpenChange]);
|
||||
|
||||
const handleRequestCommentFromSelection = useCallback(() => {
|
||||
if (newCommentDisabled) return;
|
||||
if (selectionAnchor) {
|
||||
handleRequestComment(selectionAnchor);
|
||||
return;
|
||||
}
|
||||
// Trigger the layer to re-read the current selection and emit a pendingAnchor.
|
||||
setCaptureSelectionRequestId((current) => current + 1);
|
||||
}, [handleRequestComment, newCommentDisabled, selectionAnchor]);
|
||||
|
||||
// ⌘⇧M / Ctrl+Shift+M global shortcut while the panel is open.
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
const isMeta = event.metaKey || event.ctrlKey;
|
||||
if (!isMeta || !event.shiftKey) return;
|
||||
if (event.key.toLowerCase() !== "m") return;
|
||||
event.preventDefault();
|
||||
handleRequestCommentFromSelection();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [panelOpen, handleRequestCommentFromSelection]);
|
||||
|
||||
const focusedThread = useMemo(() => {
|
||||
if (!focusedThreadId) return null;
|
||||
return allThreads.find((thread) => thread.id === focusedThreadId) ?? null;
|
||||
}, [allThreads, focusedThreadId]);
|
||||
|
||||
const overlayThreads = useMemo(
|
||||
() => allThreads.map((thread) => ({
|
||||
id: thread.id,
|
||||
selectedText: thread.selectedText,
|
||||
status: thread.status,
|
||||
anchorState: thread.anchorState,
|
||||
})),
|
||||
[allThreads],
|
||||
);
|
||||
|
||||
const annotationPanel = panelOpen ? (
|
||||
<DocumentAnnotationPanel
|
||||
open={panelOpen}
|
||||
onOpenChange={(open) => {
|
||||
onPanelOpenChange(open);
|
||||
if (!open) {
|
||||
setSelectionAnchor(null);
|
||||
setComposerAnchor(null);
|
||||
setFocusedThreadId(null);
|
||||
setFocusedCommentId(null);
|
||||
}
|
||||
}}
|
||||
issueId={issueId}
|
||||
documentKey={doc.key}
|
||||
documentRevisionNumber={doc.latestRevisionNumber}
|
||||
baseRevisionId={doc.latestRevisionId}
|
||||
baseRevisionNumber={doc.latestRevisionNumber}
|
||||
threads={allThreads as DocumentAnnotationThreadWithComments[]}
|
||||
focusedThreadId={focusedThreadId}
|
||||
focusedCommentId={focusedCommentId}
|
||||
onFocusThread={(id) => {
|
||||
setFocusedThreadId(id);
|
||||
if (!id) setFocusedCommentId(null);
|
||||
}}
|
||||
pendingAnchor={composerAnchor}
|
||||
onClearPendingAnchor={handleClearComposerAnchor}
|
||||
onRequestCommentFromSelection={handleRequestCommentFromSelection}
|
||||
newCommentDisabled={newCommentDisabled}
|
||||
newCommentDisabledReason={newCommentDisabledReason}
|
||||
isMobile={isMobile}
|
||||
desktopWidth={desktopPanelFrame?.width}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="paperclip-doc-annotation-host relative">
|
||||
<section
|
||||
ref={(element) => {
|
||||
containerRef.current = element;
|
||||
}}
|
||||
className="relative min-w-0"
|
||||
data-testid={`document-annotation-body-${doc.key}`}
|
||||
>
|
||||
<div className="relative z-[1]">
|
||||
{children}
|
||||
</div>
|
||||
{!historicalPreview && doc.latestRevisionId ? (
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={containerRef}
|
||||
markdown={bodyMarkdown}
|
||||
threads={overlayThreads}
|
||||
focusedThreadId={focusedThread?.id ?? null}
|
||||
onThreadFocus={handleThreadFocus}
|
||||
pendingAnchor={selectionAnchor}
|
||||
onPendingAnchorChange={handleSelectionAnchorChange}
|
||||
onRequestComment={handleRequestComment}
|
||||
newCommentDisabled={newCommentDisabled}
|
||||
newCommentDisabledReason={newCommentDisabledReason}
|
||||
hideResolved
|
||||
captureSelectionRequestId={captureSelectionRequestId}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
{panelOpen && !isMobile && desktopPanelFrame ? (
|
||||
<div
|
||||
data-testid="document-annotation-panel-anchor"
|
||||
className="pointer-events-auto fixed hidden lg:block"
|
||||
style={{
|
||||
left: desktopPanelFrame.left,
|
||||
maxHeight: desktopPanelFrame.maxHeight,
|
||||
top: desktopPanelFrame.top,
|
||||
width: desktopPanelFrame.width,
|
||||
}}
|
||||
>
|
||||
{annotationPanel}
|
||||
</div>
|
||||
) : null}
|
||||
{panelOpen && isMobile ? annotationPanel : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DocumentAnnotationsCountChipProps {
|
||||
issueId: string;
|
||||
docKey: string;
|
||||
panelOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the unresolved-count chip for a document. Lives in the document header row
|
||||
* (next to `rev N ▾`) so it stays visible when the document is folded.
|
||||
*/
|
||||
export function DocumentAnnotationsCountChip({
|
||||
issueId,
|
||||
docKey,
|
||||
panelOpen,
|
||||
onToggle,
|
||||
}: DocumentAnnotationsCountChipProps) {
|
||||
const annotationsQuery = useQuery({
|
||||
queryKey: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
|
||||
queryFn: () => documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const threads = annotationsQuery.data ?? [];
|
||||
const openCount = useMemo(
|
||||
() => threads.filter((thread) => thread.status === "open" && thread.anchorState !== "orphaned").length,
|
||||
[threads],
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
data-state={panelOpen ? "open" : "closed"}
|
||||
className={cn(
|
||||
"h-auto gap-1 rounded-md px-1.5 py-0 text-[11px] font-normal text-muted-foreground hover:text-foreground",
|
||||
panelOpen && "bg-muted text-foreground",
|
||||
openCount > 0 && "text-foreground",
|
||||
)}
|
||||
onClick={onToggle}
|
||||
data-testid={`document-annotation-count-${docKey}`}
|
||||
aria-label={openCount === 0
|
||||
? `Open comments on ${docKey}`
|
||||
: `Open ${openCount} unresolved comments on ${docKey}`}
|
||||
aria-expanded={panelOpen}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" aria-hidden="true" />
|
||||
<span className="tabular-nums">{openCount}</span>
|
||||
<span className="hidden sm:inline">
|
||||
{openCount === 1 ? "comment" : "comments"}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
Agent,
|
||||
DocumentRevision,
|
||||
FeedbackDataSharingPreference,
|
||||
FeedbackVote,
|
||||
@@ -14,9 +15,11 @@ import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
import { deriveDocumentRevisionState } from "../lib/document-revisions";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { FoldCurtain } from "./FoldCurtain";
|
||||
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||
@@ -151,6 +154,11 @@ export function IssueDocumentsSection({
|
||||
imageUploadHandler,
|
||||
onVote,
|
||||
extraActions,
|
||||
agentMap,
|
||||
userProfileMap,
|
||||
defaultAnnotationPanelOpenKeys,
|
||||
defaultAnnotationFocusedThreadIds,
|
||||
forceEditDocumentKey,
|
||||
}: {
|
||||
issue: Issue;
|
||||
canDeleteDocuments: boolean;
|
||||
@@ -166,6 +174,17 @@ export function IssueDocumentsSection({
|
||||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) => Promise<void>;
|
||||
extraActions?: ReactNode;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
/**
|
||||
* Seed which document annotation panels are open on first render. Mostly useful
|
||||
* for Storybook / screenshot harnesses; runtime callers usually omit this.
|
||||
*/
|
||||
defaultAnnotationPanelOpenKeys?: string[];
|
||||
/** Per-doc seed for the focused annotation thread id (Storybook-only). */
|
||||
defaultAnnotationFocusedThreadIds?: Readonly<Record<string, string>>;
|
||||
/** Force a doc into edit mode on mount (Storybook-only). */
|
||||
forceEditDocumentKey?: string | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
@@ -174,6 +193,9 @@ export function IssueDocumentsSection({
|
||||
const [draft, setDraft] = useState<DraftState | null>(null);
|
||||
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
|
||||
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
|
||||
const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState<string[]>(
|
||||
() => (defaultAnnotationPanelOpenKeys ?? []),
|
||||
);
|
||||
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
||||
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||
@@ -213,8 +235,10 @@ export function IssueDocumentsSection({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey)
|
||||
&& query.queryKey[0] === "issues"
|
||||
&& query.queryKey[1] === "document-revisions"
|
||||
&& query.queryKey[2] === issue.id,
|
||||
&& (
|
||||
(query.queryKey[1] === "document-revisions" && query.queryKey[2] === issue.id)
|
||||
|| (query.queryKey[1] === "document-annotations" && query.queryKey[2] === issue.id)
|
||||
),
|
||||
});
|
||||
}, [issue.id, queryClient]);
|
||||
|
||||
@@ -368,6 +392,17 @@ export function IssueDocumentsSection({
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const initialEditAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!forceEditDocumentKey) return;
|
||||
if (initialEditAppliedRef.current) return;
|
||||
const target = (documents ?? []).find((entry) => entry.key === forceEditDocumentKey);
|
||||
if (!target) return;
|
||||
initialEditAppliedRef.current = true;
|
||||
beginEdit(forceEditDocumentKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [forceEditDocumentKey, documents]);
|
||||
|
||||
const cancelDraft = () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
@@ -726,6 +761,24 @@ export function IssueDocumentsSection({
|
||||
: [...current, key],
|
||||
);
|
||||
};
|
||||
const setAnnotationPanelOpen = useCallback((key: string, nextOpen: boolean) => {
|
||||
setAnnotationPanelOpenKeys((current) => {
|
||||
const isOpen = current.includes(key);
|
||||
if (nextOpen && !isOpen) return [...current, key];
|
||||
if (!nextOpen && isOpen) return current.filter((entry) => entry !== key);
|
||||
return current;
|
||||
});
|
||||
if (nextOpen) {
|
||||
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
|
||||
}
|
||||
}, []);
|
||||
const toggleAnnotationPanel = useCallback((key: string) => {
|
||||
setAnnotationPanelOpenKeys((current) => {
|
||||
if (current.includes(key)) return current.filter((entry) => entry !== key);
|
||||
setFoldedDocumentKeys((folded) => folded.filter((entry) => entry !== key));
|
||||
return [...current, key];
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -936,6 +989,14 @@ export function IssueDocumentsSection({
|
||||
>
|
||||
updated {relativeTime(displayedUpdatedAt)}
|
||||
</a>
|
||||
{!isSystemIssueDocumentKey(doc.key) ? (
|
||||
<DocumentAnnotationsCountChip
|
||||
issueId={issue.id}
|
||||
docKey={doc.key}
|
||||
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
|
||||
onToggle={() => toggleAnnotationPanel(doc.key)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{showTitle && <p className="mt-2 text-sm font-medium">{displayedTitle}</p>}
|
||||
</div>
|
||||
@@ -1153,31 +1214,49 @@ export function IssueDocumentsSection({
|
||||
activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10"
|
||||
}`}
|
||||
>
|
||||
{isHistoricalPreview ? (
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
) : activeDraft ? (
|
||||
<MarkdownEditor
|
||||
value={displayedBody}
|
||||
onChange={(body) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
) : (
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
)}
|
||||
<IssueDocumentAnnotations
|
||||
issueId={issue.id}
|
||||
doc={doc}
|
||||
bodyMarkdown={displayedBody}
|
||||
draftDirty={Boolean(activeDraft) && (
|
||||
(activeDraft?.body ?? doc.body) !== doc.body
|
||||
|| (autosaveDocumentKey === doc.key && autosaveState === "saving")
|
||||
)}
|
||||
draftConflicted={Boolean(activeConflict)}
|
||||
historicalPreview={isHistoricalPreview}
|
||||
locationHash={location.hash}
|
||||
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
|
||||
onPanelOpenChange={(next) => setAnnotationPanelOpen(doc.key, next)}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]}
|
||||
>
|
||||
{isHistoricalPreview ? (
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
) : activeDraft ? (
|
||||
<MarkdownEditor
|
||||
value={displayedBody}
|
||||
onChange={(body) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
) : (
|
||||
renderFoldableBody(displayedBody, documentBodyContentClassName)
|
||||
)}
|
||||
</IssueDocumentAnnotations>
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-end px-1">
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Agent, AcceptedPlanDecompositionSummary } from "@paperclipai/shared";
|
||||
import { ChevronRight, GitBranch, Repeat, CheckCircle2, Loader2 } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, formatDateTime, relativeTime } from "../lib/utils";
|
||||
|
||||
interface IssuePlanDecompositionsSectionProps {
|
||||
issueId: string;
|
||||
issueIdentifier: string | null;
|
||||
agentMap?: Map<string, Agent>;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: AcceptedPlanDecompositionSummary["status"] }) {
|
||||
if (status === "completed") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-sm border border-emerald-500/50 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-900 dark:text-emerald-100">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Completed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-sm border border-amber-500/50 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-900 dark:text-amber-100">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
In flight
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssuePlanDecompositionsSection({
|
||||
issueId,
|
||||
issueIdentifier,
|
||||
agentMap,
|
||||
}: IssuePlanDecompositionsSectionProps) {
|
||||
const { data: decompositions } = useQuery({
|
||||
queryKey: queryKeys.issues.acceptedPlanDecompositions(issueId),
|
||||
queryFn: () => issuesApi.listAcceptedPlanDecompositions(issueId),
|
||||
});
|
||||
|
||||
const items = useMemo(() => decompositions ?? [], [decompositions]);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Plan decomposition</h3>
|
||||
<span className="text-[11px] text-muted-foreground/80">
|
||||
{items.length === 1 ? "1 accepted plan revision" : `${items.length} accepted plan revisions`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{items.map((record) => {
|
||||
const requested = record.requestedChildCount ?? 0;
|
||||
const created = record.childIssueIds?.length ?? 0;
|
||||
const ownerName = record.ownerAgentId
|
||||
? agentMap?.get(record.ownerAgentId)?.name ?? "agent"
|
||||
: null;
|
||||
const revisionLabel =
|
||||
record.acceptedPlanRevisionNumber != null
|
||||
? `revision ${record.acceptedPlanRevisionNumber}`
|
||||
: `revision ${record.acceptedPlanRevisionId.slice(0, 8)}`;
|
||||
const completedAt =
|
||||
record.completedAt && typeof record.completedAt === "string"
|
||||
? record.completedAt
|
||||
: record.completedAt instanceof Date
|
||||
? record.completedAt.toISOString()
|
||||
: null;
|
||||
const updatedAt =
|
||||
typeof record.updatedAt === "string"
|
||||
? record.updatedAt
|
||||
: record.updatedAt instanceof Date
|
||||
? record.updatedAt.toISOString()
|
||||
: null;
|
||||
const startedAt =
|
||||
typeof record.createdAt === "string"
|
||||
? record.createdAt
|
||||
: record.createdAt instanceof Date
|
||||
? record.createdAt.toISOString()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={record.id}
|
||||
className="rounded-md border border-border bg-card/50 p-3 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={record.status} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Plan {revisionLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/70">·</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-foreground">
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" />
|
||||
{created} of {requested} child {requested === 1 ? "issue" : "issues"} created
|
||||
</span>
|
||||
{record.status === "completed" && requested > 0 ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-sm border border-sky-500/40 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-medium text-sky-900 dark:text-sky-100"
|
||||
title="Repeat attempts with this fingerprint reuse this record instead of creating new children"
|
||||
>
|
||||
<Repeat className="h-3 w-3" />
|
||||
Idempotent claim
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
|
||||
{ownerName ? <span>Owner: {ownerName}</span> : null}
|
||||
{startedAt ? (
|
||||
<span title={formatDateTime(startedAt)}>Started {relativeTime(startedAt)}</span>
|
||||
) : null}
|
||||
{completedAt ? (
|
||||
<span title={formatDateTime(completedAt)}>Completed {relativeTime(completedAt)}</span>
|
||||
) : updatedAt ? (
|
||||
<span title={formatDateTime(updatedAt)}>Updated {relativeTime(updatedAt)}</span>
|
||||
) : null}
|
||||
{issueIdentifier ? (
|
||||
<Link
|
||||
to={`/issues/${issueIdentifier}#document-plan`}
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
Plan document
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{record.childIssues && record.childIssues.length > 0 ? (
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||
{record.childIssues.map((child) => (
|
||||
<li key={child.id}>
|
||||
<Link
|
||||
to={`/issues/${child.identifier ?? child.id}`}
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-1 rounded-sm border border-border bg-background px-2 py-0.5 text-[11px] text-foreground transition-colors hover:bg-accent/40",
|
||||
)}
|
||||
title={child.title}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{child.identifier ?? child.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="truncate max-w-[24ch] text-muted-foreground">
|
||||
{child.title}
|
||||
</span>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { JsonSchemaForm } from "./JsonSchemaForm";
|
||||
import { JsonSchemaForm, getDefaultValues } from "./JsonSchemaForm";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -204,6 +204,177 @@ describe("JsonSchemaForm secret-ref rendering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders no Advanced disclosure when no field opts in", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonSchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
region: { type: "string" },
|
||||
},
|
||||
}}
|
||||
values={{ apiKey: "", region: "" }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
// No disclosure button should be present in the passthrough case.
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const advancedButton = buttons.find((b) =>
|
||||
b.textContent?.includes("Advanced options"),
|
||||
);
|
||||
expect(advancedButton).toBeUndefined();
|
||||
|
||||
// Both fields render in the flat layout: the secret picker (rendered as
|
||||
// a <select> stub) for apiKey and a text input for region.
|
||||
expect(
|
||||
container.querySelector('[data-testid="secret-binding-picker"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.querySelector('input[type="text"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides advanced fields behind a collapsed disclosure with group headings", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonSchemaForm
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
sshPort: {
|
||||
type: "number",
|
||||
"x-paperclip-advanced": true,
|
||||
"x-paperclip-group": "SSH access",
|
||||
},
|
||||
namePrefix: {
|
||||
type: "string",
|
||||
"x-paperclip-advanced": true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
values={{ apiKey: "", sshPort: 22, namePrefix: "paperclip" }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const advancedButton = buttons.find((b) =>
|
||||
b.textContent?.includes("Advanced options"),
|
||||
);
|
||||
expect(advancedButton).toBeDefined();
|
||||
expect(advancedButton!.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
// Collapsed: number/text inputs from advanced fields aren't rendered.
|
||||
expect(container.querySelector('input[type="number"]')).toBeNull();
|
||||
// Group headings aren't visible while collapsed.
|
||||
expect(container.textContent).not.toContain("SSH access");
|
||||
expect(container.textContent).not.toContain("More options");
|
||||
|
||||
// Expand and verify both groups + the default bucket appear.
|
||||
await act(async () => {
|
||||
advancedButton!.click();
|
||||
});
|
||||
|
||||
expect(advancedButton!.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(container.querySelector('input[type="number"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("SSH access");
|
||||
expect(container.textContent).toContain("More options");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("force-opens the disclosure when an error lands on a hidden advanced field", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
const schema = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
apiKey: { type: "string" as const, format: "secret-ref" as const },
|
||||
sshPort: {
|
||||
type: "number" as const,
|
||||
"x-paperclip-advanced": true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// No errors -> collapsed
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonSchemaForm
|
||||
schema={schema}
|
||||
values={{ apiKey: "", sshPort: 22 }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
let advancedButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Advanced options"),
|
||||
);
|
||||
expect(advancedButton!.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
// Submit validation error on the hidden advanced field -> forced open
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<JsonSchemaForm
|
||||
schema={schema}
|
||||
values={{ apiKey: "", sshPort: 22 }}
|
||||
onChange={() => {}}
|
||||
errors={{ "/sshPort": "Must be at least 1" }}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
advancedButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Advanced options"),
|
||||
);
|
||||
expect(advancedButton!.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(container.textContent).toContain("Must be at least 1");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("omits optional scalar fields from getDefaultValues so empty inputs aren't submitted as 0/''", () => {
|
||||
const defaults = getDefaultValues({
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: { type: "string", format: "secret-ref" },
|
||||
sshPort: { type: "number", default: 22 },
|
||||
cpu: { type: "number" },
|
||||
memory: { type: "string" },
|
||||
reuseLease: { type: "boolean", default: false },
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
});
|
||||
|
||||
// Fields with explicit defaults round-trip.
|
||||
expect(defaults.sshPort).toBe(22);
|
||||
expect(defaults.reuseLease).toBe(false);
|
||||
expect(defaults.tags).toEqual([]);
|
||||
|
||||
// Optional scalars without explicit defaults stay out of the payload so
|
||||
// the server doesn't see e.g. `cpu: 0` and reject the submission.
|
||||
expect("apiKey" in defaults).toBe(false);
|
||||
expect("cpu" in defaults).toBe(false);
|
||||
expect("memory" in defaults).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the password fallback for short raw values", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
||||
@@ -76,6 +76,19 @@ export interface JsonSchemaNode {
|
||||
readOnly?: boolean;
|
||||
writeOnly?: boolean;
|
||||
|
||||
// Paperclip extensions
|
||||
/**
|
||||
* When true, the field is hidden behind an "Advanced options" disclosure
|
||||
* in the top-level `JsonSchemaForm`. Defaults to false (essential).
|
||||
*/
|
||||
"x-paperclip-advanced"?: boolean;
|
||||
/**
|
||||
* Optional sub-section name used to group advanced fields under headings
|
||||
* inside the disclosure (e.g. "SSH access", "VM resources"). Ignored when
|
||||
* `x-paperclip-advanced` is not true.
|
||||
*/
|
||||
"x-paperclip-group"?: string;
|
||||
|
||||
// Allow extra keys
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -121,7 +134,14 @@ export function labelFromKey(key: string, schema: JsonSchemaNode): string {
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Produce a sensible default value for a schema node. */
|
||||
/**
|
||||
* Produce a sensible default value for a schema node.
|
||||
*
|
||||
* Optional scalar fields (string, number, integer, secret-ref) without an
|
||||
* explicit `default` return `undefined` so they stay out of the submitted
|
||||
* payload — otherwise an empty field would round-trip as `""` or `0` and
|
||||
* trip server-side "X must be greater than 0 when provided" style validators.
|
||||
*/
|
||||
export function getDefaultForSchema(schema: JsonSchemaNode): unknown {
|
||||
if (schema.default !== undefined) return schema.default;
|
||||
|
||||
@@ -129,10 +149,9 @@ export function getDefaultForSchema(schema: JsonSchemaNode): unknown {
|
||||
switch (type) {
|
||||
case "string":
|
||||
case "secret-ref":
|
||||
return "";
|
||||
case "number":
|
||||
case "integer":
|
||||
return schema.minimum ?? 0;
|
||||
return undefined;
|
||||
case "boolean":
|
||||
return false;
|
||||
case "enum":
|
||||
@@ -143,12 +162,13 @@ export function getDefaultForSchema(schema: JsonSchemaNode): unknown {
|
||||
if (!schema.properties) return {};
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
||||
obj[key] = getDefaultForSchema(propSchema);
|
||||
const def = getDefaultForSchema(propSchema);
|
||||
if (def !== undefined) obj[key] = def;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1138,6 +1158,64 @@ export function JsonSchemaForm({
|
||||
[onChange, values],
|
||||
);
|
||||
|
||||
const { essentials, advancedGroups, advancedKeys } = useMemo(() => {
|
||||
const essentials: Array<[string, JsonSchemaNode]> = [];
|
||||
// Preserve original key order while bucketing into groups.
|
||||
const groupOrder: string[] = [];
|
||||
const groups = new Map<string, Array<[string, JsonSchemaNode]>>();
|
||||
const advancedKeys = new Set<string>();
|
||||
const DEFAULT_GROUP = "More options";
|
||||
|
||||
for (const entry of Object.entries(properties)) {
|
||||
const [key, propSchema] = entry;
|
||||
if (propSchema["x-paperclip-advanced"] === true) {
|
||||
advancedKeys.add(key);
|
||||
const rawGroup = propSchema["x-paperclip-group"];
|
||||
const group = typeof rawGroup === "string" && rawGroup.length > 0
|
||||
? rawGroup
|
||||
: DEFAULT_GROUP;
|
||||
if (!groups.has(group)) {
|
||||
groups.set(group, []);
|
||||
groupOrder.push(group);
|
||||
}
|
||||
groups.get(group)!.push(entry);
|
||||
} else {
|
||||
essentials.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
essentials,
|
||||
advancedGroups: groupOrder.map((group) => ({
|
||||
group,
|
||||
fields: groups.get(group)!,
|
||||
})),
|
||||
advancedKeys,
|
||||
};
|
||||
}, [properties]);
|
||||
|
||||
const hasAdvanced = advancedGroups.length > 0;
|
||||
|
||||
const hasAdvancedError = useMemo(() => {
|
||||
if (!hasAdvanced) return false;
|
||||
for (const errorKey of Object.keys(errors)) {
|
||||
// Top-level errors arrive as "/<key>" or "/<key>/<...>".
|
||||
const stripped = errorKey.startsWith("/") ? errorKey.slice(1) : errorKey;
|
||||
const topKey = stripped.split("/")[0];
|
||||
if (advancedKeys.has(topKey)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [errors, advancedKeys, hasAdvanced]);
|
||||
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
|
||||
// Force the disclosure open when a validation error lands on a hidden field
|
||||
// so the user can see and fix it. Never auto-close — once open, the user
|
||||
// controls collapse.
|
||||
useEffect(() => {
|
||||
if (hasAdvancedError) setIsAdvancedOpen(true);
|
||||
}, [hasAdvancedError]);
|
||||
|
||||
if (Object.keys(properties).length === 0) {
|
||||
return (
|
||||
<div
|
||||
@@ -1151,30 +1229,65 @@ export function JsonSchemaForm({
|
||||
);
|
||||
}
|
||||
|
||||
const renderField = ([key, propSchema]: [string, JsonSchemaNode]) => {
|
||||
const value = values[key];
|
||||
const isRequired = requiredFields.has(key);
|
||||
const error = errors[`/${key}`];
|
||||
const label = labelFromKey(key, propSchema);
|
||||
const path = `/${key}`;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
propSchema={propSchema}
|
||||
value={value}
|
||||
onChange={(val) => handleFieldChange(key, val)}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
isRequired={isRequired}
|
||||
errors={errors}
|
||||
path={path}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", className)}>
|
||||
{Object.entries(properties).map(([key, propSchema]) => {
|
||||
const value = values[key];
|
||||
const isRequired = requiredFields.has(key);
|
||||
const error = errors[`/${key}`];
|
||||
const label = labelFromKey(key, propSchema);
|
||||
const path = `/${key}`;
|
||||
{essentials.map(renderField)}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
propSchema={propSchema}
|
||||
value={value}
|
||||
onChange={(val) => handleFieldChange(key, val)}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
isRequired={isRequired}
|
||||
errors={errors}
|
||||
path={path}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{hasAdvanced && (
|
||||
<div className="space-y-3 rounded-lg border border-dashed">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left"
|
||||
onClick={() => setIsAdvancedOpen((open) => !open)}
|
||||
aria-expanded={isAdvancedOpen}
|
||||
>
|
||||
<span className="text-sm font-medium">Advanced options</span>
|
||||
{isAdvancedOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isAdvancedOpen && (
|
||||
<div className="space-y-6 px-4 pb-4">
|
||||
{advancedGroups.map(({ group, fields }) => (
|
||||
<div key={group} className="space-y-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{fields.map(renderField)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@
|
||||
--chip-match-identifier-bg: var(--muted);
|
||||
--chip-match-identifier-fg: var(--muted-foreground);
|
||||
--chip-match-identifier-border: var(--border);
|
||||
--paperclip-doc-annotation-highlight-open: #fef08a;
|
||||
--paperclip-doc-annotation-highlight-focused: #fde047;
|
||||
--paperclip-doc-annotation-highlight-stale: #fef08a;
|
||||
--paperclip-doc-annotation-highlight-resolved: #fef9c3;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -136,6 +140,30 @@
|
||||
--chip-match-identifier-bg: var(--muted);
|
||||
--chip-match-identifier-fg: var(--muted-foreground);
|
||||
--chip-match-identifier-border: var(--border);
|
||||
--paperclip-doc-annotation-highlight-open: #a16207;
|
||||
--paperclip-doc-annotation-highlight-focused: #ca8a04;
|
||||
--paperclip-doc-annotation-highlight-stale: #854d0e;
|
||||
--paperclip-doc-annotation-highlight-resolved: #713f12;
|
||||
}
|
||||
|
||||
::highlight(paperclip-doc-annotation-open) {
|
||||
background-color: var(--paperclip-doc-annotation-highlight-open);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::highlight(paperclip-doc-annotation-focused) {
|
||||
background-color: var(--paperclip-doc-annotation-highlight-focused);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::highlight(paperclip-doc-annotation-stale) {
|
||||
background-color: var(--paperclip-doc-annotation-highlight-stale);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::highlight(paperclip-doc-annotation-resolved) {
|
||||
background-color: var(--paperclip-doc-annotation-highlight-resolved);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -48,6 +48,7 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
||||
"issue.successful_run_handoff_required": "flagged missing next step on",
|
||||
"issue.successful_run_handoff_resolved": "recorded next step chosen on",
|
||||
"issue.successful_run_handoff_escalated": "escalated missing next step on",
|
||||
"issue.accepted_plan_decomposition_updated": "updated accepted-plan decomposition on",
|
||||
"issue.recovery_action_opened": "opened a recovery action on",
|
||||
"issue.recovery_action_resolved": "resolved the recovery action on",
|
||||
"issue.recovery_action_escalated": "escalated the recovery action on",
|
||||
@@ -110,6 +111,7 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
||||
"issue.recovery_action_opened": "Opened a source-scoped recovery action",
|
||||
"issue.recovery_action_resolved": "Resolved the recovery action",
|
||||
"issue.recovery_action_escalated": "Escalated the recovery action",
|
||||
"issue.accepted_plan_decomposition_updated": "updated the accepted-plan decomposition",
|
||||
"agent.created": "created an agent",
|
||||
"agent.updated": "updated the agent",
|
||||
"agent.paused": "paused the agent",
|
||||
@@ -189,6 +191,34 @@ function formatChangedEntityLabel(
|
||||
return `${labels.length} ${plural}`;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function readStringArrayLength(value: unknown): number {
|
||||
if (!Array.isArray(value)) return 0;
|
||||
return value.filter((entry) => typeof entry === "string" && entry.length > 0).length;
|
||||
}
|
||||
|
||||
function formatAcceptedPlanDecompositionDetail(details: ActivityDetails): string | null {
|
||||
if (!details) return null;
|
||||
const status = typeof details.status === "string" ? details.status : null;
|
||||
const requested = readNumber(details.requestedChildCount);
|
||||
const totalChildren = readStringArrayLength(details.childIssueIds);
|
||||
const newlyCreated = readStringArrayLength(details.newlyCreatedChildIssueIds);
|
||||
const reused = Math.max(0, totalChildren - newlyCreated);
|
||||
const parts: string[] = [];
|
||||
if (newlyCreated > 0) parts.push(`created ${newlyCreated} new`);
|
||||
if (reused > 0) parts.push(`reused ${reused} existing`);
|
||||
if (parts.length === 0 && requested !== null) parts.push(`${requested} requested`);
|
||||
const summary = parts.length > 0 ? parts.join(", ") : null;
|
||||
if (status === "completed" && summary) return `decomposition completed (${summary})`;
|
||||
if (status === "completed") return "decomposition completed";
|
||||
if (status === "in_flight" && summary) return `decomposition in flight (${summary})`;
|
||||
return summary;
|
||||
}
|
||||
|
||||
function formatIssueUpdatedVerb(details: ActivityDetails): string | null {
|
||||
if (!details) return null;
|
||||
const previous = asRecord(details._previous) ?? {};
|
||||
@@ -332,6 +362,11 @@ export function formatIssueActivityAction(
|
||||
});
|
||||
if (structuredChange) return structuredChange;
|
||||
|
||||
if (action === "issue.accepted_plan_decomposition_updated") {
|
||||
const detail = formatAcceptedPlanDecompositionDetail(details);
|
||||
if (detail) return detail;
|
||||
}
|
||||
|
||||
if (action.startsWith("issue.monitor_") && details) {
|
||||
const serviceName = typeof details.serviceName === "string" && details.serviceName.trim()
|
||||
? details.serviceName.trim()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDocumentAnnotationHash,
|
||||
parseDocumentAnnotationHash,
|
||||
} from "./document-annotation-hash";
|
||||
|
||||
describe("parseDocumentAnnotationHash", () => {
|
||||
it("returns null for non-document hashes", () => {
|
||||
expect(parseDocumentAnnotationHash("")).toBeNull();
|
||||
expect(parseDocumentAnnotationHash("#issue-foo")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses document key only", () => {
|
||||
expect(parseDocumentAnnotationHash("#document-plan")).toEqual({
|
||||
documentKey: "plan",
|
||||
threadId: null,
|
||||
commentId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses thread and comment targets", () => {
|
||||
expect(
|
||||
parseDocumentAnnotationHash("#document-plan&thread=t1&comment=c2"),
|
||||
).toEqual({
|
||||
documentKey: "plan",
|
||||
threadId: "t1",
|
||||
commentId: "c2",
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes URI-encoded keys", () => {
|
||||
expect(parseDocumentAnnotationHash("#document-my%20notes&thread=abc")).toEqual({
|
||||
documentKey: "my notes",
|
||||
threadId: "abc",
|
||||
commentId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDocumentAnnotationHash", () => {
|
||||
it("builds a hash without thread or comment", () => {
|
||||
expect(buildDocumentAnnotationHash({ documentKey: "plan", threadId: null, commentId: null })).toBe(
|
||||
"#document-plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes thread target", () => {
|
||||
expect(
|
||||
buildDocumentAnnotationHash({ documentKey: "plan", threadId: "t1", commentId: null }),
|
||||
).toBe("#document-plan&thread=t1");
|
||||
});
|
||||
|
||||
it("includes both targets", () => {
|
||||
expect(
|
||||
buildDocumentAnnotationHash({ documentKey: "plan", threadId: "t1", commentId: "c2" }),
|
||||
).toBe("#document-plan&thread=t1&comment=c2");
|
||||
});
|
||||
|
||||
it("survives a round trip", () => {
|
||||
const target = { documentKey: "plan-2", threadId: "t-abc", commentId: "c-xyz" };
|
||||
expect(parseDocumentAnnotationHash(buildDocumentAnnotationHash(target))).toEqual(target);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface DocumentAnnotationHashTarget {
|
||||
documentKey: string;
|
||||
threadId: string | null;
|
||||
commentId: string | null;
|
||||
}
|
||||
|
||||
const DOCUMENT_HASH_PREFIX = "#document-";
|
||||
|
||||
export function parseDocumentAnnotationHash(hash: string): DocumentAnnotationHashTarget | null {
|
||||
if (!hash.startsWith(DOCUMENT_HASH_PREFIX)) return null;
|
||||
const stripped = hash.slice(DOCUMENT_HASH_PREFIX.length);
|
||||
const [rawKey, ...rest] = stripped.split("&");
|
||||
if (!rawKey) return null;
|
||||
const documentKey = decodeURIComponent(rawKey);
|
||||
const params = new URLSearchParams(rest.join("&"));
|
||||
const threadId = params.get("thread");
|
||||
const commentId = params.get("comment");
|
||||
return {
|
||||
documentKey,
|
||||
threadId: threadId && threadId.length > 0 ? threadId : null,
|
||||
commentId: commentId && commentId.length > 0 ? commentId : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDocumentAnnotationHash(target: DocumentAnnotationHashTarget): string {
|
||||
const params = new URLSearchParams();
|
||||
if (target.threadId) params.set("thread", target.threadId);
|
||||
if (target.commentId) params.set("comment", target.commentId);
|
||||
const qs = params.toString();
|
||||
const encodedKey = encodeURIComponent(target.documentKey);
|
||||
return qs ? `${DOCUMENT_HASH_PREFIX}${encodedKey}&${qs}` : `${DOCUMENT_HASH_PREFIX}${encodedKey}`;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { verifyDocumentAnchorSelector } from "@paperclipai/shared";
|
||||
import {
|
||||
buildAnchorFromContainerSelection,
|
||||
getContainerTextOffset,
|
||||
rangesForNormalizedSpan,
|
||||
} from "./document-annotation-selection";
|
||||
|
||||
const MARKDOWN = `# Plan
|
||||
|
||||
We **should** keep the current markdown stack for the first version.
|
||||
|
||||
- Highlight a text segment in a plan document.
|
||||
- Anchor comments without mutating markdown.
|
||||
|
||||
## Acceptance
|
||||
|
||||
The annotation feature is ready when the basic flow works.`;
|
||||
|
||||
const RENDERED_HTML = `
|
||||
<div>
|
||||
<h1>Plan</h1>
|
||||
<p>We should keep the current markdown stack for the first version.</p>
|
||||
<ul>
|
||||
<li>Highlight a text segment in a plan document.</li>
|
||||
<li>Anchor comments without mutating markdown.</li>
|
||||
</ul>
|
||||
<h2>Acceptance</h2>
|
||||
<p>The annotation feature is ready when the basic flow works.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = RENDERED_HTML;
|
||||
document.body.appendChild(div);
|
||||
return div.firstElementChild as HTMLElement;
|
||||
}
|
||||
|
||||
function selectText(container: HTMLElement, needle: string): Range {
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
const data = (node as Text).data;
|
||||
const index = data.indexOf(needle);
|
||||
if (index !== -1) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, index);
|
||||
range.setEnd(node, index + needle.length);
|
||||
return range;
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
throw new Error(`Could not find "${needle}" in container`);
|
||||
}
|
||||
|
||||
describe("buildAnchorFromContainerSelection", () => {
|
||||
it("produces a selector that verifies against the same markdown", () => {
|
||||
const container = makeContainer();
|
||||
const range = selectText(container, "current markdown stack");
|
||||
const offset = getContainerTextOffset(container, range);
|
||||
expect(offset).not.toBeNull();
|
||||
const anchor = buildAnchorFromContainerSelection({
|
||||
markdown: MARKDOWN,
|
||||
containerOffset: offset!,
|
||||
});
|
||||
expect(anchor).not.toBeNull();
|
||||
const verified = verifyDocumentAnchorSelector({
|
||||
markdown: MARKDOWN,
|
||||
selector: anchor!.selector,
|
||||
});
|
||||
expect(verified.ok).toBe(true);
|
||||
expect(verified.anchor?.selectedText).toBe("current markdown stack");
|
||||
});
|
||||
|
||||
it("returns null for empty selections", () => {
|
||||
const container = makeContainer();
|
||||
const range = document.createRange();
|
||||
range.setStart(container, 0);
|
||||
range.setEnd(container, 0);
|
||||
const offset = getContainerTextOffset(container, range);
|
||||
expect(offset).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when selection is outside container", () => {
|
||||
const container = makeContainer();
|
||||
const outside = document.createElement("div");
|
||||
outside.textContent = "outside";
|
||||
document.body.appendChild(outside);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(outside);
|
||||
const offset = getContainerTextOffset(container, range);
|
||||
expect(offset).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rangesForNormalizedSpan", () => {
|
||||
it("walks DOM text nodes to find span ranges", () => {
|
||||
const container = makeContainer();
|
||||
const ranges = rangesForNormalizedSpan({
|
||||
container,
|
||||
selectedText: "Highlight a text segment",
|
||||
});
|
||||
expect(ranges.length).toBeGreaterThan(0);
|
||||
const merged = ranges.map((range) => range.toString()).join("");
|
||||
expect(merged.replace(/\s+/g, " ")).toContain("Highlight a text segment");
|
||||
});
|
||||
|
||||
it("returns an empty array if selected text is missing", () => {
|
||||
const container = makeContainer();
|
||||
const ranges = rangesForNormalizedSpan({
|
||||
container,
|
||||
selectedText: "this string does not exist in the document",
|
||||
});
|
||||
expect(ranges).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
createDocumentAnchorSelector,
|
||||
normalizeAnchorText,
|
||||
projectMarkdownToText,
|
||||
resolveProjectionRange,
|
||||
type DocumentAnnotationAnchorSelector,
|
||||
type DocumentTextProjection,
|
||||
type DocumentTextRange,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export interface ContainerTextOffset {
|
||||
/** Byte offset of the selection start within the flattened container text. */
|
||||
startOffset: number;
|
||||
/** Byte offset of the selection end within the flattened container text. */
|
||||
endOffset: number;
|
||||
/** Raw flattened text content of the container. */
|
||||
containerText: string;
|
||||
/** Raw text inside the selection. */
|
||||
selectedText: string;
|
||||
}
|
||||
|
||||
export function getContainerTextOffset(
|
||||
container: HTMLElement,
|
||||
range: Range,
|
||||
): ContainerTextOffset | null {
|
||||
if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) {
|
||||
return null;
|
||||
}
|
||||
const preRange = document.createRange();
|
||||
preRange.selectNodeContents(container);
|
||||
preRange.setEnd(range.startContainer, range.startOffset);
|
||||
const startOffset = preRange.toString().length;
|
||||
preRange.setEnd(range.endContainer, range.endOffset);
|
||||
const endOffset = preRange.toString().length;
|
||||
if (endOffset <= startOffset) return null;
|
||||
return {
|
||||
startOffset,
|
||||
endOffset,
|
||||
containerText: container.textContent ?? "",
|
||||
selectedText: range.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelectionAnchorResult {
|
||||
selector: DocumentAnnotationAnchorSelector;
|
||||
range: DocumentTextRange;
|
||||
projection: DocumentTextProjection;
|
||||
}
|
||||
|
||||
export function buildAnchorFromContainerSelection(input: {
|
||||
markdown: string;
|
||||
containerOffset: ContainerTextOffset;
|
||||
}): SelectionAnchorResult | null {
|
||||
const projection = projectMarkdownToText(input.markdown);
|
||||
const needle = normalizeAnchorText(input.containerOffset.selectedText);
|
||||
if (!needle) return null;
|
||||
|
||||
const occurrences = findAllOccurrences(projection.text, needle);
|
||||
if (occurrences.length === 0) return null;
|
||||
|
||||
const renderedTextLength = Math.max(1, normalizeAnchorText(input.containerOffset.containerText).length);
|
||||
const renderedRatio = input.containerOffset.startOffset / renderedTextLength;
|
||||
const projectionLength = Math.max(1, projection.text.length);
|
||||
const expectedNormalized = Math.round(renderedRatio * projectionLength);
|
||||
|
||||
const best = pickClosestOccurrence(occurrences, expectedNormalized);
|
||||
if (best == null) return null;
|
||||
|
||||
const normalizedStart = best;
|
||||
const normalizedEnd = best + needle.length;
|
||||
const range = resolveProjectionRange(projection, normalizedStart, normalizedEnd);
|
||||
if (!range) return null;
|
||||
if (normalizeAnchorText(range.text) !== needle) return null;
|
||||
|
||||
const selector = createDocumentAnchorSelector(projection, range);
|
||||
return { selector, range, projection };
|
||||
}
|
||||
|
||||
function findAllOccurrences(haystack: string, needle: string): number[] {
|
||||
if (!needle) return [];
|
||||
const out: number[] = [];
|
||||
let cursor = haystack.indexOf(needle);
|
||||
while (cursor !== -1) {
|
||||
out.push(cursor);
|
||||
cursor = haystack.indexOf(needle, cursor + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickClosestOccurrence(occurrences: number[], expected: number): number | null {
|
||||
if (occurrences.length === 0) return null;
|
||||
if (occurrences.length === 1) return occurrences[0] ?? null;
|
||||
let best = occurrences[0] ?? 0;
|
||||
let bestDistance = Math.abs(best - expected);
|
||||
for (const candidate of occurrences) {
|
||||
const distance = Math.abs(candidate - expected);
|
||||
if (distance < bestDistance) {
|
||||
best = candidate;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk text nodes inside `container` and return a list of `Range`s that cover the
|
||||
* normalized-text span `[normalizedStart, normalizedEnd)`. Each Range can be
|
||||
* rectangle-projected to draw a highlight overlay.
|
||||
*/
|
||||
export function rangesForNormalizedSpan(input: {
|
||||
container: HTMLElement;
|
||||
selectedText: string;
|
||||
}): Range[] {
|
||||
const normalizedNeedle = normalizeAnchorText(input.selectedText);
|
||||
if (!normalizedNeedle) return [];
|
||||
const containerText = input.container.textContent ?? "";
|
||||
const normalizedContainerText = normalizeAnchorText(containerText);
|
||||
const containerOccurrenceIndex = normalizedContainerText.indexOf(normalizedNeedle);
|
||||
if (containerOccurrenceIndex === -1) return [];
|
||||
|
||||
// Convert from normalized container offset back to raw container offset
|
||||
// by walking the raw text and matching whitespace squashing.
|
||||
const rawIndex = mapNormalizedOffsetToRaw(containerText, containerOccurrenceIndex);
|
||||
if (rawIndex < 0) return [];
|
||||
|
||||
const rawNeedleLength = matchRawLengthForNormalized(
|
||||
containerText.slice(rawIndex),
|
||||
normalizedNeedle.length,
|
||||
);
|
||||
if (rawNeedleLength <= 0) return [];
|
||||
|
||||
const rawStart = rawIndex;
|
||||
const rawEnd = rawIndex + rawNeedleLength;
|
||||
return buildRangesForRawSpan(input.container, rawStart, rawEnd);
|
||||
}
|
||||
|
||||
function mapNormalizedOffsetToRaw(rawText: string, normalizedOffset: number): number {
|
||||
let normalizedCursor = 0;
|
||||
let lastWasWhitespace = true; // mimic trim() at start
|
||||
for (let index = 0; index < rawText.length; index += 1) {
|
||||
const char = rawText[index] ?? "";
|
||||
if (/\s/.test(char)) {
|
||||
if (!lastWasWhitespace) {
|
||||
if (normalizedCursor === normalizedOffset) return index;
|
||||
normalizedCursor += 1;
|
||||
lastWasWhitespace = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (normalizedCursor === normalizedOffset) return index;
|
||||
normalizedCursor += 1;
|
||||
lastWasWhitespace = false;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function matchRawLengthForNormalized(rawTail: string, normalizedLength: number): number {
|
||||
let normalizedCount = 0;
|
||||
let lastWasWhitespace = false;
|
||||
for (let index = 0; index < rawTail.length; index += 1) {
|
||||
const char = rawTail[index] ?? "";
|
||||
if (/\s/.test(char)) {
|
||||
if (!lastWasWhitespace) {
|
||||
normalizedCount += 1;
|
||||
if (normalizedCount >= normalizedLength) return index;
|
||||
lastWasWhitespace = true;
|
||||
}
|
||||
} else {
|
||||
normalizedCount += 1;
|
||||
lastWasWhitespace = false;
|
||||
if (normalizedCount >= normalizedLength) return index + 1;
|
||||
}
|
||||
}
|
||||
return rawTail.length;
|
||||
}
|
||||
|
||||
function buildRangesForRawSpan(container: HTMLElement, rawStart: number, rawEnd: number): Range[] {
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
||||
const ranges: Range[] = [];
|
||||
let cursor = 0;
|
||||
let node: Node | null = walker.nextNode();
|
||||
while (node) {
|
||||
const textNode = node as Text;
|
||||
const length = textNode.data.length;
|
||||
const nodeStart = cursor;
|
||||
const nodeEnd = cursor + length;
|
||||
if (nodeEnd > rawStart && nodeStart < rawEnd) {
|
||||
const startWithin = Math.max(0, rawStart - nodeStart);
|
||||
const endWithin = Math.min(length, rawEnd - nodeStart);
|
||||
if (endWithin > startWithin) {
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, startWithin);
|
||||
range.setEnd(textNode, endWithin);
|
||||
ranges.push(range);
|
||||
}
|
||||
}
|
||||
cursor = nodeEnd;
|
||||
if (cursor >= rawEnd) break;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export const queryKeys = {
|
||||
["company-skills", companyId, skillId, "update-status"] as const,
|
||||
file: (companyId: string, skillId: string, relativePath: string) =>
|
||||
["company-skills", companyId, skillId, "file", relativePath] as const,
|
||||
catalog: (filters: { kind?: string; category?: string; q?: string } = {}) =>
|
||||
["company-skills", "catalog", filters.kind ?? "__all-kinds__", filters.category ?? "__all-categories__", filters.q ?? ""] as const,
|
||||
catalogDetail: (catalogRef: string) => ["company-skills", "catalog", "detail", catalogRef] as const,
|
||||
catalogFile: (catalogRef: string, relativePath: string) =>
|
||||
["company-skills", "catalog", "file", catalogRef, relativePath] as const,
|
||||
},
|
||||
agents: {
|
||||
list: (companyId: string) => ["agents", companyId] as const,
|
||||
@@ -54,6 +59,8 @@ export const queryKeys = {
|
||||
detail: (id: string) => ["issues", "detail", id] as const,
|
||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||
interactions: (issueId: string) => ["issues", "interactions", issueId] as const,
|
||||
acceptedPlanDecompositions: (issueId: string) =>
|
||||
["issues", "accepted-plan-decompositions", issueId] as const,
|
||||
feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const,
|
||||
costSummary: (issueId: string, options: { excludeRoot?: boolean } = {}) =>
|
||||
options.excludeRoot
|
||||
@@ -63,6 +70,8 @@ export const queryKeys = {
|
||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||
document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,
|
||||
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
|
||||
documentAnnotations: (issueId: string, key: string, status: "open" | "resolved" | "all" = "all") =>
|
||||
["issues", "document-annotations", issueId, key, status] as const,
|
||||
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||
|
||||
@@ -2801,6 +2801,14 @@ export function AgentSkillsTab({
|
||||
})),
|
||||
[companySkillKeys, skillSnapshot],
|
||||
);
|
||||
const installedSkillRows = useMemo(
|
||||
() => optionalSkillRows.filter((skill) => skillDraft.includes(skill.key)),
|
||||
[optionalSkillRows, skillDraft],
|
||||
);
|
||||
const otherSkillRows = useMemo(
|
||||
() => optionalSkillRows.filter((skill) => !skillDraft.includes(skill.key)),
|
||||
[optionalSkillRows, skillDraft],
|
||||
);
|
||||
const desiredOnlyMissingSkills = useMemo(
|
||||
() => skillDraft.filter((key) => !companySkillByKey.has(key)),
|
||||
[companySkillByKey, skillDraft],
|
||||
@@ -2965,6 +2973,30 @@ export function AgentSkillsTab({
|
||||
);
|
||||
};
|
||||
|
||||
const renderSkillSection = (
|
||||
title: string,
|
||||
rows: SkillRow[],
|
||||
emptyMessage?: string,
|
||||
) => {
|
||||
if (rows.length === 0 && !emptyMessage) return null;
|
||||
return (
|
||||
<section className="border-y border-border">
|
||||
<div className="border-b border-border bg-muted/40 px-3 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{rows.length > 0 ? (
|
||||
rows.map(renderSkillRow)
|
||||
) : (
|
||||
<div className="px-3 py-3 text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
if (optionalSkillRows.length === 0 && requiredSkillRows.length === 0 && unmanagedSkillRows.length === 0) {
|
||||
return (
|
||||
<section className="border-y border-border">
|
||||
@@ -2977,22 +3009,17 @@ export function AgentSkillsTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
{optionalSkillRows.length > 0 && (
|
||||
<section className="border-y border-border">
|
||||
{optionalSkillRows.map(renderSkillRow)}
|
||||
</section>
|
||||
)}
|
||||
{optionalSkillRows.length > 0
|
||||
? renderSkillSection(
|
||||
"Installed skills",
|
||||
installedSkillRows,
|
||||
"No company-library skills installed on this agent.",
|
||||
)
|
||||
: null}
|
||||
|
||||
{requiredSkillRows.length > 0 && (
|
||||
<section className="border-y border-border">
|
||||
<div className="border-b border-border bg-muted/40 px-3 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Required by Paperclip
|
||||
</span>
|
||||
</div>
|
||||
{requiredSkillRows.map(renderSkillRow)}
|
||||
</section>
|
||||
)}
|
||||
{renderSkillSection("Other skills", otherSkillRows)}
|
||||
|
||||
{renderSkillSection("Required by Paperclip", requiredSkillRows)}
|
||||
|
||||
{unmanagedSkillRows.length > 0 && (
|
||||
<section className="border-y border-border">
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Loader2, ShieldCheck, Terminal, TriangleAlert } from "lucide-react";
|
||||
import { BOOTSTRAP_FALLBACK_COMMAND } from "@/bootstrapSetup";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type LabFixtureKey =
|
||||
| "signed-out-private"
|
||||
| "signed-in-private"
|
||||
| "claiming"
|
||||
| "claim-error"
|
||||
| "claim-success"
|
||||
| "public-invite-only";
|
||||
|
||||
const FIXTURE_LABELS: Record<LabFixtureKey, string> = {
|
||||
"signed-out-private": "1 · authenticated/private — signed out (browser claim available)",
|
||||
"signed-in-private": "2 · authenticated/private — signed in (claim CTA primary)",
|
||||
claiming: "3 · authenticated/private — claim in flight",
|
||||
"claim-error": "4 · authenticated/private — claim error (e.g. 409 already claimed)",
|
||||
"claim-success": "5 · authenticated/private — claim succeeded, redirect pending",
|
||||
"public-invite-only": "6 · authenticated/public — invite-only (no browser claim)",
|
||||
};
|
||||
|
||||
const FIXTURE_ORDER: LabFixtureKey[] = [
|
||||
"signed-out-private",
|
||||
"signed-in-private",
|
||||
"claiming",
|
||||
"claim-error",
|
||||
"claim-success",
|
||||
"public-invite-only",
|
||||
];
|
||||
|
||||
function CliFallback({ hasActiveInvite }: { hasActiveInvite: boolean }) {
|
||||
return (
|
||||
<div className="mt-6 border-t border-border pt-5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Terminal className="size-4 text-muted-foreground" aria-hidden />
|
||||
<span>Prefer to finish setup from the host?</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasActiveInvite
|
||||
? "A bootstrap invite is already active. Check your Paperclip startup logs for the first‑admin URL, or run this command on the host to rotate it:"
|
||||
: "Run this command on the host that runs Paperclip to print a one‑time first‑admin invite URL:"}
|
||||
</p>
|
||||
<pre className="mt-3 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 font-mono text-xs">
|
||||
{BOOTSTRAP_FALLBACK_COMMAND}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignedOutPrivate() {
|
||||
return (
|
||||
<StateChrome>
|
||||
<h1 className="text-xl font-semibold">Finish setting up this Paperclip</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No admin has claimed this instance yet. Sign in or create your Paperclip account to become the first
|
||||
admin from this browser.
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
<Button asChild>
|
||||
<a href="/auth?next=/">Sign in / Create account</a>
|
||||
</Button>
|
||||
</div>
|
||||
<CliFallback hasActiveInvite={false} />
|
||||
</StateChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function SignedInPrivate() {
|
||||
return (
|
||||
<StateChrome>
|
||||
<h1 className="text-xl font-semibold">Finish setting up this Paperclip</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No admin has claimed this instance yet. Claim it now to become the first admin and start onboarding.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<Button>Claim this instance</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Signed in as <span className="font-medium text-foreground">jane@appliance.local</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Wrong account?{" "}
|
||||
<a href="/auth?next=/" className="underline underline-offset-2">
|
||||
Switch account
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<CliFallback hasActiveInvite={false} />
|
||||
</StateChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimingPrivate() {
|
||||
return (
|
||||
<StateChrome>
|
||||
<h1 className="text-xl font-semibold">Finish setting up this Paperclip</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No admin has claimed this instance yet. Claim it now to become the first admin and start onboarding.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<Button disabled>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden />
|
||||
Claiming…
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Signed in as <span className="font-medium text-foreground">jane@appliance.local</span>
|
||||
</span>
|
||||
</div>
|
||||
<CliFallback hasActiveInvite={false} />
|
||||
</StateChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimErrorPrivate() {
|
||||
return (
|
||||
<StateChrome>
|
||||
<h1 className="text-xl font-semibold">Finish setting up this Paperclip</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No admin has claimed this instance yet. Claim it now to become the first admin and start onboarding.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<Button>Claim this instance</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Signed in as <span className="font-medium text-foreground">jane@appliance.local</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
<TriangleAlert className="mt-0.5 size-4 flex-shrink-0" aria-hidden />
|
||||
<div>
|
||||
<p className="font-medium">Someone else has already claimed this instance.</p>
|
||||
<p className="mt-1 text-destructive/90">
|
||||
Refresh to sign in, or ask the existing admin to invite you from{" "}
|
||||
<span className="font-mono">Instance settings → Access</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CliFallback hasActiveInvite={false} />
|
||||
</StateChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimSuccess() {
|
||||
return (
|
||||
<StateChrome>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-9 flex-shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
||||
<ShieldCheck className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">You’re the instance admin</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Setup is complete. Taking you to onboarding to create your first company…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" aria-hidden />
|
||||
<span className="text-sm text-muted-foreground">Redirecting…</span>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Button asChild variant="outline">
|
||||
<a href="/">Continue to dashboard</a>
|
||||
</Button>
|
||||
</div>
|
||||
</StateChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicInviteOnly() {
|
||||
return (
|
||||
<StateChrome>
|
||||
<h1 className="text-xl font-semibold">This Paperclip is waiting on its first admin</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This instance runs in invite‑only mode. The operator must generate a one‑time first‑admin invite URL
|
||||
from the host. Once you have the link, open it from this browser to finish setup.
|
||||
</p>
|
||||
<CliFallback hasActiveInvite />
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Browser‑based claim is intentionally disabled in public mode so anyone on the network can’t
|
||||
promote themselves.
|
||||
</p>
|
||||
</StateChrome>
|
||||
);
|
||||
}
|
||||
|
||||
const FIXTURE_BODIES: Record<LabFixtureKey, ReactElement> = {
|
||||
"signed-out-private": <SignedOutPrivate />,
|
||||
"signed-in-private": <SignedInPrivate />,
|
||||
claiming: <ClaimingPrivate />,
|
||||
"claim-error": <ClaimErrorPrivate />,
|
||||
"claim-success": <ClaimSuccess />,
|
||||
"public-invite-only": <PublicInviteOnly />,
|
||||
};
|
||||
|
||||
export function BootstrapSetupUxLab() {
|
||||
return (
|
||||
<div className="bg-background min-h-screen pb-16">
|
||||
<header className="border-b border-border bg-muted/20">
|
||||
<div className="mx-auto max-w-3xl px-6 py-6">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">UX Lab</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">Bootstrap-pending setup states</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
|
||||
Fixtures for the bootstrap-pending screen in <span className="font-mono">CloudAccessGate</span>. Used
|
||||
as the UX spec for{" "}
|
||||
<a className="underline underline-offset-2" href="/PAP/issues/PAP-10113">
|
||||
PAP-10113
|
||||
</a>{" "}
|
||||
and the implementation reference for{" "}
|
||||
<a className="underline underline-offset-2" href="/PAP/issues/PAP-10114">
|
||||
PAP-10114
|
||||
</a>
|
||||
. The browser claim CTA only appears when{" "}
|
||||
<span className="font-mono">deploymentMode === "authenticated"</span> and{" "}
|
||||
<span className="font-mono">deploymentExposure === "private"</span>.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-3xl space-y-12 px-6 pt-10">
|
||||
{FIXTURE_ORDER.map((key) => (
|
||||
<section key={key} aria-labelledby={`lab-${key}`}>
|
||||
<h2
|
||||
id={`lab-${key}`}
|
||||
className="mb-3 text-xs font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{FIXTURE_LABELS[key]}
|
||||
</h2>
|
||||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/10 p-2">
|
||||
{FIXTURE_BODIES[key]}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -709,7 +709,7 @@ export function CompanyEnvironments() {
|
||||
) : null}
|
||||
|
||||
{environmentForm.driver === "sandbox" ? (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<Field label="Provider" hint="Installed run-capable sandbox provider plugins appear here.">
|
||||
<select
|
||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||
@@ -736,26 +736,24 @@ export function CompanyEnvironments() {
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="md:col-span-2 space-y-3">
|
||||
{selectedSandboxProvider?.description ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedSandboxProvider.description}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedSandboxSchema ? (
|
||||
<JsonSchemaForm
|
||||
schema={selectedSandboxSchema as any}
|
||||
values={environmentForm.sandboxConfig}
|
||||
onChange={(values) =>
|
||||
setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))}
|
||||
errors={sandboxConfigErrors}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
This provider does not declare additional configuration fields.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedSandboxProvider?.description ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedSandboxProvider.description}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedSandboxSchema ? (
|
||||
<JsonSchemaForm
|
||||
schema={selectedSandboxSchema as any}
|
||||
values={environmentForm.sandboxConfig}
|
||||
onChange={(values) =>
|
||||
setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))}
|
||||
errors={sandboxConfigErrors}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
This provider does not declare additional configuration fields.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
+1431
-244
File diff suppressed because it is too large
Load Diff
@@ -205,6 +205,8 @@ export function InstanceExperimentalSettings() {
|
||||
|
||||
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
||||
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||
const enableIssuePlanDecompositions =
|
||||
experimentalQuery.data?.enableIssuePlanDecompositions === true;
|
||||
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
|
||||
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
||||
const enableIssueGraphLivenessAutoRecovery =
|
||||
@@ -299,6 +301,28 @@ export function InstanceExperimentalSettings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Issue Plan Decomposition Panel</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Show accepted-plan decomposition history on issue detail pages. Intended for debugging and validating
|
||||
subtask creation behavior while the presentation is still being refined.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={enableIssuePlanDecompositions}
|
||||
onCheckedChange={() =>
|
||||
toggleMutation.mutate({
|
||||
enableIssuePlanDecompositions: !enableIssuePlanDecompositions,
|
||||
})
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label="Toggle issue plan decomposition panel experimental setting"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { canBoardResolveRecoveryAction, IssueDetail } from "./IssueDetail";
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
listAcceptedPlanDecompositions: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
listAttachments: vi.fn(),
|
||||
listFeedbackVotes: vi.fn(),
|
||||
@@ -59,6 +60,7 @@ const mockProjectsApi = vi.hoisted(() => ({
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(),
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
@@ -823,6 +825,10 @@ describe("IssueDetail", () => {
|
||||
keyboardShortcuts: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
});
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIssuePlanDecompositions: false,
|
||||
});
|
||||
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
|
||||
mockIssuesListRender.mockClear();
|
||||
mockIssueChatThreadRender.mockClear();
|
||||
});
|
||||
@@ -858,6 +864,79 @@ describe("IssueDetail", () => {
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the plan decomposition panel by default", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).not.toContain("Plan decomposition");
|
||||
expect(mockIssuesApi.listAcceptedPlanDecompositions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the plan decomposition panel when the experimental flag is enabled", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIssuePlanDecompositions: true,
|
||||
});
|
||||
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([
|
||||
{
|
||||
id: "decomp-1",
|
||||
companyId: "company-1",
|
||||
sourceIssueId: "issue-1",
|
||||
acceptedPlanRevisionId: "plan-rev-1",
|
||||
acceptedPlanRevisionNumber: 2,
|
||||
acceptedInteractionId: null,
|
||||
status: "completed",
|
||||
requestFingerprint: "fingerprint-1",
|
||||
requestedChildCount: 2,
|
||||
childIssueIds: ["issue-2", "issue-3"],
|
||||
childIssues: [
|
||||
{
|
||||
id: "issue-2",
|
||||
identifier: "PAP-2",
|
||||
title: "First child issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
ownerAgentId: null,
|
||||
ownerUserId: null,
|
||||
ownerRunId: null,
|
||||
completedAt: "2026-05-28T06:00:00.000Z",
|
||||
createdAt: "2026-05-28T05:50:00.000Z",
|
||||
updatedAt: "2026-05-28T06:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Plan decomposition");
|
||||
expect(container.textContent).toContain("Plan revision 2");
|
||||
expect(container.textContent).toContain("2 of 2 child issues created");
|
||||
expect(container.textContent).toContain("First child issue");
|
||||
expect(mockIssuesApi.listAcceptedPlanDecompositions).toHaveBeenCalledWith("issue-1");
|
||||
});
|
||||
|
||||
it("renders sibling previous and next navigation at the chat footer", async () => {
|
||||
const issue = createIssue({
|
||||
id: "issue-2",
|
||||
|
||||
@@ -66,6 +66,7 @@ import { InlineEditor } from "../components/InlineEditor";
|
||||
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
|
||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection";
|
||||
import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
@@ -1440,8 +1441,16 @@ export function IssueDetail() {
|
||||
enabled: !!issueId,
|
||||
retry: false,
|
||||
});
|
||||
const { data: instanceExperimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
enabled: !!issueId,
|
||||
retry: false,
|
||||
});
|
||||
const keyboardShortcutsEnabled = instanceGeneralSettings?.keyboardShortcuts === true;
|
||||
const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt";
|
||||
const showPlanDecompositionsSection =
|
||||
instanceExperimentalSettings?.enableIssuePlanDecompositions === true;
|
||||
const { orderedProjects } = useProjectOrder({
|
||||
projects: projects ?? [],
|
||||
companyId: selectedCompanyId,
|
||||
@@ -3713,6 +3722,14 @@ export function IssueDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPlanDecompositionsSection ? (
|
||||
<IssuePlanDecompositionsSection
|
||||
issueId={issue.id}
|
||||
issueIdentifier={issue.identifier}
|
||||
agentMap={agentMap}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<IssueDocumentsSection
|
||||
issue={issue}
|
||||
canDeleteDocuments={Boolean(session?.user?.id)}
|
||||
@@ -3736,6 +3753,8 @@ export function IssueDetail() {
|
||||
});
|
||||
}}
|
||||
extraActions={!hasAttachments ? attachmentUploadButton : null}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
|
||||
{attachmentsInitialLoading ? (
|
||||
|
||||
@@ -43,6 +43,31 @@ function getPluginErrorSummary(plugin: PluginRecord): string {
|
||||
return firstNonEmptyLine(plugin.lastError) ?? "Plugin entered an error state without a stored error message.";
|
||||
}
|
||||
|
||||
function isExperimentalPluginIdentity(input: {
|
||||
packageName?: string | null;
|
||||
packagePath?: string | null;
|
||||
manifestJson?: PluginRecord["manifestJson"] | null;
|
||||
bundledExperimental?: boolean;
|
||||
}) {
|
||||
if (input.bundledExperimental) return true;
|
||||
|
||||
const packageName = input.packageName ?? "";
|
||||
const packagePath = input.packagePath ?? "";
|
||||
if (packageName.includes("sandbox") || packagePath.includes("sandbox")) return true;
|
||||
return input.manifestJson?.environmentDrivers?.some((driver) => driver.kind === "sandbox_provider") === true;
|
||||
}
|
||||
|
||||
function ExperimentalBadge() {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/30 bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-200"
|
||||
>
|
||||
Experimental
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PluginManager page component.
|
||||
*
|
||||
@@ -85,9 +110,9 @@ export function PluginManager() {
|
||||
queryFn: () => pluginsApi.list(),
|
||||
});
|
||||
|
||||
const examplesQuery = useQuery({
|
||||
const bundledQuery = useQuery({
|
||||
queryKey: queryKeys.plugins.examples,
|
||||
queryFn: () => pluginsApi.listExamples(),
|
||||
queryFn: () => pluginsApi.listBundled(),
|
||||
});
|
||||
|
||||
const invalidatePluginQueries = () => {
|
||||
@@ -144,9 +169,9 @@ export function PluginManager() {
|
||||
});
|
||||
|
||||
const installedPlugins = plugins ?? [];
|
||||
const examples = examplesQuery.data ?? [];
|
||||
const bundledPlugins = bundledQuery.data ?? [];
|
||||
const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin]));
|
||||
const examplePackageNames = new Set(examples.map((example) => example.packageName));
|
||||
const bundledByPackageName = new Map(bundledPlugins.map((plugin) => [plugin.packageName, plugin]));
|
||||
const errorSummaryByPluginId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -223,30 +248,37 @@ export function PluginManager() {
|
||||
<Badge variant="outline">Bundled</Badge>
|
||||
</div>
|
||||
|
||||
{examplesQuery.isLoading ? (
|
||||
{bundledQuery.isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading bundled plugins...</div>
|
||||
) : examplesQuery.error ? (
|
||||
) : bundledQuery.error ? (
|
||||
<div className="text-sm text-destructive">Failed to load bundled plugins.</div>
|
||||
) : examples.length === 0 ? (
|
||||
) : bundledPlugins.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||
No bundled plugins were found in this checkout.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border bg-card">
|
||||
{examples.map((example) => {
|
||||
const installedPlugin = installedByPackageName.get(example.packageName);
|
||||
{bundledPlugins.map((bundledPlugin) => {
|
||||
const installedPlugin = installedByPackageName.get(bundledPlugin.packageName);
|
||||
const installPending =
|
||||
installMutation.isPending &&
|
||||
installMutation.variables?.isLocalPath &&
|
||||
installMutation.variables.packageName === example.localPath;
|
||||
installMutation.variables.packageName === bundledPlugin.localPath;
|
||||
|
||||
return (
|
||||
<li key={example.packageName}>
|
||||
<li key={bundledPlugin.packageName}>
|
||||
<div className="flex items-center gap-4 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{example.displayName}</span>
|
||||
<Badge variant="outline">{example.tag === "first-party" ? "First-party" : "Example"}</Badge>
|
||||
<span className="font-medium">{bundledPlugin.displayName}</span>
|
||||
<Badge variant="outline">
|
||||
{bundledPlugin.tag === "first-party" ? "First-party" : "Example"}
|
||||
</Badge>
|
||||
{isExperimentalPluginIdentity({
|
||||
packageName: bundledPlugin.packageName,
|
||||
packagePath: bundledPlugin.localPath,
|
||||
bundledExperimental: bundledPlugin.experimental,
|
||||
}) && <ExperimentalBadge />}
|
||||
{installedPlugin ? (
|
||||
<Badge
|
||||
variant={installedPlugin.status === "ready" ? "default" : "secondary"}
|
||||
@@ -258,8 +290,8 @@ export function PluginManager() {
|
||||
<Badge variant="secondary">Not installed</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{example.description}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{example.packageName}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{bundledPlugin.description}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{bundledPlugin.packageName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{installedPlugin ? (
|
||||
@@ -286,12 +318,12 @@ export function PluginManager() {
|
||||
disabled={installPending || installMutation.isPending}
|
||||
onClick={() =>
|
||||
installMutation.mutate({
|
||||
packageName: example.localPath,
|
||||
packageName: bundledPlugin.localPath,
|
||||
isLocalPath: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{installPending ? "Installing..." : "Install Example"}
|
||||
{installPending ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -333,9 +365,19 @@ export function PluginManager() {
|
||||
>
|
||||
{plugin.manifestJson.displayName ?? plugin.packageName}
|
||||
</Link>
|
||||
{examplePackageNames.has(plugin.packageName) && (
|
||||
<Badge variant="outline">Example</Badge>
|
||||
{bundledByPackageName.has(plugin.packageName) && (
|
||||
<Badge variant="outline">
|
||||
{bundledByPackageName.get(plugin.packageName)?.tag === "first-party"
|
||||
? "First-party"
|
||||
: "Example"}
|
||||
</Badge>
|
||||
)}
|
||||
{isExperimentalPluginIdentity({
|
||||
packageName: plugin.packageName,
|
||||
packagePath: plugin.packagePath,
|
||||
manifestJson: plugin.manifestJson,
|
||||
bundledExperimental: bundledByPackageName.get(plugin.packageName)?.experimental,
|
||||
}) && <ExperimentalBadge />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate" title={plugin.packageName}>
|
||||
|
||||
Reference in New Issue
Block a user