mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
feat(slack): add threaded CEO conversations and live progress
This commit is contained in:
1 parent
91339784ce
commit
fbcae3cd88
39 files changed
+2384
-74
No files matched your search
@@ -71,6 +71,8 @@ export interface ChatConversation {
|
||||
|
||||
export interface ExternalChannelBindingSummary {
|
||||
slackReplyAvailable?: boolean;
|
||||
slackThreadUpgradeAvailable?: boolean;
|
||||
bindingMode?: "legacy" | "slack_dm_thread_v2";
|
||||
endpointId: string;
|
||||
provider: ChatProvider;
|
||||
botLabel?: string | null;
|
||||
@@ -220,6 +222,9 @@ export const chatEndpointsApi = {
|
||||
startSlackBotOAuth: (endpointId: string) => api.post<{ authorizationUrl: string; expiresAt: string }>(
|
||||
`/chat-endpoints/${endpointId}/slack/oauth/start`, { permissionProfile: "ceo-dm-v1" },
|
||||
),
|
||||
startSlackThreadUpgrade: (endpointId: string) => api.post<{ authorizationUrl: string; expiresAt: string }>(
|
||||
`/chat-endpoints/${endpointId}/slack/threading/upgrade`, { permissionProfile: "ceo-dm-threaded-v2" },
|
||||
),
|
||||
setupTestStatus: (endpointId: string) => api.get<{ messageReceivedAt: string | null }>(`/chat-endpoints/${endpointId}/test-status`),
|
||||
requestIdentityAccess: (token: string) => api.post<{ status: "member" | "pending_approval" }>("/chat-identity-links/request-access", { token }),
|
||||
listResources: async (endpointId: string) =>
|
||||
|
||||
@@ -14,6 +14,7 @@ const mockChatEndpointsApi = vi.hoisted(() => ({
|
||||
getIssueBinding: vi.fn(),
|
||||
publishBoardMessage: vi.fn(),
|
||||
getPublicationBatchStatus: vi.fn(),
|
||||
startSlackThreadUpgrade: vi.fn(),
|
||||
}));
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
const uploadAttachmentMock = vi.hoisted(() => vi.fn());
|
||||
@@ -155,6 +156,28 @@ describe("ExternallyConnectedTaskBanner publication truth", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("offers explicit threaded consent without changing the connection on render", async () => {
|
||||
const binding = await mockChatEndpointsApi.getIssueBinding();
|
||||
mockChatEndpointsApi.getIssueBinding.mockResolvedValue({ ...binding, slackThreadUpgradeAvailable: true });
|
||||
mockChatEndpointsApi.startSlackThreadUpgrade.mockRejectedValue(new Error("Connection changed; reload before upgrading"));
|
||||
await renderBanner();
|
||||
expect(container.textContent).toContain("Existing conversations stay unchanged");
|
||||
expect(mockChatEndpointsApi.startSlackThreadUpgrade).not.toHaveBeenCalled();
|
||||
await act(() => findButton(container, "Enable threaded DMs").click());
|
||||
await flushReact();
|
||||
expect(mockChatEndpointsApi.startSlackThreadUpgrade).toHaveBeenCalledOnce();
|
||||
expect(mockChatEndpointsApi.startSlackThreadUpgrade).toHaveBeenCalledWith("endpoint-1");
|
||||
expect(container.textContent).toContain("Connection changed; reload before upgrading");
|
||||
});
|
||||
|
||||
it("labels the threaded preview's remaining composer limitation", async () => {
|
||||
const binding = await mockChatEndpointsApi.getIssueBinding();
|
||||
mockChatEndpointsApi.getIssueBinding.mockResolvedValue({ ...binding, bindingMode: "slack_dm_thread_v2" });
|
||||
await renderBanner();
|
||||
expect(container.textContent).toContain("normal Paperclip composer does not send messages to Slack yet");
|
||||
expect(container.textContent).not.toContain("Enable threaded DMs");
|
||||
});
|
||||
|
||||
it("only reports success and clears the draft after confirmed publication", async () => {
|
||||
mockChatEndpointsApi.publishBoardMessage.mockResolvedValue({
|
||||
id: "publication-1",
|
||||
|
||||
@@ -157,6 +157,16 @@ function ConnectedTaskComposer({
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [composing, setComposing] = useState(false);
|
||||
const upgradeThreads = useMutation({
|
||||
mutationFn: () => chatEndpointsApi.startSlackThreadUpgrade(binding.endpointId),
|
||||
onSuccess: ({ authorizationUrl }) => {
|
||||
const url = new URL(authorizationUrl);
|
||||
if (url.origin !== "https://slack.com" || url.pathname !== "/oauth/v2/authorize" || url.username || url.password) {
|
||||
throw new Error("Unexpected Slack consent destination");
|
||||
}
|
||||
window.location.assign(url.href);
|
||||
},
|
||||
});
|
||||
const [body, setBody] = useState("");
|
||||
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<string[]>(
|
||||
[],
|
||||
@@ -523,7 +533,11 @@ function ConnectedTaskComposer({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{binding.slackReplyAvailable && <SlackReplyComposer companyId={companyId} issueId={issueId} issueCacheRefs={issueCacheRefs}
|
||||
{binding.slackThreadUpgradeAvailable && <SlackThreadUpgradeNotice pending={upgradeThreads.isPending}
|
||||
error={upgradeThreads.isError ? (upgradeThreads.error instanceof Error ? upgradeThreads.error.message : "Could not start the upgrade.") : null}
|
||||
onUpgrade={() => upgradeThreads.mutate()} />}
|
||||
{binding.bindingMode === "slack_dm_thread_v2" && <p className="text-xs text-muted-foreground">Thread preview: use Reply via Slack to share a request and the CEO’s answer in this thread. The normal Paperclip composer does not send messages to Slack yet.</p>}
|
||||
{binding.slackReplyAvailable && <SlackReplyComposer sharedThread={binding.bindingMode === "slack_dm_thread_v2"} companyId={companyId} issueId={issueId} issueCacheRefs={issueCacheRefs}
|
||||
endpointId={binding.endpointId} conversationId={binding.conversationId} />}
|
||||
{composing && (
|
||||
<div className="space-y-2 border-t border-border pt-3">
|
||||
@@ -892,3 +906,15 @@ function ConnectedTaskComposer({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlackThreadUpgradeNotice({ pending = false, error = null, onUpgrade }: {
|
||||
pending?: boolean; error?: string | null; onUpgrade: () => void;
|
||||
}) {
|
||||
return <div className="space-y-2 border-t border-border pt-3">
|
||||
<p className="text-xs text-muted-foreground">Enable a separate task/thread for each new Slack DM, with an acknowledgement reaction and working status. Adds only the bot’s reactions permission. Existing conversations stay unchanged.</p>
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={onUpgrade}>
|
||||
{pending ? "Opening Slack…" : "Enable threaded DMs"}
|
||||
</Button>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -21,10 +21,18 @@ describe("explicit Slack reply composer", () => {
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user" } });
|
||||
});
|
||||
afterEach(() => { flushSync(() => root.unmount()); container.remove(); client.clear(); localStorage.clear(); });
|
||||
async function render() {
|
||||
await act(async () => { root.render(<QueryClientProvider client={client}><SlackReplyComposer companyId="company" issueId="issue" endpointId="endpoint" conversationId="conversation" /></QueryClientProvider>); });
|
||||
async function render(sharedThread = false) {
|
||||
await act(async () => { root.render(<QueryClientProvider client={client}><SlackReplyComposer sharedThread={sharedThread} companyId="company" issueId="issue" endpointId="endpoint" conversationId="conversation" /></QueryClientProvider>); });
|
||||
}
|
||||
const button = (text: string) => [...container.querySelectorAll("button")].find(b => b.textContent === text)!;
|
||||
it("explains shared request delivery before execution in the new thread", async () => {
|
||||
await render(true);
|
||||
await act(async () => button("Reply via Slack").click());
|
||||
expect(container.textContent).toContain("Your request and the agent’s answer are shared");
|
||||
expect(container.textContent).toContain("starts after your request is delivered");
|
||||
expect(container.textContent).not.toContain("Only the agent’s answer");
|
||||
expect(chatEndpointsApi.requestSlackReply).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not send anything on opening and explains the private default", async () => {
|
||||
await render();
|
||||
await act(async () => button("Reply via Slack").click());
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
type Draft = { body: string; clientRequestId: string };
|
||||
export function SlackReplyComposer({ companyId, issueId, endpointId, conversationId, issueCacheRefs = [] }: {
|
||||
companyId: string; issueId: string; endpointId: string; conversationId: string; issueCacheRefs?: string[];
|
||||
export function SlackReplyComposer({ companyId, issueId, endpointId, conversationId, issueCacheRefs = [], sharedThread = false }: {
|
||||
companyId: string; issueId: string; endpointId: string; conversationId: string; issueCacheRefs?: string[]; sharedThread?: boolean;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const session = useQuery({ queryKey: queryKeys.auth.session, queryFn: authApi.getSession });
|
||||
@@ -54,8 +54,10 @@ export function SlackReplyComposer({ companyId, issueId, endpointId, conversatio
|
||||
return <div className="space-y-3">
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(value => !value)}>Reply via Slack</Button>
|
||||
{open && <div className="space-y-3 border-t border-border pt-3">
|
||||
<label htmlFor="slack-agent-reply" className="text-sm font-medium">Ask the agent to reply in your linked Slack DM</label>
|
||||
<p className="text-xs text-muted-foreground">Only the agent’s answer to this request is sent to Slack. Your request is saved in Paperclip. Ordinary task messages stay internal.</p>
|
||||
<label htmlFor="slack-agent-reply" className="text-sm font-medium">Ask the agent to reply in your linked Slack {sharedThread ? "thread" : "DM"}</label>
|
||||
<p className="text-xs text-muted-foreground">{sharedThread
|
||||
? "Your request and the agent’s answer are shared in the linked Slack thread. The agent starts after your request is delivered."
|
||||
: "Only the agent’s answer to this request is sent to Slack. Your request is saved in Paperclip. Ordinary task messages stay internal."}</p>
|
||||
<Textarea id="slack-agent-reply" value={body} onChange={event => setBody(event.target.value)}
|
||||
disabled={send.isPending || Boolean(saved)} maxLength={8000} placeholder="What should the agent answer in Slack?" />
|
||||
{storageError && <p role="alert" className="text-sm text-destructive">The saved request could not be read. Restore browser storage before sending.</p>}
|
||||
|
||||
@@ -592,7 +592,7 @@ function ChatSdkEndpointSetup() {
|
||||
)}
|
||||
{endpoint && step === tryStep && (
|
||||
<TryStep
|
||||
slackDmOnly={endpoint.setup?.slackOAuth?.profile === "ceo-dm-v1"}
|
||||
slackDmOnly={Boolean(endpoint.setup?.slackOAuth)}
|
||||
endpointId={endpoint.id}
|
||||
provider={provider}
|
||||
agentName={selectedAgent?.name ?? endpoint.assignedAgentName}
|
||||
@@ -811,7 +811,7 @@ function ProviderConnectStep({
|
||||
const slackCommand = slackApp.command.trim();
|
||||
const slackWebhookUrl =
|
||||
endpoint.setup?.webhookUrl ?? "<paperclip-webhook-url>";
|
||||
const slackManifest = endpoint.setup?.slackOAuth?.profile === "ceo-dm-v1" ? `display_information:
|
||||
const slackManifest = endpoint.setup?.slackOAuth ? `display_information:
|
||||
name: ${JSON.stringify(slackAppName)}
|
||||
features:
|
||||
app_home:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { SlackThreadUpgradeNotice } from "@/components/chat/ExternallyConnectedTaskBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Connections/Slack threaded DM upgrade",
|
||||
component: SlackThreadUpgradeNotice,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onUpgrade: fn() },
|
||||
} satisfies Meta<typeof SlackThreadUpgradeNotice>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Ready: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
await userEvent.click(within(canvasElement).getByRole("button", { name: "Enable threaded DMs" }));
|
||||
await expect(args.onUpgrade).toHaveBeenCalledOnce();
|
||||
},
|
||||
};
|
||||
export const Opening: Story = { args: { pending: true } };
|
||||
export const Failed: Story = { args: { error: "Slack upgrade expired or changed. Start the upgrade again." } };
|
||||
Reference in new issue
Block a user