diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs
index 99480ff8a..3da6e84f9 100644
--- a/desktop/src-tauri/src/commands/media.rs
+++ b/desktop/src-tauri/src/commands/media.rs
@@ -71,7 +71,12 @@ fn fd_real_path(file: &std::fs::File) -> Result {
let handle = file.as_raw_handle() as isize;
let mut buf = vec![0u16; 1024];
let len = unsafe {
- GetFinalPathNameByHandleW(handle, buf.as_mut_ptr(), buf.len() as u32, FILE_NAME_NORMALIZED)
+ GetFinalPathNameByHandleW(
+ handle,
+ buf.as_mut_ptr(),
+ buf.len() as u32,
+ FILE_NAME_NORMALIZED,
+ )
};
if len == 0 {
return Err(format!(
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index b119c686f..4cac69ead 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -2,6 +2,7 @@ import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Settings2 } from "lucide-react";
+import { ChannelPane } from "@/app/ChannelPane";
import { AgentsView } from "@/features/agents/ui/AgentsView";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import {
@@ -35,11 +36,13 @@ import {
} from "@/features/presence/hooks";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
-import { MessageComposer } from "@/features/messages/ui/MessageComposer";
-import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
import { ChannelBrowserDialog } from "@/features/channels/ui/ChannelBrowserDialog";
import { SearchDialog } from "@/features/search/ui/SearchDialog";
-import { SettingsView } from "@/features/settings/ui/SettingsView";
+import {
+ DEFAULT_SETTINGS_SECTION,
+ SettingsView,
+ type SettingsSection,
+} from "@/features/settings/ui/SettingsView";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { relayClient } from "@/shared/api/relayClient";
import { getEventById, joinChannel } from "@/shared/api/tauri";
@@ -53,6 +56,7 @@ import {
} from "@/shared/ui/sidebar";
type AppView = "home" | "channel" | "settings" | "agents";
+type MainView = Exclude;
function createSearchAnchorEvent(hit: SearchHit): RelayEvent {
return {
@@ -68,6 +72,9 @@ function createSearchAnchorEvent(hit: SearchHit): RelayEvent {
export function AppShell() {
const [selectedView, setSelectedView] = React.useState("home");
+ const [settingsSection, setSettingsSection] = React.useState(
+ DEFAULT_SETTINGS_SECTION,
+ );
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
@@ -81,6 +88,7 @@ export function AppShell() {
const [searchAnchorEvent, setSearchAnchorEvent] =
React.useState(null);
const [replyTargetId, setReplyTargetId] = React.useState(null);
+ const lastNonSettingsViewRef = React.useRef("home");
const queryClient = useQueryClient();
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
@@ -268,14 +276,29 @@ export function AppShell() {
[queryClient],
);
- const handleOpenSettings = React.useCallback(() => {
- setIsSearchOpen(false);
- setIsChannelManagementOpen(false);
+ const handleOpenSettings = React.useCallback(
+ (section: SettingsSection = DEFAULT_SETTINGS_SECTION) => {
+ setIsSearchOpen(false);
+ setIsChannelManagementOpen(false);
+ setSettingsSection(section);
+
+ React.startTransition(() => {
+ setSelectedView("settings");
+ });
+ },
+ [],
+ );
+
+ const handleCloseSettings = React.useCallback(() => {
+ const nextView: MainView =
+ lastNonSettingsViewRef.current === "channel" && !selectedChannel
+ ? "home"
+ : lastNonSettingsViewRef.current;
React.startTransition(() => {
- setSelectedView("settings");
+ setSelectedView(nextView);
});
- }, []);
+ }, [selectedChannel]);
const handleOpenSearchResult = React.useCallback(
(hit: SearchHit) => {
@@ -329,6 +352,14 @@ export function AppShell() {
requestedAncestorIdsRef.current.clear();
}, [activeChannelId]);
+ React.useEffect(() => {
+ if (selectedView === "settings") {
+ return;
+ }
+
+ lastNonSettingsViewRef.current = selectedView;
+ }, [selectedView]);
+
React.useEffect(() => {
if (replyTargetId && !replyTargetMessage) {
setReplyTargetId(null);
@@ -411,6 +442,11 @@ export function AppShell() {
}
event.preventDefault();
+ if (selectedView === "settings") {
+ handleCloseSettings();
+ return;
+ }
+
handleOpenSettings();
}
@@ -418,273 +454,235 @@ export function AppShell() {
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
- }, [handleOpenSettings]);
+ }, [handleCloseSettings, handleOpenSettings, selectedView]);
return (
-
- {
- const createdChannel = await createChannelMutation.mutateAsync({
- name,
- description,
- channelType: "stream",
- visibility: "open",
- });
+ {selectedView === "settings" ? (
+
+
+
+ ) : (
+
+
+ {
+ const createdChannel = await createChannelMutation.mutateAsync({
+ name,
+ description,
+ channelType: "stream",
+ visibility: "open",
+ });
- React.startTransition(() => {
- setSelectedChannelId(createdChannel.id);
- setSelectedView("channel");
- });
- }}
- onOpenBrowseChannels={() => {
- setIsBrowseChannelsOpen(true);
- void refetchChannels();
- }}
- onOpenSearch={() => {
- setIsSearchOpen(true);
- void refetchChannels();
- }}
- onSelectAgents={() => {
- React.startTransition(() => {
- setSelectedView("agents");
- });
- }}
- onSelectHome={() => {
- React.startTransition(() => {
- setSelectedView("home");
- });
+ React.startTransition(() => {
+ setSelectedChannelId(createdChannel.id);
+ setSelectedView("channel");
+ });
+ }}
+ onOpenBrowseChannels={() => {
+ setIsBrowseChannelsOpen(true);
+ void refetchChannels();
+ }}
+ onOpenSearch={() => {
+ setIsSearchOpen(true);
+ void refetchChannels();
+ }}
+ onSelectAgents={() => {
+ React.startTransition(() => {
+ setSelectedView("agents");
+ });
+ }}
+ onSelectHome={() => {
+ React.startTransition(() => {
+ setSelectedView("home");
+ });
- void homeFeedQuery.refetch();
- }}
+ void homeFeedQuery.refetch();
+ }}
+ onSelectChannel={handleOpenChannel}
+ onSelectSettings={handleOpenSettings}
+ profile={profileQuery.data}
+ selectedChannelId={selectedChannel?.id ?? null}
+ selectedView={selectedView}
+ unreadChannelIds={unreadChannelIds}
+ />
+
+
+ {selectedView === "home" ? (
+
+ ) : selectedView === "agents" ? (
+
+ ) : (
+ {
+ setIsChannelManagementOpen(true);
+ }}
+ size="icon"
+ type="button"
+ variant="outline"
+ >
+
+
+ ) : null
+ }
+ channelType={activeChannel?.channelType}
+ description={channelDescription}
+ statusBadge={
+ activeChannel?.channelType === "dm" &&
+ activeDmPresenceStatus ? (
+
+ ) : null
+ }
+ title={activeChannel?.name ?? "Channels"}
+ />
+ )}
+
+
+ {selectedView === "home" ? (
+
{
+ void homeFeedQuery.refetch();
+ }}
+ />
+ ) : selectedView === "agents" ? (
+
+ ) : (
+ {
+ setReplyTargetId(null);
+ }}
+ onReply={(message) => {
+ setReplyTargetId((current) =>
+ current === message.id ? null : message.id,
+ );
+ }}
+ onSend={async (content, mentionPubkeys, mediaTags) => {
+ await sendMessageMutation.mutateAsync({
+ content,
+ mentionPubkeys,
+ parentEventId: replyTargetId,
+ mediaTags,
+ });
+ setReplyTargetId(null);
+ }}
+ onTargetReached={(messageId) => {
+ setSearchAnchor((current) =>
+ current?.eventId === messageId ? null : current,
+ );
+ }}
+ onToggleReaction={
+ activeChannel &&
+ activeChannel.archivedAt === null &&
+ activeChannel.channelType !== "forum"
+ ? async (message, emoji, remove) => {
+ await toggleReactionMutation.mutateAsync({
+ emoji,
+ eventId: message.id,
+ remove,
+ });
+ }
+ : undefined
+ }
+ profiles={messageProfilesQuery.data?.profiles}
+ replyTargetId={replyTargetId}
+ replyTargetMessage={replyTargetMessage}
+ targetMessageId={
+ activeChannel &&
+ searchAnchor?.channelId === activeChannel.id
+ ? searchAnchor.eventId
+ : null
+ }
+ />
+ )}
+
+
+
+ )}
+
+
-
- {selectedView === "home" ? (
-
- ) : selectedView === "agents" ? (
-
- ) : selectedView === "settings" ? (
-
- ) : (
- {
- setIsChannelManagementOpen(true);
- }}
- size="icon"
- type="button"
- variant="outline"
- >
-
-
- ) : null
- }
- channelType={activeChannel?.channelType}
- description={channelDescription}
- statusBadge={
- activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
-
- ) : null
- }
- title={activeChannel?.name ?? "Channels"}
- />
- )}
+
-
- {selectedView === "home" ? (
-
{
- void homeFeedQuery.refetch();
- }}
- />
- ) : selectedView === "agents" ? (
-
- ) : selectedView === "settings" ? (
-
- ) : (
-
- {
- setReplyTargetId((current) =>
- current === message.id ? null : message.id,
- );
- }}
- onToggleReaction={
- activeChannel &&
- activeChannel.archivedAt === null &&
- activeChannel.channelType !== "forum"
- ? async (message, emoji, remove) => {
- await toggleReactionMutation.mutateAsync({
- emoji,
- eventId: message.id,
- remove,
- });
- }
- : undefined
- }
- onTargetReached={(messageId) => {
- setSearchAnchor((current) =>
- current?.eventId === messageId ? null : current,
- );
- }}
- targetMessageId={
- activeChannel && searchAnchor?.channelId === activeChannel.id
- ? searchAnchor.eventId
- : null
- }
- />
- {
- setReplyTargetId(null);
- }}
- onSend={async (content, mentionPubkeys, mediaTags) => {
- await sendMessageMutation.mutateAsync({
- content,
- mentionPubkeys,
- parentEventId: replyTargetId,
- mediaTags,
- });
- setReplyTargetId(null);
- }}
- placeholder={
- activeChannel?.archivedAt
- ? "Archived channels are read-only."
- : activeChannel && !activeChannel.isMember
- ? "Join this channel to message."
- : activeChannel?.channelType === "forum"
- ? "Forum posting is not wired in this pass."
- : activeChannel
- ? `Message #${activeChannel.name}`
- : "Select a channel"
- }
- replyTarget={
- replyTargetMessage
- ? {
- author: replyTargetMessage.author,
- body: replyTargetMessage.body,
- id: replyTargetMessage.id,
- }
- : null
- }
- />
-
- )}
-
-
-
-
-
-
- {
- React.startTransition(() => {
- setIsChannelManagementOpen(false);
- setSelectedView("home");
- });
- }}
- onOpenChange={setIsChannelManagementOpen}
- open={isChannelManagementOpen && activeChannel !== null}
- />
-
+ {
+ React.startTransition(() => {
+ setIsChannelManagementOpen(false);
+ setSelectedView("home");
+ });
+ }}
+ onOpenChange={setIsChannelManagementOpen}
+ open={isChannelManagementOpen && activeChannel !== null}
+ />
);
}
diff --git a/desktop/src/app/ChannelPane.tsx b/desktop/src/app/ChannelPane.tsx
new file mode 100644
index 000000000..f389cfc8a
--- /dev/null
+++ b/desktop/src/app/ChannelPane.tsx
@@ -0,0 +1,111 @@
+import * as React from "react";
+
+import { MessageComposer } from "@/features/messages/ui/MessageComposer";
+import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
+import type { TimelineMessage } from "@/features/messages/types";
+import type { UserProfileLookup } from "@/features/profile/lib/identity";
+import type { Channel } from "@/shared/api/types";
+
+type ChannelPaneProps = {
+ activeChannel: Channel | null;
+ currentPubkey?: string;
+ isSending: boolean;
+ isTimelineLoading: boolean;
+ messages: TimelineMessage[];
+ onCancelReply: () => void;
+ onReply: (message: TimelineMessage) => void;
+ onSend: (
+ content: string,
+ mentionPubkeys: string[],
+ mediaTags?: string[][],
+ ) => Promise;
+ onTargetReached: (messageId: string) => void;
+ onToggleReaction?: (
+ message: TimelineMessage,
+ emoji: string,
+ remove: boolean,
+ ) => Promise;
+ profiles?: UserProfileLookup;
+ replyTargetId: string | null;
+ replyTargetMessage: TimelineMessage | null;
+ targetMessageId: string | null;
+};
+
+export function ChannelPane({
+ activeChannel,
+ currentPubkey,
+ isSending,
+ isTimelineLoading,
+ messages,
+ onCancelReply,
+ onReply,
+ onSend,
+ onTargetReached,
+ onToggleReaction,
+ profiles,
+ replyTargetId,
+ replyTargetMessage,
+ targetMessageId,
+}: ChannelPaneProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx
index 4254a55ef..763c04fb8 100644
--- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx
+++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx
@@ -23,7 +23,6 @@ import {
import {
CreateAgentBasicsFields,
CreateAgentOptionToggles,
- CreateAgentPrerequisitesCard,
CreateAgentRuntimeProviderField,
CreateAgentRuntimeFields,
CreateAgentTokenSection,
@@ -74,27 +73,6 @@ export function CreateAgentDialog({
const spawnToggleDisabled =
prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported);
const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading;
- const prerequisiteCards = [
- {
- id: "admin",
- label: "Token minting",
- info: prereqs?.admin ?? null,
- command: prereqs?.admin.command ?? "sprout-admin",
- },
- {
- id: "acp",
- label: "ACP harness",
- info: prereqs?.acp ?? null,
- command: prereqs?.acp.command ?? (acpCommand.trim() || "sprout-acp"),
- },
- {
- id: "mcp",
- label: "MCP server",
- info: prereqs?.mcp ?? null,
- command:
- prereqs?.mcp.command ?? (mcpCommand.trim() || "sprout-mcp-server"),
- },
- ];
React.useEffect(() => {
if (hasSyncedProviderSelection || providersQuery.isLoading) {
@@ -302,8 +280,7 @@ export function CreateAgentDialog({
Advanced setup
- Relay overrides, raw commands, timeout, and local binary
- checks.
+ Relay overrides, raw commands, timeout, and doctor guidance.
@@ -332,11 +309,10 @@ export function CreateAgentDialog({
turnTimeoutSeconds={turnTimeoutSeconds}
/>
-
+
+ Local Sprout binary checks and ACP runtime discovery now
+ live in Settings > Doctor.
+
{providersQuery.error instanceof Error ? (
diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx
index abde304d4..c78798387 100644
--- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx
+++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx
@@ -1,6 +1,5 @@
import type {
AcpProvider,
- CommandAvailability,
ManagedAgentPrereqs,
TokenScope,
} from "@/shared/api/types";
@@ -9,13 +8,6 @@ import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { describeResolvedCommand } from "./agentUi";
-export type PrerequisiteCard = {
- id: string;
- label: string;
- info: CommandAvailability | null;
- command: string;
-};
-
export function CreateAgentBasicsFields({
name,
onNameChange,
@@ -215,74 +207,6 @@ export function CreateAgentRuntimeFields({
);
}
-export function CreateAgentPrerequisitesCard({
- isLoading,
- prereqs,
- prerequisiteCards,
-}: {
- isLoading: boolean;
- prereqs: ManagedAgentPrereqs | null;
- prerequisiteCards: PrerequisiteCard[];
-}) {
- return (
-
-
-
-
- Local Sprout binaries
-
-
- The desktop app uses these commands to mint tokens and spawn
- harnesses.
-
-
- {isLoading ? (
-
Checking...
- ) : null}
-
-
-
- {prerequisiteCards.map((card) => (
-
-
- {card.label}
-
-
{card.command}
-
- {card.info?.resolvedPath
- ? `Available via ${describeResolvedCommand(card.command, card.info.resolvedPath)}`
- : isLoading
- ? "Looking for a matching binary..."
- : "Not currently available."}
-
-
- ))}
-
-
- {prereqs &&
- (!prereqs.admin.available ||
- !prereqs.acp.available ||
- !prereqs.mcp.available) ? (
-
- Build the workspace binaries with `cargo build --release --workspace`
- or point the command fields at installed binaries before enabling
- token minting or spawn.
-
- ) : null}
-
- );
-}
-
export function CreateAgentOptionToggles({
isMintSupported,
isSpawnSupported,
diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx
new file mode 100644
index 000000000..6097f7233
--- /dev/null
+++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx
@@ -0,0 +1,368 @@
+import {
+ AlertTriangle,
+ CheckCircle2,
+ RefreshCw,
+ Sparkles,
+ Stethoscope,
+ TerminalSquare,
+} from "lucide-react";
+import * as React from "react";
+
+import {
+ useAcpProvidersQuery,
+ useManagedAgentPrereqsQuery,
+} from "@/features/agents/hooks";
+import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
+import type { CommandAvailability } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
+import { Button } from "@/shared/ui/button";
+import { Input } from "@/shared/ui/input";
+
+function StatusIcon({ available }: { available: boolean }) {
+ return available ? (
+
+ ) : (
+
+ );
+}
+
+function CommandCheckRow({
+ availability,
+ id,
+ isLoading,
+ label,
+ purpose,
+}: {
+ availability: CommandAvailability | null;
+ id: string;
+ isLoading: boolean;
+ label: string;
+ purpose: string;
+}) {
+ const command = availability?.command ?? "Unavailable";
+ const isAvailable = availability?.available ?? false;
+
+ return (
+
+
+
+
+
+
+
+
{label}
+
+ {command}
+
+
+
{purpose}
+
+ {availability?.resolvedPath
+ ? `Available via ${describeResolvedCommand(command, availability.resolvedPath)}`
+ : isLoading
+ ? "Checking for a matching binary..."
+ : "Not currently available."}
+
+ {availability?.resolvedPath ? (
+
+ {availability.resolvedPath}
+
+ ) : null}
+
+
+ );
+}
+
+function ProviderRow({
+ command,
+ defaultArgs,
+ label,
+ providerId,
+ resolvedPath,
+}: {
+ command: string;
+ defaultArgs: string[];
+ label: string;
+ providerId: string;
+ resolvedPath: string;
+}) {
+ return (
+
+
+
+
+
+
+
+
{label}
+
+ {command}
+
+
+
+ Available via {describeResolvedCommand(command, resolvedPath)}.
+
+ {defaultArgs.length > 0 ? (
+
+ Default args:{" "}
+ {defaultArgs.join(", ")}
+
+ ) : null}
+
+ {resolvedPath}
+
+
+
+ );
+}
+
+function SetupHelpCard() {
+ return (
+
+
+
+
Setup help
+
+
+
+
+ Build the local Sprout tools with{" "}
+
+ cargo build --release --workspace
+ {" "}
+ when you want the desktop app to mint tokens or spawn ACP harnesses
+ from this checkout.
+
+
+ If you keep binaries outside your PATH, use the custom ACP and MCP
+ commands below and then copy those same values into Create agent >
+ Advanced setup.
+
+
+ ACP runtimes like Goose or Codex are optional. They appear
+ automatically once their commands are installed on your PATH.
+
+
+
+ );
+}
+
+export function DoctorSettingsPanel() {
+ const [acpCommand, setAcpCommand] = React.useState("sprout-acp");
+ const [mcpCommand, setMcpCommand] = React.useState("sprout-mcp-server");
+ const providersQuery = useAcpProvidersQuery();
+ const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand);
+ const prereqs = prereqsQuery.data ?? null;
+ const providers = providersQuery.data ?? [];
+ const isRefreshing = providersQuery.isFetching || prereqsQuery.isFetching;
+
+ const toolChecks = [
+ {
+ id: "admin",
+ label: "Token minting",
+ purpose:
+ "Desktop uses `sprout-admin` to mint managed-agent bearer tokens.",
+ availability: prereqs?.admin ?? null,
+ },
+ {
+ id: "acp",
+ label: "ACP harness",
+ purpose:
+ "Desktop launches this command to bridge a local runtime into ACP.",
+ availability: prereqs?.acp ?? null,
+ },
+ {
+ id: "mcp",
+ label: "MCP server",
+ purpose:
+ "Desktop uses this server when the ACP harness requests Sprout tools.",
+ availability: prereqs?.mcp ?? null,
+ },
+ ];
+
+ const hasMissingSproutTools =
+ prereqs !== null &&
+ (!prereqs.admin.available ||
+ !prereqs.acp.available ||
+ !prereqs.mcp.available);
+
+ return (
+
+
+
+
+
+
+
Doctor
+
+
+ Verify the local Sprout tools and ACP runtime commands used by the
+ desktop app.
+
+
+
+
{
+ void providersQuery.refetch();
+ void prereqsQuery.refetch();
+ }}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+
+ Re-run
+
+
+
+
+
+
+
+
+
+ Local Sprout binaries
+
+
+
+ These checks replace the old binary status card from Create
+ agent.
+
+
+
+ {toolChecks.map((check) => (
+
+ ))}
+
+
+ {hasMissingSproutTools ? (
+
+ Build the workspace binaries with{" "}
+
+ cargo build --release --workspace
+ {" "}
+ or point agent creation at custom ACP and MCP commands.
+
+ ) : null}
+
+ {prereqsQuery.error instanceof Error ? (
+
+ {prereqsQuery.error.message}
+
+ ) : null}
+
+
+
+
+ Custom harness commands
+
+
+ Verify non-default ACP or MCP binaries before using them in
+ agent creation.
+
+
+
+
+
+ Token minting always checks the default{" "}
+ sprout-admin command.
+
+
+
+
+
+
+
+ ACP runtimes
+
+
+ Installed runtimes that the desktop app can offer in Create
+ agent.
+
+
+
+ {providersQuery.isLoading ? (
+
+ Looking for installed ACP runtimes...
+
+ ) : providers.length > 0 ? (
+ providers.map((provider) => (
+
+ ))
+ ) : (
+
+ No known ACP runtime was detected on your PATH yet. You can
+ still use a custom command in Create agent.
+
+ )}
+
+
+ {providersQuery.error instanceof Error ? (
+
+ {providersQuery.error.message}
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx
new file mode 100644
index 000000000..09541b2e8
--- /dev/null
+++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx
@@ -0,0 +1,315 @@
+import { AtSign, Check, Fingerprint, Link2, UserRound } from "lucide-react";
+import * as React from "react";
+
+import {
+ useProfileQuery,
+ useUpdateProfileMutation,
+} from "@/features/profile/hooks";
+import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import { Button } from "@/shared/ui/button";
+import { Input } from "@/shared/ui/input";
+import { Separator } from "@/shared/ui/separator";
+import { Textarea } from "@/shared/ui/textarea";
+
+type ProfileSettingsCardProps = {
+ currentPubkey?: string;
+ fallbackDisplayName?: string;
+};
+
+function Section({
+ title,
+ description,
+ children,
+}: React.PropsWithChildren<{
+ title: string;
+ description?: string;
+}>) {
+ return (
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+ {children}
+
+ );
+}
+
+function ReadOnlyField({
+ label,
+ value,
+ testId,
+}: {
+ label: string;
+ value: string;
+ testId: string;
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
+
+export function ProfileSettingsCard({
+ currentPubkey,
+ fallbackDisplayName,
+}: ProfileSettingsCardProps) {
+ const profileQuery = useProfileQuery();
+ const updateProfileMutation = useUpdateProfileMutation();
+ const profile = profileQuery.data;
+
+ const currentDisplayName = profile?.displayName ?? "";
+ const currentAvatarUrl = profile?.avatarUrl ?? "";
+ const currentAbout = profile?.about ?? "";
+ const currentNip05Handle = profile?.nip05Handle ?? "";
+
+ const [displayNameDraft, setDisplayNameDraft] = React.useState("");
+ const [avatarUrlDraft, setAvatarUrlDraft] = React.useState("");
+ const [aboutDraft, setAboutDraft] = React.useState("");
+ const [nip05HandleDraft, setNip05HandleDraft] = React.useState("");
+
+ React.useEffect(() => {
+ setDisplayNameDraft(currentDisplayName);
+ setAvatarUrlDraft(currentAvatarUrl);
+ setAboutDraft(currentAbout);
+ setNip05HandleDraft(currentNip05Handle);
+ }, [currentAbout, currentAvatarUrl, currentDisplayName, currentNip05Handle]);
+
+ const nextDisplayName = displayNameDraft.trim();
+ const nextAvatarUrl = avatarUrlDraft.trim();
+ const nextAbout = aboutDraft.trim();
+ const nextNip05Handle = nip05HandleDraft.trim();
+
+ const updatePayload: {
+ displayName?: string;
+ avatarUrl?: string;
+ about?: string;
+ nip05Handle?: string;
+ } = {};
+
+ if (nextDisplayName.length > 0 && nextDisplayName !== currentDisplayName) {
+ updatePayload.displayName = nextDisplayName;
+ }
+ if (nextAvatarUrl.length > 0 && nextAvatarUrl !== currentAvatarUrl) {
+ updatePayload.avatarUrl = nextAvatarUrl;
+ }
+ if (nextAbout.length > 0 && nextAbout !== currentAbout) {
+ updatePayload.about = nextAbout;
+ }
+ if (nextNip05Handle !== currentNip05Handle) {
+ updatePayload.nip05Handle = nextNip05Handle;
+ }
+
+ const hasPendingClearRequest =
+ (currentDisplayName.length > 0 && nextDisplayName.length === 0) ||
+ (currentAvatarUrl.length > 0 && nextAvatarUrl.length === 0) ||
+ (currentAbout.length > 0 && nextAbout.length === 0);
+ const canSave =
+ Object.keys(updatePayload).length > 0 && !updateProfileMutation.isPending;
+
+ const resolvedName =
+ nextDisplayName ||
+ profile?.displayName ||
+ fallbackDisplayName ||
+ "Your profile";
+ const resolvedPubkey = profile?.pubkey ?? currentPubkey ?? "Unavailable";
+ const resolvedAvatarUrl =
+ nextAvatarUrl.length > 0 ? nextAvatarUrl : (profile?.avatarUrl ?? null);
+ const nip05Handle = profile?.nip05Handle ?? "Not set";
+
+ return (
+
+
+
+
+
+
+ {resolvedName}
+
+
+ Manage how your identity appears across Sprout.
+
+
+
+
+ Your relay profile
+
+
+
+
+
+ {profileQuery.error instanceof Error ? (
+
+ {profileQuery.error.message}
+
+ ) : null}
+
+ {updateProfileMutation.error instanceof Error ? (
+
+ {updateProfileMutation.error.message}
+
+ ) : null}
+
+ {updateProfileMutation.isSuccess ? (
+
+
+ Profile saved.
+
+ ) : null}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
new file mode 100644
index 000000000..bea3bcdde
--- /dev/null
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -0,0 +1,290 @@
+import {
+ CircleDot,
+ KeyRound,
+ MonitorCog,
+ Moon,
+ Stethoscope,
+ Sun,
+ UserRound,
+ type LucideIcon,
+} from "lucide-react";
+import {
+ PresenceBadge,
+ PresenceDot,
+} from "@/features/presence/ui/PresenceBadge";
+import { TokenSettingsCard } from "@/features/tokens/ui/TokenSettingsCard";
+import type { PresenceStatus } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
+import { useTheme } from "@/shared/theme/ThemeProvider";
+import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
+import { ProfileSettingsCard } from "./ProfileSettingsCard";
+
+export type SettingsSection =
+ | "profile"
+ | "presence"
+ | "appearance"
+ | "tokens"
+ | "doctor";
+
+export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
+
+export type SettingsSectionDescriptor = {
+ value: SettingsSection;
+ label: string;
+ icon: LucideIcon;
+};
+
+export type SettingsPanelProps = {
+ currentPubkey?: string;
+ fallbackDisplayName?: string;
+ isPresenceLoading: boolean;
+ isUpdatingPresence: boolean;
+ onSetPresence: (status: PresenceStatus) => Promise;
+ presenceError: Error | null;
+ presenceStatus: PresenceStatus;
+};
+
+type ThemeOption = {
+ value: "light" | "dark" | "system";
+ label: string;
+ icon: LucideIcon;
+};
+
+export const settingsSections: SettingsSectionDescriptor[] = [
+ {
+ value: "profile",
+ label: "Profile",
+ icon: UserRound,
+ },
+ {
+ value: "presence",
+ label: "Presence",
+ icon: CircleDot,
+ },
+ {
+ value: "appearance",
+ label: "Appearance",
+ icon: MonitorCog,
+ },
+ {
+ value: "tokens",
+ label: "Tokens",
+ icon: KeyRound,
+ },
+ {
+ value: "doctor",
+ label: "Doctor",
+ icon: Stethoscope,
+ },
+];
+
+const themeOptions: ThemeOption[] = [
+ {
+ value: "light",
+ label: "Light",
+ icon: Sun,
+ },
+ {
+ value: "dark",
+ label: "Dark",
+ icon: Moon,
+ },
+ {
+ value: "system",
+ label: "System",
+ icon: MonitorCog,
+ },
+];
+
+const presenceOptions: Array<{
+ value: PresenceStatus;
+ label: string;
+ description: string;
+}> = [
+ {
+ value: "online",
+ label: "Online",
+ description:
+ "Automatically active while you use the app and away when idle.",
+ },
+ {
+ value: "away",
+ label: "Away",
+ description:
+ "Forces this desktop session to appear idle until you change it.",
+ },
+ {
+ value: "offline",
+ label: "Offline",
+ description: "Hides this desktop session and stops presence heartbeats.",
+ },
+];
+
+function ThemeSettingsCard() {
+ const { setTheme, theme } = useTheme();
+
+ return (
+
+
+
+
Appearance
+
+ Choose how Sprout looks on this device.
+
+
+
+
+ {themeOptions.map(({ value, label, icon: Icon }) => {
+ const isActive = theme === value;
+
+ return (
+ {
+ setTheme(value);
+ }}
+ type="button"
+ >
+
+ {label}
+
+ );
+ })}
+
+
+
+ );
+}
+
+function PresenceStatusBadge({ status }: { status: PresenceStatus }) {
+ return (
+
+ );
+}
+
+function PresenceSettingsCard({
+ isLoading,
+ isUpdating,
+ onSetPresence,
+ presenceError,
+ presenceStatus,
+}: {
+ isLoading: boolean;
+ isUpdating: boolean;
+ onSetPresence: (status: PresenceStatus) => Promise;
+ presenceError: Error | null;
+ presenceStatus: PresenceStatus;
+}) {
+ return (
+
+
+
+
Presence
+
+ Choose how this desktop session appears on the relay.
+
+
+
+
+
+
+ {presenceOptions.map((option) => {
+ const isActive = presenceStatus === option.value;
+
+ return (
+
{
+ void onSetPresence(option.value);
+ }}
+ type="button"
+ >
+
+
+ {option.description}
+
+
+ );
+ })}
+
+
+ {presenceError ? (
+
+ {presenceError.message}
+
+ ) : null}
+
+
+ Sprout refreshes presence every minute while it is running. Online will
+ switch to away after a few minutes of inactivity or when the app is
+ hidden. The relay expires presence after 90 seconds.
+
+
+ );
+}
+
+export function renderSettingsSection(
+ section: SettingsSection,
+ props: SettingsPanelProps,
+): React.ReactNode {
+ switch (section) {
+ case "profile":
+ return (
+
+ );
+ case "presence":
+ return (
+
+ );
+ case "appearance":
+ return ;
+ case "tokens":
+ return ;
+ case "doctor":
+ return ;
+ default: {
+ const exhaustiveCheck: never = section;
+ return exhaustiveCheck;
+ }
+ }
+}
diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx
index c553622cc..1d6436101 100644
--- a/desktop/src/features/settings/ui/SettingsView.tsx
+++ b/desktop/src/features/settings/ui/SettingsView.tsx
@@ -1,521 +1,75 @@
-import {
- AtSign,
- Check,
- Fingerprint,
- Link2,
- MonitorCog,
- Moon,
- Sun,
- UserRound,
- type LucideIcon,
-} from "lucide-react";
-import * as React from "react";
+import { getCurrentWindow } from "@tauri-apps/api/window";
+import { X } from "lucide-react";
+import type * as React from "react";
-import {
- useProfileQuery,
- useUpdateProfileMutation,
-} from "@/features/profile/hooks";
-import { TokenSettingsCard } from "@/features/tokens/ui/TokenSettingsCard";
-import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
-import {
- PresenceBadge,
- PresenceDot,
-} from "@/features/presence/ui/PresenceBadge";
-import type { PresenceStatus } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
-import { useTheme } from "@/shared/theme/ThemeProvider";
import { Button } from "@/shared/ui/button";
-import { Input } from "@/shared/ui/input";
-import { Separator } from "@/shared/ui/separator";
-import { Textarea } from "@/shared/ui/textarea";
+import {
+ renderSettingsSection,
+ settingsSections,
+ type SettingsPanelProps,
+ type SettingsSection,
+} from "./SettingsPanels";
-type SettingsViewProps = {
- currentPubkey?: string;
- fallbackDisplayName?: string;
- isPresenceLoading: boolean;
- isUpdatingPresence: boolean;
- onSetPresence: (status: PresenceStatus) => Promise;
- presenceError: Error | null;
- presenceStatus: PresenceStatus;
+export {
+ DEFAULT_SETTINGS_SECTION,
+ type SettingsSection,
+} from "./SettingsPanels";
+
+type SettingsViewProps = SettingsPanelProps & {
+ onClose: () => void;
+ onSectionChange: (section: SettingsSection) => void;
+ section: SettingsSection;
};
-type ThemeOption = {
- value: "light" | "dark" | "system";
- label: string;
- icon: LucideIcon;
-};
+function handleSettingsHeaderPointerDown(event: React.PointerEvent) {
+ if (event.button !== 0) {
+ return;
+ }
-const themeOptions: ThemeOption[] = [
- {
- value: "light",
- label: "Light",
- icon: Sun,
- },
- {
- value: "dark",
- label: "Dark",
- icon: Moon,
- },
- {
- value: "system",
- label: "System",
- icon: MonitorCog,
- },
-];
+ const target = event.target as HTMLElement;
+ if (target.closest('button, a, input, textarea, [role="button"]')) {
+ return;
+ }
-const presenceOptions: Array<{
- value: PresenceStatus;
- label: string;
- description: string;
-}> = [
- {
- value: "online",
- label: "Online",
- description:
- "Automatically active while you use the app and away when idle.",
- },
- {
- value: "away",
- label: "Away",
- description:
- "Forces this desktop session to appear idle until you change it.",
- },
- {
- value: "offline",
- label: "Offline",
- description: "Hides this desktop session and stops presence heartbeats.",
- },
-];
-
-function Section({
- title,
- description,
- children,
-}: React.PropsWithChildren<{
- title: string;
- description?: string;
-}>) {
- return (
-
-
-
{title}
- {description ? (
-
{description}
- ) : null}
-
- {children}
-
- );
+ event.preventDefault();
+ getCurrentWindow().startDragging();
}
-function ReadOnlyField({
- label,
- value,
- testId,
+function SettingsSectionButton({
+ active,
+ onSelect,
+ section,
}: {
- label: string;
- value: string;
- testId: string;
+ active: boolean;
+ onSelect: (section: SettingsSection) => void;
+ section: (typeof settingsSections)[number];
}) {
- return (
-
-
{label}
-
- {value}
-
-
- );
-}
-
-function ThemeSettingsCard() {
- const { setTheme, theme } = useTheme();
+ const Icon = section.icon;
return (
- onSelect(section.value)}
+ type="button"
>
-
-
-
Appearance
-
- Choose how Sprout looks on this device.
-
-
-
-
- {themeOptions.map(({ value, label, icon: Icon }) => {
- const isActive = theme === value;
-
- return (
- {
- setTheme(value);
- }}
- type="button"
- >
-
- {label}
-
- );
- })}
-
-
-
- );
-}
-
-function PresenceStatusBadge({ status }: { status: PresenceStatus }) {
- return (
-
- );
-}
-
-function PresenceSettingsCard({
- isLoading,
- isUpdating,
- onSetPresence,
- presenceError,
- presenceStatus,
-}: {
- isLoading: boolean;
- isUpdating: boolean;
- onSetPresence: (status: PresenceStatus) => Promise;
- presenceError: Error | null;
- presenceStatus: PresenceStatus;
-}) {
- return (
-
-
-
-
Presence
-
- Choose how this desktop session appears on the relay.
-
-
-
-
-
-
- {presenceOptions.map((option) => {
- const isActive = presenceStatus === option.value;
-
- return (
-
{
- void onSetPresence(option.value);
- }}
- type="button"
- >
-
-
- {option.description}
-
-
- );
- })}
-
-
- {presenceError ? (
-
- {presenceError.message}
-
- ) : null}
-
-
- Sprout refreshes presence every minute while it is running. Online will
- switch to away after a few minutes of inactivity or when the app is
- hidden. The relay expires presence after 90 seconds.
-
-
- );
-}
-
-function ProfileSettingsCard({
- currentPubkey,
- fallbackDisplayName,
-}: Pick) {
- const profileQuery = useProfileQuery();
- const updateProfileMutation = useUpdateProfileMutation();
- const profile = profileQuery.data;
-
- const currentDisplayName = profile?.displayName ?? "";
- const currentAvatarUrl = profile?.avatarUrl ?? "";
- const currentAbout = profile?.about ?? "";
- const currentNip05Handle = profile?.nip05Handle ?? "";
-
- const [displayNameDraft, setDisplayNameDraft] = React.useState("");
- const [avatarUrlDraft, setAvatarUrlDraft] = React.useState("");
- const [aboutDraft, setAboutDraft] = React.useState("");
- const [nip05HandleDraft, setNip05HandleDraft] = React.useState("");
-
- React.useEffect(() => {
- setDisplayNameDraft(currentDisplayName);
- setAvatarUrlDraft(currentAvatarUrl);
- setAboutDraft(currentAbout);
- setNip05HandleDraft(currentNip05Handle);
- }, [currentAbout, currentAvatarUrl, currentDisplayName, currentNip05Handle]);
-
- const nextDisplayName = displayNameDraft.trim();
- const nextAvatarUrl = avatarUrlDraft.trim();
- const nextAbout = aboutDraft.trim();
- const nextNip05Handle = nip05HandleDraft.trim();
-
- const updatePayload: {
- displayName?: string;
- avatarUrl?: string;
- about?: string;
- nip05Handle?: string;
- } = {};
-
- if (nextDisplayName.length > 0 && nextDisplayName !== currentDisplayName) {
- updatePayload.displayName = nextDisplayName;
- }
- if (nextAvatarUrl.length > 0 && nextAvatarUrl !== currentAvatarUrl) {
- updatePayload.avatarUrl = nextAvatarUrl;
- }
- if (nextAbout.length > 0 && nextAbout !== currentAbout) {
- updatePayload.about = nextAbout;
- }
- if (nextNip05Handle !== currentNip05Handle) {
- updatePayload.nip05Handle = nextNip05Handle;
- }
-
- const hasPendingClearRequest =
- (currentDisplayName.length > 0 && nextDisplayName.length === 0) ||
- (currentAvatarUrl.length > 0 && nextAvatarUrl.length === 0) ||
- (currentAbout.length > 0 && nextAbout.length === 0);
- const canSave =
- Object.keys(updatePayload).length > 0 && !updateProfileMutation.isPending;
-
- const resolvedName =
- nextDisplayName ||
- profile?.displayName ||
- fallbackDisplayName ||
- "Your profile";
- const resolvedPubkey = profile?.pubkey ?? currentPubkey ?? "Unavailable";
- const resolvedAvatarUrl =
- nextAvatarUrl.length > 0 ? nextAvatarUrl : (profile?.avatarUrl ?? null);
- const nip05Handle = profile?.nip05Handle ?? "Not set";
-
- return (
-
-
-
-
-
-
- {resolvedName}
-
-
- Manage how your identity appears across Sprout.
-
-
-
-
- Your relay profile
-
-
-
-
-
- {profileQuery.error instanceof Error ? (
-
- {profileQuery.error.message}
-
- ) : null}
-
- {updateProfileMutation.error instanceof Error ? (
-
- {updateProfileMutation.error.message}
-
- ) : null}
-
- {updateProfileMutation.isSuccess ? (
-
-
- Profile saved.
-
- ) : null}
-
-
-
-
-
-
- {
- event.preventDefault();
- if (!canSave) {
- return;
- }
-
- void updateProfileMutation.mutateAsync(updatePayload);
- }}
- >
-
-
- Display name
-
-
-
- setDisplayNameDraft(event.target.value)}
- placeholder="How people should see you"
- value={displayNameDraft}
- />
-
-
-
-
-
- NIP-05 handle
-
-
-
-
setNip05HandleDraft(event.target.value)}
- placeholder="alice@localhost"
- value={nip05HandleDraft}
- />
-
-
- Must match this relay's domain. Leave blank to clear your
- current handle.
-
-
-
-
-
- Avatar URL
-
-
-
- setAvatarUrlDraft(event.target.value)}
- placeholder="https://example.com/avatar.png"
- value={avatarUrlDraft}
- />
-
-
-
-
-
- About
-
-
-
-
setAboutDraft(event.target.value)}
- placeholder="A short description for your profile"
- value={aboutDraft}
- />
-
-
-
-
- {updateProfileMutation.isPending ? "Saving..." : "Save profile"}
-
-
- {hasPendingClearRequest ? (
-
- Clearing existing profile fields is not supported yet. Blank
- display name, avatar, and about values are ignored for now.
-
- ) : null}
-
-
-
-
+
+ {section.label}
+
);
}
@@ -524,29 +78,82 @@ export function SettingsView({
fallbackDisplayName,
isPresenceLoading,
isUpdatingPresence,
+ onClose,
+ onSectionChange,
onSetPresence,
presenceError,
presenceStatus,
+ section,
}: SettingsViewProps) {
return (
-
-
-
-
-
+
+
+
+
+
+ {settingsSections.map((entry) => (
+
+ ))}
+
+
+
+
+
+ {renderSettingsSection(section, {
+ currentPubkey,
+ fallbackDisplayName,
+ isPresenceLoading,
+ isUpdatingPresence,
+ onSetPresence,
+ presenceError,
+ presenceStatus,
+ })}
+
+
);
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 04084030c..78af23595 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -566,7 +566,7 @@ export function AppSidebar({
className="h-auto gap-3 rounded-xl px-2 py-2"
data-testid="open-settings"
isActive={selectedView === "settings"}
- onClick={onSelectSettings}
+ onClick={() => onSelectSettings()}
type="button"
>
{
await page.getByTestId("open-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
- await expect(page.getByTestId("chat-title")).toHaveText("Settings");
- await expect(page.getByTestId("open-settings")).toHaveAttribute(
- "aria-pressed",
- "true",
- );
+ await page.getByTestId("settings-nav-profile").click();
+ await expect(page.getByTestId("settings-title")).toHaveText("Settings");
+ await expect(page.getByTestId("open-settings")).toHaveCount(0);
await expect(page.getByTestId("profile-pubkey")).toContainText("deadbeef");
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
@@ -42,15 +40,13 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
await expect(page.getByTestId("profile-avatar-url")).toHaveValue(avatarUrl);
await expect(page.getByTestId("profile-about")).toHaveValue(about);
- await page.getByRole("button", { name: "Home" }).click();
+ await page.getByTestId("settings-close").click();
await expect(page.getByTestId("chat-title")).toHaveText("Home");
- await expect(page.getByTestId("open-settings")).toHaveAttribute(
- "aria-pressed",
- "false",
- );
+ await expect(page.getByTestId("open-settings")).toBeVisible();
await page.getByTestId("open-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
+ await page.getByTestId("settings-nav-profile").click();
await expect(page.getByTestId("profile-display-name")).toHaveValue(
displayName,
);
@@ -67,6 +63,7 @@ test("updates presence from settings", async ({ page }) => {
await page.getByTestId("open-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
+ await page.getByTestId("settings-nav-presence").click();
await expect(page.getByTestId("presence-current-status")).toContainText(
"Offline",
);
@@ -76,10 +73,11 @@ test("updates presence from settings", async ({ page }) => {
"Away",
);
- await page.getByRole("button", { name: "Home" }).click();
+ await page.getByTestId("settings-close").click();
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await page.getByTestId("open-settings").click();
+ await page.getByTestId("settings-nav-presence").click();
await expect(page.getByTestId("presence-current-status")).toContainText(
"Away",
);
@@ -100,6 +98,7 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
);
await expect(page.getByTestId("settings-view")).toBeVisible();
+ await page.getByTestId("settings-nav-appearance").click();
await page.getByTestId("theme-option-dark").click();
await expect
@@ -107,4 +106,32 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
page.evaluate(() => document.documentElement.classList.contains("dark")),
)
.toBe(true);
+
+ await page.keyboard.press(
+ process.platform === "darwin" ? "Meta+," : "Control+,",
+ );
+ await expect(page.getByTestId("settings-view")).toHaveCount(0);
+ await expect(page.getByTestId("chat-title")).toHaveText("Home");
+});
+
+test("shows doctor checks for local sprout tooling", async ({ page }) => {
+ await page.goto("/");
+
+ await page.getByTestId("open-settings").click();
+ await expect(page.getByTestId("settings-view")).toBeVisible();
+ await page.getByTestId("settings-nav-doctor").click();
+
+ await expect(page.getByTestId("settings-doctor")).toBeVisible();
+ await expect(page.getByTestId("doctor-check-admin")).toContainText(
+ "sprout-admin",
+ );
+ await expect(page.getByTestId("doctor-check-acp")).toContainText(
+ "sprout-acp",
+ );
+ await expect(page.getByTestId("doctor-check-mcp")).toContainText(
+ "sprout-mcp-server",
+ );
+ await expect(page.getByTestId("doctor-provider-goose")).toContainText(
+ "Goose",
+ );
});
diff --git a/desktop/tests/e2e/tokens.spec.ts b/desktop/tests/e2e/tokens.spec.ts
index 4b2978c5e..b9416edf7 100644
--- a/desktop/tests/e2e/tokens.spec.ts
+++ b/desktop/tests/e2e/tokens.spec.ts
@@ -13,6 +13,7 @@ test("creates a channel-scoped token from settings and can revoke it", async ({
await page.getByTestId("open-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
+ await page.getByTestId("settings-nav-tokens").click();
const tokenCard = page.getByTestId("settings-tokens");
await tokenCard.getByRole("button", { name: "Create token" }).click();
@@ -63,6 +64,7 @@ test("surfaces token mint errors in the dialog", async ({ page }) => {
await page.getByTestId("open-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
+ await page.getByTestId("settings-nav-tokens").click();
await page
.getByTestId("settings-tokens")