Merge origin/master and integrate environment selection

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-11 06:18:05 -05:00
commit fed4802b72
159 files changed
+13953 -2027

No files matched your search

+2
View File
@@ -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" />}>
+36
View File
@@ -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`}
+3 -1
View File
@@ -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">
+2 -1
View File
@@ -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 -2
View File
@@ -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>}
+1
View File
@@ -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) => {
+1
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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);
}
+2
View File
@@ -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,
+46
View File
@@ -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>
);
}
+7 -3
View File
@@ -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();
+1 -1
View File
@@ -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"],
);
});
+5 -2
View File
@@ -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."
+10 -5
View File
@@ -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."