mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-02 01:33:15 +00:00
feat: add default model selection to onboarding dialog (#1542)
Extend the first-run Slack connect modal into a two-step onboarding dialog that first prompts new users to pick a default agent model (persisted to their profile) before connecting Slack, so model choice happens up front during onboarding alongside Slack authentication. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
e3025dee77
commit
3021bbe4b5
3 changed files with 207 additions and 73 deletions
|
|
@ -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 (
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto px-6 py-8">
|
||||
<SlackConnectDialog />
|
||||
<OnboardingDialog />
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col items-center justify-center">
|
||||
<div className="flex w-full flex-col items-center gap-6">
|
||||
<Logo />
|
||||
|
|
|
|||
205
ui/src/components/agents/OnboardingDialog.tsx
Normal file
205
ui/src/components/agents/OnboardingDialog.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setDismissed(true)
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Backdrop className="fixed inset-0 z-50 bg-black/50 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0" />
|
||||
<Dialog.Popup className="fixed top-1/2 left-1/2 z-50 w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-lg bg-popover p-6 text-popover-foreground shadow-md ring-1 ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95">
|
||||
{step === "model" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Dialog.Title className="text-base font-semibold">
|
||||
Choose your default model
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-sm text-muted-foreground">
|
||||
Pick the model Open SWE uses when you don't specify one. You can
|
||||
change this anytime in your settings.
|
||||
</Dialog.Description>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">Default model</span>
|
||||
<Select
|
||||
value={modelId}
|
||||
onValueChange={(v) => v && setModelId(v)}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Pick a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.data?.models.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">Reasoning effort</span>
|
||||
<Select
|
||||
value={effort}
|
||||
onValueChange={(v) => v && setEffort(v)}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currentModel?.efforts.map((e) => (
|
||||
<SelectItem key={e} value={e}>
|
||||
{e}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
Maybe later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveModel}
|
||||
disabled={!modelId || save.isPending}
|
||||
>
|
||||
{save.isPending
|
||||
? "Saving…"
|
||||
: needsSlack
|
||||
? "Save & continue"
|
||||
: "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<IoLogoSlack className="size-6 shrink-0 text-muted-foreground" />
|
||||
<Dialog.Title className="text-base font-semibold">
|
||||
Connect your Slack account
|
||||
</Dialog.Title>
|
||||
</div>
|
||||
<Dialog.Description className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</Dialog.Description>
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
Maybe later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => window.location.assign(slackConnectUrl())}
|
||||
>
|
||||
<IoLogoSlack className="size-4" />
|
||||
Connect Slack
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Popup>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setDismissed(true)
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Backdrop className="fixed inset-0 z-50 bg-black/50 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0" />
|
||||
<Dialog.Popup className="fixed top-1/2 left-1/2 z-50 w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-lg bg-popover p-6 text-popover-foreground shadow-md ring-1 ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<IoLogoSlack className="size-6 shrink-0 text-muted-foreground" />
|
||||
<Dialog.Title className="text-base font-semibold">
|
||||
Connect your Slack account
|
||||
</Dialog.Title>
|
||||
</div>
|
||||
<Dialog.Description className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</Dialog.Description>
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
Maybe later
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => window.location.assign(slackConnectUrl())}
|
||||
>
|
||||
<IoLogoSlack className="size-4" />
|
||||
Connect Slack
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Popup>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue