fix(ui): retain Slack conversation handoff after setup (#18722)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Slack setup connects a person with an agent in Slack.
> - Welcome delivery can complete setup before the person chooses Open
Slack.
> - Success and saved settings then offer no link to the conversation.
> - This pull request retains Open Slack in both views.
> - The benefit is that people can use the connected bot without finding
its URL elsewhere.

## Linked Issues or Issue Description

Refs: #18707. This is the next problem found during its deployed user
walkthrough.

**What happened?**

Managed setup completed after welcome delivery. The Open Slack link
disappeared. Success offered Done, and saved settings offered only Slack
app configuration. The bot could answer, but the handoff to its
conversation was lost.

**Expected behavior**

A person can open the connected bot from Success and from the saved
connection.

**Steps to reproduce**

1. Complete managed Slack setup with welcome delivery.
2. Wait for automatic Success before choosing Open Slack.
3. Choose Done and open the saved connection. No conversation link is
available.

## What Changed

- Retain Open Slack on Success while keeping the centered Done action.
- Add Open Slack to completed Slack connection pages. Keep app
configuration separate.
- Use a saved DM only when its linked owner matches the current
signed-in account. Other managers and unknown identities use the
provider-link or Slack fallback.
- Update the existing component regressions, three Storybook cases,
setup guidance and feature recipe.

The Slack UI owns the shared URL helper. This change adds no API or
permission contract and leaves unfinished setup on its existing path.

## Verification

- Focused Slack component and prompt suites: 40 passed. All related chat
UI suites: 269 passed across 19 files. The settings regression switches
from the installer to a second user and then to a signed-out state.
- Repository typecheck and production build: passed.
- Token gates, map structure and Storybook catalog checks: passed. The
map/catalog Node suite passed all 30 tests; these are structural checks.
- Storybook managed and own-app Success play assertions passed. The
managed mobile view retained readable Open Slack and Done controls after
scrolling. Saved settings play assertions passed after a fresh page
load. Fixtures simulate Slack; no live provider actions were sent from
Storybook.
- The first local typecheck rejected an unsupported Storybook assertion
option. It passed after using an exact-name regular expression. The
settings story first hit a stale Vite dynamic-import cache; a normal
reload fetched the current module and all settings play assertions
passed. Restarting the owned review server later emitted Storybook
manager state timeouts; all three production-page play cases passed
again on the corrected head.
- The preceding deployed fix produced real Slack DM and thread replies.
This follow-up still needs deployed acceptance through its visible Open
Slack link.
- Greptile first found that a second manager could receive the
installer's DM link. The correction checks the linked owner against the
resolved account and falls back while identity is unknown. The two
threads have replies and are resolved. Greptile reviewed corrected head
94a6946719 at 5/5 with no new findings.
The optional Paperclip agent check ended neutral without a complete
assessment; one fresh review was requested. No fresh secondary
assessment was available at merge.
- [All required CI suites
passed](https://github.com/paperclipai/paperclip/actions/runs/41571276944)
on corrected head 94a6946719. Full local
Vitest was not repeated for this UI-only follow-up.

## Risks

- Low risk. This adds navigation to existing Slack destinations.
- Old connections without a known DM use their provider link or Slack.
- No schema, credential, scope, identity-link or transport changes. The
UI consumes the existing shared account identity query.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository editing, browser
use and command execution. The runtime does not expose a more precise
model ID or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta authored and GitHub committed 2026-11-02 21:33:59 -06:00
1 parent 09162fe893
commit f8b35b3318
11 files changed
+56 -6

No files matched your search

+4 -2
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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`
@@ -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");
@@ -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() {
</div>
<div className="flex flex-wrap items-center gap-2">
{endpoint.provider === "speko" && <a href="https://platform.speko.ai/agents" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-sm text-primary underline">Open Speko<ExternalLink className="size-3.5" aria-hidden="true" /></a>}
{endpoint.provider === "slack" && !setupIncomplete && <Button asChild variant="outline"><a href={slackConversationUrl(endpoint, identitySettled ? userId : null)} target="_blank" rel="noopener noreferrer">Open Slack <ExternalLink className="size-4" /></a></Button>}
{slackUrl && <Button asChild variant="outline"><a href={slackUrl} target="_blank" rel="noopener noreferrer">Open Slack configuration <ExternalLink className="size-4" /></a></Button>}
{setupIncomplete ? (
<Button
+6 -1
View File
@@ -4,6 +4,8 @@ import { buildSlackAppManifest, defaultSlackAppConfiguration } from "@paperclipa
import { SlackAppDetails, SlackSetupAdvanced } from "./SlackAppDetails";
import { SlackSetup } from "./slack/SlackSetup";
import { isProvisionedSlackSetup, slackSetupState } from "./slack/setup-state";
import { slackConversationUrl } from "./slack/conversation-url";
import { useAccountIdentity } from "@/api/companies-query";
import { defaultSlackAppName } from "./slack-app-name";
import { GitHubChatSetup } from "./GitHubChatSetup";
import { GitHubAgentTrustWarning } from "@/components/GitHubAgentTrustWarning";
@@ -175,6 +177,7 @@ export function ChatEndpointSetup() {
}
function ChatSdkEndpointSetup() {
const { userId, settled: identitySettled } = useAccountIdentity();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -666,6 +669,7 @@ function ChatSdkEndpointSetup() {
<span>You can now talk to {selectedAgent?.name ?? endpoint.assignedAgentName} in Slack</span>
<img src="/brands/apps/slack.svg" alt="" aria-hidden="true" className="size-6" />
</p>
<Button asChild variant="link"><a href={slackConversationUrl(endpoint, identitySettled ? userId : null)} target="_blank" rel="noopener noreferrer">Open Slack <ExternalLink className="size-4" /></a></Button>
</div>
<Button disabled={testConnection.isPending} onClick={() => testConnection.mutate()}>
{testConnection.isPending && <Loader2 className="size-4 animate-spin" />}Done
@@ -749,6 +753,7 @@ function ProviderConnectStep({
values?: Record<string, string>,
) => void;
}) {
const { userId, settled: identitySettled } = useAccountIdentity();
const { pushToast } = useToast();
const navigate = useNavigate();
const slackBotToken = (credentials.botToken ?? "").trim();
@@ -1538,7 +1543,7 @@ function ProviderConnectStep({
<h1 className="text-xl font-bold">{automaticSlack ? "Send a message to your agent" : "Verify Slack connection"}</h1>
{!automaticSlack && <p className="mt-1 text-sm text-muted-foreground">Slack needs to confirm that it can reach your Paperclip instance.</p>}
</div>
{automaticSlack ? <Button asChild variant="outline"><a href={endpoint.setup?.slackAccount?.dmChannelId && endpoint.providerAccountId ? `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/${encodeURIComponent(endpoint.setup.slackAccount.dmChannelId)}` : endpoint.setup?.providerUrl ?? "https://app.slack.com"} target="_blank" rel="noopener noreferrer">Open Slack <ExternalLink className="size-4" /></a></Button> : slackRequestUrlInstructions}
{automaticSlack ? <Button asChild variant="outline"><a href={slackConversationUrl(endpoint, identitySettled ? userId : null)} target="_blank" rel="noopener noreferrer">Open Slack <ExternalLink className="size-4" /></a></Button> : slackRequestUrlInstructions}
{endpoint.setup?.webhookVerifiedAt ? (
<p role="status" className="flex items-center gap-2 text-sm">
<CheckCircle2 className="size-4 text-(--status-task-done)" />
+1 -1
View File
@@ -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 @<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. 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 @<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,8 @@
import type { ChatEndpoint } from "@/api/chatEndpoints";
export function slackConversationUrl(endpoint: Pick<ChatEndpoint, "providerAccountId" | "setup">, 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";
}
@@ -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();
},
@@ -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`] },
@@ -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" } };