diff --git a/ui/src/components/agents/AgentsHome.tsx b/ui/src/components/agents/AgentsHome.tsx
index c1a0b5b2..1fd4174a 100644
--- a/ui/src/components/agents/AgentsHome.tsx
+++ b/ui/src/components/agents/AgentsHome.tsx
@@ -7,7 +7,7 @@ import type { AgentThread, ImageChunk } from "@/lib/agents/types"
import type { CreateAgentThreadVariables } from "@/lib/agents/queries"
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
-import { SlackConnectDialog } from "@/components/agents/SlackConnectDialog"
+import { OnboardingDialog } from "@/components/agents/OnboardingDialog"
import { Logo } from "@/components/agents/ported/Logo"
import {
agentThreadKeys,
@@ -107,7 +107,7 @@ export function AgentsHome() {
return (
-
+
diff --git a/ui/src/components/agents/OnboardingDialog.tsx b/ui/src/components/agents/OnboardingDialog.tsx
new file mode 100644
index 00000000..457d7361
--- /dev/null
+++ b/ui/src/components/agents/OnboardingDialog.tsx
@@ -0,0 +1,205 @@
+import { Dialog } from "@base-ui/react/dialog"
+import { useQuery } from "@tanstack/react-query"
+import { useEffect, useState } from "react"
+import { IoLogoSlack } from "react-icons/io5"
+
+import type { ModelOption } from "@/lib/api"
+import { Button } from "@/components/ui/button"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { api, slackConnectUrl } from "@/lib/api"
+import {
+ buildProfileUpdate,
+ useOptions,
+ useProfile,
+ useSaveProfile,
+} from "@/lib/profile"
+import { useSession } from "@/lib/session"
+
+/**
+ * First-run onboarding modal: pick a default agent model, then connect Slack.
+ *
+ * The model step shows until the user has saved a default model; the Slack step
+ * shows (where Sign in with Slack is enabled) until their Slack account is
+ * linked. Both steps live in the same dialog so a new user is walked through
+ * picking a model and connecting Slack in one place. Dismissing hides it for
+ * the session; an incomplete step reappears on the next login.
+ */
+export function OnboardingDialog() {
+ const session = useSession()
+ const mapping = useQuery({ queryKey: ["myMapping"], queryFn: api.myMapping })
+ const profile = useProfile()
+ const options = useOptions()
+ const save = useSaveProfile()
+ const [dismissed, setDismissed] = useState(false)
+ const [error, setError] = useState(null)
+
+ const firstModel: ModelOption | undefined = options.data?.models[0]
+ const defaultModel = options.data?.default_agent_model ?? firstModel?.id ?? ""
+ const defaultEffort =
+ options.data?.default_agent_reasoning_effort ??
+ firstModel?.default_effort ??
+ ""
+ const [modelId, setModelId] = useState("")
+ const [effort, setEffort] = useState("")
+
+ useEffect(() => {
+ if (!modelId && defaultModel) setModelId(defaultModel)
+ }, [modelId, defaultModel])
+
+ const currentModel: ModelOption | undefined =
+ options.data?.models.find((m) => m.id === modelId) ?? firstModel
+
+ useEffect(() => {
+ if (!currentModel) return
+ if (!effort || !currentModel.efforts.includes(effort)) {
+ setEffort(currentModel.default_effort)
+ }
+ }, [currentModel, effort])
+
+ const slackEnabled = session.data?.slack_oauth_enabled ?? false
+ const slackConnected = !!mapping.data?.slack_user_id
+ const hasDefaultModel = !!profile.data?.default_model
+
+ const needsModel =
+ !profile.isLoading && profile.data !== undefined && !hasDefaultModel
+ const needsSlack =
+ slackEnabled && !slackConnected && !mapping.isLoading && !mapping.isError
+ const open = !dismissed && (needsModel || needsSlack)
+ const step: "model" | "slack" = needsModel ? "model" : "slack"
+
+ const handleSaveModel = () => {
+ if (!modelId) return
+ setError(null)
+ save
+ .mutateAsync(
+ buildProfileUpdate(
+ profile.data,
+ { default_model: modelId, reasoning_effort: effort },
+ defaultModel,
+ defaultEffort
+ )
+ )
+ .catch((e: Error) => setError(e.message))
+ }
+
+ return (
+ {
+ if (!next) setDismissed(true)
+ }}
+ >
+
+
+
+ {step === "model" ? (
+
+
+ Choose your default model
+
+
+ Pick the model Open SWE uses when you don't specify one. You can
+ change this anytime in your settings.
+
+
+
+ Default model
+
+
+
+ Reasoning effort
+
+
+
+ {error &&
{error}
}
+
+
+
+
+
+ ) : (
+
+
+
+
+ Connect your Slack account
+
+
+
+ Connect Slack so that when you tag Open SWE, it can resolve your
+ GitHub account. We use the email Slack verifies, which also lets
+ Linear mentions resolve to you.
+
+
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/ui/src/components/agents/SlackConnectDialog.tsx b/ui/src/components/agents/SlackConnectDialog.tsx
deleted file mode 100644
index 6ddcbb86..00000000
--- a/ui/src/components/agents/SlackConnectDialog.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { Dialog } from "@base-ui/react/dialog"
-import { useQuery } from "@tanstack/react-query"
-import { useState } from "react"
-import { IoLogoSlack } from "react-icons/io5"
-
-import { Button } from "@/components/ui/button"
-import { api, slackConnectUrl } from "@/lib/api"
-import { useSession } from "@/lib/session"
-
-/**
- * Modal shown on first login (and until connected) prompting the user to link
- * Slack so Open SWE can resolve their GitHub account when tagged in Slack.
- * ``open`` is derived from the mapping query, so it appears once the data
- * resolves to "not connected" and closes itself once Slack is linked; dismissing
- * it hides it for the session.
- */
-export function SlackConnectDialog() {
- const session = useSession()
- const mapping = useQuery({ queryKey: ["myMapping"], queryFn: api.myMapping })
- const [dismissed, setDismissed] = useState(false)
-
- const slackEnabled = session.data?.slack_oauth_enabled ?? false
- const connected = !!mapping.data?.slack_user_id
- const shouldShow =
- slackEnabled && !connected && !mapping.isLoading && !mapping.isError
- const open = shouldShow && !dismissed
-
- return (
- {
- if (!next) setDismissed(true)
- }}
- >
-
-
-
-
-
-
-
- Connect your Slack account
-
-
-
- Connect Slack so that when you tag Open SWE, it can resolve your
- GitHub account. We use the email Slack verifies, which also lets
- Linear mentions resolve to you.
-
-