mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add desktop settings shell and doctor panel (#80)
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 > 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 >
|
||||
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'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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user