diff --git a/DESIGN.md b/DESIGN.md index 9d53a3c844b..8eada0c3c8d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -44,8 +44,10 @@ footer: do not render Save & exit in a separate parent block below it. Check thi alignment in every step and conditional state, not just the first screen. Completed Slack setup is a success view: show the full agent avatar, success message, -and one centered Done action. Omit Save & exit after the connection has been -confirmed. The shared footer rule applies to its unfinished setup steps. +and one centered Done action. Retain an Open Slack link in the success message +and on the completed connection page so automatic completion does not remove +the way to use the bot. Omit Save & exit after the connection has been confirmed. +The shared footer rule applies to its unfinished setup steps. The New task form opens immediately. Load its code with the board and fill agent and project choices in place as their requests finish. Keep text entered diff --git a/doc/connections/slack-setup.md b/doc/connections/slack-setup.md index 51be34335b7..cdb39ccbd7c 100644 --- a/doc/connections/slack-setup.md +++ b/doc/connections/slack-setup.md @@ -163,6 +163,10 @@ The action records a durable request before sending. Repeating the same request returns its saved result; an uncertain delivery is never automatically resent. **Open Slack configuration** opens the saved app's OAuth & Permissions page. Legacy connections without an app ID open Slack's app list instead. +**Open Slack** remains available after automatic setup completion and on the +saved connection page. It opens the current user's bot DM when its workspace +and channel are known and its linked owner matches the signed-in user. Another +manager or an unknown identity uses the saved provider link or Slack instead. An explicit managed reconnect reinstalls the saved app and verifies the replacement bot credentials. Its request ID is saved before dispatch, so a duplicate completed diff --git a/maps/feature-map/chat-channels.md b/maps/feature-map/chat-channels.md index 7db0748a607..c6d2e34dfed 100644 --- a/maps/feature-map/chat-channels.md +++ b/maps/feature-map/chat-channels.md @@ -55,6 +55,10 @@ must resolve the selected Slack manager grant, reject malformed or missing IDs, and deny another company's or person's grant before provider calls. The channel integration suite exercises this route with a board fixture and provider fixtures; live Slack setup remains a separate acceptance check. +After welcome delivery completes setup automatically, verify **Open Slack** +remains on Success and on the saved connection page. Follow that visible link, +send a real DM and thread follow-up, then reload the catalog to confirm setup +stays complete. A welcome message alone is not an agent response. ### `external-thread` diff --git a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx index 8b30ba2da62..dc6c52282e3 100644 --- a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx +++ b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx @@ -488,6 +488,8 @@ describe("chat setup and identity-link clipboard actions", () => { expect(container.querySelectorAll('aside nav button')).toHaveLength(4); expect(container.querySelector('h1')?.textContent).toBe("Success"); expect(container.textContent).toContain("You can now talk to Maya in Slack"); + expect([...container.querySelectorAll("a")].find(link => link.textContent?.trim() === "Open Slack")?.getAttribute("href")) + .toBe("https://app.slack.com/client/workspace-a/DTEST"); expect(container.querySelector('[aria-label="Maya avatar"]')).not.toBeNull(); expect(container.querySelector('main')?.textContent ?? container.textContent).not.toContain("Save & exit"); expect(container.textContent).not.toContain("Copy message"); @@ -505,9 +507,25 @@ describe("chat setup and identity-link clipboard actions", () => { it("keeps settings focused on channel access and the test-message action", async () => { mocks.tab = "settings"; - await render("slack", true); + const endpoint = await render("slack", true); expect(container.textContent).toContain("Send me a test message"); expect(container.textContent).toContain("Open Slack configuration"); + expect([...container.querySelectorAll("a")].some(link => link.textContent?.trim() === "Open Slack")).toBe(false); + flushSync(() => client.setQueryData(queryKeys.chatEndpoints.detail(endpoint.id), { + ...endpoint, status: "active", providerAccountId: "workspace-a", + setup: { ...endpoint.setup, step: "complete", slackAccount: { externalUserId: "UPERSON", paperclipUserId: "owner-user", status: "linked", dmChannelId: "DTEST" } }, + })); + await settle(); + expect([...container.querySelectorAll("a")].find(link => link.textContent?.trim() === "Open Slack")?.getAttribute("href")) + .toBe("https://app.slack.com/client/workspace-a/DTEST"); + flushSync(() => client.setQueryData(queryKeys.auth.session, { user: { id: "other-user" } })); + await settle(); + expect([...container.querySelectorAll("a")].find(link => link.textContent?.trim() === "Open Slack")?.getAttribute("href")) + .toBe("https://app.slack.com"); + flushSync(() => client.setQueryData(queryKeys.auth.session, null)); + await settle(); + expect([...container.querySelectorAll("a")].find(link => link.textContent?.trim() === "Open Slack")?.getAttribute("href")) + .toBe("https://app.slack.com"); expect(container.textContent).not.toContain("Message in a channel"); expect(container.textContent).not.toContain("Agent avatar"); expect(container.textContent).not.toContain("Manage action permissions"); diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index cd58f45fa9e..59db46978b5 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -7,6 +7,8 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { agentAvatarUrl } from "@/lib/agent-avatar-url"; import { resolveAgentAppearance } from "@paperclipai/shared"; import { SlackTestMessageButton } from "./SlackTestMessageButton"; +import { slackConversationUrl } from "./slack/conversation-url"; +import { useAccountIdentity } from "@/api/companies-query"; import { AgentAvatar } from "@/components/AgentAvatar"; import { SlackSetupAdvanced } from "./SlackAppDetails"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; @@ -243,6 +245,7 @@ export function connectionHealthPresentation( } export function ChatEndpointDetail() { + const { userId, settled: identitySettled } = useAccountIdentity(); const { endpointId = "", tab: routeTab, reviewId } = useParams<{ endpointId: string; tab?: string; @@ -357,6 +360,7 @@ export function ChatEndpointDetail() {
{endpoint.provider === "speko" && Open Speko} + {endpoint.provider === "slack" && !setupIncomplete && } {slackUrl && } {setupIncomplete ? (
: slackRequestUrlInstructions} + {automaticSlack ? : slackRequestUrlInstructions} {endpoint.setup?.webhookVerifiedAt ? (

diff --git a/ui/src/pages/apps/chat/SlackSetupPrompt.tsx b/ui/src/pages/apps/chat/SlackSetupPrompt.tsx index 2cc0568ea77..e1b28ab8afc 100644 --- a/ui/src/pages/apps/chat/SlackSetupPrompt.tsx +++ b/ui/src/pages/apps/chat/SlackSetupPrompt.tsx @@ -26,7 +26,7 @@ For the own-app path, create Slack app automatically. Review Slack app name, Bot 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 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 @ 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. Use Open Slack on Success or on the saved connection page 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 @ 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. diff --git a/ui/src/pages/apps/chat/slack/conversation-url.ts b/ui/src/pages/apps/chat/slack/conversation-url.ts new file mode 100644 index 00000000000..d40a09040a6 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/conversation-url.ts @@ -0,0 +1,8 @@ +import type { ChatEndpoint } from "@/api/chatEndpoints"; + +export function slackConversationUrl(endpoint: Pick, userId: string | null): string { + const account = endpoint.setup?.slackAccount; + return userId && account?.status === "linked" && account.paperclipUserId === userId && account.dmChannelId && endpoint.providerAccountId + ? `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/${encodeURIComponent(account.dmChannelId)}` + : endpoint.setup?.providerUrl ?? "https://app.slack.com"; +} diff --git a/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx b/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx index 172b46d6a77..423a1482b62 100644 --- a/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx +++ b/ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx @@ -166,6 +166,7 @@ export const TestMessage: Story = { 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.getByRole("link", { name: /^Open Slack$/ })).toHaveAttribute("href", "https://app.slack.com"); await expect(canvas.queryByText("Agent avatar")).not.toBeInTheDocument(); await expect(canvas.queryByText("Message in a channel")).not.toBeInTheDocument(); }, diff --git a/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx b/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx index 6fc78465ccb..117444cae60 100644 --- a/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx @@ -35,6 +35,7 @@ export const SendAMessage: Story = { name: "04 · Send a message", args: { scena export const Success: Story = { name: "04 · Success", args: { scenario: "success" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(await canvas.findByRole("heading", { name: "Success" })).toBeVisible(); + await expect(canvas.getByRole("link", { name: /^Open Slack$/ })).toHaveAttribute("href", "https://app.slack.com/client/TSTORY/DSTORY"); } }; export const ChooseAgentSelected: Story = { name: "Choose agent · Advanced defaults", args: { scenario: "choose" }, parameters: { initialEntries: [`${start}&agentId=agent-codex`] }, diff --git a/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx b/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx index 21bc1276b00..172c29e0b3a 100644 --- a/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx +++ b/ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx @@ -57,7 +57,10 @@ export const AddToSlack: Story = { name: "02 · Connect · Returning workspace", } }; 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 Success: Story = { name: "02 · Connect · Success", args: { scenario: "success" }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole("link", { name: /^Open Slack$/ })).toHaveAttribute("href", "https://app.slack.com/client/TSTORY/DSTORY"); +} }; 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" } };