mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
Merge origin/master and integrate environment selection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
fed4802b72
159 files changed
+13953
-2027
No files matched your search
@@ -60,6 +60,7 @@ import { WhatNeedsMe } from "./pages/WhatNeedsMe";
|
||||
import { DecisionQueuePage } from "./pages/DecisionQueuePage";
|
||||
import { BoardChat } from "./pages/BoardChat";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanyConnections } from "./pages/CompanyConnections";
|
||||
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
|
||||
import { BootstrapSetupUxLab } from "./pages/BootstrapSetupUxLab";
|
||||
import { ResponsibleUserDenialUxLab } from "./pages/ResponsibleUserDenialUxLab";
|
||||
@@ -162,6 +163,7 @@ function boardRoutes(streamlinedUiEnabled: boolean, combinedInboxTasksEnabled: b
|
||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="company/settings/connections" element={<CompanyConnections />} />
|
||||
<Route path="company/settings/environments" element={<Navigate to="/company/settings/instance/environments" replace />} />
|
||||
<Route path="company/settings/cloud-upstream" element={<Navigate to="/company/export" replace />} />
|
||||
<Route element={<HiddenSettingsPageGate pageKey="company.members" />}>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
FastResponseSettingsResponse,
|
||||
FastResponseSettings,
|
||||
FastResponseTestResult,
|
||||
FastResponseHistoryEntry,
|
||||
UpdateFastResponse,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
export const fastResponsesApi = {
|
||||
settings: (companyId: string) =>
|
||||
api.get<FastResponseSettingsResponse>(
|
||||
`/companies/${companyId}/fast-response`,
|
||||
),
|
||||
update: (companyId: string, settings: UpdateFastResponse) =>
|
||||
api.put<FastResponseSettings>(
|
||||
`/companies/${companyId}/fast-response`,
|
||||
settings,
|
||||
),
|
||||
models: (companyId: string, connectionId: string) =>
|
||||
api.get<Array<{ id: string; label?: string }>>(
|
||||
`/companies/${companyId}/fast-response/models?connectionId=${encodeURIComponent(connectionId)}`,
|
||||
),
|
||||
test: (companyId: string) =>
|
||||
api.post<FastResponseTestResult>(
|
||||
`/companies/${companyId}/fast-response/test`,
|
||||
{},
|
||||
),
|
||||
history: (companyId: string, from?: string, to?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
if (to) params.set("to", to);
|
||||
return api.get<FastResponseHistoryEntry[]>(
|
||||
`/companies/${companyId}/fast-response/history?${params}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Download,
|
||||
FlaskConical,
|
||||
KeyRound,
|
||||
Link2,
|
||||
MailPlus,
|
||||
MonitorCog,
|
||||
Puzzle,
|
||||
@@ -27,6 +28,7 @@ import { useCompany } from "@/context/CompanyContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { useFastResponsesEnabled } from "@/hooks/useFastResponsesEnabled";
|
||||
import { usePluginSlots } from "@/plugins/slots";
|
||||
import { SidebarNavItem } from "./SidebarNavItem.production";
|
||||
|
||||
@@ -46,6 +48,7 @@ export function CompanySettingsSidebar() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { hidden: hiddenSettings } = useHiddenSettings();
|
||||
const { enabled: fastResponsesEnabled } = useFastResponsesEnabled();
|
||||
const showPage = (pageKey: string) => !hiddenSettings.has(pageKey);
|
||||
const showPlugins = showPage("instance.plugins");
|
||||
// Import is floored server-side on cloud-managed instances (403 cloud_managed), so the
|
||||
@@ -143,6 +146,9 @@ export function CompanySettingsSidebar() {
|
||||
end
|
||||
/>
|
||||
)}
|
||||
{fastResponsesEnabled && (
|
||||
<SidebarNavItem to="/company/settings/connections" label="Connections" icon={Link2} end />
|
||||
)}
|
||||
{showPage("instance.access") && (
|
||||
<SidebarNavItem
|
||||
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/access`}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Download,
|
||||
FlaskConical,
|
||||
KeyRound,
|
||||
Link2,
|
||||
MonitorCog,
|
||||
Puzzle,
|
||||
Shield,
|
||||
@@ -24,6 +25,7 @@ import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { useFastResponsesEnabled } from "@/hooks/useFastResponsesEnabled";
|
||||
import { usePluginSlots } from "@/plugins/slots";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
|
||||
@@ -44,6 +46,7 @@ function isSandboxProviderOnly(plugin: PluginRecord): boolean {
|
||||
export function CompanySettingsSidebar() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { hidden: hiddenSettings } = useHiddenSettings();
|
||||
const { enabled: fastResponsesEnabled } = useFastResponsesEnabled();
|
||||
const showPage = (pageKey: string) => !hiddenSettings.has(pageKey);
|
||||
const showPlugins = showPage("instance.plugins");
|
||||
// Import is floored server-side on cloud-managed instances (403 cloud_managed), so the
|
||||
@@ -142,6 +145,9 @@ export function CompanySettingsSidebar() {
|
||||
end
|
||||
/>
|
||||
)}
|
||||
{fastResponsesEnabled && (
|
||||
<SidebarNavItem to="/company/settings/connections" label="Connections" icon={Link2} end />
|
||||
)}
|
||||
{showPage("instance.access") && (
|
||||
<SidebarNavItem
|
||||
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/access`}
|
||||
|
||||
@@ -42,7 +42,9 @@ export function EmailMessageCard({
|
||||
message,
|
||||
publication,
|
||||
issueId,
|
||||
contextNotice = false,
|
||||
}: {
|
||||
contextNotice?: boolean;
|
||||
message: EmailMessage;
|
||||
publication?: EmailPublicationSummary;
|
||||
issueId: string;
|
||||
@@ -57,7 +59,7 @@ export function EmailMessageCard({
|
||||
aria-label={
|
||||
message.direction === "inbound" ? "Email received" : "Email sent"
|
||||
}
|
||||
className="space-y-4 rounded-xl border border-border bg-card p-5"
|
||||
className={contextNotice ? "task-context-notice space-y-3" : "space-y-4 rounded-xl border border-border bg-card p-5"}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold">
|
||||
|
||||
@@ -36,6 +36,7 @@ export function EmailTaskActivity({
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<EmailMessageCard
|
||||
contextNotice
|
||||
key={m.id}
|
||||
issueId={issueId}
|
||||
message={m}
|
||||
@@ -78,7 +79,7 @@ function EmailDelivery({
|
||||
{p.request && !p.providerMessageId && (
|
||||
<article
|
||||
aria-label="Email send intent"
|
||||
className="space-y-3 rounded-lg border border-border p-4"
|
||||
className="task-context-notice space-y-3"
|
||||
>
|
||||
<p className="font-semibold">{p.request.subject ?? "Email reply"}</p>
|
||||
{p.request.to && <p>To: {p.request.to.join(", ")}</p>}
|
||||
|
||||
@@ -36,8 +36,7 @@ describe("stopped task recovery notice", () => {
|
||||
const notice = container.querySelector('[role="status"][aria-label="Task recovery"]')!;
|
||||
expect(notice.textContent).toContain("Recorded work is preserved");
|
||||
expect(notice.textContent).toContain("Retry");
|
||||
expect(notice.classList.contains("border")).toBe(true);
|
||||
expect(notice.classList.contains("bg-muted")).toBe(true);
|
||||
expect(notice.classList.contains("task-context-notice")).toBe(true);
|
||||
expect(notice.querySelector("a")?.getAttribute("href")).toBe("/agents/agent/runs/failed-run");
|
||||
});
|
||||
it.each([false, true])("explains cancelled runs and saved input; continue eligibility %s", async canContinue => {
|
||||
|
||||
@@ -4,9 +4,11 @@ import { agentsApi } from "../api/agents";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Link } from "../lib/router";
|
||||
|
||||
export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried }: {
|
||||
export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried, className }: {
|
||||
className?: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
blocker: ExecutionBlocker;
|
||||
@@ -33,7 +35,7 @@ export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div role="status" aria-label="Task recovery" className="mx-(--sz-execution-blocker-inline) my-(--sz-execution-blocker-block) flex flex-wrap items-center justify-between execution-blocker-notice border border-border bg-muted text-foreground">
|
||||
<div role="status" aria-label="Task recovery" className={cn("task-context-notice flex flex-wrap items-center justify-between gap-2", className)}>
|
||||
<div className="min-w-0 flex-1 break-words">
|
||||
<p>{modelRejected ? "Model unavailable." : "Recovery needed."}{blocker.runError ? ` ${blocker.runError}` : ""}</p>
|
||||
{modelRejected && <p>Choose a supported model or clear the task's model override, then retry.</p>}
|
||||
|
||||
@@ -2487,6 +2487,7 @@ function IssueChatAssistantMessage({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{custom.origin === "fast_response" && <div className="max-w-xs px-2 py-2 text-xs text-muted-foreground">Platform-generated acknowledgement. The agent’s substantive response follows separately.</div>}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(copyText).catch((error) => {
|
||||
|
||||
@@ -2656,6 +2656,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
return (
|
||||
<TaskChatBubbleActions
|
||||
copyText={item.text}
|
||||
fastResponse={item.origin === "fast_response"}
|
||||
feedback={
|
||||
onVote
|
||||
? {
|
||||
|
||||
@@ -3,12 +3,14 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import type { IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
/** Copyback repair preserves the accepted result and never wakes the provider. */
|
||||
export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued }: {
|
||||
export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued, className }: {
|
||||
className?: string;
|
||||
issueId: string; action: IssueRecoveryAction | null; canManage: boolean; onQueued: () => void;
|
||||
}) {
|
||||
const noteId = useId();
|
||||
@@ -23,7 +25,7 @@ export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued }
|
||||
if (!action || !isNativeWorkspaceExportRepairCause(action.cause) || action.ownerType !== "board"
|
||||
|| !["active", "escalated"].includes(action.status) || typeof action.evidence.runId !== "string") return null;
|
||||
const queued = queuedActionVersion === String(action.updatedAt) || action.wakePolicy?.kind === "resume_native_run";
|
||||
return <section aria-label="Workspace export repair" className="flex flex-col gap-2 p-4 text-sm">
|
||||
return <section aria-label="Workspace export repair" className={cn("task-context-notice flex flex-col gap-2", className)}>
|
||||
<p className="font-medium">Workspace export needs repair</p>
|
||||
{queued ? <p role="status">Export is queued for the saved result. The agent will not repeat its work.</p> : <>
|
||||
<p className="text-muted-foreground">{"Automatic workspace export retries stopped. Inspect the export failure, restore provider or destination availability, and preserve the saved files in the retained sandbox. Retry export here when the cause is resolved."}</p>
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("CompanySettingsNav", () => {
|
||||
it("maps company settings routes to the expected shared tab value", () => {
|
||||
expect(getCompanySettingsTab("/company/settings")).toBe("general");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/connections")).toBe("connections");
|
||||
expect(getCompanySettingsTab("/company/settings/environments")).toBe("instance-environments");
|
||||
expect(getCompanySettingsTab("/company/export")).toBe("export");
|
||||
expect(getCompanySettingsTab("/PAP/company/export")).toBe("export");
|
||||
@@ -93,8 +94,10 @@ describe("CompanySettingsNav", () => {
|
||||
root: ReturnType<typeof createRoot>,
|
||||
hiddenSettings?: string[],
|
||||
cloud?: { managed: boolean },
|
||||
fastResponsesEnabled = true,
|
||||
) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableFastResponses: fastResponsesEnabled });
|
||||
queryClient.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
...(hiddenSettings ? { hiddenSettings } : {}),
|
||||
@@ -107,6 +110,13 @@ describe("CompanySettingsNav", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("hides Connections while fast responses are disabled", async () => {
|
||||
const root = createRoot(container);
|
||||
await act(async () => { renderNav(root, undefined, undefined, false); });
|
||||
expect(pageTabBarMock.mock.calls.at(-1)?.[0]?.items).not.toContainEqual({ value: "connections", label: "Connections" });
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
|
||||
it("renders the active tab and navigates when a different tab is selected", async () => {
|
||||
currentPathname = "/PAP/company/settings/members";
|
||||
const root = createRoot(container);
|
||||
@@ -127,6 +137,7 @@ describe("CompanySettingsNav", () => {
|
||||
{ value: "secrets", label: "Secrets" },
|
||||
{ value: "instance-profile", label: "Profile" },
|
||||
{ value: "instance-environments", label: "Environments" },
|
||||
{ value: "connections", label: "Connections" },
|
||||
{ value: "instance-access", label: "Access" },
|
||||
{ value: "instance-experimental", label: "Experimental" },
|
||||
{ value: "instance-plugins", label: "Plugins" },
|
||||
@@ -168,6 +179,7 @@ describe("CompanySettingsNav", () => {
|
||||
"secrets",
|
||||
"instance-profile",
|
||||
"instance-environments",
|
||||
"connections",
|
||||
"instance-access",
|
||||
"instance-experimental",
|
||||
"instance-adapters",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PageTabBar } from "@/components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { useFastResponsesEnabled } from "@/hooks/useFastResponsesEnabled";
|
||||
import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
|
||||
@@ -13,6 +14,7 @@ const items = [
|
||||
{ value: "secrets", label: "Secrets", href: "/company/settings/secrets" },
|
||||
{ value: "instance-profile", label: "Profile", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/profile` },
|
||||
{ value: "instance-environments", label: "Environments", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/environments` },
|
||||
{ value: "connections", label: "Connections", href: "/company/settings/connections" },
|
||||
{ value: "instance-access", label: "Access", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/access` },
|
||||
{ value: "instance-experimental", label: "Experimental", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/experimental` },
|
||||
{ value: "instance-plugins", label: "Plugins", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/plugins` },
|
||||
@@ -36,6 +38,10 @@ const hiddenSettingKeyByTab: Partial<Record<CompanySettingsTab, string>> = {
|
||||
};
|
||||
|
||||
export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
||||
if (pathname.includes("/company/settings/connections")) {
|
||||
return "connections";
|
||||
}
|
||||
|
||||
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`)) {
|
||||
return "instance-profile";
|
||||
}
|
||||
@@ -96,11 +102,13 @@ export function CompanySettingsNav() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { hidden: hiddenSettings } = useHiddenSettings();
|
||||
const { enabled: fastResponsesEnabled } = useFastResponsesEnabled();
|
||||
// Import is floored server-side on cloud-managed instances (403 cloud_managed), so the
|
||||
// tab is suppressed there rather than dead-ending.
|
||||
const isCloud = Boolean(useCloudInstance());
|
||||
const activeTab = getCompanySettingsTab(location.pathname);
|
||||
const visibleItems = items.filter((item) => {
|
||||
if (item.value === "connections" && !fastResponsesEnabled) return false;
|
||||
if (item.value === "import" && isCloud) return false;
|
||||
const hiddenKey = hiddenSettingKeyByTab[item.value];
|
||||
return !hiddenKey || !hiddenSettings.has(hiddenKey);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
@@ -124,6 +125,7 @@ export function useIssueChatBinding(companyId: string, issueId: string) {
|
||||
}
|
||||
|
||||
type ConnectedTaskProps = {
|
||||
className?: string;
|
||||
attachments?: IssueAttachment[];
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
@@ -148,6 +150,7 @@ export function ExternallyConnectedTaskBanner(props: ConnectedTaskProps) {
|
||||
}
|
||||
|
||||
function ConnectedTaskComposer({
|
||||
className,
|
||||
attachments = [],
|
||||
companyId,
|
||||
issueId,
|
||||
@@ -486,7 +489,7 @@ function ConnectedTaskComposer({
|
||||
return (
|
||||
<section
|
||||
aria-label="External conversation"
|
||||
className="space-y-3 rounded-lg border border-border bg-muted/40 p-3 text-sm"
|
||||
className={cn("task-context-notice space-y-3", className)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex min-w-0 flex-1 basis-64 items-center gap-3">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DecisionHistoryTable } from "./DecisionHistory";
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompany: { issuePrefix: "ACM" } }),
|
||||
}));
|
||||
|
||||
describe("model history setup links", () => {
|
||||
it.each([
|
||||
[false, true, "/ACM/company/settings/connections", "Configure a decision model"],
|
||||
[false, false, "/ACM/company/settings", "Configure a decision model"],
|
||||
[true, true, "/ACM/company/settings/connections", "Configure fast response"],
|
||||
[true, false, "/ACM/company/settings/instance/experimental", "Enable experimental fast responses"],
|
||||
] as const)("routes setup with fast response %s and experiment %s", (fastResponse, connectionsEnabled, href, label) => {
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
flushSync(() => root.render(
|
||||
<MemoryRouter>
|
||||
<DecisionHistoryTable entries={[]} fastResponse={fastResponse} connectionsEnabled={connectionsEnabled} />
|
||||
</MemoryRouter>,
|
||||
));
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBe(href);
|
||||
expect(container.querySelector("a")?.textContent).toBe(label);
|
||||
} finally {
|
||||
flushSync(() => root.unmount());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,19 +4,21 @@ import { decisionModelsApi } from "@/api/decision-models";
|
||||
import { Link } from "@/lib/router";
|
||||
import { formatDateTime, formatTokens, formatDetailedCents } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFastResponsesEnabled } from "@/hooks/useFastResponsesEnabled";
|
||||
|
||||
export function DecisionHistoryTable({ entries }: { entries: DecisionHistoryEntry[] }) {
|
||||
if (!entries.length) return <p className="text-sm text-muted-foreground">No decision requests in this period. <Link to="/company/settings" className="underline underline-offset-4">Configure a decision model</Link> to run a test.</p>;
|
||||
export function DecisionHistoryTable({ entries, fastResponse = false, connectionsEnabled = true }: { entries: (DecisionHistoryEntry & { publicationStatus?: string; connectionName?: string | null })[]; fastResponse?: boolean; connectionsEnabled?: boolean }) {
|
||||
if (!entries.length) return <p className="text-sm text-muted-foreground">{fastResponse ? "No fast responses in this period." : "No decision requests in this period."} <Link to={connectionsEnabled ? "/company/settings/connections" : fastResponse ? "/company/settings/instance/experimental" : "/company/settings"} className="underline underline-offset-4">{fastResponse ? connectionsEnabled ? "Configure fast response" : "Enable experimental fast responses" : "Configure a decision model"}</Link> to run a test.</p>;
|
||||
return <div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">Recent requests. Inputs and answers are not retained.</p>
|
||||
<div className="overflow-x-auto"><table className="w-full text-sm" aria-label="Decision requests">
|
||||
<thead><tr className="border-b border-border text-muted-foreground">{["When / feature", "Responsible", "Task", "Model", "Status", "Duration", "Tokens in / out", "Cost"].map(label => <th key={label} className="px-3 py-2 text-left font-medium">{label}</th>)}</tr></thead>
|
||||
<p className="text-xs text-muted-foreground">Recent requests. Inputs are not retained.</p>
|
||||
<div className="overflow-x-auto"><table className="w-full text-sm" aria-label={fastResponse ? "Fast responses" : "Decision requests"}>
|
||||
<thead><tr className="border-b border-border text-muted-foreground">{["When / feature", "Responsible", "Task", ...(fastResponse ? ["Connection"] : []), "Model", "Status", "Duration", "Tokens in / out", "Cost"].map(label => <th key={label} className="px-3 py-2 text-left font-medium">{label}</th>)}</tr></thead>
|
||||
<tbody>{entries.map(row => <tr key={row.id} className="border-b border-border last:border-0">
|
||||
<td className="px-3 py-3"><div className="whitespace-nowrap">{formatDateTime(row.startedAt)}</div><div className="text-xs text-muted-foreground">{row.feature === "settings.test" ? "Setup test" : row.feature}</div></td>
|
||||
<td className="px-3 py-3">{row.actorType === "system" ? "Paperclip services" : row.userName ?? (row.responsibleUserId === "local-board" ? "Board" : "Former user")}</td>
|
||||
<td className="px-3 py-3">{row.issueId ? <Link className="underline underline-offset-4" to={`/issues/${row.issueIdentifier ?? row.issueId}`}>{row.issueIdentifier ?? "View task"}</Link> : "—"}</td>
|
||||
{fastResponse && <td className="px-3 py-3">{row.connectionName ?? (row.connectionId ? "Removed connection" : "—")}</td>}
|
||||
<td className="px-3 py-3 font-mono text-xs">{row.model}</td>
|
||||
<td className="px-3 py-3"><div>{row.status === "succeeded" ? "Succeeded" : row.status === "running" ? "Running" : row.status === "unknown" ? "Unresolved" : "Failed"}</div>{row.errorCode && <div className="text-xs text-muted-foreground">{row.errorCode.replaceAll("_", " ")}</div>}</td>
|
||||
<td className="px-3 py-3"><div>{row.status === "succeeded" ? "Succeeded" : row.status === "running" ? "Running" : row.status === "unknown" ? "Unresolved" : row.status === "skipped" ? "Skipped" : row.status === "pending" ? "Pending" : "Failed"}</div>{row.publicationStatus && <div className="text-xs text-muted-foreground">{row.publicationStatus}</div>}{row.errorCode && <div className="text-xs text-muted-foreground">{row.errorCode.replaceAll("_", " ")}</div>}</td>
|
||||
<td className="px-3 py-3 font-mono">{row.durationMs === null ? "—" : `${(row.durationMs / 1000).toFixed(1)}s`}</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 font-mono">{row.inputTokens === null ? "—" : formatTokens(row.inputTokens)} / {row.outputTokens === null ? "—" : formatTokens(row.outputTokens)}</td>
|
||||
<td className="px-3 py-3 font-mono"><div>{row.costCents === null ? "Unknown" : formatDetailedCents(row.costCents)}</div>{row.costStatus !== "unpriced" && row.costStatus && <span className="text-xs text-muted-foreground">{row.costStatus === "estimated" ? "Estimated" : "Reported"}</span>}</td>
|
||||
@@ -25,8 +27,9 @@ export function DecisionHistoryTable({ entries }: { entries: DecisionHistoryEntr
|
||||
</div>;
|
||||
}
|
||||
export function DecisionHistory({ companyId, from, to }: { companyId: string; from?: string | null; to?: string | null }) {
|
||||
const { enabled } = useFastResponsesEnabled();
|
||||
const query = useQuery({ queryKey: ["decision-history", companyId, from, to], queryFn: () => decisionModelsApi.history(companyId, from, to), refetchInterval: q => q.state.data?.some(row => row.status === "running") ? 5000 : false });
|
||||
if (query.isPending) return <p className="text-sm text-muted-foreground">Loading decision requests…</p>;
|
||||
if (query.error) return <div role="alert" className="space-y-2"><p className="text-sm text-destructive">Could not load decision requests.</p><Button variant="outline" onClick={() => void query.refetch()}>Try again</Button></div>;
|
||||
return <DecisionHistoryTable entries={query.data ?? []} />;
|
||||
return <DecisionHistoryTable entries={query.data ?? []} connectionsEnabled={enabled} />;
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export function DecisionModelSettingsView({ settings, choices, saving, testing,
|
||||
const unavailable = result && result.status !== "succeeded" ? failureMessages[result.status === "unavailable" ? result.reason : result.errorCode] ?? "The decision could not be completed. Review its entry in Costs." : null;
|
||||
return <section aria-labelledby="decision-model-heading" className="max-w-2xl space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 id="decision-model-heading" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Decision model</h2>
|
||||
<h2 id="decision-model-heading" className="text-lg font-semibold">Decision model</h2>
|
||||
<Link to="/activity/costs?tab=decisions" className="text-sm underline underline-offset-4">View usage</Link>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Choose the shared connection Paperclip uses for optional decision features. API usage is charged to this connection.</p>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fastResponsesApi } from "@/api/fast-responses";
|
||||
import { DecisionHistoryTable } from "@/components/decision-models/DecisionHistory";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFastResponsesEnabled } from "@/hooks/useFastResponsesEnabled";
|
||||
export function FastResponseHistory({
|
||||
companyId,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
companyId: string;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
}) {
|
||||
const { enabled } = useFastResponsesEnabled();
|
||||
const query = useQuery({
|
||||
queryKey: ["fast-response-history", companyId, from, to],
|
||||
queryFn: () =>
|
||||
fastResponsesApi.history(companyId, from ?? undefined, to ?? undefined),
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.some(
|
||||
(r) =>
|
||||
["pending", "running"].includes(r.status) ||
|
||||
["pending", "streaming", "retry"].includes(r.publicationStatus),
|
||||
)
|
||||
? 3000
|
||||
: false,
|
||||
});
|
||||
if (query.isPending)
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Loading fast responses…</p>
|
||||
);
|
||||
if (query.error)
|
||||
return (
|
||||
<div role="alert">
|
||||
<p className="text-sm text-destructive">
|
||||
Could not load fast responses.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void query.refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return <DecisionHistoryTable entries={query.data ?? []} fastResponse connectionsEnabled={enabled} />;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { updateFastResponseSchema } from "@paperclipai/shared";
|
||||
import { FastResponseSettingsView } from "./FastResponseSettings";
|
||||
import { choices } from "../../../storybook/stories/decision-models/fixtures";
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-storybook" }),
|
||||
}));
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
it("saves only API settings and keeps the draft across unchanged query refetches", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container),
|
||||
onSave = vi.fn();
|
||||
const settings = {
|
||||
companyId: "company-storybook",
|
||||
enabled: false,
|
||||
connectionId: null,
|
||||
grantId: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
allowSponsored: false,
|
||||
};
|
||||
const render = () => (
|
||||
<MemoryRouter>
|
||||
<FastResponseSettingsView
|
||||
settings={{ ...settings }}
|
||||
choices={choices}
|
||||
onSave={onSave}
|
||||
onTest={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
try {
|
||||
await act(async () => root.render(render()));
|
||||
await act(async () =>
|
||||
[...container.querySelectorAll("button")]
|
||||
.find((b) => b.textContent?.includes("Company OpenRouter"))!
|
||||
.click(),
|
||||
);
|
||||
await act(async () => root.render(render()));
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>("input[list]")?.value,
|
||||
).toBe("openai/gpt-oss-120b");
|
||||
await act(async () =>
|
||||
[...container.querySelectorAll("button")]
|
||||
.find((b) => b.textContent === "Save fast response")!
|
||||
.click(),
|
||||
);
|
||||
const saved = onSave.mock.calls[0][0];
|
||||
expect(updateFastResponseSchema.safeParse(saved).success).toBe(true);
|
||||
expect(saved).toMatchObject({
|
||||
enabled: true,
|
||||
allowSponsored: false,
|
||||
connectionId: choices[1]!.id,
|
||||
});
|
||||
expect(saved).not.toHaveProperty("companyId");
|
||||
expect(saved).not.toHaveProperty("provider");
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
it("selects a newly created connection and recommends its default model", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container),
|
||||
onSave = vi.fn(),
|
||||
onConnectionChange = vi.fn();
|
||||
const settings = {
|
||||
companyId: "company-storybook",
|
||||
enabled: false,
|
||||
connectionId: null,
|
||||
grantId: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
allowSponsored: true,
|
||||
};
|
||||
try {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<FastResponseSettingsView
|
||||
settings={settings}
|
||||
choices={choices}
|
||||
newlyConnectedId={choices[1]!.id}
|
||||
onConnectionChange={onConnectionChange}
|
||||
onSave={onSave}
|
||||
onTest={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>("input[list]")?.value,
|
||||
).toBe("openai/gpt-oss-120b");
|
||||
await act(async () =>
|
||||
[...container.querySelectorAll("button")]
|
||||
.find((b) => b.textContent === "Save fast response")!
|
||||
.click(),
|
||||
);
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectionId: choices[1]!.id,
|
||||
enabled: true,
|
||||
allowSponsored: true,
|
||||
}),
|
||||
);
|
||||
expect(onConnectionChange).toHaveBeenCalledWith(choices[1]!.id);
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
FAST_RESPONSE_DEFAULT_MODEL,
|
||||
type FastResponseSettings,
|
||||
type AiManagedConnectionSummary,
|
||||
type FastResponseTestResult,
|
||||
type UpdateFastResponse,
|
||||
type AiProvider,
|
||||
} from "@paperclipai/shared";
|
||||
import { fastResponsesApi } from "@/api/fast-responses";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ToggleField } from "@/components/agent-config-primitives";
|
||||
import { ConnectionChoiceList } from "@/features/connections/ConnectionChoiceList";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Link } from "@/lib/router";
|
||||
|
||||
const editable = (s: FastResponseSettings): UpdateFastResponse => ({
|
||||
enabled: s.enabled,
|
||||
connectionId: s.connectionId,
|
||||
grantId: s.grantId,
|
||||
model: s.model,
|
||||
allowSponsored: s.allowSponsored,
|
||||
});
|
||||
|
||||
export function FastResponseSettingsView({
|
||||
settings,
|
||||
choices,
|
||||
models = [],
|
||||
saving,
|
||||
testing,
|
||||
error,
|
||||
result,
|
||||
newlyConnectedId,
|
||||
onSave,
|
||||
onTest,
|
||||
onConnectionChange,
|
||||
onAdd,
|
||||
}: {
|
||||
settings: FastResponseSettings;
|
||||
choices: AiManagedConnectionSummary[];
|
||||
models?: Array<{ id: string; label?: string }>;
|
||||
newlyConnectedId?: string | null;
|
||||
saving?: boolean;
|
||||
testing?: boolean;
|
||||
error?: string | null;
|
||||
result?: FastResponseTestResult | null;
|
||||
onAdd?: () => void;
|
||||
onSave: (value: UpdateFastResponse) => void;
|
||||
onTest: () => void;
|
||||
onConnectionChange?: (id: string | null) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<UpdateFastResponse>(() =>
|
||||
editable(settings),
|
||||
);
|
||||
const modelListId = useId();
|
||||
const revision = JSON.stringify(editable(settings));
|
||||
useEffect(() => {
|
||||
setDraft(editable(settings));
|
||||
onConnectionChange?.(settings.connectionId);
|
||||
}, [revision]);
|
||||
const appliedConnection = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!newlyConnectedId) {
|
||||
appliedConnection.current = null;
|
||||
return;
|
||||
}
|
||||
if (appliedConnection.current === newlyConnectedId) return;
|
||||
const connection = choices.find((row) => row.id === newlyConnectedId);
|
||||
if (!connection) return;
|
||||
appliedConnection.current = newlyConnectedId;
|
||||
setDraft((value) => ({
|
||||
...value,
|
||||
connectionId: connection.id,
|
||||
grantId: connection.grantId,
|
||||
model:
|
||||
connection.provider === "openrouter" ||
|
||||
connection.routing?.kind === "openrouter"
|
||||
? FAST_RESPONSE_DEFAULT_MODEL
|
||||
: (connection.routing?.models[0]?.id ?? null),
|
||||
enabled: value.connectionId ? value.enabled : true,
|
||||
}));
|
||||
onConnectionChange?.(connection.id);
|
||||
}, [newlyConnectedId, choices, onConnectionChange]);
|
||||
const selected = choices.find((c) => c.grantId === draft.grantId);
|
||||
const dirty =
|
||||
draft.enabled !== settings.enabled ||
|
||||
draft.grantId !== settings.grantId ||
|
||||
draft.model !== settings.model ||
|
||||
draft.allowSponsored !== settings.allowSponsored;
|
||||
return (
|
||||
<section className="max-w-2xl space-y-4" aria-label="Fast response">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Fast response
|
||||
</h2>
|
||||
<Link
|
||||
to="/activity/costs?tab=fast-responses"
|
||||
className="text-sm underline underline-offset-4"
|
||||
>
|
||||
View usage
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send a short acknowledgement while your agent starts work. Usage is
|
||||
charged to the selected shared API connection.
|
||||
</p>
|
||||
<ConnectionChoiceList
|
||||
selectedId={draft.grantId ?? undefined}
|
||||
disabled={saving || testing}
|
||||
choices={choices.map((c) => ({
|
||||
id: c.grantId,
|
||||
name: c.name,
|
||||
description:
|
||||
c.status === "connected" ? "Connected" : "Needs attention",
|
||||
icon: <AppLogo name={c.provider} brandKey={c.provider} size={24} />,
|
||||
}))}
|
||||
onSelect={(grantId) => {
|
||||
const c = choices.find((c) => c.grantId === grantId)!;
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
connectionId: c.id,
|
||||
grantId,
|
||||
model:
|
||||
c.provider === "openrouter" || c.routing?.kind === "openrouter"
|
||||
? FAST_RESPONSE_DEFAULT_MODEL
|
||||
: (c.routing?.models[0]?.id ?? null),
|
||||
enabled: d.connectionId ? d.enabled : true,
|
||||
}));
|
||||
onConnectionChange?.(c.id);
|
||||
}}
|
||||
/>
|
||||
{!choices.length && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add a shared API connection in Apps to get started.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
{onAdd && (
|
||||
<Button variant="outline" size="sm" onClick={onAdd}>
|
||||
Add connection
|
||||
</Button>
|
||||
)}
|
||||
<Link to="/apps" className="text-sm underline underline-offset-4">
|
||||
Manage connections
|
||||
</Link>
|
||||
</div>
|
||||
{draft.connectionId && (
|
||||
<fieldset disabled={saving || testing} className="space-y-4">
|
||||
{!selected && (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
The saved connection is unavailable. Disable fast responses or
|
||||
choose another connection.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={`${modelListId}-input`}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<Input
|
||||
id={`${modelListId}-input`}
|
||||
list={modelListId}
|
||||
value={draft.model ?? ""}
|
||||
placeholder="Choose or enter a model ID"
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, model: e.target.value || null }))
|
||||
}
|
||||
/>
|
||||
<datalist id={modelListId}>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label ?? m.id}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<ToggleField
|
||||
label="Enable fast responses"
|
||||
checked={draft.enabled}
|
||||
onChange={(enabled) => setDraft((d) => ({ ...d, enabled }))}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<ToggleField
|
||||
label="Allow company-sponsored fast responses"
|
||||
checked={draft.allowSponsored}
|
||||
onChange={(allowSponsored) =>
|
||||
setDraft((d) => ({ ...d, allowSponsored }))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Covers accepted external messages from people without a linked
|
||||
Paperclip account.
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!dirty &&
|
||||
result &&
|
||||
(result.status === "succeeded" ? (
|
||||
<div role="status" className="space-y-2 text-sm">
|
||||
<p>{result.text}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(result.durationMs / 1000).toFixed(2)} seconds ·{" "}
|
||||
{result.usage.inputTokens ?? "—"} input /{" "}
|
||||
{result.usage.outputTokens ?? "—"} output tokens · Usage recorded
|
||||
in Costs.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{result.reason === "timeout"
|
||||
? "The model did not respond within three seconds. Try a faster model."
|
||||
: "Could not generate a response. Check the connection and model, then try again."}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onTest}
|
||||
disabled={dirty || !settings.enabled || saving || testing}
|
||||
>
|
||||
{testing ? "Testing…" : "Test response"}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dirty
|
||||
? "Save changes before testing."
|
||||
: "Runs a small sample with an API charge."}
|
||||
</p>
|
||||
</div>
|
||||
{dirty && (
|
||||
<Button
|
||||
disabled={
|
||||
saving ||
|
||||
testing ||
|
||||
(draft.enabled &&
|
||||
(!draft.model || selected?.status !== "connected"))
|
||||
}
|
||||
onClick={() => onSave(draft)}
|
||||
>
|
||||
{saving ? "Saving…" : "Save fast response"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function FastResponseSettingsSection({
|
||||
companyId,
|
||||
}: {
|
||||
companyId: string;
|
||||
}) {
|
||||
const client = useQueryClient(),
|
||||
key = ["fast-response", companyId];
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => fastResponsesApi.settings(companyId),
|
||||
});
|
||||
const [newlyConnectedId, setNewlyConnectedId] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [provider, setProvider] = useState<AiProvider | null>(null);
|
||||
const [connectionId, setConnectionId] = useState<string | null>(null);
|
||||
const models = useQuery({
|
||||
queryKey: ["fast-response-models", companyId, connectionId],
|
||||
queryFn: () => fastResponsesApi.models(companyId, connectionId!),
|
||||
enabled: Boolean(connectionId),
|
||||
});
|
||||
const settingsRevision = JSON.stringify(
|
||||
query.data?.settings ?? { companyId },
|
||||
);
|
||||
const test = useMutation({
|
||||
mutationFn: (_revision: string) => fastResponsesApi.test(companyId),
|
||||
onSettled: () => {
|
||||
void client.invalidateQueries({
|
||||
queryKey: ["fast-response-history", companyId],
|
||||
});
|
||||
void client.invalidateQueries({
|
||||
predicate: (q) => String(q.queryKey[0]).includes("cost"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const resetTest = test.reset;
|
||||
useEffect(() => {
|
||||
resetTest();
|
||||
}, [settingsRevision, resetTest]);
|
||||
const save = useMutation({
|
||||
mutationFn: (settings: UpdateFastResponse) =>
|
||||
fastResponsesApi.update(companyId, settings),
|
||||
onSuccess: (settings) => {
|
||||
test.reset();
|
||||
client.setQueryData(key, { ...query.data, settings });
|
||||
},
|
||||
});
|
||||
if (query.isPending)
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Loading fast response…</p>
|
||||
);
|
||||
if (query.error)
|
||||
return (
|
||||
<div role="alert">
|
||||
<p className="text-sm text-destructive">
|
||||
Could not load fast response settings.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void query.refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
if (!query.data?.canManage || !query.data.settings) return null;
|
||||
return (
|
||||
<>
|
||||
<FastResponseSettingsView
|
||||
key={companyId}
|
||||
newlyConnectedId={newlyConnectedId}
|
||||
settings={query.data.settings}
|
||||
choices={query.data.choices}
|
||||
models={models.data}
|
||||
saving={save.isPending}
|
||||
testing={test.isPending}
|
||||
error={
|
||||
save.error?.message ??
|
||||
test.error?.message ??
|
||||
(models.error
|
||||
? "Could not load models. Enter a model ID or retry later."
|
||||
: null)
|
||||
}
|
||||
result={test.variables === settingsRevision ? test.data : null}
|
||||
onConnectionChange={setConnectionId}
|
||||
onSave={(v) => save.mutate(v)}
|
||||
onTest={() => test.mutate(settingsRevision)}
|
||||
onAdd={() => {
|
||||
setProvider(null);
|
||||
setAdding(true);
|
||||
}}
|
||||
/>
|
||||
<Dialog open={adding} onOpenChange={setAdding}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-(--sz-85vh) overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a fast response connection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Use a shared API connection for short acknowledgements.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{provider ? (
|
||||
<ConnectionSetupFlow
|
||||
host="dialog"
|
||||
serviceSlug={provider}
|
||||
forceNewConnection
|
||||
aiConnection={{ provider, method: "api_key", mode: "shared" }}
|
||||
requiredAiOwnership="shared"
|
||||
onCancel={() => setAdding(false)}
|
||||
onComplete={async (result) => {
|
||||
const refreshed = await query.refetch();
|
||||
if (
|
||||
"connectionId" in result &&
|
||||
result.connectionId &&
|
||||
refreshed.data?.choices.some(
|
||||
(row) => row.id === result.connectionId,
|
||||
)
|
||||
)
|
||||
setNewlyConnectedId(result.connectionId);
|
||||
setAdding(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ConnectionChoiceList
|
||||
choices={[
|
||||
"openrouter",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
"xai",
|
||||
].map((id) => ({
|
||||
id,
|
||||
description: "Shared API connection",
|
||||
name:
|
||||
id === "openrouter"
|
||||
? "OpenRouter"
|
||||
: id === "openai"
|
||||
? "OpenAI"
|
||||
: id === "xai"
|
||||
? "xAI"
|
||||
: id === "google"
|
||||
? "Google"
|
||||
: "Anthropic",
|
||||
icon: <AppLogo name={id} brandKey={id} size={24} />,
|
||||
}))}
|
||||
onSelect={(id) => setProvider(id as AiProvider)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import type {
|
||||
} from "@paperclipai/shared";
|
||||
import { useCopyAction } from "@/lib/use-copy-action";
|
||||
import { IssueChatFeedbackButtons } from "@/components/AgentBubbleActionRow";
|
||||
import { Check, Copy, X } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, Copy, X, Info } from "lucide-react";
|
||||
|
||||
/** Feedback-vote wiring for an agent bubble, resolved per comment by the host. */
|
||||
export interface TaskChatBubbleFeedback {
|
||||
@@ -28,8 +29,10 @@ export interface TaskChatBubbleFeedback {
|
||||
export function TaskChatBubbleActions({
|
||||
copyText,
|
||||
feedback,
|
||||
fastResponse,
|
||||
}: {
|
||||
copyText: string;
|
||||
fastResponse?: boolean;
|
||||
feedback?: TaskChatBubbleFeedback | null;
|
||||
}) {
|
||||
const { copied, failed, copy } = useCopyAction(2000);
|
||||
@@ -54,6 +57,7 @@ export function TaskChatBubbleActions({
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{fastResponse && <Popover><PopoverTrigger asChild><button type="button" aria-label="Message details" title="Message details" className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"><Info className="h-3.5 w-3.5" /></button></PopoverTrigger><PopoverContent className="text-xs">Platform-generated acknowledgement. The agent’s substantive response follows separately.</PopoverContent></Popover>}
|
||||
{feedback ? (
|
||||
<IssueChatFeedbackButtons
|
||||
activeVote={feedback.activeVote}
|
||||
|
||||
@@ -135,6 +135,7 @@ export function commentsToTaskChatItems(
|
||||
} : {}),
|
||||
agent: effectiveAgentId(comment) ? ctx.agentMap?.get(effectiveAgentId(comment)!) ?? { id: effectiveAgentId(comment)! } : undefined,
|
||||
text: comment.body,
|
||||
origin: comment.origin ?? "comment",
|
||||
sourceChannel: kind === "human" ? comment.metadata?.sourceChannel : undefined,
|
||||
timestamp: formatTaskChatCommentTimestamp(comment, kind),
|
||||
optimistic,
|
||||
|
||||
@@ -101,6 +101,7 @@ export interface TaskChatTokenUsage {
|
||||
|
||||
/** A human/agent/system message bubble. */
|
||||
export interface TaskChatMessageItem {
|
||||
origin?: "comment" | "fast_response";
|
||||
/** Stable UI identity through optimistic acknowledgement; id remains canonical. */
|
||||
renderKey?: string;
|
||||
id: string;
|
||||
|
||||
@@ -2069,6 +2069,7 @@ export function attachSettledTurns(
|
||||
item.settled &&
|
||||
prev?.kind === "message" &&
|
||||
prev.author === "agent" &&
|
||||
prev.origin !== "fast_response" &&
|
||||
!prev.interstitial &&
|
||||
!prev.streaming &&
|
||||
prev.attachedTurn == null
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
export function useFastResponsesEnabled() {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
return { enabled: !query.isError && query.data?.enableFastResponses === true, loaded: query.isFetched };
|
||||
}
|
||||
+7
-5
@@ -3120,11 +3120,13 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
inset-inline: 0;
|
||||
}
|
||||
|
||||
/* Task recovery panel uses the shared spacing, radius, and type scales. */
|
||||
.execution-blocker-notice {
|
||||
gap: calc(var(--spacing) * 2);
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
|
||||
border-radius: var(--radius-md);
|
||||
/* Connection and recovery cards share one surface and spacing scale. */
|
||||
.task-context-notice {
|
||||
padding: calc(var(--spacing) * 3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--muted);
|
||||
color: var(--foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--text-sm--line-height);
|
||||
}
|
||||
|
||||
@@ -599,6 +599,8 @@ function createCommentMessage(args: {
|
||||
followUpRequested: comment.followUpRequested === true,
|
||||
presentation: comment.presentation ?? null,
|
||||
commentMetadata: comment.metadata ?? null,
|
||||
origin: comment.origin ?? "comment",
|
||||
fastResponseRequestId: comment.fastResponseRequestId ?? null,
|
||||
deletedAt: comment.deletedAt ? toDate(comment.deletedAt).toISOString() : null,
|
||||
deletedByType: comment.deletedByType ?? null,
|
||||
deletedByAgentId: comment.deletedByAgentId ?? null,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from "react";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { DecisionModelSettingsSection } from "../components/decision-models/DecisionModelSettings";
|
||||
import { FastResponseSettingsSection } from "../components/fast-responses/FastResponseSettings";
|
||||
import { useFastResponsesEnabled } from "../hooks/useFastResponsesEnabled";
|
||||
import { Navigate } from "@/lib/router";
|
||||
|
||||
export function CompanyConnections() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { enabled, loaded } = useFastResponsesEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Connections" },
|
||||
]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
|
||||
if (!loaded) return null;
|
||||
if (!enabled) return <Navigate to="/company/settings" replace />;
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No organization selected. Select an organization from the switcher above.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Connections</h1>
|
||||
</div>
|
||||
<DecisionModelSettingsSection key={selectedCompanyId} companyId={selectedCompanyId} />
|
||||
<div className="max-w-2xl border-t border-border pt-8">
|
||||
<FastResponseSettingsSection key={`fast-${selectedCompanyId}`} companyId={selectedCompanyId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DecisionModelSettingsSection } from "../components/decision-models/DecisionModelSettings";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -30,6 +29,8 @@ import {
|
||||
ToggleField,
|
||||
} from "../components/agent-config-primitives";
|
||||
import { InstanceGeneralSettings } from "./InstanceGeneralSettings";
|
||||
import { DecisionModelSettingsSection } from "../components/decision-models/DecisionModelSettings";
|
||||
import { useFastResponsesEnabled } from "../hooks/useFastResponsesEnabled";
|
||||
|
||||
export function CompanySettings() {
|
||||
const {
|
||||
@@ -43,6 +44,7 @@ export function CompanySettings() {
|
||||
const navigate = useNavigate();
|
||||
const toastActions = useOptionalToastActions();
|
||||
const cloud = useCloudInstance();
|
||||
const fastResponses = useFastResponsesEnabled();
|
||||
// Managed instances derive the task ID prefix from the company name, so a
|
||||
// rename here also renumbers the existing task IDs.
|
||||
const isCloudManaged = Boolean(cloud);
|
||||
@@ -356,8 +358,6 @@ export function CompanySettings() {
|
||||
</div>
|
||||
|
||||
{/* Interaction governance */}
|
||||
{selectedCompanyId && <DecisionModelSettingsSection key={selectedCompanyId} companyId={selectedCompanyId} />}
|
||||
|
||||
<InteractionGovernancePanel
|
||||
governance={governance}
|
||||
onChange={handleGovernanceChange}
|
||||
@@ -371,6 +371,10 @@ export function CompanySettings() {
|
||||
}
|
||||
/>
|
||||
|
||||
{fastResponses.loaded && !fastResponses.enabled && (
|
||||
<DecisionModelSettingsSection key={selectedCompanyId} companyId={selectedCompanyId!} />
|
||||
)}
|
||||
|
||||
<InstanceGeneralSettings embedded />
|
||||
|
||||
{/* Danger Zone */}
|
||||
|
||||
@@ -50,6 +50,10 @@ vi.mock("../context/CompanyContext", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../components/decision-models/DecisionModelSettings", () => ({
|
||||
DecisionModelSettingsSection: () => <div data-testid="decision-model-settings" />,
|
||||
}));
|
||||
|
||||
// Both panels below the name field own their own queries and are not part of
|
||||
// what this test covers.
|
||||
vi.mock("../components/InteractionGovernancePanel", () => ({
|
||||
@@ -93,13 +97,14 @@ describe("CompanySettings rename hint", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function render(health: unknown) {
|
||||
function render(health: unknown, enableFastResponses = true) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
// CloudAccessGate owns the health fetch in the app; seeding the cache is how
|
||||
// useCloudInstance sees a managed instance under test.
|
||||
queryClient.setQueryData(queryKeys.health, health);
|
||||
queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableFastResponses });
|
||||
const root = createRoot(container);
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
@@ -121,6 +126,12 @@ describe("CompanySettings rename hint", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it.each([false, true])("keeps decision setup in General only while the experiment is off (%s)", (enabled) => {
|
||||
const root = render(SELF_HOSTED_HEALTH, enabled);
|
||||
expect(Boolean(container.querySelector("[data-testid=decision-model-settings]"))).toBe(!enabled);
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
|
||||
it("warns that a rename re-keys task IDs on a managed instance", () => {
|
||||
const root = render(CLOUD_HEALTH);
|
||||
expect(hintText()).toBeDefined();
|
||||
|
||||
@@ -439,7 +439,7 @@ describe("Shared Costs surfaces", () => {
|
||||
expect(setBreadcrumbsMock).toHaveBeenCalledWith([{ label: "Costs" }]);
|
||||
}
|
||||
expect([...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent)).toEqual(
|
||||
streamlined ? ["Overview", "Providers", "Billers", "Finance", "Decisions"] : ["Overview", "Budgets", "Providers", "Billers", "Finance", "Decisions"],
|
||||
streamlined ? ["Overview", "Providers", "Billers", "Finance", "Decisions", "Fast responses"] : ["Overview", "Budgets", "Providers", "Billers", "Finance", "Decisions", "Fast responses"],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FastResponseHistory } from "../components/fast-responses/FastResponseHistory";
|
||||
import { DecisionHistory } from "../components/decision-models/DecisionHistory";
|
||||
import { SubscriptionCostCard, SubscriptionTokenUsage } from "../components/SubscriptionCostCard";
|
||||
import { subscriptionsApi } from "../api/subscriptions";
|
||||
@@ -44,7 +45,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const NO_COMPANY = "__none__";
|
||||
export type CostsMainTab = "overview" | "budgets" | "providers" | "billers" | "finance" | "decisions";
|
||||
export type CostsMainTab = "overview" | "budgets" | "providers" | "billers" | "finance" | "decisions" | "fast-responses";
|
||||
|
||||
export interface CostsProps {
|
||||
/** Render inside Audit without a second page-level title or breadcrumb. */
|
||||
@@ -201,7 +202,7 @@ export function Costs({
|
||||
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
setMainTab(!lockTab && tab && ["overview", "providers", "billers", "finance", "budgets", "decisions"].includes(tab) ? tab as CostsMainTab : initialTab);
|
||||
setMainTab(!lockTab && tab && ["overview", "providers", "billers", "finance", "budgets", "decisions", "fast-responses"].includes(tab) ? tab as CostsMainTab : initialTab);
|
||||
}, [initialTab, lockTab, searchParams]);
|
||||
|
||||
const [today, setToday] = useState(() => new Date().toDateString());
|
||||
@@ -738,10 +739,12 @@ export function Costs({
|
||||
<TabsTrigger value="billers">Billers</TabsTrigger>
|
||||
<TabsTrigger value="finance">Finance</TabsTrigger>
|
||||
<TabsTrigger value="decisions">Decisions</TabsTrigger>
|
||||
<TabsTrigger value="fast-responses">Fast responses</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="fast-responses" className="mt-4">{showCustomPrompt ? <p className="text-sm text-muted-foreground">Select a start and end date to load data.</p> : <FastResponseHistory companyId={companyId} from={from} to={to} />}</TabsContent>
|
||||
<TabsContent value="decisions" className="mt-4">{showCustomPrompt ? <p className="text-sm text-muted-foreground">Select a start and end date to load data.</p> : <DecisionHistory companyId={companyId} from={from} to={to} />}</TabsContent>
|
||||
<TabsContent value="overview" className="mt-4 space-y-4">
|
||||
{showCustomPrompt ? (
|
||||
|
||||
@@ -104,6 +104,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
||||
enableSummaries: false,
|
||||
enableStatusCards: false,
|
||||
enableDecisions: false,
|
||||
enableFastResponses: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
enablePaperclipDeveloperMode: false,
|
||||
@@ -472,6 +473,17 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders and patches the fast responses experimental toggle", async () => {
|
||||
await renderPage();
|
||||
expect(container.textContent).toContain("Experimental fast responses");
|
||||
const toggle = container.querySelector<HTMLButtonElement>('[aria-label="Toggle fast responses experimental setting"]');
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
await act(async () => { toggle?.click(); });
|
||||
await flushReact();
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableFastResponses: true });
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders and patches the Goals Sidebar Link experimental toggle", async () => {
|
||||
await renderPage();
|
||||
|
||||
|
||||
@@ -433,6 +433,17 @@ export function InstanceExperimentalSettings() {
|
||||
ariaLabel="Toggle isolated workspaces experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Experimental fast responses"
|
||||
description="Show Connections in company settings and allow short acknowledgements while agents begin work."
|
||||
checked={experimentalQuery.data?.enableFastResponses ?? false}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableFastResponses: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableFastResponses"
|
||||
managed={managedKeys.enableFastResponses}
|
||||
ariaLabel="Toggle fast responses experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Experimental File Viewer"
|
||||
description="Show task detail controls for browsing and previewing workspace files relative to a task."
|
||||
|
||||
@@ -7642,6 +7642,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
{taskChatShellEnabled ? null : createdFromNote}
|
||||
|
||||
<ExternallyConnectedTaskBanner
|
||||
className={cn(shellSectionClass, "mt-3")}
|
||||
key={issue.id}
|
||||
attachments={attachments ?? []}
|
||||
companyId={issue.companyId}
|
||||
@@ -7649,6 +7650,15 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
issueCacheRefs={issueCacheRefs}
|
||||
/>
|
||||
|
||||
{resolvedDetailTab === "chat" && (
|
||||
<WorkspaceExportRecovery key={`workspace-export:${issue.activeRecoveryAction?.id ?? issue.id}`} issueId={issue.id}
|
||||
className={shellSectionClass}
|
||||
action={issue.activeRecoveryAction ?? null} canManage={canManageBoardRuntime} onQueued={invalidateIssueDetail} />
|
||||
)}
|
||||
{resolvedDetailTab === "chat" && issue.executionBlocker && (
|
||||
<ExecutionBlockerNotice className={shellSectionClass} companyId={issue.companyId} issueId={issue.id} blocker={issue.executionBlocker} onRetried={invalidateIssueDetail} />
|
||||
)}
|
||||
|
||||
{issue.status === "in_review" && issue.externalConversationState === "waiting" && (
|
||||
<p role="status" className="text-sm text-muted-foreground">Reply sent. Send a message to continue.</p>
|
||||
)}
|
||||
@@ -7952,11 +7962,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<WorkspaceExportRecovery key={issue.activeRecoveryAction?.id ?? issue.id} issueId={issue.id}
|
||||
action={issue.activeRecoveryAction ?? null} canManage={canManageBoardRuntime} onQueued={invalidateIssueDetail} />
|
||||
{issue.executionBlocker && (
|
||||
<ExecutionBlockerNotice companyId={issue.companyId} issueId={issue.id} blocker={issue.executionBlocker} onRetried={invalidateIssueDetail} />
|
||||
)}
|
||||
{resolvedDetailTab === "chat" ? (
|
||||
<WorkspaceBaseRefRecoveryProvider issue={issue} agentMap={agentMap} onRepaired={() => { invalidateIssueDetail(); invalidateIssueCollections(); }}
|
||||
unavailableReason={!canManageBoardRuntime || !canResolveBoardRecoveryAction ? "You don’t have permission to repair this task’s workspace."
|
||||
|
||||
Reference in new issue
Block a user