Add desktop settings shell and doctor panel (#80)

This commit is contained in:
Wes
2026-03-16 10:17:16 -07:00
committed by GitHub
parent d7b45125e9
commit 0cebddaf3e
13 changed files with 1527 additions and 902 deletions
+6 -1
View File
@@ -71,7 +71,12 @@ fn fd_real_path(file: &std::fs::File) -> Result<std::path::PathBuf, String> {
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!(
+264 -266
View File
@@ -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<AppView, "settings">;
function createSearchAnchorEvent(hit: SearchHit): RelayEvent {
return {
@@ -68,6 +72,9 @@ function createSearchAnchorEvent(hit: SearchHit): RelayEvent {
export function AppShell() {
const [selectedView, setSelectedView] = React.useState<AppView>("home");
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
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<RelayEvent | null>(null);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
const lastNonSettingsViewRef = React.useRef<MainView>("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 (
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
<SidebarTrigger className="fixed left-[80px] top-[9px] z-50 h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<AppSidebar
channels={memberChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
isCreatingChannel={createChannelMutation.isPending}
isLoading={channelsQuery.isLoading}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({ description, name }) => {
const createdChannel = await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility: "open",
});
{selectedView === "settings" ? (
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
<SettingsView
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isPresenceLoading={presenceSession.isLoading}
isUpdatingPresence={presenceSession.isPending}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetPresence={presenceSession.setStatus}
presenceError={presenceSession.error}
presenceStatus={presenceSession.currentStatus}
section={settingsSection}
/>
</div>
) : (
<React.Fragment>
<SidebarTrigger className="fixed left-[80px] top-[9px] z-50 h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<AppSidebar
channels={memberChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
isCreatingChannel={createChannelMutation.isPending}
isLoading={channelsQuery.isLoading}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({ description, name }) => {
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}
/>
<SidebarInset
className="min-h-0 min-w-0 overflow-hidden"
key={contentPaneKey}
>
{selectedView === "home" ? (
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."
mode="home"
title="Home"
/>
) : selectedView === "agents" ? (
<ChatHeader
description="Create local ACP workers, mint agent tokens, and monitor the relay-visible agent directory."
mode="agents"
title="Agents"
/>
) : (
<ChatHeader
actions={
activeChannel ? (
<Button
aria-label="Manage channel"
data-testid="channel-management-trigger"
onClick={() => {
setIsChannelManagementOpen(true);
}}
size="icon"
type="button"
variant="outline"
>
<Settings2 className="h-4 w-4" />
</Button>
) : null
}
channelType={activeChannel?.channelType}
description={channelDescription}
statusBadge={
activeChannel?.channelType === "dm" &&
activeDmPresenceStatus ? (
<PresenceBadge
data-testid="chat-presence-badge"
status={activeDmPresenceStatus}
/>
) : null
}
title={activeChannel?.name ?? "Channels"}
/>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{selectedView === "home" ? (
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={handleOpenChannel}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
) : selectedView === "agents" ? (
<AgentsView />
) : (
<ChannelPane
activeChannel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelReply={() => {
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
}
/>
)}
</div>
</SidebarInset>
</React.Fragment>
)}
<ChannelBrowserDialog
channels={channels}
onJoinChannel={handleBrowseChannelJoin}
onOpenChange={setIsBrowseChannelsOpen}
onSelectChannel={handleOpenChannel}
onSelectSettings={handleOpenSettings}
profile={profileQuery.data}
selectedChannelId={selectedChannel?.id ?? null}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
open={isBrowseChannelsOpen}
/>
<SidebarInset
className="min-h-0 min-w-0 overflow-hidden"
key={contentPaneKey}
>
{selectedView === "home" ? (
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."
mode="home"
title="Home"
/>
) : selectedView === "agents" ? (
<ChatHeader
description="Create local ACP workers, mint agent tokens, and monitor the relay-visible agent directory."
mode="agents"
title="Agents"
/>
) : selectedView === "settings" ? (
<ChatHeader
description="Theme, appearance, and profile preferences for your current identity."
mode="settings"
title="Settings"
/>
) : (
<ChatHeader
actions={
activeChannel ? (
<Button
aria-label="Manage channel"
data-testid="channel-management-trigger"
onClick={() => {
setIsChannelManagementOpen(true);
}}
size="icon"
type="button"
variant="outline"
>
<Settings2 className="h-4 w-4" />
</Button>
) : null
}
channelType={activeChannel?.channelType}
description={channelDescription}
statusBadge={
activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
<PresenceBadge
data-testid="chat-presence-badge"
status={activeDmPresenceStatus}
/>
) : null
}
title={activeChannel?.name ?? "Channels"}
/>
)}
<SearchDialog
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
onOpenResult={handleOpenSearchResult}
onOpenChange={setIsSearchOpen}
open={isSearchOpen}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{selectedView === "home" ? (
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={handleOpenChannel}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
) : selectedView === "agents" ? (
<AgentsView />
) : selectedView === "settings" ? (
<SettingsView
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isPresenceLoading={presenceSession.isLoading}
isUpdatingPresence={presenceSession.isPending}
onSetPresence={presenceSession.setStatus}
presenceError={presenceSession.error}
presenceStatus={presenceSession.currentStatus}
/>
) : (
<React.Fragment key={activeChannel?.id ?? "no-channel"}>
<MessageTimeline
activeReplyTargetId={replyTargetId}
currentPubkey={identityQuery.data?.pubkey}
profiles={messageProfilesQuery.data?.profiles}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
: "Messages and sub-replies will appear here once the relay has history for this channel."
}
emptyTitle={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={isTimelineLoading}
messages={timelineMessages}
onReply={(message) => {
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
}
/>
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={
!activeChannel ||
!activeChannel.isMember ||
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
sendMessageMutation.isPending
}
isSending={sendMessageMutation.isPending}
onCancelReply={() => {
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.Fragment>
)}
</div>
<ChannelBrowserDialog
channels={channels}
onJoinChannel={handleBrowseChannelJoin}
onOpenChange={setIsBrowseChannelsOpen}
onSelectChannel={handleOpenChannel}
open={isBrowseChannelsOpen}
/>
<SearchDialog
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
onOpenResult={handleOpenSearchResult}
onOpenChange={setIsSearchOpen}
open={isSearchOpen}
/>
<ChannelManagementSheet
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onDeleted={() => {
React.startTransition(() => {
setIsChannelManagementOpen(false);
setSelectedView("home");
});
}}
onOpenChange={setIsChannelManagementOpen}
open={isChannelManagementOpen && activeChannel !== null}
/>
</SidebarInset>
<ChannelManagementSheet
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onDeleted={() => {
React.startTransition(() => {
setIsChannelManagementOpen(false);
setSelectedView("home");
});
}}
onOpenChange={setIsChannelManagementOpen}
open={isChannelManagementOpen && activeChannel !== null}
/>
</SidebarProvider>
);
}
+111
View File
@@ -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<void>;
onTargetReached: (messageId: string) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
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 (
<React.Fragment key={activeChannel?.id ?? "no-channel"}>
<MessageTimeline
activeReplyTargetId={replyTargetId}
currentPubkey={currentPubkey}
profiles={profiles}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
: "Messages and sub-replies will appear here once the relay has history for this channel."
}
emptyTitle={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={isTimelineLoading}
messages={messages}
onReply={onReply}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
targetMessageId={targetMessageId}
/>
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={
!activeChannel ||
!activeChannel.isMember ||
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
isSending
}
isSending={isSending}
onCancelReply={onCancelReply}
onSend={onSend}
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.Fragment>
);
}
@@ -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
</p>
<p className="text-sm text-muted-foreground">
Relay overrides, raw commands, timeout, and local binary
checks.
Relay overrides, raw commands, timeout, and doctor guidance.
</p>
</div>
<span className="shrink-0 self-center text-muted-foreground">
@@ -332,11 +309,10 @@ export function CreateAgentDialog({
turnTimeoutSeconds={turnTimeoutSeconds}
/>
<CreateAgentPrerequisitesCard
isLoading={prereqsQuery.isLoading}
prereqs={prereqs}
prerequisiteCards={prerequisiteCards}
/>
<p className="rounded-2xl border border-border/70 bg-background/70 px-4 py-3 text-sm text-muted-foreground">
Local Sprout binary checks and ACP runtime discovery now
live in Settings &gt; Doctor.
</p>
{providersQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
@@ -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 (
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold tracking-tight">
Local Sprout binaries
</p>
<p className="text-sm text-muted-foreground">
The desktop app uses these commands to mint tokens and spawn
harnesses.
</p>
</div>
{isLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : null}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{prerequisiteCards.map((card) => (
<div
className="rounded-2xl border border-border/70 bg-background/80 px-3 py-3"
key={card.id}
>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{card.label}
</p>
<p className="mt-2 text-sm font-medium">{card.command}</p>
<p
className={cn(
"mt-1 text-xs",
card.info?.available
? "text-muted-foreground"
: "text-destructive",
)}
>
{card.info?.resolvedPath
? `Available via ${describeResolvedCommand(card.command, card.info.resolvedPath)}`
: isLoading
? "Looking for a matching binary..."
: "Not currently available."}
</p>
</div>
))}
</div>
{prereqs &&
(!prereqs.admin.available ||
!prereqs.acp.available ||
!prereqs.mcp.available) ? (
<p className="mt-4 rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
Build the workspace binaries with `cargo build --release --workspace`
or point the command fields at installed binaries before enabling
token minting or spawn.
</p>
) : null}
</div>
);
}
export function CreateAgentOptionToggles({
isMintSupported,
isSpawnSupported,
@@ -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 ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
);
}
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 (
<div
className="flex items-start gap-3 rounded-xl border border-border/70 bg-background/80 px-4 py-3"
data-testid={`doctor-check-${id}`}
>
<div className="mt-0.5 shrink-0">
<StatusIcon available={isAvailable} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold tracking-tight">{label}</p>
<code className="rounded bg-muted px-1.5 py-0.5 text-[11px]">
{command}
</code>
</div>
<p className="mt-1 text-sm text-muted-foreground">{purpose}</p>
<p
className={cn(
"mt-2 text-xs",
isAvailable
? "text-muted-foreground"
: "text-amber-700 dark:text-amber-300",
)}
>
{availability?.resolvedPath
? `Available via ${describeResolvedCommand(command, availability.resolvedPath)}`
: isLoading
? "Checking for a matching binary..."
: "Not currently available."}
</p>
{availability?.resolvedPath ? (
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/80">
{availability.resolvedPath}
</p>
) : null}
</div>
</div>
);
}
function ProviderRow({
command,
defaultArgs,
label,
providerId,
resolvedPath,
}: {
command: string;
defaultArgs: string[];
label: string;
providerId: string;
resolvedPath: string;
}) {
return (
<div
className="flex items-start gap-3 rounded-xl border border-border/70 bg-background/80 px-4 py-3"
data-testid={`doctor-provider-${providerId}`}
>
<div className="mt-0.5 shrink-0">
<StatusIcon available={true} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold tracking-tight">{label}</p>
<code className="rounded bg-muted px-1.5 py-0.5 text-[11px]">
{command}
</code>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Available via {describeResolvedCommand(command, resolvedPath)}.
</p>
{defaultArgs.length > 0 ? (
<p className="mt-2 text-xs text-muted-foreground">
Default args:{" "}
<code className="font-mono">{defaultArgs.join(", ")}</code>
</p>
) : null}
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/80">
{resolvedPath}
</p>
</div>
</div>
);
}
function SetupHelpCard() {
return (
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold tracking-tight">Setup help</h3>
</div>
<div className="mt-3 space-y-3 text-sm text-muted-foreground">
<p>
Build the local Sprout tools with{" "}
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-[12px]">
cargo build --release --workspace
</code>{" "}
when you want the desktop app to mint tokens or spawn ACP harnesses
from this checkout.
</p>
<p>
If you keep binaries outside your PATH, use the custom ACP and MCP
commands below and then copy those same values into Create agent &gt;
Advanced setup.
</p>
<p>
ACP runtimes like Goose or Codex are optional. They appear
automatically once their commands are installed on your PATH.
</p>
</div>
</div>
);
}
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 (
<div className="space-y-4" data-testid="settings-doctor">
<section className="rounded-xl border border-border/80 bg-card/80 p-5 shadow-sm">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Stethoscope className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold tracking-tight">Doctor</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Verify the local Sprout tools and ACP runtime commands used by the
desktop app.
</p>
</div>
<Button
className="shrink-0"
disabled={isRefreshing}
onClick={() => {
void providersQuery.refetch();
void prereqsQuery.refetch();
}}
size="sm"
type="button"
variant="outline"
>
<RefreshCw
className={cn("h-4 w-4", isRefreshing && "animate-spin")}
/>
Re-run
</Button>
</div>
<div className="mt-5 grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="space-y-4">
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
<div className="flex items-center gap-2">
<TerminalSquare className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold tracking-tight">
Local Sprout binaries
</h3>
</div>
<p className="mt-1 text-sm text-muted-foreground">
These checks replace the old binary status card from Create
agent.
</p>
<div className="mt-4 space-y-2">
{toolChecks.map((check) => (
<CommandCheckRow
availability={check.availability}
id={check.id}
isLoading={prereqsQuery.isLoading}
key={check.id}
label={check.label}
purpose={check.purpose}
/>
))}
</div>
{hasMissingSproutTools ? (
<p className="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
Build the workspace binaries with{" "}
<code className="font-mono">
cargo build --release --workspace
</code>{" "}
or point agent creation at custom ACP and MCP commands.
</p>
) : null}
{prereqsQuery.error instanceof Error ? (
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{prereqsQuery.error.message}
</p>
) : null}
</div>
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
<h3 className="text-sm font-semibold tracking-tight">
Custom harness commands
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Verify non-default ACP or MCP binaries before using them in
agent creation.
</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="doctor-acp">
ACP command
</label>
<Input
data-testid="doctor-acp-command"
id="doctor-acp"
onChange={(event) => setAcpCommand(event.target.value)}
value={acpCommand}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="doctor-mcp">
MCP command
</label>
<Input
data-testid="doctor-mcp-command"
id="doctor-mcp"
onChange={(event) => setMcpCommand(event.target.value)}
value={mcpCommand}
/>
</div>
</div>
<p className="mt-3 text-xs text-muted-foreground">
Token minting always checks the default{" "}
<code className="font-mono">sprout-admin</code> command.
</p>
</div>
</div>
<div className="space-y-4">
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
<h3 className="text-sm font-semibold tracking-tight">
ACP runtimes
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Installed runtimes that the desktop app can offer in Create
agent.
</p>
<div className="mt-4 space-y-2">
{providersQuery.isLoading ? (
<p className="text-sm text-muted-foreground">
Looking for installed ACP runtimes...
</p>
) : providers.length > 0 ? (
providers.map((provider) => (
<ProviderRow
command={provider.command}
defaultArgs={provider.defaultArgs}
key={provider.id}
label={provider.label}
providerId={provider.id}
resolvedPath={provider.binaryPath}
/>
))
) : (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
No known ACP runtime was detected on your PATH yet. You can
still use a custom command in Create agent.
</div>
)}
</div>
{providersQuery.error instanceof Error ? (
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{providersQuery.error.message}
</p>
) : null}
</div>
<SetupHelpCard />
</div>
</div>
</section>
</div>
);
}
@@ -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 (
<section className="min-w-0 space-y-3">
<div className="space-y-1">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{children}
</section>
);
}
function ReadOnlyField({
label,
value,
testId,
}: {
label: string;
value: string;
testId: string;
}) {
return (
<div className="min-w-0 space-y-1.5">
<p className="text-sm font-medium">{label}</p>
<div
className="min-w-0 break-all whitespace-normal rounded-xl border border-border/80 bg-muted/25 px-3 py-2 text-sm text-muted-foreground"
data-testid={testId}
>
{value}
</div>
</div>
);
}
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 (
<section
className="rounded-xl border border-border/80 bg-card/80 p-5 shadow-sm"
data-testid="settings-profile"
>
<div className="flex min-w-0 items-start gap-4">
<ProfileAvatar
avatarUrl={resolvedAvatarUrl}
className="h-16 w-16 rounded-3xl text-lg"
iconClassName="h-6 w-6"
key={resolvedAvatarUrl ?? "profile-fallback-avatar"}
label={resolvedName}
/>
<div className="min-w-0 space-y-2">
<div>
<h2 className="break-words text-base font-semibold tracking-tight">
{resolvedName}
</h2>
<p className="text-sm text-muted-foreground">
Manage how your identity appears across Sprout.
</p>
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground">
<Fingerprint className="h-3.5 w-3.5" />
<span>Your relay profile</span>
</div>
</div>
</div>
<div className="mt-6 space-y-6">
{profileQuery.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{profileQuery.error.message}
</p>
) : null}
{updateProfileMutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{updateProfileMutation.error.message}
</p>
) : null}
{updateProfileMutation.isSuccess ? (
<div className="flex items-center gap-2 rounded-xl border border-primary/20 bg-primary/10 px-3 py-2 text-sm text-primary">
<Check className="h-4 w-4" />
<span>Profile saved.</span>
</div>
) : null}
<Section
description="Your keypair is fixed for this device. Profile fields and NIP-05 are editable below."
title="Identity"
>
<div className="space-y-3">
<ReadOnlyField
label="Public key"
testId="profile-pubkey"
value={resolvedPubkey}
/>
<ReadOnlyField
label="NIP-05 handle"
testId="profile-nip05"
value={nip05Handle}
/>
</div>
</Section>
<Separator />
<Section
description="These values are stored on the relay for your current identity."
title="Profile"
>
<form
className="min-w-0 space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!canSave) {
return;
}
void updateProfileMutation.mutateAsync(updatePayload);
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="profile-display-name"
>
Display name
</label>
<div className="relative min-w-0">
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-display-name"
disabled={updateProfileMutation.isPending}
id="profile-display-name"
onChange={(event) => setDisplayNameDraft(event.target.value)}
placeholder="How people should see you"
value={displayNameDraft}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="profile-nip05">
NIP-05 handle
</label>
<div className="relative min-w-0">
<AtSign className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-nip05-input"
disabled={updateProfileMutation.isPending}
id="profile-nip05"
onChange={(event) => setNip05HandleDraft(event.target.value)}
placeholder="alice@localhost"
value={nip05HandleDraft}
/>
</div>
<p className="text-sm text-muted-foreground">
Must match this relay&apos;s domain. Leave blank to clear your
current handle.
</p>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="profile-avatar-url"
>
Avatar URL
</label>
<div className="relative min-w-0">
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-avatar-url"
disabled={updateProfileMutation.isPending}
id="profile-avatar-url"
onChange={(event) => setAvatarUrlDraft(event.target.value)}
placeholder="https://example.com/avatar.png"
value={avatarUrlDraft}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="profile-about">
About
</label>
<div className="relative min-w-0">
<AtSign className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
className="min-h-28 pl-9"
data-testid="profile-about"
disabled={updateProfileMutation.isPending}
id="profile-about"
onChange={(event) => setAboutDraft(event.target.value)}
placeholder="A short description for your profile"
value={aboutDraft}
/>
</div>
</div>
<Button
data-testid="profile-save"
disabled={!canSave}
size="sm"
type="submit"
>
{updateProfileMutation.isPending ? "Saving..." : "Save profile"}
</Button>
{hasPendingClearRequest ? (
<p className="text-sm text-muted-foreground">
Clearing existing profile fields is not supported yet. Blank
display name, avatar, and about values are ignored for now.
</p>
) : null}
</form>
</Section>
</div>
</section>
);
}
@@ -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<void>;
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 (
<section
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
data-testid="settings-theme"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold tracking-tight">Appearance</h2>
<p className="text-sm text-muted-foreground">
Choose how Sprout looks on this device.
</p>
</div>
<div className="inline-flex w-full flex-col gap-1 rounded-xl border border-border/70 bg-background/70 p-1 sm:w-auto sm:flex-row">
{themeOptions.map(({ value, label, icon: Icon }) => {
const isActive = theme === value;
return (
<button
aria-pressed={isActive}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`theme-option-${value}`}
key={value}
onClick={() => {
setTheme(value);
}}
type="button"
>
<Icon className="h-4 w-4" />
<span>{label}</span>
</button>
);
})}
</div>
</div>
</section>
);
}
function PresenceStatusBadge({ status }: { status: PresenceStatus }) {
return (
<PresenceBadge data-testid="presence-current-status" status={status} />
);
}
function PresenceSettingsCard({
isLoading,
isUpdating,
onSetPresence,
presenceError,
presenceStatus,
}: {
isLoading: boolean;
isUpdating: boolean;
onSetPresence: (status: PresenceStatus) => Promise<void>;
presenceError: Error | null;
presenceStatus: PresenceStatus;
}) {
return (
<section
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
data-testid="settings-presence"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold tracking-tight">Presence</h2>
<p className="text-sm text-muted-foreground">
Choose how this desktop session appears on the relay.
</p>
</div>
<PresenceStatusBadge status={presenceStatus} />
</div>
<div className="mt-4 grid gap-2 md:grid-cols-3">
{presenceOptions.map((option) => {
const isActive = presenceStatus === option.value;
return (
<button
aria-pressed={isActive}
className={cn(
"flex min-h-24 flex-col items-start justify-between rounded-xl border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`presence-option-${option.value}`}
disabled={isLoading || isUpdating}
key={option.value}
onClick={() => {
void onSetPresence(option.value);
}}
type="button"
>
<div className="flex items-center gap-2">
<PresenceDot className="h-4 w-4" status={option.value} />
<span className="font-medium text-foreground">
{option.label}
</span>
</div>
<p className="text-sm text-muted-foreground">
{option.description}
</p>
</button>
);
})}
</div>
{presenceError ? (
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{presenceError.message}
</p>
) : null}
<p
className="mt-4 text-sm text-muted-foreground"
data-testid="presence-help"
>
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.
</p>
</section>
);
}
export function renderSettingsSection(
section: SettingsSection,
props: SettingsPanelProps,
): React.ReactNode {
switch (section) {
case "profile":
return (
<ProfileSettingsCard
currentPubkey={props.currentPubkey}
fallbackDisplayName={props.fallbackDisplayName}
/>
);
case "presence":
return (
<PresenceSettingsCard
isLoading={props.isPresenceLoading}
isUpdating={props.isUpdatingPresence}
onSetPresence={props.onSetPresence}
presenceError={props.presenceError}
presenceStatus={props.presenceStatus}
/>
);
case "appearance":
return <ThemeSettingsCard />;
case "tokens":
return <TokenSettingsCard currentPubkey={props.currentPubkey} />;
case "doctor":
return <DoctorSettingsPanel />;
default: {
const exhaustiveCheck: never = section;
return exhaustiveCheck;
}
}
}
+125 -518
View File
@@ -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<void>;
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 (
<section className="min-w-0 space-y-3">
<div className="space-y-1">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{children}
</section>
);
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 (
<div className="min-w-0 space-y-1.5">
<p className="text-sm font-medium">{label}</p>
<div
className="min-w-0 break-all whitespace-normal rounded-xl border border-border/80 bg-muted/25 px-3 py-2 text-sm text-muted-foreground"
data-testid={testId}
>
{value}
</div>
</div>
);
}
function ThemeSettingsCard() {
const { setTheme, theme } = useTheme();
const Icon = section.icon;
return (
<section
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
data-testid="settings-theme"
<button
aria-pressed={active}
className={cn(
"group inline-flex min-w-fit items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:w-full lg:justify-start",
active
? "border-border bg-background text-foreground shadow-sm"
: "border-transparent bg-transparent text-muted-foreground hover:bg-background/70 hover:text-foreground",
)}
data-testid={`settings-nav-${section.value}`}
onClick={() => onSelect(section.value)}
type="button"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold tracking-tight">Appearance</h2>
<p className="text-sm text-muted-foreground">
Choose how Sprout looks on this device.
</p>
</div>
<div className="inline-flex w-full flex-col gap-1 rounded-xl border border-border/70 bg-background/70 p-1 sm:w-auto sm:flex-row">
{themeOptions.map(({ value, label, icon: Icon }) => {
const isActive = theme === value;
return (
<button
aria-pressed={isActive}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`theme-option-${value}`}
key={value}
onClick={() => {
setTheme(value);
}}
type="button"
>
<Icon className="h-4 w-4" />
<span>{label}</span>
</button>
);
})}
</div>
</div>
</section>
);
}
function PresenceStatusBadge({ status }: { status: PresenceStatus }) {
return (
<PresenceBadge data-testid="presence-current-status" status={status} />
);
}
function PresenceSettingsCard({
isLoading,
isUpdating,
onSetPresence,
presenceError,
presenceStatus,
}: {
isLoading: boolean;
isUpdating: boolean;
onSetPresence: (status: PresenceStatus) => Promise<void>;
presenceError: Error | null;
presenceStatus: PresenceStatus;
}) {
return (
<section
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
data-testid="settings-presence"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold tracking-tight">Presence</h2>
<p className="text-sm text-muted-foreground">
Choose how this desktop session appears on the relay.
</p>
</div>
<PresenceStatusBadge status={presenceStatus} />
</div>
<div className="mt-4 grid gap-2 md:grid-cols-3">
{presenceOptions.map((option) => {
const isActive = presenceStatus === option.value;
return (
<button
aria-pressed={isActive}
className={cn(
"flex min-h-24 flex-col items-start justify-between rounded-xl border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`presence-option-${option.value}`}
disabled={isLoading || isUpdating}
key={option.value}
onClick={() => {
void onSetPresence(option.value);
}}
type="button"
>
<div className="flex items-center gap-2">
<PresenceDot className="h-4 w-4" status={option.value} />
<span className="font-medium text-foreground">
{option.label}
</span>
</div>
<p className="text-sm text-muted-foreground">
{option.description}
</p>
</button>
);
})}
</div>
{presenceError ? (
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{presenceError.message}
</p>
) : null}
<p
className="mt-4 text-sm text-muted-foreground"
data-testid="presence-help"
>
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.
</p>
</section>
);
}
function ProfileSettingsCard({
currentPubkey,
fallbackDisplayName,
}: Pick<SettingsViewProps, "currentPubkey" | "fallbackDisplayName">) {
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 (
<section
className="rounded-xl border border-border/80 bg-card/80 p-5 shadow-sm"
data-testid="settings-profile"
>
<div className="flex min-w-0 items-start gap-4">
<ProfileAvatar
avatarUrl={resolvedAvatarUrl}
className="h-16 w-16 rounded-3xl text-lg"
iconClassName="h-6 w-6"
key={resolvedAvatarUrl ?? "profile-fallback-avatar"}
label={resolvedName}
/>
<div className="min-w-0 space-y-2">
<div>
<h2 className="break-words text-base font-semibold tracking-tight">
{resolvedName}
</h2>
<p className="text-sm text-muted-foreground">
Manage how your identity appears across Sprout.
</p>
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground">
<Fingerprint className="h-3.5 w-3.5" />
<span>Your relay profile</span>
</div>
</div>
</div>
<div className="mt-6 space-y-6">
{profileQuery.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{profileQuery.error.message}
</p>
) : null}
{updateProfileMutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{updateProfileMutation.error.message}
</p>
) : null}
{updateProfileMutation.isSuccess ? (
<div className="flex items-center gap-2 rounded-xl border border-primary/20 bg-primary/10 px-3 py-2 text-sm text-primary">
<Check className="h-4 w-4" />
<span>Profile saved.</span>
</div>
) : null}
<Section
description="Your keypair is fixed for this device. Profile fields and NIP-05 are editable below."
title="Identity"
>
<div className="space-y-3">
<ReadOnlyField
label="Public key"
testId="profile-pubkey"
value={resolvedPubkey}
/>
<ReadOnlyField
label="NIP-05 handle"
testId="profile-nip05"
value={nip05Handle}
/>
</div>
</Section>
<Separator />
<Section
description="These values are stored on the relay for your current identity."
title="Profile"
>
<form
className="min-w-0 space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!canSave) {
return;
}
void updateProfileMutation.mutateAsync(updatePayload);
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="profile-display-name"
>
Display name
</label>
<div className="relative min-w-0">
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-display-name"
disabled={updateProfileMutation.isPending}
id="profile-display-name"
onChange={(event) => setDisplayNameDraft(event.target.value)}
placeholder="How people should see you"
value={displayNameDraft}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="profile-nip05">
NIP-05 handle
</label>
<div className="relative min-w-0">
<AtSign className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-nip05-input"
disabled={updateProfileMutation.isPending}
id="profile-nip05"
onChange={(event) => setNip05HandleDraft(event.target.value)}
placeholder="alice@localhost"
value={nip05HandleDraft}
/>
</div>
<p className="text-sm text-muted-foreground">
Must match this relay&apos;s domain. Leave blank to clear your
current handle.
</p>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="profile-avatar-url"
>
Avatar URL
</label>
<div className="relative min-w-0">
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-avatar-url"
disabled={updateProfileMutation.isPending}
id="profile-avatar-url"
onChange={(event) => setAvatarUrlDraft(event.target.value)}
placeholder="https://example.com/avatar.png"
value={avatarUrlDraft}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="profile-about">
About
</label>
<div className="relative min-w-0">
<AtSign className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
className="min-h-28 pl-9"
data-testid="profile-about"
disabled={updateProfileMutation.isPending}
id="profile-about"
onChange={(event) => setAboutDraft(event.target.value)}
placeholder="A short description for your profile"
value={aboutDraft}
/>
</div>
</div>
<Button
data-testid="profile-save"
disabled={!canSave}
size="sm"
type="submit"
>
{updateProfileMutation.isPending ? "Saving..." : "Save profile"}
</Button>
{hasPendingClearRequest ? (
<p className="text-sm text-muted-foreground">
Clearing existing profile fields is not supported yet. Blank
display name, avatar, and about values are ignored for now.
</p>
) : null}
</form>
</Section>
</div>
</section>
<Icon
className={cn(
"h-4 w-4 shrink-0 transition-colors",
active
? "text-primary"
: "text-muted-foreground group-hover:text-foreground",
)}
/>
<span className="truncate">{section.label}</span>
</button>
);
}
@@ -524,29 +78,82 @@ export function SettingsView({
fallbackDisplayName,
isPresenceLoading,
isUpdatingPresence,
onClose,
onSectionChange,
onSetPresence,
presenceError,
presenceStatus,
section,
}: SettingsViewProps) {
return (
<div
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6"
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"
data-testid="settings-view"
>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<ThemeSettingsCard />
<PresenceSettingsCard
isLoading={isPresenceLoading}
isUpdating={isUpdatingPresence}
onSetPresence={onSetPresence}
presenceError={presenceError}
presenceStatus={presenceStatus}
/>
<ProfileSettingsCard
currentPubkey={currentPubkey}
fallbackDisplayName={fallbackDisplayName}
/>
<TokenSettingsCard currentPubkey={currentPubkey} />
<header
className="flex items-start justify-between gap-4 border-b border-border/80 bg-background px-4 pb-4 pt-8 sm:px-6"
onPointerDown={handleSettingsHeaderPointerDown}
>
<div className="min-w-0 pt-0.5">
<h1
className="text-lg font-semibold tracking-tight"
data-testid="settings-title"
>
Settings
</h1>
<p className="text-sm text-muted-foreground">
Manage your relay identity, desktop preferences, and local access
tokens.
</p>
</div>
<Button
aria-label="Close settings"
className="shrink-0 text-muted-foreground hover:text-foreground"
data-testid="settings-close"
onClick={onClose}
size="icon"
title="Close settings"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</header>
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden lg:grid-cols-[260px_minmax(0,1fr)] lg:grid-rows-1">
<aside className="border-b border-border/70 bg-muted/20 lg:border-b-0 lg:border-r">
<nav
aria-label="Settings sections"
className="flex gap-2 overflow-x-auto px-3 py-4 lg:flex-col lg:overflow-y-auto"
>
{settingsSections.map((entry) => (
<SettingsSectionButton
active={entry.value === section}
key={entry.value}
onSelect={onSectionChange}
section={entry}
/>
))}
</nav>
</aside>
<section className="min-h-0 overflow-y-auto px-4 py-4 sm:px-6">
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-4"
data-testid={`settings-panel-${section}`}
>
{renderSettingsSection(section, {
currentPubkey,
fallbackDisplayName,
isPresenceLoading,
isUpdatingPresence,
onSetPresence,
presenceError,
presenceStatus,
})}
</div>
</section>
</div>
</div>
);
@@ -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"
>
<div
+2
View File
@@ -175,8 +175,10 @@ test("shows your avatar on your own message when profile avatar is set", async (
await page.goto("/");
await page.getByTestId("open-settings").click();
await page.getByTestId("settings-nav-profile").click();
await page.getByTestId("profile-avatar-url").fill(avatarUrl);
await page.getByTestId("profile-save").click();
await page.getByTestId("settings-close").click();
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
+38 -11
View File
@@ -17,11 +17,9 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
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",
);
});
+2
View File
@@ -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")