feat(slack): add threaded CEO conversations and live progress

This commit is contained in:
Scott Tong committed 2026-09-23 00:12:02 -07:00
1 parent 91339784ce
commit fbcae3cd88
39 files changed
+2384 -74

No files matched your search

+5
View File
@@ -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>}
+2 -2
View File
@@ -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." } };