merge: reconcile published native prerequisite and current master

Preserve canonical startup ownership, scoped evidence, current Storybook IDs, and the Codex form controls while carrying Claude effort and immutable source bindings.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 20:40:11 -05:00
commit 48ffca9438
415 files changed
+22501 -15340

No files matched your search

+9 -2
View File
@@ -1,3 +1,4 @@
import type { SlackSetupOptions, SlackManagedProvisionInput, SlackManagedResult } from "@paperclipai/shared";
import { api } from "./client";
import type {
SlackAppConfiguration,
@@ -140,7 +141,8 @@ export interface ChatEndpoint {
messagingEndpoint?: string | null;
command?: string | null;
slackApp?: SlackAppConfiguration;
slackSetupMethod?: "automatic" | "manual" | "existing";
slackSetupMethod?: "managed" | "automatic" | "manual" | "existing";
slackManagerError?: string;
slackRegistration?: SlackRegistrationState;
slackAvatar?: SlackAvatarState;
slackAccount?: SlackAccountState;
@@ -202,7 +204,7 @@ export const chatEndpointsApi = {
api.get<ChatEndpoint>(`/chat-endpoints/${endpointId}`),
create: (
companyId: string,
input: { provider: ChatProvider; assignedAgentId: string; slackApp?: SlackAppConfiguration },
input: { provider: ChatProvider; assignedAgentId: string; slackApp?: SlackAppConfiguration; slackSetupMethod?: "managed" | "automatic" | "manual" | "existing" },
) => api.post<ChatEndpoint>(`/companies/${companyId}/chat-endpoints`, input),
update: (
endpointId: string,
@@ -216,6 +218,11 @@ export const chatEndpointsApi = {
photon?: PhotonChannelConfiguration;
},
) => api.post<ChatEndpoint>(`/chat-endpoints/${endpointId}/setup`, input),
sendSlackTestMessage: (endpointId: string, input: import("@paperclipai/shared").SlackTestMessageInput) =>
api.post<import("@paperclipai/shared").SlackTestMessageResult>(`/chat-endpoints/${endpointId}/slack/test-message`, input),
slackSetupOptions: (companyId: string) => api.get<SlackSetupOptions>(`/companies/${companyId}/chat-slack/setup-options`),
authorizeSlackManager: (endpointId: string) => api.post<import("@paperclipai/shared").SlackManagerAuthorization>(`/chat-endpoints/${endpointId}/slack/managed/authorize`, {}),
provisionManagedSlack: (endpointId: string, input: SlackManagedProvisionInput) => api.post<SlackManagedResult>(`/chat-endpoints/${endpointId}/slack/managed/provision`, input),
createSlackApp: (endpointId: string, input: SlackRegistrationInput) =>
api.post<ChatEndpoint>(`/chat-endpoints/${endpointId}/slack/registration`, input),
installSlackApp: (endpointId: string) =>
+11 -9
View File
@@ -3,7 +3,7 @@ import {
createTenantSessionRecoveryCoordinator,
tenantSessionRecovery,
} from "@/lib/tenant-session-recovery";
import { __inflightGetCount, api, detachInflightGet } from "./client";
import { api, detachInflightGet } from "./client";
import { toolsApi } from "./tools";
interface Deferred<T> {
@@ -93,6 +93,10 @@ describe("tenant-session recovery", () => {
status: 401,
message: "unauthorized",
});
fetchMock.mockResolvedValue(jsonResponse({ recovered: true }));
await expect(api.get("/ordinary-401")).resolves.toEqual({ recovered: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
@@ -104,13 +108,10 @@ describe("in-tab GET coalescing", () => {
const p1 = api.get("/coalesce-a");
const p2 = api.get("/coalesce-a");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(__inflightGetCount()).toBe(1);
d.resolve(jsonResponse({ value: 1 }));
expect(await p1).toEqual({ value: 1 });
expect(await p2).toEqual({ value: 1 });
// Entry cleared once settled.
expect(__inflightGetCount()).toBe(0);
});
it("issues a fresh fetch after the previous one settles", async () => {
@@ -140,10 +141,8 @@ describe("in-tab GET coalescing", () => {
const first = deferred<Response>();
fetchMock.mockReturnValueOnce(first.promise);
const previousAccount = api.get("/detach-me");
expect(__inflightGetCount()).toBe(1);
detachInflightGet("/detach-me");
expect(__inflightGetCount()).toBe(0);
const second = deferred<Response>();
fetchMock.mockReturnValueOnce(second.promise);
@@ -155,7 +154,6 @@ describe("in-tab GET coalescing", () => {
second.resolve(jsonResponse({ companies: ["current"] }));
expect(await previousAccount).toEqual({ companies: ["previous"] });
expect(await currentAccount).toEqual({ companies: ["current"] });
expect(__inflightGetCount()).toBe(0);
});
it("never coalesces mutations", async () => {
@@ -188,7 +186,7 @@ describe("per-caller abort semantics", () => {
expect(await p2).toEqual({ value: 2 });
});
it("aborts the shared fetch once every caller has aborted", async () => {
it("aborts the shared fetch after all callers cancel and permits a fresh request", async () => {
const d = deferred<Response>();
let sharedSignal: AbortSignal | undefined;
fetchMock.mockImplementation((_url: string, init: RequestInit) => {
@@ -206,7 +204,11 @@ describe("per-caller abort semantics", () => {
await expect(p1).rejects.toMatchObject({ name: "AbortError" });
await expect(p2).rejects.toMatchObject({ name: "AbortError" });
expect(sharedSignal?.aborted).toBe(true);
expect(__inflightGetCount()).toBe(0);
fetchMock.mockResolvedValueOnce(jsonResponse({ value: "fresh" }));
const fresh = api.get("/abort-b");
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(await fresh).toEqual({ value: "fresh" });
});
it("rejects immediately if the caller signal is already aborted", async () => {
-5
View File
@@ -165,11 +165,6 @@ export function detachInflightGet(path: string): void {
inflightGets.delete(path);
}
/** Test-only: number of in-flight coalesced GET keys. */
export function __inflightGetCount(): number {
return inflightGets.size;
}
function isRequestOptions(value: unknown): value is RequestOptions {
return typeof value === "object" && value !== null && "signal" in value;
}
+201 -444
View File
@@ -266,6 +266,46 @@ function setInputValue(input: HTMLInputElement, value: string) {
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function daytonaEnvironments() {
return [
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
];
}
function e2bEnvironments() {
return [
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
];
}
function renderFormView(root: Root, queryClient: QueryClient, children: Parameters<Root["render"]>[0]) {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>{children}</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
}
function createFormQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
}
async function renderForm(
environments: Environment[],
agentOverrides: Partial<Agent> = {},
@@ -286,35 +326,24 @@ async function renderForm(
document.body.appendChild(container);
const root = createRoot(container);
const onSave = vi.fn();
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const queryClient = createFormQueryClient();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AgentConfigForm
mode="edit"
agent={makeAgent(agentOverrides)}
onSave={onSave}
hidePromptTemplate
content={options.content}
environmentVariablesPlacement={options.environmentVariablesPlacement}
hideInlineSave={options.hideInlineSave}
onDirtyChange={options.onDirtyChange}
onSaveActionChange={options.onSaveActionChange}
onCancelActionChange={options.onCancelActionChange}
showAdapterTypeField={options.showAdapterTypeField ?? false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AgentConfigForm
mode="edit"
agent={makeAgent(agentOverrides)}
onSave={onSave}
hidePromptTemplate
content={options.content}
environmentVariablesPlacement={options.environmentVariablesPlacement}
hideInlineSave={options.hideInlineSave}
onDirtyChange={options.onDirtyChange}
onSaveActionChange={options.onSaveActionChange}
onCancelActionChange={options.onCancelActionChange}
showAdapterTypeField={options.showAdapterTypeField ?? false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
);
});
@@ -332,12 +361,7 @@ async function renderCreateForm(
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const queryClient = createFormQueryClient();
const values = {
...defaultCreateValues,
@@ -347,21 +371,15 @@ async function renderCreateForm(
const onChange = vi.fn();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AgentConfigForm
mode="create"
values={values}
onChange={onChange}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AgentConfigForm
mode="create"
values={values}
onChange={onChange}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
);
});
@@ -466,15 +484,7 @@ function findByAriaLabel(container: HTMLElement, label: string) {
async function renderCodexSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
],
daytonaEnvironments(),
{ defaultEnvironmentId: "sandbox-1", ...agentOverrides },
{ showAdapterTestEnvironmentButton: true },
);
@@ -485,15 +495,7 @@ async function renderCodexSandbox(agentOverrides: Partial<Agent> = {}) {
// provider advertises the login pseudo-terminal capability the login needs.
async function renderVendorSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
],
daytonaEnvironments(),
{ adapterType: "vendor_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
{ showAdapterTestEnvironmentButton: true },
);
@@ -505,15 +507,7 @@ async function renderVendorSandbox(agentOverrides: Partial<Agent> = {}) {
// login needs.
async function renderGrokSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
],
daytonaEnvironments(),
{ adapterType: "grok_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
{ showAdapterTestEnvironmentButton: true },
);
@@ -521,15 +515,7 @@ async function renderGrokSandbox(agentOverrides: Partial<Agent> = {}) {
async function renderClaudeSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
],
daytonaEnvironments(),
{ adapterType: "claude_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
{ showAdapterTestEnvironmentButton: true },
);
@@ -539,15 +525,7 @@ async function renderCreateClaudeSandbox(
valueOverrides: Partial<typeof defaultCreateValues> = {},
) {
return renderCreateForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
],
daytonaEnvironments(),
{ adapterType: "claude_local", defaultEnvironmentId: "sandbox-1", ...valueOverrides },
{ showAdapterTestEnvironmentButton: true },
);
@@ -565,9 +543,7 @@ async function renderStatefulCreateClaudeSandbox(environments: Environment[]) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
const valuesRef: { current: typeof defaultCreateValues } = {
current: {
@@ -593,15 +569,7 @@ async function renderStatefulCreateClaudeSandbox(environments: Environment[]) {
}
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<Harness />
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
renderFormView(root, queryClient, <Harness />);
});
await flushReact();
@@ -1057,15 +1025,7 @@ describe("AgentConfigForm environment selector", () => {
});
it("shows concise Environment copy when one runnable non-local environment exists", async () => {
const result = await renderForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
]);
const result = await renderForm(e2bEnvironments());
roots.push(result.root);
const text = result.container.textContent ?? "";
@@ -1082,15 +1042,7 @@ describe("AgentConfigForm environment selector", () => {
it("shows the environment override for Grok local agents", async () => {
const result = await renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
],
e2bEnvironments(),
{ adapterType: "grok_local" },
);
roots.push(result.root);
@@ -1104,15 +1056,7 @@ describe("AgentConfigForm environment selector", () => {
it("shows the environment override for Kimi local agents", async () => {
const result = await renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
],
e2bEnvironments(),
{ adapterType: "kimi_local" },
);
roots.push(result.root);
@@ -1405,15 +1349,7 @@ describe("AgentConfigForm environment selector", () => {
// no capability, so the panel stays hidden even after the auth-missing check.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
],
e2bEnvironments(),
{ adapterType: "codex_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
@@ -1484,15 +1420,7 @@ describe("AgentConfigForm environment selector", () => {
it("hides the Login button for a Claude sandbox whose provider lacks the setup-token login capability", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
],
e2bEnvironments(),
{ adapterType: "claude_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
@@ -1521,15 +1449,7 @@ describe("AgentConfigForm environment selector", () => {
}),
);
const result = await renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
],
daytonaEnvironments(),
{ adapterType: "claude_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
@@ -1547,15 +1467,7 @@ describe("AgentConfigForm environment selector", () => {
// after the auth-missing check.
mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT);
const result = await renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
],
e2bEnvironments(),
{ adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
@@ -1572,15 +1484,7 @@ describe("AgentConfigForm environment selector", () => {
// provider capability, not the adapter name.
mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT);
const result = await renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
],
daytonaEnvironments(),
{ adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
@@ -1597,23 +1501,13 @@ describe("AgentConfigForm environment selector", () => {
// lifts the feedback and renders `AdapterLoginPanel` from the lifted login
// descriptor. Without the descriptor the Login button never appears.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
]);
mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments());
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
function LiftedFeedbackHarness() {
const [login, setLogin] = useState<AdapterLoginDescriptor | null>(null);
@@ -1644,15 +1538,7 @@ describe("AgentConfigForm environment selector", () => {
}
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<LiftedFeedbackHarness />
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
renderFormView(root, queryClient, <LiftedFeedbackHarness />);
});
await flushReact();
@@ -1917,12 +1803,12 @@ describe("AgentConfigForm environment selector", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container); roots.push(root);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
const queryClient = createFormQueryClient();
await act(async () => {
root.render(<QueryClientProvider client={queryClient}><ToastProvider><TooltipProvider>
renderFormView(root, queryClient,
<AdapterLoginPanel companyId="company-1" adapterType="grok_local" environmentId="new-sandbox"
aiConnection={{ ...intent, agentIds: [] }} chrome="onboarding" autoStart />
</TooltipProvider></ToastProvider></QueryClientProvider>);
aiConnection={{ ...intent, agentIds: [] }} chrome="onboarding" autoStart />
);
});
await flushUntil(() => Boolean(findButton(container, "Cancel previous sign-in and retry")));
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
@@ -1954,25 +1840,17 @@ describe("AgentConfigForm environment selector", () => {
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
chrome="onboarding"
autoStart
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
chrome="onboarding"
autoStart
/>
);
});
// Wait for the card itself, then assert it is actually there: an absence
@@ -2009,25 +1887,17 @@ describe("AgentConfigForm environment selector", () => {
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
);
});
await flushUntil(() => onAccountBinding.mock.calls.length > 0);
@@ -2040,6 +1910,7 @@ describe("AgentConfigForm environment selector", () => {
// The panel narrates the bind as its own state, separate from the login's
// success line — the bind is a second save that can still fail.
await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false);
expect(container.textContent).toContain("Agent bound to the signed-in account");
});
it("a failed bind save renders an explicit Retry instead of silently latching the claim", async () => {
@@ -2071,25 +1942,17 @@ describe("AgentConfigForm environment selector", () => {
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
);
});
await flushUntil(() => container.textContent?.includes("Could not bind this agent") ?? false);
@@ -2147,25 +2010,17 @@ describe("AgentConfigForm environment selector", () => {
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
);
});
await flushUntil(() => onAccountBinding.mock.calls.length === 1);
@@ -2219,25 +2074,17 @@ describe("AgentConfigForm environment selector", () => {
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
);
});
await flushUntil(() => container.textContent?.includes("Binding this agent") ?? false);
@@ -2270,7 +2117,7 @@ describe("AgentConfigForm environment selector", () => {
<AgentConfigForm mode="edit" agent={saved} onSave={onSave} hidePromptTemplate showAdapterTypeField={false} />
</>;
}
await act(async () => root.render(<QueryClientProvider client={queryClient}><ToastProvider><TooltipProvider><Harness /></TooltipProvider></ToastProvider></QueryClientProvider>));
await act(async () => renderFormView(root, queryClient, <Harness />));
await flushReact();
const nameInput = container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]')!;
setInputValue(nameInput, "Unsaved name");
@@ -2322,23 +2169,13 @@ describe("AgentConfigForm environment selector", () => {
releaseSaves.push(resolve);
}),
);
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
]);
mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments());
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
// The harness swaps the agent object the way the page does after a save
// refresh: same mounted form, new agent identity.
@@ -2358,15 +2195,7 @@ describe("AgentConfigForm environment selector", () => {
);
}
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<RefreshHarness />
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
renderFormView(root, queryClient, <RefreshHarness />);
});
await flushReact();
@@ -2873,15 +2702,7 @@ describe("AgentConfigForm environment selector", () => {
.mockResolvedValueOnce(staleList)
.mockResolvedValue(freshList);
const result = await renderStatefulCreateClaudeSandbox([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
]);
const result = await renderStatefulCreateClaudeSandbox(daytonaEnvironments());
roots.push(result.root);
await runTest(result.container);
@@ -2921,24 +2742,16 @@ describe("AgentConfigForm environment selector", () => {
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
onStored={onStored}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
onStored={onStored}
/>
);
});
await startLogin(container);
@@ -2958,25 +2771,17 @@ describe("AgentConfigForm environment selector", () => {
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
chrome="onboarding"
autoStart
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
chrome="onboarding"
autoStart
/>
);
});
// Wait for the card itself, then assert it is actually there: an absence
@@ -2998,24 +2803,16 @@ describe("AgentConfigForm environment selector", () => {
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
onApplyStored={onApplyStored}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
onApplyStored={onApplyStored}
/>
);
});
await flushUntil(() => Boolean(findButton(container, "Use saved login")));
@@ -3040,24 +2837,16 @@ describe("AgentConfigForm environment selector", () => {
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
onApplyStored={onApplyStored}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
onApplyStored={onApplyStored}
/>
);
});
await flushUntil(() => Boolean(findButton(container, "Sign in")));
@@ -3445,42 +3234,26 @@ describe("AgentConfigForm environment selector", () => {
await flushFake();
};
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
]);
mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments());
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AgentConfigForm
mode="edit"
agent={makeAgent({
adapterType: "claude_local",
defaultEnvironmentId: "sandbox-1",
})}
onSave={vi.fn()}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AgentConfigForm
mode="edit"
agent={makeAgent({
adapterType: "claude_local",
defaultEnvironmentId: "sandbox-1",
})}
onSave={vi.fn()}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
/>
);
});
await flushFake();
@@ -3767,44 +3540,28 @@ describe("AgentConfigForm create-mode Claude OAuth binding", () => {
// environment. The helper returns the `onSave` spy so a test can read the patch
// that a stored login sends to the agent-update path.
async function renderEditClaudeSandbox(agentOverrides: Partial<Agent> = {}) {
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
]);
mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments());
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryClient = createFormQueryClient();
const onSave = vi.fn().mockResolvedValue(undefined);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<AgentConfigForm
mode="edit"
agent={makeAgent({
adapterType: "claude_local",
defaultEnvironmentId: "sandbox-1",
...agentOverrides,
})}
onSave={onSave}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
renderFormView(root, queryClient,
<AgentConfigForm
mode="edit"
agent={makeAgent({
adapterType: "claude_local",
defaultEnvironmentId: "sandbox-1",
...agentOverrides,
})}
onSave={onSave}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
/>
);
});
File diff suppressed because it is too large. Load diff
+29 -153
View File
@@ -439,6 +439,18 @@ function createExecutionState(overrides: Partial<IssueExecutionState> = {}): Iss
};
}
function createRelation(id: string, identifier: string, title: string, status: Issue["status"] = "todo") {
return {
id,
identifier,
title,
status,
priority: "medium" as const,
assigneeAgentId: null,
assigneeUserId: null,
};
}
function renderPropertiesWithQueryClient(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>, hiddenSettings: string[] = []) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -663,24 +675,8 @@ describe("IssueProperties", () => {
});
it("stacks property chips vertically and aligns each label with the first chip", async () => {
const blockedBy = Array.from({ length: 3 }, (_, index) => ({
id: `blocker-${index + 1}`,
identifier: `BLOCK-${index + 1}`,
title: `Blocker ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blockedBy"]>;
const blocks = Array.from({ length: 3 }, (_, index) => ({
id: `blocked-${index + 1}`,
identifier: `BLOCKED-${index + 1}`,
title: `Blocked task ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blocks"]>;
const blockedBy = Array.from({ length: 3 }, (_, index) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) as NonNullable<Issue["blockedBy"]>;
const blocks = Array.from({ length: 3 }, (_, index) => (createRelation(`blocked-${index + 1}`, `BLOCKED-${index + 1}`, `Blocked task ${index + 1}`))) as NonNullable<Issue["blocks"]>;
const labels = Array.from({ length: 3 }, (_, index) => createLabel({
id: `label-${index + 1}`,
name: `Label ${index + 1}`,
@@ -1416,15 +1412,7 @@ describe("IssueProperties", () => {
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Existing blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"),
],
}),
childIssues: [],
@@ -1516,24 +1504,8 @@ describe("IssueProperties", () => {
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Existing blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
{
id: "issue-4",
identifier: "PAP-4",
title: "Keep blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"),
createRelation("issue-4", "PAP-4", "Keep blocker"),
],
}),
childIssues: [],
@@ -1569,24 +1541,8 @@ describe("IssueProperties", () => {
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Existing blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
{
id: "issue-4",
identifier: "PAP-4",
title: "Keep blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
createRelation("issue-2", "PAP-2", "Existing blocker", "in_progress"),
createRelation("issue-4", "PAP-4", "Keep blocker"),
],
}),
childIssues: [],
@@ -1617,15 +1573,7 @@ describe("IssueProperties", () => {
});
it("summarizes long blocked-by and subtask relationships in their picker triggers", async () => {
const blockedBy = Array.from({ length: 7 }, (_, index) => ({
id: `blocker-${index + 1}`,
identifier: `BLOCK-${index + 1}`,
title: `Blocker ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blockedBy"]>;
const blockedBy = Array.from({ length: 7 }, (_, index) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) as NonNullable<Issue["blockedBy"]>;
const childIssues = Array.from({ length: 7 }, (_, index) => createIssue({
id: `child-${index + 1}`,
identifier: `SUB-${index + 1}`,
@@ -1677,15 +1625,7 @@ describe("IssueProperties", () => {
enableTaskWatchdogs: false,
enableClassicTaskInterface: true,
});
const blocking = Array.from({ length: 7 }, (_, index) => ({
id: `blocking-${index + 1}`,
identifier: `BLOCKING-${index + 1}`,
title: `Blocking issue ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blocks"]>;
const blocking = Array.from({ length: 7 }, (_, index) => (createRelation(`blocking-${index + 1}`, `BLOCKING-${index + 1}`, `Blocking issue ${index + 1}`))) as NonNullable<Issue["blocks"]>;
const relatedIssues = Array.from({ length: 7 }, (_, index) => ({
id: `related-${index + 1}`,
identifier: `RELATED-${index + 1}`,
@@ -1803,15 +1743,7 @@ describe("IssueProperties", () => {
});
it("updates the blocked-by relationship summary when the issue changes", async () => {
const blockedBy = Array.from({ length: 7 }, (_, index) => ({
id: `blocker-${index + 1}`,
identifier: `BLOCK-${index + 1}`,
title: `Blocker ${index + 1}`,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
})) as NonNullable<Issue["blockedBy"]>;
const blockedBy = Array.from({ length: 7 }, (_, index) => (createRelation(`blocker-${index + 1}`, `BLOCK-${index + 1}`, `Blocker ${index + 1}`))) as NonNullable<Issue["blockedBy"]>;
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
@@ -2134,30 +2066,14 @@ describe("IssueProperties", () => {
relatedWork: {
outbound: [
{
issue: {
id: "issue-22",
identifier: "PAP-22",
title: "Related task",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
issue: createRelation("issue-22", "PAP-22", "Related task"),
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }],
},
],
inbound: [
{
issue: {
id: "issue-23",
identifier: "PAP-23",
title: "Mentions this task",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
issue: createRelation("issue-23", "PAP-23", "Mentions this task", "in_progress"),
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-1" }],
},
@@ -2193,65 +2109,25 @@ describe("IssueProperties", () => {
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-22",
identifier: "PAP-22",
title: "Blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
createRelation("issue-22", "PAP-22", "Blocker"),
],
blocks: [
{
id: "issue-33",
identifier: "PAP-33",
title: "Blocked issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
createRelation("issue-33", "PAP-33", "Blocked issue"),
],
relatedWork: {
outbound: [
{
issue: {
id: "issue-22",
identifier: "PAP-22",
title: "Blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
issue: createRelation("issue-22", "PAP-22", "Blocker"),
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-22" }],
},
{
issue: {
id: "issue-33",
identifier: "PAP-33",
title: "Blocked issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
issue: createRelation("issue-33", "PAP-33", "Blocked issue"),
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-33" }],
},
{
issue: {
id: "child-44",
identifier: "PAP-44",
title: "Child issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
issue: createRelation("child-44", "PAP-44", "Child issue"),
mentionCount: 1,
sources: [{ kind: "description", sourceRecordId: null, label: "description", matchedText: "PAP-44" }],
},
@@ -214,6 +214,10 @@ describe("IssueThreadInteractionCard", () => {
expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeTruthy();
expect(host.textContent).toContain("Connect");
expect(host.textContent).toContain("Not now");
const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim());
expect(labels).toContain("Connect");
expect(labels).toContain("Not now");
expect(host.textContent).toContain("Access is added only for this agent");
const loadButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.trim() === "Connect",
);
@@ -241,16 +245,7 @@ describe("IssueThreadInteractionCard", () => {
expect(dialog?.textContent).toContain("Complete connection setup without leaving this task.");
});
it("keeps connection resolution controls exclusive to the addressed user", () => {
const host = renderCard({
interaction: pendingConnectionIntentInteraction,
currentUserId: "another-user",
});
expect(host.querySelector('[data-testid="connection-intent-waiting"]')).toBeTruthy();
expect(Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim())).not.toContain("Connect");
expect(host.textContent).not.toContain("Not now");
});
;
it("exposes pending question options as selectable radio and checkbox controls", () => {
const host = renderCard({
@@ -258,6 +253,8 @@ describe("IssueThreadInteractionCard", () => {
onSubmitInteractionAnswers: vi.fn(),
});
expect(host.childElementCount).toBeGreaterThan(0);
const singleGroup = host.querySelector('[role="radiogroup"]');
expect(singleGroup?.getAttribute("aria-labelledby")).toBe(
"interaction-questions-default-collapse-depth-prompt",
@@ -433,15 +430,7 @@ describe("IssueThreadInteractionCard", () => {
expect(host.textContent).toBe("");
});
it("still renders a legitimate ask_user_questions card", () => {
const host = renderCard({
interaction: pendingAskUserQuestionsInteraction,
onSubmitInteractionAnswers: vi.fn(),
});
expect(host.childElementCount).toBeGreaterThan(0);
expect(host.querySelectorAll('[role="radio"]').length).toBeGreaterThan(0);
});
;
it("keeps a closed native select set closed while preserving direct-question defaults", () => {
const closed = {
@@ -1589,17 +1578,7 @@ describe("IssueThreadInteractionCard connection-authorization card", () => {
describe("IssueThreadInteractionCard connection-intent card", () => {
const USER_LABELS = new Map([[issueThreadInteractionFixtureMeta.currentUserId, "Carol"]]);
it("offers the addressed user the shared setup entry point and Not now", () => {
const host = renderCard({
interaction: pendingConnectionIntentInteraction,
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
});
expect(host.querySelector('[data-testid="connection-intent-actions"]')).not.toBeNull();
const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim());
expect(labels).toContain("Connect");
expect(labels).toContain("Not now");
expect(host.textContent).toContain("Access is added only for this agent");
});
;
it("shows another viewer only who it is waiting for", () => {
const host = renderCard({
@@ -1612,6 +1591,7 @@ describe("IssueThreadInteractionCard connection-intent card", () => {
expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeNull();
expect(host.querySelectorAll("button")).toHaveLength(0);
expect(host.innerHTML).not.toContain("authorizationUrl");
expect(host.textContent).not.toContain("Not now");
});
it("renders retry and connected terminal states without generic confirmation actions", () => {
+6 -16
View File
@@ -584,9 +584,12 @@ describe("MarkdownBody", () => {
it("opens external links in a new tab with safe rel attributes", () => {
const html = renderMarkdown("[docs](https://example.com/docs)");
expect(html).toContain('href="https://example.com/docs"');
expect(html).toContain("<a href=\"https://example.com/docs\"");
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
expect(html).not.toContain("lucide-github");
expect(html).toContain("lucide-external-link");
expect(html).toMatch(/<span style="white-space:nowrap">s<svg[^>]*lucide-external-link/);
});
it("opens GitHub links in a new tab", () => {
@@ -623,22 +626,9 @@ describe("MarkdownBody", () => {
expect(html).toContain('class="lucide lucide-github mr-1 inline h-3.5 w-3.5 align-(--va-0_125em)"');
});
it("does not prefix non-GitHub markdown links with the GitHub icon", () => {
const html = renderMarkdown("[docs](https://example.com/docs)");
;
expect(html).toContain('<a href="https://example.com/docs"');
expect(html).not.toContain("lucide-github");
});
it("suffixes external links with a new-tab icon glued to the last character", () => {
const html = renderMarkdown("[docs](https://example.com/docs)");
expect(html).toContain('target="_blank"');
expect(html).toContain("lucide-external-link");
// Last character "s" must sit in a no-wrap span with the icon so the
// indicator never wraps away from the link text.
expect(html).toMatch(/<span style="white-space:nowrap">s<svg[^>]*lucide-external-link/);
});
;
it("does not render the new-tab icon on internal links", () => {
const html = renderMarkdown("[settings](/company/settings)");
+34 -158
View File
@@ -273,6 +273,16 @@ async function flushReact() {
});
}
function renderWizardView(root: ReturnType<typeof createRoot>, queryClient: QueryClient) {
return act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
}
const SESSION_USER_ID = "user-b";
function render() {
@@ -422,13 +432,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
const { root, queryClient } = render();
const renderTree = () =>
act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
renderWizardView(root, queryClient);
await renderTree();
await flushReact();
return { root, renderTree };
@@ -516,13 +520,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
]);
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
const clickText = async (match: (t: string) => boolean) => {
@@ -592,13 +590,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
const { root, queryClient } = render();
const renderTree = () =>
act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
renderWizardView(root, queryClient);
await renderTree();
await flushReact();
@@ -772,13 +764,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockCompaniesApi.list.mockResolvedValue([]);
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
const clickByText = async (match: (text: string) => boolean) => {
@@ -842,6 +828,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
"No stored Claude login was found for this agent.",
);
expect(document.body.textContent).toContain("No working authentication was found");
await act(async () => root.unmount());
});
@@ -1240,31 +1228,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
it("blocks the hire when the probe reports warn with adapter_auth_missing and no stored login exists", async () => {
// The default `beforeEach` mock already rejects with a 404 `ApiError`.
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
status: "warn" as const,
checks: [
{
code: ADAPTER_AUTH_MISSING_CHECK_CODE,
level: "warn" as const,
message: "No stored Claude login was found for this agent.",
},
],
testedAt: new Date().toISOString(),
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
"No working authentication was found",
);
await act(async () => root.unmount());
});
;
it("reads the stored-login status once for each create attempt", async () => {
mockAgentsApi.getClaudeOAuthTokenStatus.mockReset();
@@ -1321,13 +1285,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const { container, root, queryClient } = render();
const renderTree = () =>
act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
renderWizardView(root, queryClient);
await renderTree();
await flushReact();
@@ -1394,13 +1352,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
const companyNameInput = document.querySelector("input") as HTMLInputElement;
@@ -1451,13 +1403,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
);
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
expect(document.querySelector("#onboarding-agent-name")).toBeNull();
@@ -1513,13 +1459,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
]);
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
// Falls back to the wizard's default first step, not the stale step 4
@@ -1553,13 +1493,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockCompaniesApi.list.mockRejectedValue(new Error("company list unavailable"));
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
// It mounts: the companies query sets `retry: false`, and with no
@@ -1598,13 +1532,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockCompany.loading = false;
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
expect(localStorage).toHaveBeenCalled();
@@ -1641,13 +1569,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed"));
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
// Mounted rather than blank...
@@ -1689,13 +1611,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockCompaniesApi.list.mockRejectedValue(new Error("company list unavailable"));
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBe(draft);
@@ -1733,13 +1649,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed for the new account"));
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
// Account A's agent name must not appear in account B's wizard.
@@ -1787,13 +1697,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
]);
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
// Mounted — so this is a real observation, not an unmounted false pass.
@@ -1823,13 +1727,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
);
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).not.toBeNull();
@@ -1861,13 +1759,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// B's session: the refetch fails, so A's data is retained.
mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed"));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
// Mounted, so this observes the real thing rather than an empty document.
@@ -1903,13 +1795,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
mockCompaniesApi.list.mockReturnValue(new Promise(() => {}));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
// Nothing mounted, so nothing could have written over the draft.
@@ -1927,13 +1813,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const { root, queryClient } = render();
mockCompaniesApi.list.mockReturnValue(new Promise(() => {}));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
await flushReact();
expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBeNull();
@@ -2002,13 +1882,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockDialog.onboardingOptions = {};
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderWizardView(root, queryClient);
// The login-panel gate chains several dependent queries (environments,
// instance settings, environment capabilities, then the auth signal
// itself), each settling on its own render. One flush is not always
@@ -3052,6 +2926,8 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
it("renders no 'Use saved login' control", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
expect(document.body.textContent).toContain("Sign in to Claude then come back and enter authorization code");
expect(document.body.textContent).not.toContain("Use saved login");
await act(async () => root.unmount());
});
@@ -6,7 +6,7 @@ import { expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { decisionModelsApi } from "@/api/decision-models";
import { DecisionModelSettingsSection, DecisionModelSettingsView } from "./DecisionModelSettings";
import { choices, configured, result } from "../../../storybook/stories/decision-models/fixtures";
import { choices, configured, result } from "../../../storybook/support/decision-models/fixtures";
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-storybook" }) }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -5,7 +5,7 @@ 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";
import { choices } from "../../../storybook/support/decision-models/fixtures";
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-storybook" }),
}));
+20 -12
View File
@@ -3,17 +3,16 @@
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __secondTickInternals, useSecondTick } from "./useSecondTick";
import { useSecondTick } from "./useSecondTick";
function mount(active: boolean) {
const container = document.createElement("div");
const root = createRoot(container);
function Probe() {
useSecondTick(active);
return null;
return <output>{useSecondTick(active)}</output>;
}
flushSync(() => root.render(<Probe />));
return { unmount: () => flushSync(() => root.unmount()) };
return { container, unmount: () => flushSync(() => root.unmount()) };
}
describe("useSecondTick", () => {
@@ -24,22 +23,31 @@ describe("useSecondTick", () => {
const a = mount(true);
const b = mount(true);
expect(__secondTickInternals.subscriberCount()).toBe(2);
expect(__secondTickInternals.isRunning()).toBe(true);
expect(vi.getTimerCount()).toBe(1);
flushSync(() => vi.advanceTimersByTime(1000));
expect(a.container.textContent).toBe("1");
expect(b.container.textContent).toBe("1");
a.unmount();
expect(__secondTickInternals.subscriberCount()).toBe(1);
expect(__secondTickInternals.isRunning()).toBe(true); // still one subscriber
expect(vi.getTimerCount()).toBe(1);
flushSync(() => vi.advanceTimersByTime(1000));
expect(b.container.textContent).toBe("2");
b.unmount();
expect(__secondTickInternals.subscriberCount()).toBe(0);
expect(__secondTickInternals.isRunning()).toBe(false); // timer stopped when idle
expect(vi.getTimerCount()).toBe(0);
});
it("does not subscribe or start a timer when inactive", () => {
const c = mount(false);
expect(__secondTickInternals.subscriberCount()).toBe(0);
expect(__secondTickInternals.isRunning()).toBe(false);
expect(vi.getTimerCount()).toBe(0);
flushSync(() => vi.advanceTimersByTime(3000));
expect(c.container.textContent).toBe("0");
const active = mount(true);
flushSync(() => vi.advanceTimersByTime(1000));
expect(active.container.textContent).toBe("1");
expect(c.container.textContent).toBe("0");
active.unmount();
expect(vi.getTimerCount()).toBe(0);
c.unmount();
});
});
-6
View File
@@ -46,9 +46,3 @@ export function useSecondTick(active: boolean): number {
}, [active]);
return tick;
}
/** Test-only hook to observe the shared ticker's internal state. */
export const __secondTickInternals = {
subscriberCount: () => subscribers.size,
isRunning: () => intervalId !== null,
};
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -7,7 +7,7 @@ import type {
// ---------------------------------------------------------------------------
// Shared Team Catalog fixtures.
//
// Used by both the Storybook stories (ui/storybook/stories/team-catalog.stories.tsx)
// Used by both the Storybook stories (ui/storybook/stories/teams/team-catalog.stories.tsx)
// and the in-app /design-guide showcase so the two surfaces stay in sync.
// ---------------------------------------------------------------------------
@@ -503,23 +503,15 @@ describe("chat setup and identity-link clipboard actions", () => {
expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("4Send a message");
});
it("keeps a terminated agent’s saved avatar available in settings", async () => {
mocks.tab = "settings";
mocks.listAgents.mockResolvedValue([]);
mocks.getAgent.mockResolvedValue({ id: "agent-a", name: "Maya", status: "terminated", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "orchid-peach" } });
await render("slack", true);
expect(container.querySelector('a[download]')?.getAttribute("href")).toBe("/api/agent-avatars/cap-v1/orchid-peach/rest.png?size=512&scale=1&background=paperclip-dark");
expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a");
});
it("keeps avatar download available in connector settings", async () => {
it("keeps settings focused on channel access and the test-message action", async () => {
mocks.tab = "settings";
await render("slack", true);
const section = container.querySelector('section[aria-label="Slack avatar"]')!;
expect(section.textContent).toContain("Download avatar");
expect(section.querySelector('a[download]')?.getAttribute("href")).toContain("/cherry-pop/rest.png?size=512&scale=1");
expect(section.querySelector("details")?.open).toBe(false);
expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a");
expect(container.textContent).toContain("Send me a test message");
expect(container.textContent).toContain("Open Slack configuration");
expect(container.textContent).not.toContain("Message in a channel");
expect(container.textContent).not.toContain("Agent avatar");
expect(container.textContent).not.toContain("Manage action permissions");
expect(container.querySelector('a[download]')).toBeNull();
});
it("discovers a newly invited, enabled Slack channel while settings stays open", async () => {
+13 -39
View File
@@ -1,14 +1,13 @@
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
import { resolveAgentAppearance } from "@paperclipai/shared";
import { SlackTestMessageButton } from "./SlackTestMessageButton";
import { AgentAvatar } from "@/components/AgentAvatar";
import { Identity } from "@/components/Identity";
import { SlackSetupAdvanced } from "./SlackAppDetails";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { SlackToolsSettings, SlackSearchAccess } from "./SlackToolSettings";
import { defaultSlackAppName } from "./slack-app-name";
import { ChatCommunicationInstructions } from "./ChatCommunicationInstructions";
import { SlackAvatarSettings } from "./SlackAvatarStep";
import { agentsApi } from "@/api/agents";
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
import { resolveAgentAppearance } from "@paperclipai/shared";
import { AgentAvatar } from "@/components/AgentAvatar";
import { ChatConversationList } from "./ChatConversationList";
import { GitHubBotManagement, GitHubReviews } from "./GitHubBotManagement";
import { GitHubBotMention, GitHubAppIdentityActions } from "./GitHubAppIdentity";
@@ -315,8 +314,9 @@ export function ChatEndpointDetail() {
endpoint.setup?.step !== "complete" &&
!(endpoint.provider === "slack" && endpoint.setup?.step === "test") &&
["draft", "verifying", "attention", "revoked"].includes(endpoint.status);
const slackUrl = endpoint.provider === "slack" && endpoint.providerAccountId && endpoint.botExternalId
? `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/user/${encodeURIComponent(endpoint.botExternalId)}`
const slackAppId = endpoint.setup?.slackRegistration?.appId;
const slackUrl = endpoint.provider === "slack"
? slackAppId ? `https://api.slack.com/apps/${encodeURIComponent(slackAppId)}/oauth` : "https://api.slack.com/apps"
: null;
return (
@@ -347,8 +347,8 @@ export function ChatEndpointDetail() {
)}
</div>
</div>
<div className="flex items-center gap-2">
{slackUrl && <Button asChild variant="outline"><a href={slackUrl} target="_blank" rel="noopener noreferrer">Open Slack <ExternalLink className="size-4" /></a></Button>}
<div className="flex flex-wrap items-center gap-2">
{slackUrl && <Button asChild variant="outline"><a href={slackUrl} target="_blank" rel="noopener noreferrer">Open Slack configuration <ExternalLink className="size-4" /></a></Button>}
{setupIncomplete ? (
<Button
variant="outline"
@@ -364,6 +364,7 @@ export function ChatEndpointDetail() {
{["paused", "attention", "revoked"].includes(endpoint.status) && <StatusBadge status={endpoint.status} />}
</div>
</header>
{endpoint.provider === "slack" && <SlackTestMessageButton key={endpoint.id} endpointId={endpoint.id} />}
{endpoint.provider === "github" && <div hidden={activeTab !== "settings" && activeTab !== "access"}>
<GitHubBotManagement key={endpoint.id} endpoint={endpoint} view={activeTab === "access" ? "access" : "settings"} />
</div>}
@@ -435,13 +436,6 @@ function Settings({
}) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [messageCopied, setMessageCopied] = useState(false);
const avatarAgent = useQuery({
queryKey: queryKeys.agents.detail(endpoint.assignedAgentId),
queryFn: () => agentsApi.get(endpoint.assignedAgentId, endpoint.companyId),
enabled: endpoint.provider === "slack",
});
const mentionMessage = `@${(endpoint.botUsername ?? endpoint.botLabel ?? endpoint.assignedAgentName).replace(/^@/, "")} you there?`;
const resourcesQuery = useQuery({
queryKey: queryKeys.chatEndpoints.resources(endpointId),
queryFn: () => chatEndpointsApi.listResources(endpointId),
@@ -512,7 +506,7 @@ function Settings({
No destinations yet.
</p>
) : (
<div className="divide-y divide-border border-y border-border">
<div className={endpoint.provider === "slack" ? "space-y-1" : "divide-y divide-border border-y border-border"}>
{destinationResources.map((resource) => (
<div key={resource.id} className="flex items-center gap-3 py-3">
<div className="min-w-0 flex-1">
@@ -595,27 +589,7 @@ function Settings({
queryClient.setQueryData(queryKeys.chatEndpoints.detail(endpointId), next);
}}
/>}
{endpoint.provider === "slack" && (
<SlackSetupAdvanced label="Message in a channel"><div className="space-y-2 text-sm">
<p>Invite the bot to a channel, then mention it to start a conversation.</p>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border p-3">
<code>{mentionMessage}</code>
<Button size="icon" variant="ghost" aria-label={messageCopied ? "Message copied" : "Copy message"} onClick={() => {
void copyTextToClipboard(mentionMessage).then(() => setMessageCopied(true), () => pushToast({ title: "Couldn’t copy the message", body: "Select and copy it manually.", tone: "error" }));
}}>{messageCopied ? <Check className="size-4" /> : <Copy className="size-4" />}</Button>
</div>
</div></SlackSetupAdvanced>
)}
{endpoint.provider === "slack" && <SlackSetupAdvanced label="Agent avatar">
{avatarAgent.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading agent avatar…</p>
: avatarAgent.isError ? <p role="alert" className="text-sm text-destructive">Couldn’t load the agent’s avatar. <button className="underline" onClick={() => void avatarAgent.refetch()}>Try again</button></p>
: <SlackAvatarSettings
agentName={avatarAgent.data?.name ?? endpoint.assignedAgentName}
appName={endpoint.setup?.slackApp?.appName ?? defaultSlackAppName(avatarAgent.data?.name ?? endpoint.assignedAgentName)}
avatarUrl={agentAvatarUrl(resolveAgentAppearance(avatarAgent.data?.appearance, endpoint.assignedAgentId), 512, 1, "rest", false, "paperclip-dark")}
/>}
</SlackSetupAdvanced>}
{endpoint.provider === "slack" && <SlackSetupAdvanced label="Slack tools"><SlackToolsSettings companyId={endpoint.companyId} endpointId={endpointId} connectionId={endpoint.connectionId} /></SlackSetupAdvanced>}
{endpoint.provider === "slack" && <SlackSetupAdvanced label="Slack tools"><SlackToolsSettings companyId={endpoint.companyId} endpointId={endpointId} /></SlackSetupAdvanced>}
</section>
);
}
@@ -634,7 +608,7 @@ function SettingToggle({
onChange: (value: boolean) => void;
}) {
return (
<div className="flex items-center gap-3 border-y border-border py-3">
<div className="flex items-center gap-3 py-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{label}</p>
{detail && <p className="text-xs text-muted-foreground">{detail}</p>}
+68 -39
View File
@@ -1,6 +1,7 @@
import { buildSlackAppManifest, defaultSlackAppConfiguration } from "@paperclipai/shared";
import { SlackAppDetails, SlackSetupAdvanced } from "./SlackAppDetails";
import { SlackAutomaticSetup } from "./SlackAutomaticSetup";
import { SlackSetup } from "./slack/SlackSetup";
import { isProvisionedSlackSetup, slackSetupState } from "./slack/setup-state";
import { defaultSlackAppName } from "./slack-app-name";
import { GitHubChatSetup } from "./GitHubChatSetup";
import { GitHubAgentTrustWarning } from "@/components/GitHubAgentTrustWarning";
@@ -105,10 +106,11 @@ export function isChatEndpointRepairing(
);
}
export function ChatConnectionPurpose({ provider, onChat, onTools }: {
export function ChatConnectionPurpose({ provider, onChat, onTools, slackLabels }: {
provider: ChatProvider;
onChat: () => void;
onTools: () => void;
slackLabels?: string[];
}) {
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {
@@ -117,7 +119,7 @@ export function ChatConnectionPurpose({ provider, onChat, onTools }: {
}, [setBreadcrumbs]);
return (
<div className="max-w-2xl space-y-6">
<ChatSetupNavigation labels={provider === "slack" ? ["Choose agent", "App configuration access token", "Install Slack app", "Send a message"] : undefined} step={0} availableStep={0} onSelect={onChat} />
<ChatSetupNavigation labels={provider === "slack" ? slackLabels ?? ["Choose agent", "Connect"] : undefined} step={0} availableStep={0} onSelect={onChat} />
<div>
<h1 className="text-xl font-bold">Choose how to connect</h1>
<p className="mt-1 text-sm text-muted-foreground">
@@ -192,6 +194,7 @@ function ChatSdkEndpointSetup() {
const [viewedStep, setViewedStep] = useState<number | null>(null);
const [slackIdentityReady, setSlackIdentityReady] = useState(false);
const [automaticBusy, setAutomaticBusy] = useState(false);
const [managedAutoStart, setManagedAutoStart] = useState(Boolean(resumeEndpointId));
const [slackDraft, setSlackDraft] = useState<{ agentId: string; app: SlackAppConfiguration } | null>(null);
const [endpoint, setEndpoint] = useState<ChatEndpoint | null>(null);
const [credentials, setCredentials] = useState<Record<string, string>>({});
@@ -220,7 +223,7 @@ function ChatSdkEndpointSetup() {
useEffect(() => {
if (!resumeQuery.data) return;
setEndpoint(current => {
if (current?.setup?.slackSetupMethod === "automatic" && !current.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null);
if (isProvisionedSlackSetup(current?.setup?.slackSetupMethod) && !current?.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null);
return resumeQuery.data;
});
setAgentId(resumeQuery.data.assignedAgentId);
@@ -273,7 +276,8 @@ function ChatSdkEndpointSetup() {
next: ChatEndpoint,
onlyIfStillVisible = false,
) => {
if (next.setup?.slackSetupMethod === "automatic" && next.setup.slackRegistration?.status === "configured"
if (next.setup?.slackSetupMethod === "managed" && endpoint && endpoint.setup?.slackSetupMethod !== "managed") setManagedAutoStart(true);
if (isProvisionedSlackSetup(next.setup?.slackSetupMethod) && next.setup?.slackRegistration?.status === "configured"
&& endpoint?.setup?.slackRegistration?.status !== "configured") setViewedStep(null);
setEndpoint((visible) =>
onlyIfStillVisible && visible?.id !== next.id ? visible : next,
@@ -287,21 +291,31 @@ function ChatSdkEndpointSetup() {
);
}
};
const slackChoices = useQuery({
queryKey: ["slack-setup-options", selectedCompanyId],
queryFn: () => chatEndpointsApi.slackSetupOptions(selectedCompanyId!),
enabled: provider === "slack" && Boolean(selectedCompanyId),
retry: false,
});
const createEndpoint = useMutation({
mutationFn: () =>
chatEndpointsApi.create(selectedCompanyId!, {
mutationFn: ({ ownApp = false }: { ownApp?: boolean; exit?: boolean }) => {
if (provider === "slack" && !slackChoices.data) throw new Error("Slack setup is still loading. Try again.");
return chatEndpointsApi.create(selectedCompanyId!, {
provider: provider!,
assignedAgentId: agentId,
...(provider === "slack" ? { slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}),
}),
onSuccess: (next) => {
setViewedStep(null);
...(provider === "slack" ? { slackSetupMethod: ownApp ? "automatic" : slackChoices.data!.defaultMethod, slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}),
});
},
onSuccess: (next, { exit }) => {
setManagedAutoStart(!exit);
setViewedStep(exit ? 0 : null);
syncEndpointSnapshot(next);
if (next.provider === "imessage-photon" || next.provider === "slack") {
const resumed = new URLSearchParams(params);
resumed.set("resume", next.id);
setParams(resumed, { replace: true });
}
if (exit) navigate("/apps");
},
onError: (error) =>
pushToast({
@@ -324,13 +338,22 @@ function ChatSdkEndpointSetup() {
if (exit && provider !== "slack") { navigate("/apps"); return; }
if (!endpoint) {
if (!agentId) { if (exit) navigate("/apps"); return; }
createEndpoint.mutate(undefined, { onSuccess: () => { if (exit) navigate("/apps"); } });
createEndpoint.mutate({ exit });
return;
}
try { if (provider === "slack") await persistSlackDetails(); }
catch { return; }
if (exit) navigate("/apps"); else setViewedStep(1);
if (exit) navigate("/apps"); else { setManagedAutoStart(true); setViewedStep(null); }
};
const useOwnSlackApp = useMutation({
mutationFn: async () => {
if (!endpoint) return createEndpoint.mutateAsync({ ownApp: true });
await persistSlackDetails();
return chatEndpointsApi.update(endpoint.id, { slackSetupMethod: "automatic" });
},
onSuccess: next => { setManagedAutoStart(false); syncEndpointSnapshot(next); setViewedStep(1); },
onError: () => pushToast({ title: "Couldn't switch Slack setup", body: "Try again.", tone: "error" }),
});
const setupAction = useMutation({
mutationFn: async ({
action,
@@ -454,21 +477,14 @@ function ChatSdkEndpointSetup() {
reconnectRequested,
);
const isSlack = provider === "slack";
const automaticSlack = isSlack && (!endpoint || endpoint.setup?.slackSetupMethod === "automatic");
const automaticSlackComplete = automaticSlack && (endpoint?.setup?.step === "test" || endpoint?.setup?.step === "complete");
const tryStep = isSlack ? automaticSlack ? 3 : 5 : 2;
const availableStep = endpoint
? automaticSlack && endpoint.setup?.slackRegistration?.status === "configured" && endpoint.setup.slackAccount?.status !== "linked" && endpoint.setup.step !== "complete" ? 2
: !repairing &&
(endpoint.setup?.step === "test" || endpoint.setup?.step === "complete")
? isSlack && endpoint.setup?.step !== "complete"
? !automaticSlack && !slackIdentityReady ? 4 : tryStep
: tryStep
: automaticSlack && endpoint.setup?.slackRegistration?.status === "credentials_saved" ? 2
: isSlack && endpoint.providerAccountId && !repairing ? 3
: isSlack && (slackCredentialsReady || repairing || endpoint.setup?.slackSetupMethod === "existing" || automaticSlack && endpoint.setup?.slackRegistration?.appId) ? 2 : 1
: 0;
const slackNavigation = slackSetupState({ endpoint, repairing, credentialsReady: slackCredentialsReady, identityReady: slackIdentityReady, defaultMethod: slackChoices.data?.defaultMethod });
const automaticSlack = isSlack && slackNavigation.provisioned;
const automaticSlackComplete = automaticSlack && slackNavigation.complete;
const tryStep = isSlack ? slackNavigation.finalStage : 2;
const availableStep = isSlack ? slackNavigation.availableStage : !endpoint ? 0
: !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") ? tryStep : 1;
const step = Math.min(viewedStep ?? availableStep, availableStep);
const agentStepBusy = automaticBusy || createEndpoint.isPending || saveSlackDetails.isPending || useOwnSlackApp.isPending;
const slackVerificationQuery = useQuery({
queryKey: ["chat-endpoint-slack-webhook-verification", endpoint?.id],
queryFn: () => chatEndpointsApi.get(endpoint!.id),
@@ -507,17 +523,17 @@ function ChatSdkEndpointSetup() {
);
if (purpose === "choice") {
return <ChatConnectionPurpose provider={provider} onChat={() => setPurpose("chat")} onTools={() => navigate(toolHref)} />;
return <ChatConnectionPurpose provider={provider} slackLabels={slackNavigation.labels} onChat={() => setPurpose("chat")} onTools={() => navigate(toolHref)} />;
}
return (
<div className={automaticSlackComplete && step === tryStep ? "mx-auto w-full max-w-2xl space-y-6" : "max-w-2xl space-y-6"}>
<ChatSetupNavigation
labels={isSlack ? automaticSlack ? ["Choose agent", "App configuration access token", "Install Slack app", "Send a message"] : ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Connect your Slack account", "Try it"] : undefined}
step={step}
availableStep={availableStep}
disabled={automaticBusy || createEndpoint.isPending || saveSlackDetails.isPending || setupAction.isPending || generateSetupSecret.isPending || testConnection.isPending}
onSelect={setViewedStep}
labels={isSlack ? slackNavigation.labels : undefined}
step={isSlack ? slackNavigation.toVisible(step) : step}
availableStep={isSlack ? slackNavigation.toVisible(availableStep) : availableStep}
disabled={agentStepBusy || setupAction.isPending || generateSetupSecret.isPending || testConnection.isPending}
onSelect={value => setViewedStep(isSlack ? slackNavigation.toStage(value) : value)}
/>
<div className="min-w-0 space-y-6">
{step === 0 ? (
@@ -541,15 +557,23 @@ function ChatSdkEndpointSetup() {
emptyMessage="No active agents are available."
/>}
{isSlack && (selectedAgent || endpoint) && <SlackSetupAdvanced>
<SlackAppDetails value={slackDetails} readOnly={!slackDetailsEditable || createEndpoint.isPending}
<SlackAppDetails value={slackDetails} readOnly={!slackDetailsEditable || agentStepBusy}
onChange={app => setSlackDraft({ agentId, app })}
onBlur={() => void persistSlackDetails().catch(() => {})} />
{saveSlackDetails.isError && <p role="alert" className="text-sm text-destructive">Couldn&apos;t save the Slack app details. Try again before continuing.</p>}
{slackChoices.data?.defaultMethod === "managed" && (!endpoint || endpoint.setup?.slackSetupMethod === "managed") && !endpoint?.setup?.slackRegistration && <Button
variant="link" className="h-auto p-0 text-muted-foreground"
disabled={agentStepBusy || !slackDetailsValidation.success}
onClick={() => useOwnSlackApp.mutate()}
>Use your own app</Button>}
</SlackSetupAdvanced>}
{isSlack && !endpoint && !slackChoices.data && (slackChoices.isError
? <div role="alert" className="text-sm text-destructive">Couldn&apos;t load Slack setup. <Button variant="link" disabled={slackChoices.isFetching} onClick={() => void slackChoices.refetch()}>Retry</Button></div>
: <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Loading Slack…</p>)}
{provider === "github" && <GitHubAgentTrustWarning agent={selectedAgent} />}
<SetupWizardFooter disabled={createEndpoint.isPending || saveSlackDetails.isPending} onSaveExit={() => void continueFromAgent(true)}>
<SetupWizardFooter disabled={agentStepBusy || isSlack && !endpoint && Boolean(agentId) && !slackChoices.data} onSaveExit={() => void continueFromAgent(true)}>
<Button
disabled={!agentId || createEndpoint.isPending || saveSlackDetails.isPending || isSlack && !slackDetailsValidation.success}
disabled={!agentId || agentStepBusy || isSlack && (!slackDetailsValidation.success || !endpoint && !slackChoices.data)}
onClick={() => void continueFromAgent(false)}
>
{createEndpoint.isPending && (
@@ -574,6 +598,7 @@ function ChatSdkEndpointSetup() {
<ProviderConnectStep
key={`${provider}:${endpoint.id}`}
provider={provider}
managedAutoStart={managedAutoStart && step !== 0}
slackStage={step === 1 ? "app" : step === 3 ? "finish" : "credentials"}
onSlackCredentialsContinue={() => setViewedStep(3)}
onSlackVerificationContinue={() => setViewedStep(4)}
@@ -684,6 +709,7 @@ function ProviderConnectStep({
repairing,
pending,
onAutomaticBusy,
managedAutoStart = false,
onEndpointSaved,
generatedWebhookSecret,
generatingSetupSecret,
@@ -702,6 +728,7 @@ function ProviderConnectStep({
setCredentials: Dispatch<SetStateAction<Record<string, string>>>;
repairing: boolean;
pending: boolean;
managedAutoStart?: boolean;
onAutomaticBusy: (busy: boolean) => void;
onEndpointSaved: (endpoint: ChatEndpoint) => void;
generatedWebhookSecret: string;
@@ -826,7 +853,7 @@ function ProviderConnectStep({
...defaultSlackAppConfiguration(agentName),
...(endpoint.setup?.command ? { command: endpoint.setup.command } : {}),
};
const automaticSlack = endpoint.setup?.slackSetupMethod === "automatic";
const automaticSlack = isProvisionedSlackSetup(endpoint.setup?.slackSetupMethod);
const slackValidation = slackAppConfigurationSchema.safeParse(slackApp);
const saveSlackAndExit = () => navigate("/apps");
const slackAppName = slackApp.appName.trim();
@@ -1553,7 +1580,7 @@ function ProviderConnectStep({
</div>
)}
<div>
<h1 className="text-xl font-bold">{slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}</h1>
<h1 className="text-xl font-bold">{endpoint.setup?.slackSetupMethod === "managed" ? "Connect to Slack" : slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}</h1>
{repairing && !automaticSlack && (
<p className="mt-1 text-sm text-muted-foreground">
Reconnect verifies or replaces credentials for this same Slack app. It does not reinstall the app or change its workspace or channel membership. Leave credentials blank to reuse the saved values.
@@ -1628,8 +1655,10 @@ function ProviderConnectStep({
</div>
</div>}
</div>
{automaticSlack && <SlackAutomaticSetup
{automaticSlack && <SlackSetup
key={endpoint.id}
autoStart={managedAutoStart}
reconnect={repairing}
endpoint={endpoint} stage={slackStage} disabled={!endpoint.setup?.webhookUrl || !endpoint.setup?.slackOAuthCallbackUri?.startsWith("https://") || !slackValidation.success}
saveDetails={async () => {}}
onBusy={onAutomaticBusy} onSaved={onEndpointSaved} onContinue={onSlackAppCreated}
+1 -183
View File
@@ -1,183 +1 @@
import { useEffect, useRef, useState } from "react";
import { ExternalLink, Loader2 } from "lucide-react";
import { slackRegistrationErrorMessage } from "@paperclipai/shared";
import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { SetupWizardFooter } from "@/components/SetupWizard";
import { sanitizedSetupErrorMessage } from "./chat-setup-error";
import { SlackSetupAdvanced } from "./SlackAppDetails";
/** Uses ordinary request-local state: configuration tokens never enter a query/mutation cache. */
export function SlackAutomaticSetup({ endpoint, stage, disabled, saveDetails, onSaved, onBusy,
onManual, onContinue, onSaveExit }: {
endpoint: ChatEndpoint;
stage: "app" | "credentials";
disabled: boolean;
saveDetails: () => Promise<void>;
onSaved: (endpoint: ChatEndpoint) => void;
onBusy: (busy: boolean) => void;
onManual: (existing: boolean) => Promise<void>;
onContinue: () => void;
onSaveExit: () => void;
}) {
const [configurationToken, setConfigurationToken] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [checkedNoApp, setCheckedNoApp] = useState(false);
const requestId = useRef(crypto.randomUUID());
const inFlight = useRef(false);
const pendingWindow = useRef<Window | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
pendingWindow.current?.close();
pendingWindow.current = null;
};
}, []);
const registration = endpoint.setup?.slackRegistration;
const uncertain = registration?.status === "uncertain";
const creating = registration?.status === "creating";
const created = Boolean(registration?.appId);
const configurationPending = registration?.errorCode === "slack_manifest_update_pending";
async function run(action: () => Promise<void>) {
if (inFlight.current) return;
inFlight.current = true;
setBusy(true);
onBusy(true);
setError(null);
try { await action(); }
catch (failure) { setError(sanitizedSetupErrorMessage(failure, { configurationToken })); }
finally {
setConfigurationToken("");
setBusy(false);
onBusy(false);
inFlight.current = false;
}
}
async function create() {
// Reserve the window during the click so async app creation cannot trigger a popup blocker.
const installWindow = window.open("about:blank", "_blank");
if (installWindow) {
installWindow.opener = null;
installWindow.document.title = "Preparing Slack installation";
installWindow.document.body.textContent = "Preparing your Slack installation…";
pendingWindow.current = installWindow;
}
let token = configurationToken.trim();
setConfigurationToken("");
try {
let next: ChatEndpoint;
try {
if (!created) await saveDetails();
if (registration?.status === "failed" || checkedNoApp) requestId.current = crypto.randomUUID();
next = await chatEndpointsApi.createSlackApp(endpoint.id, {
requestId: requestId.current,
credentials: { configurationToken: token },
...(checkedNoApp ? { confirmedNoAppCreated: true } : {}),
});
} catch (failure) {
// A response can be lost after Slack creates the app. Only saved state can advance.
const current = await chatEndpointsApi.get(endpoint.id).catch(() => null);
if (current && mounted.current) onSaved(current);
if (!current?.setup?.slackRegistration?.appId) {
throw new Error(sanitizedSetupErrorMessage(failure, { configurationToken: token }));
}
next = current;
}
token = "";
if (!mounted.current) return;
setCheckedNoApp(false);
onSaved(next);
const saved = next.setup?.slackRegistration;
if (!saved?.appId) return;
onContinue();
// Pending/uncertain configuration and existing installations must not start OAuth.
if (saved.status !== "install" || saved.errorCode) return;
if (!installWindow || installWindow.closed) {
setError("Select Install in Slack to open the installation page.");
return;
}
const result = await chatEndpointsApi.installSlackApp(endpoint.id);
if (!mounted.current) return;
if (installWindow.closed) {
setError("Select Install in Slack to reopen the installation page.");
return;
}
installWindow.location.replace(result.authorizationUrl);
pendingWindow.current = null;
} finally {
token = "";
pendingWindow.current?.close();
pendingWindow.current = null;
}
}
async function authorize() {
const result = await chatEndpointsApi.installSlackApp(endpoint.id);
// Current-tab navigation avoids popup blockers. The callback resumes this saved draft.
window.location.assign(result.authorizationUrl);
}
return <div className="space-y-5">
{error || registration?.errorCode ? <p role="alert" className="text-sm text-destructive">
{error ?? slackRegistrationErrorMessage(registration!.errorCode!)}
</p> : null}
{(stage === "app" && !created && !creating || configurationPending) && <>
{uncertain && <div className="space-y-3 text-sm">
<a href={registration?.managementUrl ?? "https://api.slack.com/apps"} target="_blank" rel="noopener noreferrer" className="underline underline-offset-4">Open Slack app settings <ExternalLink className="inline size-3" /></a>
<label className="flex items-center gap-2">
<Checkbox checked={checkedNoApp} disabled={busy} onCheckedChange={value => setCheckedNoApp(value === true)} />
I checked Slack and no app was created. Create a new app.
</label>
</div>}
{(!uncertain || checkedNoApp) && <div className="space-y-2">
<Button asChild size="lg" className="mb-4 h-auto w-full whitespace-normal py-4">
<a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer">Get your App configuration access token <ExternalLink className="size-4" /></a>
</Button>
<div id="slack-configuration-token-help" className="space-y-3 pb-3 text-sm">
<ol className="list-decimal space-y-2 pl-5">
<li>In Slack app settings, find <strong>Your App Configuration Tokens</strong> and choose <strong>Generate Token</strong>.</li>
<li>Select the workspace where you want to install your bot.</li>
<li>Copy the <strong>Access Token</strong> and paste it below.</li>
</ol>
<p className="text-muted-foreground">Paperclip uses this token to create your app</p>
</div>
<label htmlFor="slack-configuration-token" className="text-sm font-medium">App configuration access token</label>
<Input id="slack-configuration-token" type="password" autoComplete="off" spellCheck={false}
value={configurationToken} disabled={busy} onChange={event => setConfigurationToken(event.target.value)}
aria-describedby="slack-configuration-token-help" />
</div>}
</>}
{creating && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Creating your Slack app. You can return to this saved setup.</p>}
{created && <p className="text-sm">
<strong>{endpoint.setup?.slackApp?.appName ?? "Your Slack app"}</strong> is created.
{stage === "credentials" && " Approve installation with your Slack account. Paperclip will link it to your signed-in Paperclip account and send you a welcome DM."}
</p>}
{stage === "credentials" && registration?.status === "credentials_saved" && <div className="space-y-2">
<p className="text-sm text-muted-foreground">Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.</p>
<Button variant="link" className="h-auto p-0" disabled={busy || disabled} onClick={() => void run(authorize)}>Authorize in Slack again</Button>
</div>}
{stage === "app" && <SlackSetupAdvanced><div className="flex flex-wrap gap-4 text-sm">
<Button variant="link" className="h-auto p-0" disabled={busy || creating} onClick={() => void run(() => onManual(false))}>Create manually</Button>
<Button variant="link" className="h-auto p-0" disabled={busy || creating} onClick={() => void run(() => onManual(true))}>Use an existing app</Button>
</div></SlackSetupAdvanced>}
<SetupWizardFooter onSaveExit={onSaveExit} disabled={busy}>
{configurationPending ? <Button disabled={busy || disabled || !configurationToken.trim()} onClick={() => void run(create)}>
{busy && <Loader2 className="size-4 animate-spin" />}Retry app configuration
</Button> : stage === "app" ? <Button disabled={busy || disabled || creating || (!created && (!configurationToken.trim() || uncertain && !checkedNoApp))}
onClick={() => created ? onContinue() : void run(create)}>
{busy && <Loader2 className="size-4 animate-spin" />}{created ? "Continue to installation" : "Create Slack app"}
</Button> : registration?.status === "credentials_saved" ? <Button disabled={busy} onClick={() => void run(async () => onSaved(await chatEndpointsApi.resumeSlackInstallation(endpoint.id)))}>
{busy && <Loader2 className="size-4 animate-spin" />}Retry connecting
</Button> : <Button disabled={busy || !created || disabled} onClick={() => void run(authorize)}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <ExternalLink className="size-4" />}Install in Slack
</Button>}
</SetupWizardFooter>
</div>;
}
export { OwnSlackAppSetup as SlackAutomaticSetup } from "./slack/OwnSlackAppSetup";
+5 -3
View File
@@ -12,19 +12,21 @@ Ask me to take over for login, two-factor authentication, CAPTCHA, workspace-adm
2. Check public HTTPS before creating the app. Slack must be able to reach Paperclip's generated webhook URL over publicly reachable HTTPS with a valid certificate. Paperclip Cloud includes HTTPS. A private Tailscale Serve address alone is not reachable by Slack; self-hosting needs an authorized public ingress, such as Tailscale Funnel or a reverse proxy with a trusted certificate. Follow the warning's Learn more link if needed. Use the callback URL generated by this connection, including its full path. Check that it uses the intended public instance hostname or an explicitly configured webhook ingress, not localhost, an internal service address, or an old pool hostname. If it is wrong or unreachable, resolve the instance configuration before registration; do not invent a callback path, disable signature verification, or bypass Cloud authentication protections.
3. Create Slack app automatically. Review Slack app name, Bot display name, and Slash command under Advanced on Choose agent in Paperclip. Keep the generated defaults unless I requested other names. Use the automatic setup path for a new bot. Ask me to open Slack's Your App Configuration Tokens > Generate Token, select the agreed workspace, and enter the temporary configuration token directly into Paperclip's App configuration access token password field. Pause while I do that. Never read, copy, reveal, screenshot, or include that token in your prompt or tool output. Do not request its refresh token. Click Create Slack app after I enter it. Paperclip creates the app, saves its signing secret, and uploads the assigned agent's avatar. Avatar upload has no wizard step; if it fails, finish setup and optionally use Settings > Agent avatar for manual upload later. Do not recreate the app or request another configuration token for an optional avatar. Its saved state is the evidence of creation; a click or navigation is not proof. Resume the matching draft after refresh or Save & exit. If creation is uncertain, open Slack app settings and check the agreed workspace for the exact app before continuing. Never retry creation automatically. Only confirm that no app was created after I explicitly confirm that check, or recover the existing app on this same draft. No Slack CLI is required.
3. Use the saved Slack setup method. For a new connection, keep managed setup when it is the instance default. Continue on Choose agent opens Slack authorization or starts installation automatically; there is no separate Add to Slack screen. To explicitly use your own app, open Advanced on Choose agent and choose Use your own app below Slash command. Select only a workspace authorized by my own Paperclip account, or let me complete the manager authorization in Slack; pause for login and administrator consent. Reuse my saved workspace authorization for additional agents. Paperclip creates and installs the child app, uploads its avatar, and links my verified Slack identity. Continue within the same two-stop wizard: Choose agent, Connect. If Slack requires child-app consent or approval, complete that for the saved app and resume. Never switch methods after creation dispatch, migrate an existing bot, read manager credentials, or reuse a teammate's grant. If managed setup becomes unavailable, preserve the draft and report its recovery action. Use your own app only when selected or when managed setup is unavailable before creation.
For the own-app path, create Slack app automatically. Review Slack app name, Bot display name, and Slash command under Advanced on Choose agent in Paperclip. Keep the generated defaults unless I requested other names. Use the automatic setup path for a new bot. Ask me to open Slack's Your App Configuration Tokens > Generate Token, select the agreed workspace, and enter the temporary configuration token directly into Paperclip's App configuration access token password field. Pause while I do that. Never read, copy, reveal, screenshot, or include that token in your prompt or tool output. Do not request its refresh token. Click Create Slack app after I enter it. Paperclip creates the app, saves its signing secret, and uploads the assigned agent's avatar. Avatar upload has no wizard step; if it fails, finish setup and optionally use Slack app settings > Basic Information to set its app icon later. Do not recreate the app or request another configuration token for an optional avatar. Its saved state is the evidence of creation; a click or navigation is not proof. Resume the matching draft after refresh or Save & exit. If creation is uncertain, open Slack app settings and check the agreed workspace for the exact app before continuing. Never retry creation automatically. Only confirm that no app was created after I explicitly confirm that check, or recover the existing app on this same draft. No Slack CLI is required.
4. Install Slack app. Successful creation and configuration advance Paperclip to Install Slack app and open Slack consent in a new window automatically. Use Install in Slack if that window was blocked or closed. Check the workspace and approve installation within the agreed scope. Pause for required login, ownership decisions, or administrator approval. Return to the saved wizard through Slack's OAuth redirect. Paperclip obtains and saves the bot token itself; do not locate, copy, or request the bot token or signing secret on the automatic path. Wait for Paperclip to report saved credentials. The fourth screen, Send a message to your agent, becomes Success automatically after Paperclip receives qualifying connection evidence. There is no separate Try it screen. If approval is declined or pending, preserve the app and resume its installation later without another configuration token. If authorization expires, start a fresh installation of this same app. If credentials are saved but connecting fails, use Retry connecting. If Slack access changed or the token was revoked, use Authorize in Slack again for this same app. Do not recreate the app. Installation automatically links the Slack account returned by OAuth to my signed-in Paperclip account and sends a welcome DM. Have me approve with my own Slack account; do not authorize with your account or a teammate’s account. There is no separate personal-linking step on the automatic path. Existing/conflicting/revoked links are managed in the connection’s Access settings later. Never overwrite them as a shortcut.
Manual fallback only when selected or needed for recovery: choose Create manually or Use an existing app on the creation step. For manual creation, use Paperclip's generated manifest, select the agreed workspace in Slack, and install that exact app. Preserve all scopes, bot events, slash command, interactivity, and signed HTTP callbacks. For an existing app, reconcile its configuration with this connection's manifest and generated URLs. Have me enter the Bot User OAuth Token (OAuth & Permissions, xoxb- prefix) and Signing Secret (Basic Information > App Credentials) directly into the matching Paperclip fields, or use safe clipboard transfer only as described above. The Signing Secret is not the Client Secret, Verification Token, or app-level token. Click Connect Slack app. On a resumed manual setup, leave credential fields blank to keep saved values. Never use Socket Mode as a shortcut.
5. On the automatic path, use Send a message to your agent and its Open Slack link. If authorized, send a short message. Paperclip accepts an authenticated message event from the saved app/workspace as connection evidence and replaces this screen with Success. The welcome event may complete the check first. If Success appears, skip the Slack settings visit even if Slack's Request URL still says unverified. If Paperclip keeps waiting, expand Didn’t work? for Request URL instructions. On the manual path, open the same Slack app's Event Subscriptions. Make sure events are enabled. Beside the manifest's prefilled Request URL, click Retry if needed, wait for Slack to display Verified, and save changes when offered. Verification can fail before Paperclip has the credentials, so retry now. Return to Paperclip and wait for its verified state and automatic advancement, or Continue when available. If verification fails, inspect the specific error and the wizard's Troubleshooting URL: check public reachability, the correct app/workspace and signing secret, and the full generated callback URL. Also confirm the generated Slash Commands request URL and Interactivity & Shortcuts request URL were retained if those features fail. Use the manifest's values for each surface; do not assume verifying events proves every callback works. Do not recreate the app or change arbitrary URLs to hide a failed check.
5. On either provisioned path, use Send a message to your agent and its Open Slack link. If authorized, send a short message. Paperclip accepts an authenticated message event from the saved app/workspace as connection evidence and replaces this screen with Success. The welcome event may complete the check first. If Success appears, skip the Slack settings visit even if Slack's Request URL still says unverified. If Paperclip keeps waiting, expand Didn’t work? for Request URL instructions. On the manual path, open the same Slack app's Event Subscriptions. Make sure events are enabled. Beside the manifest's prefilled Request URL, click Retry if needed, wait for Slack to display Verified, and save changes when offered. Verification can fail before Paperclip has the credentials, so retry now. Return to Paperclip and wait for its verified state and automatic advancement, or Continue when available. If verification fails, inspect the specific error and the wizard's Troubleshooting URL: check public reachability, the correct app/workspace and signing secret, and the full generated callback URL. Also confirm the generated Slash Commands request URL and Interactivity & Shortcuts request URL were retained if those features fail. Use the manifest's values for each surface; do not assume verifying events proves every callback works. Do not recreate the app or change arbitrary URLs to hide a failed check.
6. Manual/existing-app paths only: connect your Slack account. Copy the exact command shown by Paperclip, consisting of this app's saved slash command followed by connect. In the correct Slack workspace, send it as my signed-in Slack user. This discovers identity without starting agent work. Return to Paperclip and wait for that identity to appear. Compare the Slack account/workspace and the target Paperclip user. Only select the ownership confirmation for that identity when they are verified as my accounts; its visible label is This is my Slack account and browser tools may identify it as Link <Slack account> to my Paperclip account. Ask me if ownership is ambiguous. Never link a teammate's account to mine. Confirm Linked to you and use Continue to message test. Keep unlinked-person access off unless I explicitly requested a different policy. On these manual paths installing the bot and linking a person are separate steps; future work uses that linked person's Paperclip permissions.
7. Prepare the agreed destination. If using a channel, invite/add this bot to that channel through Slack's UI if authorized. In the connection's Settings, inspect Where this agent can work. Use its Refresh action if available, or reload the page after the bot invitation if the channel is missing. Inspect Allowed Channels and make sure the intended test channel is enabled; newly invited channels start enabled, while an explicitly disabled channel stays off until a person re-enables it. Do not invite the bot to unrelated channels, enable every channel, or broaden anyone's access just to get a test to pass. For a DM, check the connection permits direct messages and use my conversation with this bot. Return to the saved wizard if you visited Settings. Reading access and allowed response destinations are distinct; seeing a channel in Slack does not prove the bot is allowed to reply there.
8. Optional live qualification when explicitly requested: on the automatic path, the wizard ends at Success and Done, with no additional test screen. Open the bot's DM directly if a conversation test was requested. The welcome DM and connection check alone do not prove an agent response. For a channel test or manual setup, send the wizard's suggested short message, for example @<actual bot display name> you there? Find the bot belonging to this Slack app in Slack's mention suggestions and select it. Slack's actual display name can differ from Paperclip's suggested username: for example, Paperclip may suggest @cedarqa while Slack shows cedar-qa. Preserve Slack's displayed punctuation and select the real bot; pasting plain text that resembles a mention may not notify it. Use the configured bot, not a literal example. For a channel test, use a mention rather than the slash connect command. Wait for a real response in the originating Slack thread. Open the connection's Conversations page, follow the matching Open task link, and verify that the assigned agent's run completed successfully; the agent's Runs page provides the execution result if the Slack reply has no task link. Send one short follow-up in the same thread without another mention and confirm continuity and a single delivered answer. Do not claim success from a typing indicator, a verified webhook, the wizard's checkmark, or a manually posted substitute reply. If a run stops or no answer arrives, inspect the task/run and connection Activity, distinguish credential/callback/destination/identity failures from an agent-runtime failure, and resolve the specific issue or report the remaining blocker without exposing secrets.
8. Optional live qualification when explicitly requested: on either provisioned path, the wizard ends at Success and Done, with no additional test screen. Open the bot's DM directly if a conversation test was requested. The welcome DM and connection check alone do not prove an agent response. For a channel test or manual setup, send the wizard's suggested short message, for example @<actual bot display name> you there? Find the bot belonging to this Slack app in Slack's mention suggestions and select it. Slack's actual display name can differ from Paperclip's suggested username: for example, Paperclip may suggest @cedarqa while Slack shows cedar-qa. Preserve Slack's displayed punctuation and select the real bot; pasting plain text that resembles a mention may not notify it. Use the configured bot, not a literal example. For a channel test, use a mention rather than the slash connect command. Wait for a real response in the originating Slack thread. Open the connection's Conversations page, follow the matching Open task link, and verify that the assigned agent's run completed successfully; the agent's Runs page provides the execution result if the Slack reply has no task link. Send one short follow-up in the same thread without another mention and confirm continuity and a single delivered answer. Do not claim success from a typing indicator, a verified webhook, the wizard's checkmark, or a manually posted substitute reply. If a run stops or no answer arrives, inspect the task/run and connection Activity, distinguish credential/callback/destination/identity failures from an agent-runtime failure, and resolve the specific issue or report the remaining blocker without exposing secrets.
9. Finish and review. Use Done to finish setup. The message test is optional and does not waive required credentials, callback verification, or identity linking. If I declined testing, finish without claiming it passed. Confirm the connection is saved for the correct agent/workspace, inspect its permitted destinations and linked identity, and leave existing access restrictions and per-action approvals intact. Slack tools for this agent's eligible tasks and routines use this bot connection and the responsible user's linked Slack account; do not create a separate personal Slack MCP connection or promise all methods work regardless of scopes or plan. Additional communication instructions are optional and apply to new tasks; add them only if I supplied preferences. Other people link their own accounts using the Access page's connect instructions; they do not share the bot secrets or my identity.
@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, Send } from "lucide-react";
import type { SlackTestMessageResult } from "@paperclipai/shared";
import { chatEndpointsApi } from "@/api/chatEndpoints";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { queryKeys } from "@/lib/queryKeys";
export function SlackTestMessageButton({ endpointId }: { endpointId: string }) {
const queryClient = useQueryClient();
const [requestId, setRequestId] = useState(() => crypto.randomUUID());
const [accounts, setAccounts] = useState<Extract<SlackTestMessageResult, { status: "connection_required" }>["accounts"] | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [selected, setSelected] = useState("");
const [invitationExpired, setInvitationExpired] = useState(false);
const [search, setSearch] = useState("");
const send = useMutation({
mutationFn: (input: { slackUserId?: string; accountCursor?: string; requestId?: string }) => chatEndpointsApi.sendSlackTestMessage(endpointId, { requestId, ...input }),
onSuccess: (result, input) => {
if (result.status === "connection_required") {
setAccounts(previous => input.accountCursor ? [...new Map([...(previous ?? []), ...result.accounts].map(account => [account.id, account])).values()] : result.accounts);
setNextCursor(result.nextCursor);
return;
}
setAccounts(null);
void queryClient.invalidateQueries({ queryKey: queryKeys.chatEndpoints.principals(endpointId) });
},
});
const sent = send.data?.status === "sent";
const pendingConnection = send.data?.status === "connection_pending" ? send.data : null;
useEffect(() => {
setInvitationExpired(false);
if (!pendingConnection) return;
const timer = setTimeout(() => setInvitationExpired(true), Math.max(0, new Date(pendingConnection.expiresAt).getTime() - Date.now()));
return () => clearTimeout(timer);
}, [pendingConnection?.expiresAt]);
const principals = useQuery({
queryKey: queryKeys.chatEndpoints.principals(endpointId),
queryFn: () => chatEndpointsApi.listPrincipals(endpointId),
enabled: Boolean(pendingConnection) && !invitationExpired, refetchInterval: pendingConnection && !invitationExpired ? 3000 : false,
});
const confirmed = Boolean(pendingConnection && principals.data?.some(link => link.principalId === pendingConnection.principalId && link.status === "linked"));
useEffect(() => {
if (confirmed && !send.isPending && !send.isError) send.mutate({ slackUserId: selected });
}, [confirmed, send.isPending, send.isError]);
const reset = () => { setRequestId(crypto.randomUUID()); send.reset(); };
const startTest = () => {
if (invitationExpired) {
const next = crypto.randomUUID(); setRequestId(next);
send.mutate({ requestId: next, slackUserId: selected });
} else send.mutate({});
};
return <div className="space-y-2">
<Button variant="outline" disabled={send.isPending || sent || Boolean(pendingConnection && !invitationExpired)} onClick={startTest}>
{send.isPending ? <Loader2 className="size-4 animate-spin" /> : sent ? <Check className="size-4" /> : <Send className="size-4" />}
{send.isPending ? "Sending…" : sent ? "Test message sent" : invitationExpired ? "Request a new invitation" : pendingConnection ? "Waiting for Slack connection…" : "Send me a test message"}
</Button>
{pendingConnection && <p role="status" className="text-sm text-muted-foreground">{invitationExpired ? "The Slack invitation expired." : "Open the invitation in Slack to connect your account."}</p>}
{send.isError && <div role="alert" className="max-w-sm text-sm text-destructive">
{send.error.message} <button className="underline" onClick={reset}>Dismiss</button>
</div>}
<Dialog open={accounts !== null} onOpenChange={(open) => { if (!open && !send.isPending) setAccounts(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Connect your Slack account</DialogTitle>
<DialogDescription>Choose your account. Messages to this agent will use your Paperclip permissions.</DialogDescription>
</DialogHeader>
<Input aria-label="Find your Slack account" placeholder="Find your name" value={search} onChange={event => setSearch(event.target.value)} />
<div className="max-h-64 space-y-1 overflow-y-auto" role="radiogroup" aria-label="Your Slack account">
{accounts?.filter(account => `${account.name} ${account.id}`.toLowerCase().includes(search.toLowerCase())).map(account =>
<label key={account.id} className="flex cursor-pointer items-center gap-3 rounded-md border border-border p-3 text-sm">
<input type="radio" name="slack-account" value={account.id} checked={selected === account.id} onChange={() => setSelected(account.id)} />
<span>{account.name} <span className="text-muted-foreground">({account.id})</span></span>
</label>)}
{accounts?.length === 0 && <p className="text-sm text-muted-foreground">No available accounts found in this Slack workspace.</p>}
</div>
{nextCursor && <Button variant="ghost" disabled={send.isPending} onClick={() => send.mutate({ accountCursor: nextCursor })}>Load more Slack accounts</Button>}
{send.isError && <p role="alert" className="text-sm text-destructive">{send.error.message}</p>}
<div className="flex items-center justify-between gap-3">
<Button variant="ghost" disabled={send.isPending} onClick={() => setAccounts(null)}>Cancel</Button>
<Button disabled={!selected || send.isPending} onClick={() => send.mutate({ slackUserId: selected })}>{send.isPending ? "Sending invitation…" : "Send connection invitation"}</Button>
</div>
</DialogContent>
</Dialog>
</div>;
}
+1 -11
View File
@@ -1,4 +1,3 @@
import { Link } from "react-router-dom";
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type {
@@ -200,11 +199,9 @@ export function SlackSearchView({
export function SlackToolsSettings({
companyId,
endpointId,
connectionId,
}: {
companyId: string;
endpointId: string;
connectionId?: string | null;
}) {
const query = useQuery({
queryKey: ["slack-capabilities", companyId, endpointId],
@@ -217,14 +214,7 @@ export function SlackToolsSettings({
capabilities={query.data}
error={query.error?.message}
/>
{connectionId && (
<Link
className="text-sm underline underline-offset-4"
to={`/apps/${connectionId}/permissions`}
>
Manage action permissions
</Link>
)}
</div>
);
}
@@ -242,7 +242,8 @@ describe("chat connector UI contract", () => {
expect(setup).toContain("defaultSlackAppConfiguration");
expect(setup).not.toContain("No credentials");
expect(setup).not.toContain("managed Microsoft app");
expect(setup).toContain("endpoint.providerAccountId && !repairing");
expect(setup).toContain("slackSetupState({ endpoint, repairing");
expect(source("./slack/setup-state.ts")).toContain("endpoint.providerAccountId && !repairing");
expect(setup).not.toContain('field("webhookSecret"');
expect(setup).not.toContain("@paperclipai/teams-connect");
expect(setup).not.toContain("Copy setup command");
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
import { StrictMode, act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, afterEach, expect, it, vi } from "vitest";
import type { ChatEndpoint } from "@/api/chatEndpoints";
import { ManagedSlackSetup } from "./ManagedSlackSetup";
const api = vi.hoisted(() => ({ slackSetupOptions: vi.fn(), provisionManagedSlack: vi.fn(), authorizeSlackManager: vi.fn(), get: vi.fn() }));
vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: api }));
// Stop at the navigation boundary without leaving jsdom.
const navigation = vi.hoisted(() => vi.fn());
vi.mock("@/lib/oauthHandoff", () => ({ prepareOAuthNavigation: navigation, savePendingCloudHandoff: vi.fn() }));
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
let endpoint: ChatEndpoint;
const props = () => ({ endpoint, stage: "app" as const, disabled: false, autoStart: true, saveDetails: vi.fn().mockResolvedValue(undefined), onSaved: vi.fn(), onBusy: vi.fn(), onSaveExit: vi.fn(), onManual: vi.fn(), onContinue: vi.fn() });
beforeEach(() => {
vi.resetAllMocks();
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
endpoint = { id: "endpoint", companyId: "company", assignedAgentName: "Maya", setup: { slackSetupMethod: "managed" } } as ChatEndpoint;
api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [{ grantId: "grant", workspaceName: "Paperclip" }] });
api.provisionManagedSlack.mockResolvedValue({}); api.get.mockResolvedValue(endpoint);
api.authorizeSlackManager.mockResolvedValue({ authorizationUrl: "https://slack.com/oauth" });
navigation.mockImplementation(() => new Promise(() => {}));
});
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); });
async function render(overrides = {}) {
await act(async () => { root.render(<StrictMode><QueryClientProvider client={client}><ManagedSlackSetup {...props()} {...overrides} /></QueryClientProvider></StrictMode>); });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
}
it("starts a single managed install after Continue even across rerenders", async () => {
await render(); await render();
expect(api.provisionManagedSlack).toHaveBeenCalledTimes(1);
expect(api.provisionManagedSlack).toHaveBeenCalledWith("endpoint", expect.objectContaining({ grantId: "grant" }));
expect(api.authorizeSlackManager).not.toHaveBeenCalled();
});
it("requests explicit reinstall when reconnecting a configured app", async () => {
endpoint.setup!.slackRegistration = { status: "configured", appId: "app", managerGrantId: "grant" } as NonNullable<ChatEndpoint["setup"]>["slackRegistration"];
await render({ autoStart: false, reconnect: true });
const button = Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Continue in Slack");
expect(button).toBeDefined();
await act(async () => button!.click());
expect(api.provisionManagedSlack).toHaveBeenCalledWith("endpoint", expect.objectContaining({ grantId: "grant", reconnect: true, requestId: expect.any(String) }));
});
it("reuses the reconnect receipt after a lost response and failed recovery read", async () => {
endpoint.setup!.slackRegistration = { status: "configured", appId: "app", managerGrantId: "grant" } as NonNullable<ChatEndpoint["setup"]>["slackRegistration"];
api.provisionManagedSlack.mockRejectedValue(new Error("Response lost"));
api.get.mockRejectedValue(new Error("Recovery read unavailable"));
await render({ autoStart: false, reconnect: true });
const button = () => Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Continue in Slack")!;
await act(async () => button().click());
await act(async () => button().click());
expect(api.provisionManagedSlack).toHaveBeenCalledTimes(2);
expect(api.provisionManagedSlack.mock.calls[1]![1]).toEqual(api.provisionManagedSlack.mock.calls[0]![1]);
});
it("opens manager authorization directly when there is no saved workspace", async () => {
api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [] });
await render();
expect(api.authorizeSlackManager).toHaveBeenCalledTimes(1);
expect(navigation).toHaveBeenCalledWith({ authorizationUrl: "https://slack.com/oauth" });
expect(api.provisionManagedSlack).not.toHaveBeenCalled();
});
it("does not start while Choose agent is visible or after Save & exit", async () => {
await render({ autoStart: false });
expect(api.provisionManagedSlack).not.toHaveBeenCalled();
expect(api.authorizeSlackManager).not.toHaveBeenCalled();
});
it("waits for an explicit workspace choice when there are several", async () => {
api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [{ grantId: "one", workspaceName: "One" }, { grantId: "two", workspaceName: "Two" }] });
await render(); expect(api.provisionManagedSlack).not.toHaveBeenCalled();
expect(container.textContent).toContain("Choose a Slack workspace");
});
it.each(["uncertain", "failed", "creating", "install", "credentials_saved"])("does not automatically retry a %s registration", async status => {
endpoint.setup!.slackRegistration = { status } as NonNullable<ChatEndpoint["setup"]>["slackRegistration"];
await render(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); expect(api.authorizeSlackManager).not.toHaveBeenCalled();
});
it("does not loop authorization after denial", async () => {
endpoint.setup!.slackManagerError = "slack_install_declined";
await render(); expect(api.authorizeSlackManager).not.toHaveBeenCalled(); expect(api.provisionManagedSlack).not.toHaveBeenCalled();
});
it("does not silently retry an ambiguous request after refresh of setup options", async () => {
api.provisionManagedSlack.mockRejectedValue(new Error("Request failed"));
await render(); await render();
expect(api.provisionManagedSlack).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,92 @@
import { useEffect, useRef, useState, type ComponentProps } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { slackRegistrationErrorMessage } from "@paperclipai/shared";
import { chatEndpointsApi } from "@/api/chatEndpoints";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SetupWizardFooter } from "@/components/SetupWizard";
import type { OwnSlackAppSetup } from "./OwnSlackAppSetup";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { sanitizedSetupErrorMessage } from "../chat-setup-error";
export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, onBusy, onSaveExit, autoStart = false, reconnect = false }: ComponentProps<typeof OwnSlackAppSetup> & { autoStart?: boolean; reconnect?: boolean }) {
const choices = useQuery({ queryKey: ["slack-managed-workspaces", endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(endpoint.companyId), retry: false });
const [selectedGrant, setSelectedGrant] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmed, setConfirmed] = useState(false);
const inFlight = useRef(false);
const requestId = useRef(crypto.randomUUID());
const registration = endpoint.setup?.slackRegistration;
const started = Boolean(registration);
const workspaces = choices.data?.workspaces ?? [];
const grantId = registration?.managerGrantId ?? selectedGrant ?? (workspaces.length === 1 ? workspaces[0]!.grantId : null);
const needsAuthorization = !grantId || registration?.errorCode === "slack_manager_reauthorize" || Boolean(grantId && !workspaces.some(workspace => workspace.grantId === grantId));
const uncertain = registration?.status === "uncertain";
const creating = registration?.status === "creating";
async function run(action: () => Promise<void>) {
if (inFlight.current) return;
inFlight.current = true; setBusy(true); onBusy(true); setError(null);
try { await action(); }
catch (failure) { setError(sanitizedSetupErrorMessage(failure, {})); await Promise.allSettled([choices.refetch(), chatEndpointsApi.get(endpoint.id).then(onSaved)]); }
finally { inFlight.current = false; setBusy(false); onBusy(false); }
}
async function authorize() {
const authorization = await chatEndpointsApi.authorizeSlackManager(endpoint.id);
const target = await prepareOAuthNavigation(authorization);
if (target.kind === "reauthentication" && authorization.handoff) savePendingCloudHandoff(authorization.handoff.session);
window.location.assign(target.url);
}
async function connect() {
if (needsAuthorization || !grantId) {
await authorize(); return;
}
if (!started) await saveDetails();
if (confirmed || registration?.status === "failed") requestId.current = crypto.randomUUID();
const input = { requestId: requestId.current, grantId, ...(reconnect ? { reconnect: true } : {}), ...(confirmed ? { confirmedNoAppCreated: true } : {}) };
setConfirmed(false);
const result = await chatEndpointsApi.provisionManagedSlack(endpoint.id, input);
await choices.refetch();
onSaved(await chatEndpointsApi.get(endpoint.id));
if (result.authorization) window.location.assign(result.authorization.authorizationUrl);
}
const autoStarted = useRef(false);
useEffect(() => {
// Only fresh setup can start itself. Declines, uncertain creation, and saved
// registration recovery require an explicit action and never recreate an app.
if (!autoStart || autoStarted.current || disabled || !choices.data?.managedAvailable
|| choices.isFetching || choices.isError || started || endpoint.setup?.slackManagerError
|| workspaces.length > 1) return;
autoStarted.current = true;
void run(connect);
});
const setupError = (code: string) => code === "slack_manifest_update_pending"
? "Your app was created. Retry to finish its configuration."
: code === "slack_creation_uncertain" ? "Slack may have created your app. Check Slack app settings before trying again."
: slackRegistrationErrorMessage(code);
const message = error ?? (endpoint.setup?.slackManagerError ? setupError(endpoint.setup.slackManagerError) : null) ?? (choices.isError ? "Couldn't load your Slack workspaces. Try again." : registration?.errorCode ? setupError(registration.errorCode) : null);
return <div className="space-y-5">
{message && <p role="alert" className="text-sm text-destructive">{message}</p>}
{busy || choices.isPending || creating ? <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />{busy ? needsAuthorization ? "Opening Slack…" : "Connecting your agent…" : creating ? "Setting up your Slack app…" : "Loading Slack…"}</p>
: !choices.data?.managedAvailable ? <p className="text-sm">Managed Slack setup is unavailable. Try again later.</p>
: <>
{workspaces.length === 1 && <p className="text-sm">Add <strong>{endpoint.assignedAgentName}</strong> to <strong>{workspaces[0]!.workspaceName}</strong>.</p>}
{workspaces.length > 1 && <Select value={grantId ?? ""} onValueChange={setSelectedGrant} disabled={busy || started}>
<SelectTrigger aria-label="Slack workspace"><SelectValue placeholder="Choose a Slack workspace" /></SelectTrigger>
<SelectContent>{workspaces.map(workspace => <SelectItem key={workspace.grantId} value={workspace.grantId}>{workspace.workspaceName}</SelectItem>)}</SelectContent>
</Select>}
{(!started || registration?.errorCode === "slack_manager_reauthorize") && workspaces.length > 0 && <div><Button variant="link" className="h-auto p-0" disabled={busy} onClick={() => void run(authorize)}>{started ? "Reconnect Slack workspace" : "Connect another Slack workspace"}</Button></div>}
</>}
{uncertain && <div className="space-y-3 text-sm">
<a className="underline underline-offset-4" href={registration?.managementUrl ?? "https://api.slack.com/apps"} target="_blank" rel="noopener noreferrer">Open Slack app settings</a>
<label className="flex items-center gap-2"><Checkbox checked={confirmed} onCheckedChange={value => setConfirmed(value === true)} disabled={busy} />I checked Slack and no app was created. Create a new app.</label>
</div>}
<SetupWizardFooter onSaveExit={onSaveExit} disabled={busy}>
{choices.isError || !choices.isPending && !choices.data?.managedAvailable ? <Button disabled={busy || choices.isFetching} onClick={() => void choices.refetch()}>Retry</Button> : <Button disabled={busy || disabled || creating || choices.isPending || !choices.data?.managedAvailable || (workspaces.length > 1 && !grantId) || (uncertain && !confirmed)} onClick={() => void run(connect)}>
{busy && <Loader2 className="size-4 animate-spin" />}{needsAuthorization && started ? "Reconnect Slack" : registration?.appId ? "Continue in Slack" : "Continue"}
</Button>}
</SetupWizardFooter>
</div>;
}
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from "react";
import { ExternalLink, Loader2 } from "lucide-react";
import { slackRegistrationErrorMessage } from "@paperclipai/shared";
import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { SetupWizardFooter } from "@/components/SetupWizard";
import { sanitizedSetupErrorMessage } from "../chat-setup-error";
import { SlackSetupAdvanced } from "../SlackAppDetails";
/** Uses ordinary request-local state: configuration tokens never enter a query/mutation cache. */
export function OwnSlackAppSetup({ endpoint, stage, disabled, saveDetails, onSaved, onBusy,
onManual, onManaged, onContinue, onSaveExit }: {
endpoint: ChatEndpoint;
stage: "app" | "credentials";
disabled: boolean;
saveDetails: () => Promise<void>;
onSaved: (endpoint: ChatEndpoint) => void;
onBusy: (busy: boolean) => void;
onManual: (existing: boolean) => Promise<void>;
onManaged?: () => Promise<void>;
onContinue: () => void;
onSaveExit: () => void;
}) {
const [configurationToken, setConfigurationToken] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [checkedNoApp, setCheckedNoApp] = useState(false);
const requestId = useRef(crypto.randomUUID());
const inFlight = useRef(false);
const pendingWindow = useRef<Window | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
pendingWindow.current?.close();
pendingWindow.current = null;
};
}, []);
const registration = endpoint.setup?.slackRegistration;
const uncertain = registration?.status === "uncertain";
const creating = registration?.status === "creating";
const created = Boolean(registration?.appId);
const configurationPending = registration?.errorCode === "slack_manifest_update_pending";
async function run(action: () => Promise<void>) {
if (inFlight.current) return;
inFlight.current = true;
setBusy(true);
onBusy(true);
setError(null);
try { await action(); }
catch (failure) { setError(sanitizedSetupErrorMessage(failure, { configurationToken })); }
finally {
setConfigurationToken("");
setBusy(false);
onBusy(false);
inFlight.current = false;
}
}
async function create() {
// Reserve the window during the click so async app creation cannot trigger a popup blocker.
const installWindow = window.open("about:blank", "_blank");
if (installWindow) {
installWindow.opener = null;
installWindow.document.title = "Preparing Slack installation";
installWindow.document.body.textContent = "Preparing your Slack installation…";
pendingWindow.current = installWindow;
}
let token = configurationToken.trim();
setConfigurationToken("");
try {
let next: ChatEndpoint;
try {
if (!created) await saveDetails();
if (registration?.status === "failed" || checkedNoApp) requestId.current = crypto.randomUUID();
setCheckedNoApp(false);
next = await chatEndpointsApi.createSlackApp(endpoint.id, {
requestId: requestId.current,
credentials: { configurationToken: token },
...(checkedNoApp ? { confirmedNoAppCreated: true } : {}),
});
} catch (failure) {
// A response can be lost after Slack creates the app. Only saved state can advance.
const current = await chatEndpointsApi.get(endpoint.id).catch(() => null);
if (current && mounted.current) onSaved(current);
if (!current?.setup?.slackRegistration?.appId) {
throw new Error(sanitizedSetupErrorMessage(failure, { configurationToken: token }));
}
next = current;
}
token = "";
if (!mounted.current) return;
onSaved(next);
const saved = next.setup?.slackRegistration;
if (!saved?.appId) return;
onContinue();
// Pending/uncertain configuration and existing installations must not start OAuth.
if (saved.status !== "install" || saved.errorCode) return;
if (!installWindow || installWindow.closed) {
setError("Select Install in Slack to open the installation page.");
return;
}
const result = await chatEndpointsApi.installSlackApp(endpoint.id);
if (!mounted.current) return;
if (installWindow.closed) {
setError("Select Install in Slack to reopen the installation page.");
return;
}
installWindow.location.replace(result.authorizationUrl);
pendingWindow.current = null;
} finally {
token = "";
pendingWindow.current?.close();
pendingWindow.current = null;
}
}
async function authorize() {
const result = await chatEndpointsApi.installSlackApp(endpoint.id);
// Current-tab navigation avoids popup blockers. The callback resumes this saved draft.
window.location.assign(result.authorizationUrl);
}
return <div className="space-y-5">
{error || registration?.errorCode ? <p role="alert" className="text-sm text-destructive">
{error ?? slackRegistrationErrorMessage(registration!.errorCode!)}
</p> : null}
{(stage === "app" && !created && !creating || configurationPending) && <>
{uncertain && <div className="space-y-3 text-sm">
<a href={registration?.managementUrl ?? "https://api.slack.com/apps"} target="_blank" rel="noopener noreferrer" className="underline underline-offset-4">Open Slack app settings <ExternalLink className="inline size-3" /></a>
<label className="flex items-center gap-2">
<Checkbox checked={checkedNoApp} disabled={busy} onCheckedChange={value => setCheckedNoApp(value === true)} />
I checked Slack and no app was created. Create a new app.
</label>
</div>}
{(!uncertain || checkedNoApp) && <div className="space-y-2">
<Button asChild size="lg" className="mb-4 h-auto w-full whitespace-normal py-4">
<a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer">Get your App configuration access token <ExternalLink className="size-4" /></a>
</Button>
<div id="slack-configuration-token-help" className="space-y-3 pb-3 text-sm">
<ol className="list-decimal space-y-2 pl-5">
<li>In Slack app settings, find <strong>Your App Configuration Tokens</strong> and choose <strong>Generate Token</strong>.</li>
<li>Select the workspace where you want to install your bot.</li>
<li>Copy the <strong>Access Token</strong> and paste it below.</li>
</ol>
<p className="text-muted-foreground">Paperclip uses this token to create your app</p>
</div>
<label htmlFor="slack-configuration-token" className="text-sm font-medium">App configuration access token</label>
<Input id="slack-configuration-token" type="password" autoComplete="off" spellCheck={false}
value={configurationToken} disabled={busy} onChange={event => setConfigurationToken(event.target.value)}
aria-describedby="slack-configuration-token-help" />
</div>}
</>}
{creating && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Creating your Slack app. You can return to this saved setup.</p>}
{created && <p className="text-sm">
<strong>{endpoint.setup?.slackApp?.appName ?? "Your Slack app"}</strong> is created.
{stage === "credentials" && " Approve installation with your Slack account. Paperclip will link it to your signed-in Paperclip account and send you a welcome DM."}
</p>}
{stage === "credentials" && registration?.status === "credentials_saved" && <div className="space-y-2">
<p className="text-sm text-muted-foreground">Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.</p>
<Button variant="link" className="h-auto p-0" disabled={busy || disabled} onClick={() => void run(authorize)}>Authorize in Slack again</Button>
</div>}
{stage === "app" && <SlackSetupAdvanced><div className="flex flex-wrap gap-4 text-sm">
{onManaged && !registration && <Button variant="link" className="h-auto p-0" disabled={busy} onClick={() => void run(onManaged)}>Use managed setup</Button>}
<Button variant="link" className="h-auto p-0" disabled={busy || creating} onClick={() => void run(() => onManual(false))}>Create manually</Button>
<Button variant="link" className="h-auto p-0" disabled={busy || creating} onClick={() => void run(() => onManual(true))}>Use an existing app</Button>
</div></SlackSetupAdvanced>}
<SetupWizardFooter onSaveExit={onSaveExit} disabled={busy}>
{configurationPending ? <Button disabled={busy || disabled || !configurationToken.trim()} onClick={() => void run(create)}>
{busy && <Loader2 className="size-4 animate-spin" />}Retry app configuration
</Button> : stage === "app" ? <Button disabled={busy || disabled || creating || (!created && (!configurationToken.trim() || uncertain && !checkedNoApp))}
onClick={() => created ? onContinue() : void run(create)}>
{busy && <Loader2 className="size-4 animate-spin" />}{created ? "Continue to installation" : "Create Slack app"}
</Button> : registration?.status === "credentials_saved" ? <Button disabled={busy} onClick={() => void run(async () => onSaved(await chatEndpointsApi.resumeSlackInstallation(endpoint.id)))}>
{busy && <Loader2 className="size-4 animate-spin" />}Retry connecting
</Button> : <Button disabled={busy || !created || disabled} onClick={() => void run(authorize)}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <ExternalLink className="size-4" />}Install in Slack
</Button>}
</SetupWizardFooter>
</div>;
}
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import type { ComponentProps } from "react";
import { chatEndpointsApi } from "@/api/chatEndpoints";
import { OwnSlackAppSetup } from "./OwnSlackAppSetup";
import { ManagedSlackSetup } from "./ManagedSlackSetup";
export function SlackSetup(props: ComponentProps<typeof OwnSlackAppSetup> & { autoStart?: boolean; reconnect?: boolean }) {
const choices = useQuery({ queryKey: ["slack-managed-workspaces", props.endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(props.endpoint.companyId), retry: false });
return props.endpoint.setup?.slackSetupMethod === "managed"
? <ManagedSlackSetup {...props} />
: <OwnSlackAppSetup {...props} onManaged={choices.data?.defaultMethod === "managed" && !props.endpoint.setup?.slackRegistration ? async () => props.onSaved(await chatEndpointsApi.update(props.endpoint.id, { slackSetupMethod: "managed" })) : undefined} />;
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import type { ChatEndpoint } from "@/api/chatEndpoints";
import { slackSetupState } from "./setup-state";
const draft = (method?: "managed" | "automatic" | "manual" | "existing") => ({ id: "endpoint", setup: { step: "provider_setup", slackSetupMethod: method } }) as ChatEndpoint;
const state = (endpoint: ChatEndpoint | null, extra = {}) => slackSetupState({ endpoint, repairing: false, credentialsReady: false, identityReady: false, ...extra });
describe("Slack setup capabilities and progress", () => {
it("does not flash token instructions while discovery is pending", () => {
expect(state(null).labels).toEqual(["Choose agent", "Connect"]);
});
it("defaults only new drafts and preserves legacy manual drafts", () => {
expect(state(null, { defaultMethod: "managed" }).labels).toEqual(["Choose agent", "Connect"]);
expect(state(draft(), { defaultMethod: "managed" }).provisioned).toBe(false);
expect(state(draft("automatic"), { defaultMethod: "managed" }).labels).toHaveLength(4);
});
it("keeps managed provider operations within the two visible stops", () => {
const endpoint = draft("managed");
endpoint.setup!.slackRegistration = { status: "install", appId: "AAPP", managementUrl: "https://api.slack.com/apps/AAPP" };
expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1);
endpoint.setup!.slackRegistration.status = "credentials_saved";
expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1);
endpoint.providerAccountId = "TWORKSPACE";
endpoint.setup!.slackRegistration.status = "configured";
endpoint.setup!.slackAccount = { status: "linked", externalUserId: "UPERSON", paperclipUserId: "person", welcomeStatus: "sent" };
expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1);
endpoint.setup!.step = "test";
expect(state(endpoint).complete).toBe(true);
expect(state(endpoint).toStage(1)).toBe(3);
expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1);
});
});
@@ -0,0 +1,30 @@
import type { ChatEndpoint } from "@/api/chatEndpoints";
import { isProvisionedSlackSetup } from "@paperclipai/shared";
export { isProvisionedSlackSetup };
export function slackSetupNavigation(method: string | undefined, availableStage = 1) {
const managed = method === "managed";
return {
labels: managed ? ["Choose agent", "Connect"] : isProvisionedSlackSetup(method)
? ["Choose agent", "App configuration access token", "Install Slack app", "Send a message"]
: ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Connect your Slack account", "Try it"],
toVisible: (step: number) => managed ? Math.min(step, 1) : step,
toStage: (step: number) => managed && step === 1 ? availableStage : step,
};
}
/** Logical stages stay compatible with existing draft URLs; navigation is derived here. */
export function slackSetupState(input: { endpoint: ChatEndpoint | null; repairing: boolean; credentialsReady: boolean; identityReady: boolean; defaultMethod?: string }) {
const { endpoint, repairing, credentialsReady, identityReady } = input;
const method = endpoint ? endpoint.setup?.slackSetupMethod : input.defaultMethod ?? "managed";
const provisioned = isProvisionedSlackSetup(method);
const complete = provisioned && (endpoint?.setup?.step === "test" || endpoint?.setup?.step === "complete");
const finalStage = provisioned ? 3 : 5;
const availableStage = !endpoint ? 0
: provisioned && endpoint.setup?.slackRegistration?.status === "configured" && endpoint.setup.slackAccount?.status !== "linked" && endpoint.setup.step !== "complete" ? 2
: !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete")
? endpoint.setup.step !== "complete" && !provisioned && !identityReady ? 4 : finalStage
: provisioned && endpoint.setup?.slackRegistration?.status === "credentials_saved" ? 2
: endpoint.providerAccountId && !repairing ? 3
: credentialsReady || repairing || method === "existing" || provisioned && endpoint.setup?.slackRegistration?.appId ? 2 : 1;
return { ...slackSetupNavigation(method, availableStage), provisioned, complete, finalStage, availableStage };
}