mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 23:20:53 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user