mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
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:
commit
48ffca9438
415 files changed
+22501
-15340
No files matched your search
@@ -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) =>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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" }),
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
+104
-418
File diff suppressed because it is too large.
Load diff
@@ -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 () => {
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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'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'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 +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";
|
||||
@@ -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,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 };
|
||||
}
|
||||
@@ -850,6 +850,73 @@ const preview: Preview = {
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
options: {
|
||||
storySort: {
|
||||
order: [
|
||||
"Overview",
|
||||
"Getting started and access",
|
||||
[
|
||||
"Onboarding and first work",
|
||||
"Login, invitations, and access",
|
||||
"Companies and portability"
|
||||
],
|
||||
"Tasks and conversations",
|
||||
[
|
||||
"Task creation and lifecycle",
|
||||
"Delegation, dependencies, and signoff",
|
||||
"Inbox, decisions, and search",
|
||||
"Agent conversations and project handoff",
|
||||
"Questions and approvals",
|
||||
"Steering and queued messages",
|
||||
"Documents, attachments, and work products"
|
||||
],
|
||||
"Agents and reusable capabilities",
|
||||
[
|
||||
"Hiring, configuration, and organization",
|
||||
"Runs, harnesses, and model accounts",
|
||||
"Skills and Skill Studio",
|
||||
"Team packages and installation"
|
||||
],
|
||||
"Projects and execution",
|
||||
[
|
||||
"Projects and repositories",
|
||||
"Goals and work alignment",
|
||||
"Workspaces, services, and files",
|
||||
"Execution environments",
|
||||
"Recovery"
|
||||
],
|
||||
"Apps, channels, and extensions",
|
||||
[
|
||||
"Connection setup",
|
||||
"App access and action permissions",
|
||||
"External chat and email",
|
||||
"Tool gateways and access profiles",
|
||||
"Secrets and proposals",
|
||||
"Plugins"
|
||||
],
|
||||
"Automation and structured work",
|
||||
[
|
||||
"Routines, schedules, and triggers",
|
||||
"Pipelines, review queues, and learnings",
|
||||
"Cases",
|
||||
"Status cards"
|
||||
],
|
||||
"Oversight and operation",
|
||||
[
|
||||
"Costs and budgets",
|
||||
"Dashboards and audit trails",
|
||||
"Navigation, profile, and announcements",
|
||||
"Instance operations",
|
||||
"CLI/API and local worktrees"
|
||||
],
|
||||
"Contributor surfaces",
|
||||
[
|
||||
"Developer previews and diagnostic labs"
|
||||
],
|
||||
"Foundations"
|
||||
],
|
||||
},
|
||||
},
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
a11y: {
|
||||
test: "error",
|
||||
|
||||
@@ -7,10 +7,10 @@ import { type ChatEndpoint } from "@/api/chatEndpoints";
|
||||
import { storybookAgents, storybookAuthSession } from "./paperclipData";
|
||||
import { defaultSlackAppConfiguration } from "@paperclipai/shared";
|
||||
|
||||
export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success";
|
||||
export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "approval_denied" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success";
|
||||
|
||||
/** Production wizard over a local provider fixture. No requests go to Slack. */
|
||||
export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSetupScenario }) {
|
||||
export function SlackSetupFixture({ scenario = "create", managed = false, managedAvailable = true, workspaceCount = 1, optionsDelayMs = 0, optionsError = false }: { scenario?: SlackSetupScenario; managed?: boolean; managedAvailable?: boolean; workspaceCount?: number; optionsDelayMs?: number; optionsError?: boolean }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [signalVerification, setSignalVerification] = useState<() => void>(() => () => {});
|
||||
useLayoutEffect(() => {
|
||||
@@ -20,14 +20,16 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet
|
||||
const endpoint: ChatEndpoint = {
|
||||
id: "slack-story", companyId: "company-storybook", provider: "slack", status: "draft",
|
||||
assignedAgentId: agent.id, assignedAgentName: agent.name, allowUnlinkedPeople: false,
|
||||
setup: { step: "provider_setup", slackSetupMethod: scenario === "manual" ? "manual" : "automatic",
|
||||
setup: { step: "provider_setup", slackSetupMethod: scenario === "manual" ? "manual" : managed ? "managed" : "automatic",
|
||||
slackApp: defaultSlackAppConfiguration(agent.name),
|
||||
webhookUrl: "https://ingress.example/api/chat-webhooks/slack-story/slack",
|
||||
slackOAuthCallbackUri: "https://board.example/api/chat-slack/oauth/callback",
|
||||
},
|
||||
};
|
||||
const grantId = "11111111-1111-4111-8111-111111111111";
|
||||
let authorized = workspaceCount > 0 || new URLSearchParams(window.location.search).get("slackFixtureAuthorized") === "1";
|
||||
const created = () => {
|
||||
endpoint.setup!.slackRegistration = { status: "install", appId: "ASTORY", managementUrl: "https://api.slack.com/apps/ASTORY" };
|
||||
endpoint.setup!.slackRegistration = { status: "install", ...(managed ? { managerGrantId: grantId } : {}), appId: "ASTORY", managementUrl: "https://api.slack.com/apps/ASTORY" };
|
||||
endpoint.setup!.slackAvatar = scenario === "avatar_failed" ? { status: "failed", errorCode: "slack_avatar_upload_failed" } : { status: "uploaded", uploadedAt: new Date().toISOString() };
|
||||
};
|
||||
let linked = false;
|
||||
@@ -37,11 +39,12 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet
|
||||
endpoint.setup!.slackAccount = { externalUserId: "UPERSON", paperclipUserId: storybookAuthSession.user.id, status: "linked", welcomeStatus: scenario === "welcome_failed" ? "failed" : "sent", ...(scenario === "welcome_failed" ? {} : { dmChannelId: "DSTORY" }) };
|
||||
endpoint.status = "verifying"; endpoint.providerAccountId = "TSTORY"; endpoint.botExternalId = "USTORY"; endpoint.botUsername = "maya";
|
||||
};
|
||||
if (["install", "declined", "recovery", "manifest_pending"].includes(scenario)) created();
|
||||
if (["install", "declined", "approval_denied", "recovery", "manifest_pending"].includes(scenario)) created();
|
||||
if (scenario === "manifest_pending") endpoint.setup!.slackRegistration!.errorCode = "slack_manifest_update_pending";
|
||||
if (scenario === "declined") endpoint.setup!.slackRegistration!.errorCode = "slack_install_declined";
|
||||
if (scenario === "declined") endpoint.setup!.slackRegistration!.errorCode = managed ? "slack_approval_pending" : "slack_install_declined";
|
||||
if (scenario === "approval_denied") endpoint.setup!.slackRegistration!.errorCode = "slack_approval_denied";
|
||||
if (scenario === "recovery") endpoint.setup!.slackRegistration = { ...endpoint.setup!.slackRegistration!, status: "credentials_saved", errorCode: "slack_configuration_incomplete" };
|
||||
if (scenario === "uncertain") endpoint.setup!.slackRegistration = { status: "uncertain", errorCode: "slack_creation_uncertain", managementUrl: "https://api.slack.com/apps" };
|
||||
if (scenario === "uncertain") endpoint.setup!.slackRegistration = { status: "uncertain", ...(managed ? { managerGrantId: grantId } : {}), errorCode: "slack_creation_uncertain", managementUrl: "https://api.slack.com/apps" };
|
||||
if (["verify", "avatar_failed", "welcome_failed", "success"].includes(scenario)) installed();
|
||||
if (["avatar_failed", "welcome_failed", "success"].includes(scenario)) {
|
||||
endpoint.setup!.webhookVerifiedAt = new Date().toISOString();
|
||||
@@ -52,12 +55,26 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet
|
||||
window.fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin);
|
||||
const path = url.pathname;
|
||||
if (path === "/api/companies/company-storybook/chat-slack/setup-options") {
|
||||
if (optionsDelayMs) await new Promise(resolve => setTimeout(resolve, optionsDelayMs));
|
||||
if (optionsError) return Response.json({ error: "Slack setup unavailable" }, { status: 503 });
|
||||
return Response.json({ managedAvailable: managed && managedAvailable, defaultMethod: managed ? "managed" : "automatic", workspaces: managed && authorized ? Array.from({ length: Math.max(1, workspaceCount) }, (_, i) => ({ grantId: i ? "22222222-2222-4222-8222-222222222222" : grantId, workspaceId: `TSTORY${i}`, workspaceName: i ? "Research workspace" : "Paperclip", userId: "UPERSON" })) : [] });
|
||||
}
|
||||
if (path === "/api/chat-endpoints/slack-story/slack/managed/authorize") {
|
||||
authorized = true;
|
||||
const returnUrl = new URL(window.location.href);
|
||||
returnUrl.hash = "";
|
||||
returnUrl.searchParams.set("slackFixtureAuthorized", "1");
|
||||
return Response.json({ authorizationUrl: returnUrl.toString(), expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
||||
}
|
||||
if (path === "/api/chat-endpoints/slack-story/slack/managed/provision") { installed(); return Response.json({}); }
|
||||
if (path === "/api/companies/company-storybook/agents") return Response.json(agents);
|
||||
if (path === `/api/agents/${agent.id}`) return Response.json(agent);
|
||||
if (path === "/api/companies/company-storybook/chat-endpoints") {
|
||||
const input = JSON.parse(String(init?.body ?? "{}"));
|
||||
agent = agents.find(value => value.id === input.assignedAgentId) ?? agent;
|
||||
endpoint.assignedAgentId = agent.id; endpoint.assignedAgentName = agent.name;
|
||||
endpoint.setup!.slackSetupMethod = input.slackSetupMethod ?? (managed ? "managed" : "automatic");
|
||||
endpoint.setup!.slackApp = input.slackApp ?? defaultSlackAppConfiguration(agent.name);
|
||||
return Response.json(endpoint);
|
||||
}
|
||||
@@ -94,7 +111,7 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet
|
||||
};
|
||||
setReady(true);
|
||||
return () => { window.fetch = original; };
|
||||
}, [scenario]);
|
||||
}, [scenario, managed, managedAvailable, workspaceCount, optionsDelayMs, optionsError]);
|
||||
return <div className="space-y-6 p-6">
|
||||
<aside className="flex flex-wrap items-center gap-3 rounded-lg border border-border p-3 text-sm text-muted-foreground">
|
||||
Preview fixture: Slack consent, callbacks, and identity discovery are simulated.
|
||||
|
||||
@@ -1010,7 +1010,7 @@ export const storybookContinuationHandoff: IssueDocument = {
|
||||
"",
|
||||
"Next action: run the Storybook build, inspect the issue management story, then request QA visual review if the build passes.",
|
||||
"",
|
||||
"Important files: `ui/storybook/stories/issue-management.stories.tsx` and `ui/storybook/fixtures/paperclipData.ts`.",
|
||||
"Important files: `ui/storybook/stories/tasks/issue-management.stories.tsx` and `ui/storybook/fixtures/paperclipData.ts`.",
|
||||
].join("\n"),
|
||||
latestRevisionId: "revision-continuation-1",
|
||||
latestRevisionNumber: 1,
|
||||
|
||||
@@ -9,7 +9,7 @@ pnpm --filter @paperclipai/ui exec storybook dev --port 6010 --host 127.0.0.1 --
|
||||
## Connector catalog chips
|
||||
|
||||
**Apps → Catalog source selection → Chips** is the focused catalog preview.
|
||||
The four text chips are **Paperclip**, **Composio**, **Installed**, and **All**.
|
||||
The five text chips are **Paperclip**, **Composio**, **Arcade**, **Installed**, and **All**.
|
||||
The selected chip has a neutral pill background; the other choices are plain text.
|
||||
Connected accounts stay above discovery. Installed shows only saved native and
|
||||
Composio apps. All includes every catalog source, including Arcade. Searching
|
||||
|
||||
+2
-1
@@ -265,7 +265,8 @@ function Overview() {
|
||||
);
|
||||
}
|
||||
export default {
|
||||
title: "Private tasks/00 Review guide",
|
||||
id: "private-tasks-00-review-guide",
|
||||
title: "Getting started and access/Login, invitations, and access/Private tasks/00 Review guide",
|
||||
parameters: { layout: "fullscreen", docs: { story: { inline: false } } },
|
||||
render: () => <Overview />,
|
||||
} satisfies Meta;
|
||||
+3
-2
@@ -10,7 +10,7 @@ import {
|
||||
privacyDecorator,
|
||||
privacyParameters,
|
||||
StoryFrame,
|
||||
} from "./PrivacyStory";
|
||||
} from "../../../support/private-tasks/PrivacyStory";
|
||||
|
||||
function Creation({
|
||||
parent = false,
|
||||
@@ -49,7 +49,8 @@ function Creation({
|
||||
return <PrivacyPage tasks />;
|
||||
}
|
||||
const meta = {
|
||||
title: "Private tasks/01 Creation",
|
||||
id: "private-tasks-01-creation",
|
||||
title: "Getting started and access/Login, invitations, and access/Private tasks/01 Creation",
|
||||
decorators: [privacyDecorator],
|
||||
parameters: { ...privacyParameters, waitForViewport: true, docs: { description: { component: "Route /PAP/issues with the new-task composer. Choose Private task from +; the lock chip keeps the selection visible. Hover, focus, or tap an inherited chip to see the parent task or private project that requires privacy. Mobile stories use the real shell and check that menu actions and composer controls stay inside the viewport." } } },
|
||||
args: { taskTitle: "Prepare my board briefing" },
|
||||
+4
-3
@@ -3,14 +3,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { IssueShareSheet } from "@/components/IssueShareSheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { privacyCompanyId } from "../../fixtures/privateTasks";
|
||||
import { privacyCompanyId } from "../../../fixtures/privateTasks";
|
||||
import {
|
||||
mobile,
|
||||
privacyDecorator,
|
||||
privacyParameters,
|
||||
StoryFrame,
|
||||
usePrivacyStory,
|
||||
} from "./PrivacyStory";
|
||||
} from "../../../support/private-tasks/PrivacyStory";
|
||||
|
||||
function Sharing({
|
||||
add = "",
|
||||
@@ -69,7 +69,8 @@ function Sharing({
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
title: "Private tasks/02 Sharing",
|
||||
id: "private-tasks-02-sharing",
|
||||
title: "Getting started and access/Login, invitations, and access/Private tasks/02 Sharing",
|
||||
decorators: [privacyDecorator],
|
||||
parameters: { ...privacyParameters, docs: { description: { component: "Task menu → Share → Add someone. Inspect avatars in search and selected values, inherited grants, reader access, confirmations, empty/loading/error states and mobile. The sticky-access paragraph is removed." } } },
|
||||
render: () => <Sharing />,
|
||||
+4
-3
@@ -4,14 +4,14 @@ import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { IssuePrivacyActions } from "@/components/IssuePrivacyActions";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { privacyCompanyId } from "../../fixtures/privateTasks";
|
||||
import { privacyCompanyId } from "../../../fixtures/privateTasks";
|
||||
import {
|
||||
mobile,
|
||||
privacyDecorator,
|
||||
privacyParameters,
|
||||
StoryFrame,
|
||||
usePrivacyStory,
|
||||
} from "./PrivacyStory";
|
||||
} from "../../../support/private-tasks/PrivacyStory";
|
||||
|
||||
function PrivacyActionsStory({ child = false }: { child?: boolean }) {
|
||||
const state = usePrivacyStory();
|
||||
@@ -56,7 +56,8 @@ function PrivacyActionsStory({ child = false }: { child?: boolean }) {
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
title: "Private tasks/03 Task actions",
|
||||
id: "private-tasks-03-task-actions",
|
||||
title: "Getting started and access/Login, invitations, and access/Private tasks/03 Task actions",
|
||||
decorators: [privacyDecorator],
|
||||
parameters: { ...privacyParameters, docs: { description: { component: "Task menu privacy actions, permission gates and disclosure confirmation." } } },
|
||||
render: () => <PrivacyActionsStory />,
|
||||
+3
-2
@@ -13,7 +13,7 @@ import {
|
||||
privacyDecorator,
|
||||
privacyParameters,
|
||||
StoryFrame,
|
||||
} from "./PrivacyStory";
|
||||
} from "../../../support/private-tasks/PrivacyStory";
|
||||
|
||||
const locked = {
|
||||
id: "privacy-root",
|
||||
@@ -110,7 +110,8 @@ function References({
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
title: "Private tasks/04 References and blockers",
|
||||
id: "private-tasks-04-references-and-blockers",
|
||||
title: "Getting started and access/Login, invitations, and access/Private tasks/04 References and blockers",
|
||||
decorators: [privacyDecorator],
|
||||
parameters: { ...privacyParameters, docs: { description: { component: "Task references, parent navigation and blockers redact unreadable task content. Inspect all existing reference variants and mobile." } } },
|
||||
render: () => <References />,
|
||||
+3
-2
@@ -14,7 +14,7 @@ import {
|
||||
privacyParameters,
|
||||
StoryFrame,
|
||||
usePrivacyStory,
|
||||
} from "./PrivacyStory";
|
||||
} from "../../../support/private-tasks/PrivacyStory";
|
||||
|
||||
function ProjectStory({
|
||||
settings = false,
|
||||
@@ -76,7 +76,8 @@ const openMembers = async (canvasElement: HTMLElement) => {
|
||||
return page;
|
||||
};
|
||||
const meta = {
|
||||
title: "Private tasks/05 Projects",
|
||||
id: "private-tasks-05-projects",
|
||||
title: "Getting started and access/Login, invitations, and access/Private tasks/05 Projects",
|
||||
decorators: [privacyDecorator],
|
||||
parameters: { ...privacyParameters, docs: { description: { component: "Project settings → Manage access. Human and agent identities appear in the search dropdown and selected value. Owner, reader, error, loading and mobile states use production components." } } },
|
||||
args: { projectName: "Executive planning" },
|
||||
+3
-2
@@ -9,7 +9,7 @@ import { ProjectDetail } from "@/pages/ProjectDetail";
|
||||
import { Projects } from "@/pages/Projects";
|
||||
import { DesignGuide } from "@/pages/DesignGuide";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { mobile, privacyDecorator, privacyParameters } from "./PrivacyStory";
|
||||
import { mobile, privacyDecorator, privacyParameters } from "../../../support/private-tasks/PrivacyStory";
|
||||
|
||||
export function PrivacyPage({
|
||||
child = false,
|
||||
@@ -51,7 +51,8 @@ export function PrivacyPage({
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
title: "Private tasks/06 Full product pages",
|
||||
id: "private-tasks-06-full-product-pages",
|
||||
title: "Getting started and access/Login, invitations, and access/Private tasks/06 Full product pages",
|
||||
excludeStories: ["PrivacyPage"],
|
||||
decorators: [privacyDecorator],
|
||||
parameters: { ...privacyParameters, waitForViewport: true },
|
||||
+3
-2
@@ -16,12 +16,13 @@ import {
|
||||
privacyDecorator,
|
||||
privacyParameters,
|
||||
mobile,
|
||||
} from "./PrivacyStory";
|
||||
} from "../../../support/private-tasks/PrivacyStory";
|
||||
|
||||
// Reuse the same real-component canvases. Each play is a complete user journey,
|
||||
// while the component stories deliberately stop at individual review states.
|
||||
const meta = {
|
||||
title: "Private tasks/07 User stories",
|
||||
id: "private-tasks-07-user-stories",
|
||||
title: "Getting started and access/Login, invitations, and access/Private tasks/07 User stories",
|
||||
decorators: [privacyDecorator],
|
||||
parameters: { ...privacyParameters, waitForViewport: true },
|
||||
} satisfies Meta;
|
||||
+2
-1
@@ -154,7 +154,8 @@ function Seeded({ events }: { events: ToolGatewayActivityEvent[] }) {
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Tools/Activity (audit feed)",
|
||||
id: "tools-activity-audit-feed",
|
||||
title: "Oversight and operation/Dashboards and audit trails/Tools/Activity (audit feed)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
+2
-1
@@ -122,7 +122,8 @@ function Panel(props: ActivityPanelProps) {
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Apps/App detail · Activity (lifecycle)",
|
||||
id: "apps-app-detail-·-activity-lifecycle",
|
||||
title: "Oversight and operation/Dashboards and audit trails/Apps/App detail · Activity (lifecycle)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
+3
-2
@@ -50,7 +50,7 @@ import {
|
||||
storybookAgents,
|
||||
storybookIssues,
|
||||
storybookLiveRuns,
|
||||
} from "../fixtures/paperclipData";
|
||||
} from "../../fixtures/paperclipData";
|
||||
|
||||
const companyId = "company-storybook";
|
||||
const primaryIssueId = "issue-storybook-1";
|
||||
@@ -642,7 +642,8 @@ function PageSkeletonMatrix() {
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Data Visualization & Misc",
|
||||
id: "product-data-visualization-misc",
|
||||
title: "Oversight and operation/Dashboards and audit trails/Product/Data Visualization & Misc",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
+4
-3
@@ -12,8 +12,8 @@ import {
|
||||
} from "@/components/timeline/WorkTimelineChart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import sampleJson from "../fixtures/workTimeline.sample.json";
|
||||
import humanSampleJson from "../fixtures/workTimeline.human.sample.json";
|
||||
import sampleJson from "../../fixtures/workTimeline.sample.json";
|
||||
import humanSampleJson from "../../fixtures/workTimeline.human.sample.json";
|
||||
|
||||
const COMPANY_ID = "company-storybook";
|
||||
const STORYBOOK_USER_AVATAR =
|
||||
@@ -159,7 +159,8 @@ function TimelineHarness({
|
||||
}
|
||||
|
||||
const meta: Meta<typeof TimelineHarness> = {
|
||||
title: "Pages/Work Timeline",
|
||||
id: "pages-work-timeline",
|
||||
title: "Oversight and operation/Dashboards and audit trails/Pages/Work Timeline",
|
||||
component: TimelineHarness,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
+3
-2
@@ -1,9 +1,10 @@
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgentChatPrototype } from "../prototypes/agent-chat/AgentChatPrototype";
|
||||
import { AgentChatPrototype } from "../../prototypes/agent-chat/AgentChatPrototype";
|
||||
|
||||
const meta = {
|
||||
title: "Design explorations/Agent chat sidebar/Pages",
|
||||
id: "design-explorations-agent-chat-sidebar-pages",
|
||||
title: "Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Pages",
|
||||
component: AgentChatPrototype,
|
||||
parameters: { layout: "fullscreen", docs: { description: { component:
|
||||
"The production chat sidebar and pages inside the real Paperclip Layout, including the existing transcript, composer, and context panel. Click Chat to see the landing page, search by name or role, or use + to start or reopen an agent conversation. Each agent has one conversation; sends stay in this Storybook's in-memory fixtures."
|
||||
+4
-3
@@ -1,15 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgentChatPrototype } from "../prototypes/agent-chat/AgentChatPrototype";
|
||||
import { AgentChatPrototype } from "../../prototypes/agent-chat/AgentChatPrototype";
|
||||
|
||||
const meta = {
|
||||
title: "Design explorations/Agent chat",
|
||||
id: "design-explorations-agent-chat",
|
||||
title: "Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat",
|
||||
component: AgentChatPrototype,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Uses the actual production Layout (Sidebar, BreadcrumbBar, PropertiesPanel), TaskChatThread (TaskChatComposer, harness activity, thinking/tool disclosures, responses), and TaskSidePanel (plans, artifacts, subtasks). Agent shortcuts compose the existing sidebar rows and sections: starred agents first, the first-created agent as a default, then recent conversations. The compose button opens a searchable picker for every agent, and the gear link opens agent configuration. The task surfaces differ only in breadcrumb/title and initially open panel tabs. All data is fixture data; sends append locally and unsupported mutations fail explicitly.",
|
||||
"Production Layout, AgentChat, IssueDetail, transcript and side-panel components run against in-memory routes. The landing page is a local ChatEntryLanding composition; first-use/default-agent behavior is not production acceptance. Sends append locally. Some unhandled API paths return empty fixture data, so this suite does not verify API contracts or primary-agent preference behavior.",
|
||||
},
|
||||
},
|
||||
},
|
||||
+4
-3
@@ -1,8 +1,8 @@
|
||||
import { userEvent, within, expect } from "storybook/test";
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgentConversationSidebar } from "../prototypes/agent-chat-sidebar/AgentConversationSidebar";
|
||||
import { sidebarAgents, sidebarPreviews, sidebarRoster } from "../prototypes/agent-chat-sidebar/fixtures";
|
||||
import { AgentConversationSidebar } from "../../prototypes/agent-chat-sidebar/AgentConversationSidebar";
|
||||
import { sidebarAgents, sidebarPreviews, sidebarRoster } from "../../prototypes/agent-chat-sidebar/fixtures";
|
||||
|
||||
function InteractiveSidebar(args: React.ComponentProps<typeof AgentConversationSidebar>) {
|
||||
const [activeId, setActiveId] = useState(args.activeId);
|
||||
@@ -14,7 +14,8 @@ function InteractiveSidebar(args: React.ComponentProps<typeof AgentConversationS
|
||||
return <div className="h-dvh w-60 bg-background text-foreground"><AgentConversationSidebar {...args} agents={agents} activeId={activeId} onSelect={selectAgent} onAddChat={selectAgent} onBrowse={undefined} /></div>;
|
||||
}
|
||||
const meta = {
|
||||
title: "Design explorations/Agent chat sidebar/Components",
|
||||
id: "design-explorations-agent-chat-sidebar-components",
|
||||
title: "Tasks and conversations/Agent conversations and project handoff/Design explorations/Agent chat sidebar/Components",
|
||||
component: AgentConversationSidebar,
|
||||
parameters: { layout: "fullscreen", docs: { description: { component:
|
||||
"Secondary agent navigation at the same width as the app's other contextual sidebars. Uses production AgentAvatar, Input, and Button. The entire row is a link; selection and search are interactive. Search matches agent name, title, and role. Escape clears the query. Browse all agents links to the existing agent directory in the page stories."
|
||||
+4
-3
@@ -1,12 +1,13 @@
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgentChatPrototype } from "../prototypes/agent-chat/AgentChatPrototype";
|
||||
import { AgentChatPrototype } from "../../prototypes/agent-chat/AgentChatPrototype";
|
||||
|
||||
const meta = {
|
||||
title: "Design explorations/Chat entry",
|
||||
id: "design-explorations-chat-entry",
|
||||
title: "Tasks and conversations/Agent conversations and project handoff/Design explorations/Chat entry",
|
||||
component: AgentChatPrototype,
|
||||
parameters: { layout: "fullscreen", docs: { description: { component:
|
||||
"Review-only proposal: Chats contains starred and recent conversations, with the first-created agent always present as a default entry. First use uses the same compact rows as returning chats. The aligned compose icon appears on hover or keyboard focus (and stays visible on touch). The picker shows search and agent results with roles and paused status. The sidebar, picker, layout, transcript, and composer are production components. Only the first-use landing page and data are Storybook fixtures. Selecting and sending use in-memory fixtures; no real agents run."
|
||||
"Historical chat-entry exploration: the local sidebar and landing composition keeps a first-created-agent default. The current product uses the personal primary-agent preference and recent-conversation rules. Shared layout, picker and conversation components remain useful here, but these entry/default rules are retained design history. Selecting and sending use in-memory fixtures; no real agents run. Use Primary Agent journeys for current default-selection examples."
|
||||
} } },
|
||||
args: { entryScenario: "returning", scenario: "returning", contextInitiallyOpen: false },
|
||||
render: (args) => <AgentChatPrototype key={JSON.stringify(args)} {...args} />,
|
||||
+3
-2
@@ -11,7 +11,7 @@ import type {
|
||||
import { AgentDetail } from "@/pages/AgentDetail";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { storybookAgentMap, storybookAgents, storybookIssues } from "../fixtures/paperclipData";
|
||||
import { storybookAgentMap, storybookAgents, storybookIssues } from "../../fixtures/paperclipData";
|
||||
|
||||
const COMPANY_ID = "company-storybook";
|
||||
const AGENT_ID = "agent-codex";
|
||||
@@ -190,7 +190,8 @@ function AgentDetailScenario() {
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Pages/Agent Detail",
|
||||
id: "pages-agent-detail",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Pages/Agent Detail",
|
||||
parameters: { layout: "fullscreen", a11y: { test: "off" } },
|
||||
};
|
||||
export default meta;
|
||||
+3
-2
@@ -6,7 +6,7 @@ import type { AgentInstructionsFileDetail } from "@paperclipai/shared";
|
||||
import { AgentFileRunNotice, PromptsTab } from "@/pages/AgentDetail";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { storybookAgents } from "../fixtures/paperclipData";
|
||||
import { storybookAgents } from "../../fixtures/paperclipData";
|
||||
|
||||
const agent = { ...storybookAgents[0]!, id: "agent-files-story", adapterType: "codex_local" as const };
|
||||
const root = "/instance/agents/agent-files-story/instructions";
|
||||
@@ -106,7 +106,8 @@ function AgentFilesStory({ failedSync = false, fullStorageRun = false, historica
|
||||
</div>
|
||||
</QueryClientProvider>;
|
||||
}
|
||||
const meta = { title: "Agents/Persistent files", component: AgentFilesStory, parameters: { layout: "fullscreen" } } satisfies Meta<typeof AgentFilesStory>;
|
||||
const meta = { id: "agents-persistent-files",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Persistent files", component: AgentFilesStory, parameters: { layout: "fullscreen" } } satisfies Meta<typeof AgentFilesStory>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const InstructionsAndFiles: Story = {};
|
||||
+3
-2
@@ -38,7 +38,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { storybookAgents, storybookIssues } from "../fixtures/paperclipData";
|
||||
import { storybookAgents, storybookIssues } from "../../fixtures/paperclipData";
|
||||
|
||||
const COMPANY_ID = "company-storybook";
|
||||
const now = new Date("2026-04-20T12:00:00.000Z");
|
||||
@@ -819,7 +819,8 @@ function AgentManagementStories() {
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Agent Management",
|
||||
id: "product-agent-management",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Product/Agent Management",
|
||||
component: AgentManagementStories,
|
||||
parameters: {
|
||||
docs: {
|
||||
+3
-2
@@ -14,7 +14,7 @@ import { NewAgent } from "@/pages/NewAgent";
|
||||
import { AgentBasicsDialog } from "@/components/new-agent/AgentBasicsDialog";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { resolveAgentAppearance } from "@paperclipai/shared";
|
||||
import { storybookAgents, storybookIssues, storybookActivityEvents, storybookLiveRuns, storybookDashboardSummary } from "../fixtures/paperclipData";
|
||||
import { storybookAgents, storybookIssues, storybookActivityEvents, storybookLiveRuns, storybookDashboardSummary } from "../../fixtures/paperclipData";
|
||||
|
||||
const companyId = "company-storybook";
|
||||
const agents = storybookAgents.map(agent => {
|
||||
@@ -109,7 +109,8 @@ function PersonaPage({ path, meet = false }: { path: string; meet?: boolean }) {
|
||||
{meet && <AgentBasicsDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onContinue={basics => { setDialogOpen(false); navigate(`/PAP/agents/new?name=${encodeURIComponent(basics.name)}&adapterType=${basics.adapterType}`); }} />}
|
||||
</PluginLauncherProvider>;
|
||||
}
|
||||
const meta = { title: "Agents/Personas/Full pages", parameters: { layout: "fullscreen", a11y: { test: "off" } } } satisfies Meta;
|
||||
const meta = { id: "agents-personas-full-pages",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas/Full pages", parameters: { layout: "fullscreen", a11y: { test: "off" } } } satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
export const AllAgents: Story = { render: () => <PersonaPage path="/PAP/agents/all" /> };
|
||||
+6
-5
@@ -3,15 +3,16 @@ import { expect, waitFor } from "storybook/test";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette, type CharacterState } from "@paperclipai/shared";
|
||||
import { AgentAvatar, avatarSizeClasses } from "../../src/components/AgentAvatar";
|
||||
import { AgentCharacter } from "../../src/components/AgentCharacter";
|
||||
import { AgentIdentity } from "../../src/components/AgentIdentity";
|
||||
import { Button } from "../../src/components/ui/button";
|
||||
import { AgentAvatar, avatarSizeClasses } from "../../../src/components/AgentAvatar";
|
||||
import { AgentCharacter } from "../../../src/components/AgentCharacter";
|
||||
import { AgentIdentity } from "../../../src/components/AgentIdentity";
|
||||
import { Button } from "../../../src/components/ui/button";
|
||||
|
||||
const appearance = appearanceForPalette("bubblegum-sky");
|
||||
const agent = { id: "storybook-agent", name: "Chief of Staff", appearance };
|
||||
const meta = {
|
||||
title: "Agents/Personas",
|
||||
id: "agents-personas",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Personas",
|
||||
component: AgentCharacter,
|
||||
args: { appearance, size: 256, state: "listening", label: "Chief of Staff" },
|
||||
parameters: { docs: { description: { component: "Persistent cap-v1 identities. The hero alone loads ClipLab. Development Storybook renders capsule PNGs locally on demand; published Storybook packages the same PNGs during its build, including every preset and both densities. Neither needs a running API." } } },
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { AgentSettingsPreview } from "../prototypes/agent-settings/AgentSettingsPreview";
|
||||
import { AgentSettingsPreview } from "../../prototypes/agent-settings/AgentSettingsPreview";
|
||||
const meta = {
|
||||
title: "Agents/Configuration refresh",
|
||||
id: "agents-configuration-refresh",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Agents/Configuration refresh",
|
||||
component: AgentSettingsPreview,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
+2
-1
@@ -111,7 +111,8 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Product/Built-in Agents",
|
||||
id: "product-built-in-agents",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Product/Built-in Agents",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
+2
-1
@@ -3,7 +3,8 @@ import { fn } from "storybook/test";
|
||||
import { DotConnectionChecks, ExternalAgentPresetPicker } from "@/components/new-agent/ExternalAgentInviteContent";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/External agent invitation/Components",
|
||||
id: "onboarding-external-agent-invitation-components",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Components",
|
||||
component: DotConnectionChecks,
|
||||
parameters: { layout: "centered", docs: { description: { component: "Controlled presentation states. Connection checks must be driven by server evidence. Subscription verification alone must never mark the connection ready." } } },
|
||||
decorators: [Story => <div className="w-full max-w-md p-6"><Story /></div>],
|
||||
+3
-2
@@ -1,9 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { ExternalAgentInvitePreview } from "./ExternalAgentInvitePreview";
|
||||
import { ExternalAgentInvitePreview } from "../../../support/external-agent-invite/ExternalAgentInvitePreview";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/External agent invitation/Journeys",
|
||||
id: "onboarding-external-agent-invitation-journeys",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/External agent invitation/Journeys",
|
||||
component: ExternalAgentInvitePreview,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
+3
-2
@@ -1,9 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { NewAgentWizard } from "../prototypes/NewAgentWizard";
|
||||
import { NewAgentWizard } from "../../prototypes/NewAgentWizard";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/New agent",
|
||||
id: "onboarding-new-agent",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Onboarding/New agent",
|
||||
component: NewAgentWizard,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
+2
-1
@@ -50,5 +50,6 @@ function ReviewGuide() {
|
||||
</div>
|
||||
</main>;
|
||||
}
|
||||
export default { title: "Primary Agent/00 Review guide", parameters: { layout: "fullscreen" }, render: () => <ReviewGuide /> } satisfies Meta;
|
||||
export default { id: "primary-agent-00-review-guide",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/00 Review guide", parameters: { layout: "fullscreen" }, render: () => <ReviewGuide /> } satisfies Meta;
|
||||
export const StartHere: StoryObj = {};
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation";
|
||||
import { primaryDecorator, primaryParameters, usePrimaryStory, type PrimaryScenario } from "./PrimaryAgentStory";
|
||||
import { primaryDecorator, primaryParameters, usePrimaryStory, type PrimaryScenario } from "../../../support/primary-agent/PrimaryAgentStory";
|
||||
|
||||
function Components(_args: PrimaryScenario) {
|
||||
const { agents } = usePrimaryStory();
|
||||
@@ -11,7 +11,8 @@ function Components(_args: PrimaryScenario) {
|
||||
</section>)}
|
||||
</div></main>;
|
||||
}
|
||||
export default { title: "Primary Agent/01 Components", component: Components, decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, render: args => <Components {...args} /> } satisfies Meta<typeof Components>;
|
||||
export default { id: "primary-agent-01-components",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/01 Components", component: Components, decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, render: args => <Components {...args} /> } satisfies Meta<typeof Components>;
|
||||
type Story = StoryObj<typeof Components>;
|
||||
export const CrownIndicator: Story = {};
|
||||
export const NoPrimary: Story = { args: { primaryId: null } };
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory";
|
||||
import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory";
|
||||
|
||||
export default { title: "Primary Agent/02 Sidebar", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => <PrimaryPage /> } satisfies Meta;
|
||||
export default { id: "primary-agent-02-sidebar",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/02 Sidebar", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => <PrimaryPage /> } satisfies Meta;
|
||||
type Story = StoryObj;
|
||||
export const IdlePrimary: Story = {};
|
||||
export const ActiveColleague: Story = { parameters: { primaryAgent: { active: true } } };
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory";
|
||||
import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory";
|
||||
|
||||
export default { title: "Primary Agent/03 Agents", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => <PrimaryPage /> } satisfies Meta;
|
||||
export default { id: "primary-agent-03-agents",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/03 Agents", decorators: [primaryDecorator], parameters: primaryParameters, args: { primaryName: "Maia" }, argTypes: { primaryName: { control: "text" } }, render: () => <PrimaryPage /> } satisfies Meta;
|
||||
type Story = StoryObj;
|
||||
export const Roster: Story = {};
|
||||
export const Profile: Story = { parameters: { initialEntries: ["/PAP/agents/maia"] } };
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory";
|
||||
import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory";
|
||||
|
||||
export default { title: "Primary Agent/04 New task", decorators: [primaryDecorator], parameters: primaryParameters, render: () => <PrimaryPage task="primary" /> } satisfies Meta;
|
||||
export default { id: "primary-agent-04-new-task",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/04 New task", decorators: [primaryDecorator], parameters: primaryParameters, render: () => <PrimaryPage task="primary" /> } satisfies Meta;
|
||||
type Story = StoryObj;
|
||||
export const PrimaryFallback: Story = {};
|
||||
export const RecentSpecialistWins: Story = { parameters: { primaryAgent: { recentAssignee: true } }, render: () => <PrimaryPage task="recent" /> };
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory";
|
||||
import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory";
|
||||
|
||||
export default { title: "Primary Agent/05 Chat", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/chats"] }, render: () => <PrimaryPage /> } satisfies Meta;
|
||||
export default { id: "primary-agent-05-chat",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/05 Chat", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/chats"] }, render: () => <PrimaryPage /> } satisfies Meta;
|
||||
type Story = StoryObj;
|
||||
export const PrimaryFallback: Story = {};
|
||||
export const RecentConversationWins: Story = { parameters: { primaryAgent: { recentChat: true } } };
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { PrimaryPage, primaryDecorator, primaryParameters } from "./PrimaryAgentStory";
|
||||
import { PrimaryPage, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory";
|
||||
|
||||
async function openActions(canvasElement: HTMLElement, name: string, profile = false) {
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
@@ -11,7 +11,8 @@ async function openActions(canvasElement: HTMLElement, name: string, profile = f
|
||||
return page;
|
||||
}
|
||||
const maiaProfile = { initialEntries: ["/PAP/agents/maia"] };
|
||||
export default { title: "Primary Agent/06 Journeys", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, render: () => <PrimaryPage /> } satisfies Meta;
|
||||
export default { id: "primary-agent-06-journeys",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/06 Journeys", decorators: [primaryDecorator], parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] }, render: () => <PrimaryPage /> } satisfies Meta;
|
||||
type Story = StoryObj;
|
||||
async function confirmPrimaryChange(canvasElement: HTMLElement) {
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
+3
-2
@@ -1,9 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "./PrimaryAgentStory";
|
||||
import { PrimaryPage, mobile, primaryDecorator, primaryParameters } from "../../../support/primary-agent/PrimaryAgentStory";
|
||||
|
||||
export default {
|
||||
title: "Primary Agent/07 Confirmation",
|
||||
id: "primary-agent-07-confirmation",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Primary Agent/07 Confirmation",
|
||||
decorators: [primaryDecorator],
|
||||
parameters: { ...primaryParameters, initialEntries: ["/PAP/agents/alex"] },
|
||||
render: () => <PrimaryPage />,
|
||||
+2
-1
@@ -325,7 +325,8 @@ const DESKTOP = 640;
|
||||
const MOBILE = 375;
|
||||
|
||||
const meta: Meta<typeof CardHarness> = {
|
||||
title: "Summaries/SummarySlotCard",
|
||||
id: "summaries-summaryslotcard",
|
||||
title: "Agents and reusable capabilities/Hiring, configuration, and organization/Summaries/SummarySlotCard",
|
||||
component: CardHarness,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
+2
-1
@@ -21,7 +21,8 @@ const baseProps = {
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ActionCard> = {
|
||||
title: "Tools & Access/ActionCard",
|
||||
id: "tools-access-actioncard",
|
||||
title: "Apps, channels, and extensions/App access and action permissions/Tools & Access/ActionCard",
|
||||
component: ActionCard,
|
||||
};
|
||||
export default meta;
|
||||
+2
-1
@@ -177,7 +177,8 @@ function PermissionsTestStory({ result, tool = TOOL, appName = "Asana", query }:
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Apps/Action permissions test",
|
||||
id: "apps-action-permissions-test",
|
||||
title: "Apps, channels, and extensions/App access and action permissions/Apps/Action permissions test",
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta;
|
||||
export default meta;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ManagedAggregatorAccounts } from "../../prototypes/ManagedAggregatorAccounts";
|
||||
const meta = { id: "apps-managed-accounts",
|
||||
title: "Apps, channels, and extensions/App access and action permissions/Apps/Managed accounts", component: ManagedAggregatorAccounts, parameters: { layout: "fullscreen", docs: { description: { component: "Production Apps page using production API routes and a disposable PostgreSQL database. Upstream Arcade, Composio and Executor responses are fixtures. Start tests/aggregator-accounts/test-drive.ts, then use the upstream controls and each gateway’s Refresh action." } } } } satisfies Meta<typeof ManagedAggregatorAccounts>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const FullStackTestDrive: Story = {};
|
||||
+2
-1
@@ -122,7 +122,8 @@ function connection(id: string, name: string, installs: InstallState): ToolConne
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Tools/Permitted vs Installed",
|
||||
id: "tools-permitted-vs-installed",
|
||||
title: "Apps, channels, and extensions/App access and action permissions/Tools/Permitted vs Installed",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ManagedAggregatorAccounts } from "../prototypes/ManagedAggregatorAccounts";
|
||||
const meta = { title: "Apps/Managed accounts", component: ManagedAggregatorAccounts, parameters: { layout: "fullscreen", docs: { description: { component: "Production Apps page using production API routes and a disposable PostgreSQL database. Upstream Arcade, Composio and Executor responses are fixtures. Start tests/aggregator-accounts/test-drive.ts, then use the upstream controls and each gateway’s Refresh action." } } } } satisfies Meta<typeof ManagedAggregatorAccounts>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const FullStackTestDrive: Story = {};
|
||||
+2
-1
@@ -765,7 +765,8 @@ function BudgetFinanceMatrix() {
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Budget & Finance",
|
||||
id: "product-budget-finance",
|
||||
title: "Oversight and operation/Costs and budgets/Product/Budget & Finance",
|
||||
component: BudgetFinanceMatrix,
|
||||
parameters: {
|
||||
docs: {
|
||||
+2
-1
@@ -100,7 +100,8 @@ function SubscriptionCostsStory({ unknownPrice = false, stale = false, costGaps
|
||||
return ready ? <><Costs embedded hideBudgetsTab /><ToastViewport /></> : null;
|
||||
}
|
||||
|
||||
const meta = { title: "Costs/Subscriptions", component: SubscriptionCostsStory, parameters: {
|
||||
const meta = { id: "costs-subscriptions",
|
||||
title: "Oversight and operation/Costs and budgets/Costs/Subscriptions", component: SubscriptionCostsStory, parameters: {
|
||||
layout: "padded", docs: { description: { component: "Production Costs page with API spend, monthly subscription estimates, user and project costs. View details to edit monthly or annual prices. Provider calls are fixtures." } },
|
||||
} } satisfies Meta<typeof SubscriptionCostsStory>;
|
||||
export default meta;
|
||||
+3
-2
@@ -6,7 +6,7 @@ import { ChatSetupSidebar } from "@/components/chat/ChatSetupNavigation";
|
||||
import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import { storybookAgents } from "../fixtures/paperclipData";
|
||||
import { storybookAgents } from "../../fixtures/paperclipData";
|
||||
|
||||
const COMPANY = "company-storybook";
|
||||
const CONNECTION = "agentmail-storybook-account";
|
||||
@@ -40,7 +40,8 @@ function AgentMailJourney({ savedConnection = true }: { savedConnection?: boolea
|
||||
|
||||
type Scenario = { noSavedKeys?: boolean; scopedKey?: boolean; missingBoundary?: boolean; denySetup?: boolean; initialTaken?: boolean; noCustomDomain?: boolean; checkFailed?: boolean };
|
||||
const meta = {
|
||||
title: "Connections/AgentMail setup",
|
||||
id: "connections-agentmail-setup",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/AgentMail setup",
|
||||
component: AgentMailJourney,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { savedConnection: true },
|
||||
+2
-1
@@ -6,7 +6,8 @@ import type { EmailEndpointSummary } from "@paperclipai/shared";
|
||||
const COMPANY = "company-storybook";
|
||||
const ENDPOINT = "agentmail-settings-preview";
|
||||
const meta = {
|
||||
title: "Connections/AgentMail settings",
|
||||
id: "connections-agentmail-settings",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/AgentMail settings",
|
||||
component: EmailEndpointSettings,
|
||||
parameters: { layout: "padded" },
|
||||
args: { endpointId: ENDPOINT, companyId: COMPANY, assignedAgentName: "Ralph" },
|
||||
+2
-1
@@ -545,7 +545,8 @@ function EmailTaskExperience({
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
title: "Connections/AgentMail tasks",
|
||||
id: "connections-agentmail-tasks",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks",
|
||||
component: EmailTaskExperience,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof EmailTaskExperience>;
|
||||
+2
-1
@@ -10,7 +10,8 @@ const endpoint: ChatEndpoint = {
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Connections/Chat connection actions",
|
||||
id: "connections-chat-connection-actions",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/Chat connection actions",
|
||||
component: ConnectorCard,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
+50
-9
@@ -9,9 +9,9 @@ import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { ChatEndpointDetail } from "@/pages/apps/chat/ChatEndpointDetail";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import type { ChatEndpoint, ChatEndpointResource, ChatIdentityLink } from "@/api/chatEndpoints";
|
||||
import { storybookAgents } from "../../fixtures/paperclipData";
|
||||
import { storybookAgents } from "../../../fixtures/paperclipData";
|
||||
|
||||
type Scenario = "populated" | "empty" | "error" | "channel-added";
|
||||
type Scenario = "populated" | "empty" | "error" | "channel-added" | "unlinked" | "invitation-expired";
|
||||
function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?: string; scenario?: Scenario }) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -26,7 +26,7 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?
|
||||
assignedAgentId: agent.id, assignedAgentName: agent.name, providerAccountId: "TSTORY",
|
||||
providerAccountLabel: "Paperclip", botExternalId: "UARLO", botLabel: "Arlo", botUsername: "arlo-paperclip",
|
||||
allowUnlinkedPeople: false, allowDirectMessages: true,
|
||||
setup: { step: "complete", slackApp: { appName: "Arlo", botName: "Arlo", command: "/arlo" } },
|
||||
setup: { step: "complete", slackRegistration: { status: "configured", appId: "ASTORY" }, slackApp: { appName: "Arlo", botName: "Arlo", command: "/arlo" } },
|
||||
};
|
||||
let resources: ChatEndpointResource[] = scenario === "empty" ? [] : [
|
||||
{ id: "engineering", type: "channel", providerResourceId: "CENGINEERING", label: "#engineering", availability: "available", enabled: true },
|
||||
@@ -37,6 +37,8 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?
|
||||
{ id: "grace", principalId: "grace", externalLabel: "Grace Hopper", status: "pending" },
|
||||
];
|
||||
let resourceReads = 0;
|
||||
let invitationSent = false;
|
||||
let invitationConfirmed = false;
|
||||
window.fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin);
|
||||
const path = url.pathname;
|
||||
@@ -62,7 +64,18 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?
|
||||
}
|
||||
return json(resources);
|
||||
}
|
||||
if (route === "principals") return json(principals);
|
||||
if (route === "slack/test-message") {
|
||||
if ((scenario === "unlinked" || scenario === "invitation-expired") && !invitationConfirmed) {
|
||||
if (!JSON.parse(String(init?.body)).slackUserId) return json({ status: "connection_required", accounts: [{ id: "UADA", name: "Ada Lovelace" }], nextCursor: null });
|
||||
invitationSent = true;
|
||||
return json({ status: "connection_pending", principalId: "ada", expiresAt: new Date(Date.now() + (scenario === "invitation-expired" ? 50 : 600_000)).toISOString() });
|
||||
}
|
||||
return json({ status: "sent", slackUrl: "https://app.slack.com/client/TSTORY/DADA" });
|
||||
}
|
||||
if (route === "principals") {
|
||||
if (invitationSent && scenario !== "invitation-expired") invitationConfirmed = true;
|
||||
return json(scenario === "invitation-expired" ? [] : principals);
|
||||
}
|
||||
if (route.endsWith("/link-intent")) return json({ confirmationUrl: "https://example.test/chat-identity/confirm?token=storybook-only" });
|
||||
if (route.endsWith("/link") && init?.method === "DELETE") {
|
||||
principals = principals.map(person => route.includes(person.id) ? { ...person, status: "revoked", paperclipUserId: null } : person);
|
||||
@@ -92,7 +105,8 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Connections/Chat management",
|
||||
id: "connections-chat-management",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/Chat management",
|
||||
component: ChatManagementPage,
|
||||
parameters: { layout: "fullscreen", a11y: { test: "off" } },
|
||||
args: { tab: "settings", scenario: "populated" },
|
||||
@@ -110,11 +124,38 @@ export const InvitePeople: Story = {
|
||||
},
|
||||
};
|
||||
export const Conversations: Story = { name: "4 · Conversations", args: { tab: "conversations" } };
|
||||
export const SlackAvatar: Story = {
|
||||
name: "5 · Slack avatar", args: { tab: "settings" },
|
||||
export const TestMessage: Story = {
|
||||
name: "5 · Test message", args: { tab: "settings" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(await within(canvasElement).findByText("Agent avatar", { selector: "summary" }));
|
||||
await expect(within(canvasElement).getByRole("img", { name: "Arlo’s Cliptoon avatar" })).toBeVisible();
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Send me a test message" }));
|
||||
await expect(await canvas.findByRole("button", { name: "Test message sent" })).toBeVisible();
|
||||
await expect(canvas.queryByRole("link", { name: "View message" })).not.toBeInTheDocument();
|
||||
await expect(canvas.getByRole("link", { name: "Open Slack configuration" })).toHaveAttribute("href", "https://api.slack.com/apps/ASTORY/oauth");
|
||||
await expect(canvas.queryByText("Agent avatar")).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByText("Message in a channel")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const ConnectAndTest: Story = {
|
||||
name: "Connect and send test message", args: { tab: "settings", scenario: "unlinked" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Send me a test message" }));
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await page.findByRole("radio", { name: "Ada Lovelace (UADA)" }));
|
||||
await userEvent.click(page.getByRole("button", { name: "Send connection invitation" }));
|
||||
await expect(await canvas.findByRole("button", { name: "Test message sent" })).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const InvitationExpired: Story = {
|
||||
name: "Expired Slack invitation", args: { tab: "settings", scenario: "invitation-expired" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Send me a test message" }));
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await page.findByRole("radio", { name: "Ada Lovelace (UADA)" }));
|
||||
await userEvent.click(page.getByRole("button", { name: "Send connection invitation" }));
|
||||
await expect(await canvas.findByRole("button", { name: "Request a new invitation" })).toBeEnabled();
|
||||
},
|
||||
};
|
||||
export const ChannelAdded: Story = {
|
||||
+4
-3
@@ -32,11 +32,12 @@ import {
|
||||
configuration,
|
||||
reviews,
|
||||
conversations,
|
||||
} from "./fixtures";
|
||||
import { FixtureApi, type FixtureState } from "./fixture-api";
|
||||
} from "../../../support/github-bot-management/fixtures";
|
||||
import { FixtureApi, type FixtureState } from "../../../support/github-bot-management/fixture-api";
|
||||
|
||||
const meta = {
|
||||
title: "Connectors/GitHub bot management",
|
||||
id: "connectors-github-bot-management",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
initialEntries: [`/PAP/apps/chat/${endpoint.id}/settings`],
|
||||
+3
-2
@@ -1,9 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { GitHubChatPreview } from "../prototypes/github-chat/GitHubChatPreview";
|
||||
import { GitHubChatPreview } from "../../prototypes/github-chat/GitHubChatPreview";
|
||||
|
||||
const meta = {
|
||||
title: "Historical/GitHub chat prototypes",
|
||||
id: "historical-github-chat-prototypes",
|
||||
title: "Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes",
|
||||
component: GitHubChatPreview,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
+3
-2
@@ -10,7 +10,7 @@ import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Browse } from "@/pages/apps/Browse";
|
||||
import { AppsConnect } from "@/pages/apps/AppsConnect";
|
||||
import { ChatEndpointSetup } from "@/pages/apps/chat/ChatEndpointSetup";
|
||||
import { storybookAgents } from "../fixtures/paperclipData";
|
||||
import { storybookAgents } from "../../fixtures/paperclipData";
|
||||
|
||||
const companyId = "company-storybook";
|
||||
// Hosted Storybooks live below a branch path; reuse their bundled GitHub marks.
|
||||
@@ -78,7 +78,8 @@ function GitHubConnections({ saved = false, chatEnabled = true }) {
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Connections/GitHub and Code Review Bot",
|
||||
id: "connections-github-and-code-review-bot",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot",
|
||||
component: GitHubConnections,
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.type(await within(canvasElement).findByRole("searchbox", { name: "Search connectors" }), "GitHub");
|
||||
+3
-2
@@ -11,7 +11,7 @@ import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ChatEndpoint, ChatIdentityLink } from "@/api/chatEndpoints";
|
||||
import type { TaskChatMessageItem } from "@/components/task-chat/task-chat-model";
|
||||
import { storybookAgents } from "../fixtures/paperclipData";
|
||||
import { storybookAgents } from "../../fixtures/paperclipData";
|
||||
|
||||
type Screen = "catalog" | "agent" | "credentials" | "access" | "task" | "reconnect";
|
||||
type Scenario = { screen?: Screen; dedicated?: boolean; outage?: boolean; noLine?: boolean; loading?: boolean; connecting?: boolean };
|
||||
@@ -96,7 +96,8 @@ function Host(props: { screen?: Screen }) {
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Connections/iMessage Photon", component: Host,
|
||||
id: "connections-imessage-photon",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/iMessage Photon", component: Host,
|
||||
parameters: { layout: "fullscreen" },
|
||||
beforeEach: ({ parameters }) => {
|
||||
const scenario = (parameters.photonScenario ?? {}) as Scenario;
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { SlackSetupFixture } from "../fixtures/SlackSetupFixture";
|
||||
import { SlackSetupFixture } from "../../fixtures/SlackSetupFixture";
|
||||
|
||||
const start = "/PAP/apps/chat/connect?provider=slack&purpose=chat";
|
||||
const meta = {
|
||||
title: "Connections/Slack/Automatic setup",
|
||||
id: "connections-slack-automatic-setup",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup",
|
||||
component: SlackSetupFixture,
|
||||
parameters: { layout: "fullscreen", initialEntries: [`${start}&resume=slack-story`],
|
||||
docs: { description: { component: "Production Slack setup pages in journey order. Automatic stories use the same four-step wizard; manual setup retains account linking. Slack creation, consent, verification, and identity discovery are simulated; use synthetic tokens only." } } },
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SlackAvatarSettingsPreview } from "../prototypes/slack-avatar/SlackAvatarSettingsPreview";
|
||||
import { SlackAvatarSettingsPreview } from "../../prototypes/slack-avatar/SlackAvatarSettingsPreview";
|
||||
const meta = {
|
||||
title: "Connections/Slack/Avatar in Settings",
|
||||
id: "connections-slack-avatar-in-settings",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings",
|
||||
component: SlackAvatarSettingsPreview,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
+3
-2
@@ -1,9 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { SlackAvatarSettingsPreview } from "../prototypes/slack-avatar/SlackAvatarSettingsPreview";
|
||||
import { SlackAvatarSettingsPreview } from "../../prototypes/slack-avatar/SlackAvatarSettingsPreview";
|
||||
|
||||
const meta = {
|
||||
title: "Connections/Slack/Communication in Settings",
|
||||
id: "connections-slack-communication-in-settings",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings",
|
||||
component: SlackAvatarSettingsPreview,
|
||||
parameters: { layout: "fullscreen", docs: { description: { component: "Production communication field in a Settings fixture. Saves use local story state; instructions apply to new tasks. Reply detail and progress are built-in behavior." } } },
|
||||
} satisfies Meta<typeof SlackAvatarSettingsPreview>;
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { SlackSetupFixture } from "../../fixtures/SlackSetupFixture";
|
||||
|
||||
const start = "/PAP/apps/chat/connect?provider=slack&purpose=chat";
|
||||
const meta = {
|
||||
id: "connections-slack-managed-setup",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup", component: SlackSetupFixture,
|
||||
args: { managed: true },
|
||||
parameters: { layout: "fullscreen", initialEntries: [`${start}&resume=slack-story`] },
|
||||
render: args => <SlackSetupFixture key={`${args.scenario}-${args.workspaceCount}-${args.managedAvailable}-${args.optionsDelayMs}-${args.optionsError}`} {...args} />,
|
||||
} satisfies Meta<typeof SlackSetupFixture>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
/** Walk the production wizard from the same entry point as the connector catalog. */
|
||||
export const DefaultJourney: Story = {
|
||||
name: "00 · Start here · Managed by default",
|
||||
args: { scenario: "choose" },
|
||||
parameters: { initialEntries: ["/PAP/apps/chat/connect?provider=slack"] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByText("App configuration access token")).not.toBeInTheDocument();
|
||||
await userEvent.click(await canvas.findByRole("button", { name: /^Chat with an agent/ }));
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Choose an active agent" }));
|
||||
await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" }));
|
||||
await waitFor(() => expect(canvas.getByRole("button", { name: "Continue" })).toBeEnabled());
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Continue" }));
|
||||
await expect(await canvas.findByRole("heading", { name: "Send a message to your agent" })).toBeVisible();
|
||||
await expect(canvas.queryByLabelText("App configuration access token")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const ChooseAgent: Story = { name: "01 · Choose agent", args: { scenario: "choose" }, parameters: { initialEntries: [start] } };
|
||||
export const ChooseAgentAdvanced: Story = {
|
||||
...ChooseAgent, name: "01 · Choose agent · Advanced",
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Choose an active agent" }));
|
||||
await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" }));
|
||||
await userEvent.click(await canvas.findByText("Advanced", { exact: true }));
|
||||
await expect(await canvas.findByRole("button", { name: "Use your own app" })).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const AuthorizeWorkspace: Story = { name: "02 · Connect · First authorization", args: { scenario: "create", workspaceCount: 0 } };
|
||||
export const AddToSlack: Story = { name: "02 · Connect · Returning workspace", args: { scenario: "create" }, play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByRole("heading", { name: "Send a message to your agent" })).toBeVisible();
|
||||
await expect(canvas.queryByText("Use your own app")).not.toBeInTheDocument();
|
||||
} };
|
||||
export const MultipleWorkspaces: Story = { name: "02 · Connect · Choose workspace", args: { scenario: "create", workspaceCount: 2 } };
|
||||
export const Connect: Story = { name: "02 · Connect · Waiting for a message", args: { scenario: "verify" } };
|
||||
export const Success: Story = { name: "02 · Connect · Success", args: { scenario: "success" } };
|
||||
export const ApprovalPending: Story = { name: "Recovery · Slack approval pending", args: { scenario: "declined" } };
|
||||
export const ApprovalDeclined: Story = { name: "Recovery · Slack approval declined", args: { scenario: "approval_denied" } };
|
||||
export const EventConfigurationPending: Story = { name: "Recovery · App configuration pending", args: { scenario: "manifest_pending" } };
|
||||
export const UncertainCreation: Story = { name: "Recovery · Uncertain creation", args: { scenario: "uncertain" } };
|
||||
export const SavedCredentials: Story = { name: "Recovery · Saved credentials", args: { scenario: "recovery" } };
|
||||
export const Unavailable: Story = { name: "Recovery · Managed setup unavailable", args: { scenario: "install", managedAvailable: false } };
|
||||
export const OwnApp: Story = { name: "Advanced · Use your own app", args: { scenario: "choose" }, parameters: { initialEntries: [start] }, play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Choose an active agent" }));
|
||||
await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" }));
|
||||
await userEvent.click(await canvas.findByText("Advanced", { exact: true }));
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Use your own app" }));
|
||||
await expect(await canvas.findByRole("heading", { name: "App configuration access token" })).toBeVisible();
|
||||
} };
|
||||
export const SelfHosted: Story = { name: "Self-hosted · Own app directly", args: { scenario: "create", managed: false, managedAvailable: false } };
|
||||
export const Mobile: Story = { ...AddToSlack, name: "Mobile · Connect", globals: { viewport: { value: "mobile1", isRotated: false } } };
|
||||
export const MobileSuccess: Story = { ...Success, name: "Mobile · Success", globals: { viewport: { value: "mobile1", isRotated: false } } };
|
||||
|
||||
export const BrokerUnavailable: Story = {
|
||||
...DefaultJourney,
|
||||
name: "Recovery · Broker unavailable · No token fallback",
|
||||
args: { scenario: "choose", managedAvailable: false },
|
||||
play: async context => {
|
||||
const entry = within(context.canvasElement);
|
||||
await userEvent.click(await entry.findByRole("button", { name: /^Chat with an agent/ }));
|
||||
await userEvent.click(await entry.findByRole("button", { name: "Choose an active agent" }));
|
||||
await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" }));
|
||||
await userEvent.click(await entry.findByRole("button", { name: "Continue" }));
|
||||
const canvas = within(context.canvasElement);
|
||||
await expect(await canvas.findByText("Managed Slack setup is unavailable. Try again later.")).toBeVisible();
|
||||
await expect(canvas.getByRole("button", { name: "Retry" })).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const LoadingOptions: Story = {
|
||||
name: "Loading · Checking Slack setup",
|
||||
args: { scenario: "choose", optionsDelayMs: 3000 },
|
||||
parameters: { initialEntries: [start] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByRole("status")).toHaveTextContent("Loading Slack");
|
||||
await expect(canvas.getByRole("button", { name: "Continue" })).toBeDisabled();
|
||||
await expect(canvas.queryByText("App configuration access token")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const OptionsError: Story = {
|
||||
name: "Recovery · Setup lookup failed · No token fallback",
|
||||
args: { scenario: "choose", optionsError: true },
|
||||
parameters: { initialEntries: [start] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByRole("alert")).toHaveTextContent("Couldn't load Slack setup");
|
||||
await expect(canvas.queryByText("App configuration access token")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
+3
-2
@@ -3,7 +3,7 @@ import { SLACK_TOOLS, type SlackSearchStatus } from "@paperclipai/shared";
|
||||
import {
|
||||
SlackCapabilitiesView,
|
||||
SlackSearchView,
|
||||
} from "../../src/pages/apps/chat/SlackToolSettings";
|
||||
} from "../../../src/pages/apps/chat/SlackToolSettings";
|
||||
const base: SlackSearchStatus = {
|
||||
canConfigure: true,
|
||||
configured: false,
|
||||
@@ -52,7 +52,8 @@ function Preview({
|
||||
);
|
||||
}
|
||||
export default {
|
||||
title: "Connections/Slack/Task tools",
|
||||
id: "connections-slack-task-tools",
|
||||
title: "Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools",
|
||||
component: Preview,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof Preview>;
|
||||
+3
-2
@@ -1,9 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { DecisionModelSettingsView } from "@/components/decision-models/DecisionModelSettings";
|
||||
import { choices, configured, result, unconfigured } from "./fixtures";
|
||||
import { choices, configured, result, unconfigured } from "../../../support/decision-models/fixtures";
|
||||
|
||||
const meta = { title: "Decision models/01 Settings", component: DecisionModelSettingsView,
|
||||
const meta = { id: "decision-models-01-settings",
|
||||
title: "Getting started and access/Companies and portability/Decision models/01 Settings", component: DecisionModelSettingsView,
|
||||
decorators: [Story => <main className="p-6"><Story /></main>],
|
||||
args: { settings: configured, choices, onSave: fn(), onTest: fn(), onAdd: fn() },
|
||||
} satisfies Meta<typeof DecisionModelSettingsView>;
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DecisionHistoryTable } from "@/components/decision-models/DecisionHistory";
|
||||
import { entry } from "./fixtures";
|
||||
import { entry } from "../../../support/decision-models/fixtures";
|
||||
|
||||
const meta = { title: "Decision models/02 History", component: DecisionHistoryTable,
|
||||
const meta = { id: "decision-models-02-history",
|
||||
title: "Getting started and access/Companies and portability/Decision models/02 History", component: DecisionHistoryTable,
|
||||
decorators: [Story => <main className="p-6"><Story /></main>], args: { entries: [entry] },
|
||||
} satisfies Meta<typeof DecisionHistoryTable>;
|
||||
export default meta;
|
||||
+3
-2
@@ -7,7 +7,7 @@ import { Layout } from "@/components/Layout";
|
||||
import { CompanySettings } from "@/pages/CompanySettings";
|
||||
import { Costs } from "@/pages/Costs";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { choices, companyId, configured, entry, result, unconfigured } from "./fixtures";
|
||||
import { choices, companyId, configured, entry, result, unconfigured } from "../../../support/decision-models/fixtures";
|
||||
import type { DecisionHistoryEntry } from "@paperclipai/shared";
|
||||
|
||||
/** Production pages and shell; only API responses and paid provider execution are simulated. */
|
||||
@@ -68,7 +68,8 @@ function Journey({ alreadyConfigured = false }: { alreadyConfigured?: boolean })
|
||||
<Route path="activity/costs" element={<Costs />} />
|
||||
</Route></Routes></PluginLauncherProvider> : null;
|
||||
}
|
||||
const meta = { title: "Decision models/03 Journey", component: Journey, parameters: {
|
||||
const meta = { id: "decision-models-03-journey",
|
||||
title: "Getting started and access/Companies and portability/Decision models/03 Journey", component: Journey, parameters: {
|
||||
layout: "fullscreen", initialEntries: ["/PAP/company/settings"], docs: { story: { inline: false }, description: { component: "Select an existing shared connection, preserve the background setting, save, run the sample, and follow View usage. Paid calls are fixtures." } },
|
||||
} } satisfies Meta<typeof Journey>;
|
||||
export default meta;
|
||||
+5
-2
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { FastResponseSettingsView } from "@/components/fast-responses/FastResponseSettings";
|
||||
import { choices } from "../decision-models/fixtures";
|
||||
import { choices } from "../../../support/decision-models/fixtures";
|
||||
import type {
|
||||
FastResponseSettings,
|
||||
FastResponseTestResult,
|
||||
@@ -36,7 +36,9 @@ export const result: FastResponseTestResult = {
|
||||
},
|
||||
};
|
||||
const meta = {
|
||||
title: "Fast responses/01 Settings",
|
||||
id: "fast-responses-01-settings",
|
||||
title: "Getting started and access/Companies and portability/Fast responses/01 Settings",
|
||||
excludeStories: ["empty", "configured", "result"],
|
||||
component: FastResponseSettingsView,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
@@ -52,6 +54,7 @@ const meta = {
|
||||
onSave: fn(),
|
||||
onTest: fn(),
|
||||
onAdd: fn(),
|
||||
onConnectionChange: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof FastResponseSettingsView>;
|
||||
export default meta;
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { FastResponseHistoryEntry } from "@paperclipai/shared";
|
||||
import { DecisionHistoryTable } from "@/components/decision-models/DecisionHistory";
|
||||
import { entry } from "../decision-models/fixtures";
|
||||
import { entry } from "../../../support/decision-models/fixtures";
|
||||
const receipt: FastResponseHistoryEntry = {
|
||||
...entry,
|
||||
feature: "message",
|
||||
@@ -16,7 +16,8 @@ const receipt: FastResponseHistoryEntry = {
|
||||
costStatus: "reported",
|
||||
};
|
||||
const meta = {
|
||||
title: "Fast responses/02 History",
|
||||
id: "fast-responses-02-history",
|
||||
title: "Getting started and access/Companies and portability/Fast responses/02 History",
|
||||
component: DecisionHistoryTable,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||||
import manifest from "../../public/brands/apps/manifest.json";
|
||||
import manifest from "../../../public/brands/apps/manifest.json";
|
||||
|
||||
const sizes = [24, 28, 32, 36, 44, 48];
|
||||
const launchOrder = ["gmail", "google-calendar", "slack", "stripe"];
|
||||
@@ -31,7 +31,8 @@ function Registry() {
|
||||
);
|
||||
}
|
||||
|
||||
const meta = { title: "Apps/Canonical icon registry", component: Registry, parameters: { layout: "fullscreen" } } satisfies Meta<typeof Registry>;
|
||||
const meta = { id: "apps-canonical-icon-registry",
|
||||
title: "Apps, channels, and extensions/Connection setup/Apps/Canonical icon registry", component: Registry, parameters: { layout: "fullscreen" } } satisfies Meta<typeof Registry>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Light: Story = { globals: { theme: "light" } };
|
||||
+3
-2
@@ -1,9 +1,10 @@
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview";
|
||||
import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview";
|
||||
|
||||
const meta = {
|
||||
title: "Apps/Connections/Arcade",
|
||||
id: "apps-connections-arcade",
|
||||
title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Arcade",
|
||||
component: RemoteMcpConnectionReview,
|
||||
args: { provider: "arcade", scenario: "journey" },
|
||||
parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } },
|
||||
+3
-2
@@ -1,9 +1,10 @@
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview";
|
||||
import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview";
|
||||
|
||||
const meta = {
|
||||
title: "Apps/Connections/Composio",
|
||||
id: "apps-connections-composio",
|
||||
title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Composio",
|
||||
component: RemoteMcpConnectionReview,
|
||||
args: { provider: "composio", scenario: "journey" },
|
||||
parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } },
|
||||
+3
-2
@@ -9,7 +9,7 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import { RemoteMcpAccountChoice } from "@/features/connections/remote-mcp/RemoteMcpAccountChoice";
|
||||
import { QuestionForm } from "@/components/task-chat/QuestionForm";
|
||||
import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview";
|
||||
import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview";
|
||||
|
||||
function ProviderChoice({
|
||||
existing = false,
|
||||
@@ -109,7 +109,8 @@ function ProviderChoice({
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
title: "Apps/Connections/Provider choice",
|
||||
id: "apps-connections-provider-choice",
|
||||
title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Provider choice",
|
||||
component: ProviderChoice,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof ProviderChoice>;
|
||||
+3
-2
@@ -1,9 +1,10 @@
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview";
|
||||
import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview";
|
||||
|
||||
const meta = {
|
||||
title: "Apps/Connections/Executor",
|
||||
id: "apps-connections-executor",
|
||||
title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Executor",
|
||||
component: RemoteMcpConnectionReview,
|
||||
args: { provider: "executor", scenario: "journey" },
|
||||
parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } },
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { RemoteMcpConnectionReview } from "../prototypes/RemoteMcpConnectionReview";
|
||||
import { RemoteMcpConnectionReview } from "../../prototypes/RemoteMcpConnectionReview";
|
||||
|
||||
const meta = {
|
||||
title: "Apps/Connections/Zapier",
|
||||
id: "apps-connections-zapier",
|
||||
title: "Apps, channels, and extensions/Connection setup/Apps/Connections/Zapier",
|
||||
component: RemoteMcpConnectionReview,
|
||||
args: { provider: "zapier", scenario: "journey" },
|
||||
parameters: { layout: "fullscreen", docs: { description: { component: "Interactive design review using application views with in-memory provider responses. No real authentication, tool calls, or credential persistence. Representative catalogs are examples; production discovery supplies the actual tools." } } },
|
||||
Loaded 100 of 415 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user