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:
@@ -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