diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index bf6cda9c..de99ddc8 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -13,15 +13,31 @@ use managed_agents::{ find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, save_managed_agents, start_managed_agent_process, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, }; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; use tauri::{http, Manager, RunEvent}; use tauri_plugin_window_state::StateFlags; -fn restore_managed_agents_on_launch(app: &tauri::AppHandle) -> Result<(), String> { +fn restore_managed_agents_on_launch( + app: &tauri::AppHandle, + shutdown_started: &AtomicBool, +) -> Result<(), String> { + if shutdown_started.load(Ordering::SeqCst) { + return Ok(()); + } + let state = app.state::(); let _store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; + + if shutdown_started.load(Ordering::SeqCst) { + return Ok(()); + } + let mut records = load_managed_agents(app)?; let mut runtimes = state .managed_agent_processes @@ -46,6 +62,10 @@ fn restore_managed_agents_on_launch(app: &tauri::AppHandle) -> Result<(), String .collect::>(); for pubkey in pubkeys_to_restore { + if shutdown_started.load(Ordering::SeqCst) { + break; + } + let record = find_managed_agent_mut(&mut records, &pubkey)?; match start_managed_agent_process(app, record, &mut runtimes) { Ok(()) => { @@ -275,6 +295,8 @@ pub fn run() { builder.plugin(tauri_plugin_updater::Builder::new().build()) }; + let shutdown_started = Arc::new(AtomicBool::new(false)); + let restore_shutdown_started = Arc::clone(&shutdown_started); let app = builder .register_asynchronous_uri_scheme_protocol("sprout-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); @@ -284,8 +306,9 @@ pub fn run() { }); }) .manage(build_app_state()) - .setup(|app| { + .setup(move |app| { let app_handle = app.handle().clone(); + let shutdown_started = Arc::clone(&restore_shutdown_started); // Migrate data from the legacy `com.wesb.sprout` directory before // resolving identity, so the persisted key is available at the new @@ -300,9 +323,15 @@ pub fn run() { resolve_persisted_identity(&app_handle, &state) .map_err(|e| -> Box { e.into() })?; - if let Err(error) = restore_managed_agents_on_launch(&app_handle) { - eprintln!("sprout-desktop: failed to restore managed agents: {error}"); - } + // Keep launch-time agent restoration off the synchronous setup path + // so the frontend can mount and reveal the window promptly. + tauri::async_runtime::spawn_blocking(move || { + if let Err(error) = + restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref()) + { + eprintln!("sprout-desktop: failed to restore managed agents: {error}"); + } + }); Ok(()) }) @@ -394,10 +423,11 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application"); - let shutdown_done = std::sync::atomic::AtomicBool::new(false); + let shutdown_done = AtomicBool::new(false); app.run(move |app_handle, event| match event { RunEvent::ExitRequested { .. } | RunEvent::Exit => { - if !shutdown_done.swap(true, std::sync::atomic::Ordering::SeqCst) { + shutdown_started.store(true, Ordering::SeqCst); + if !shutdown_done.swap(true, Ordering::SeqCst) { if let Err(error) = shutdown_managed_agents(app_handle) { eprintln!("sprout-desktop: failed to stop managed agents: {error}"); } diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 048ad529..7e3e512a 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -1,11 +1,11 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; -import { useEffect } from "react"; +import { useLayoutEffect } from "react"; import { AppShell } from "@/app/AppShell"; export function App() { - useEffect(() => { - getCurrentWindow().show(); + useLayoutEffect(() => { + void getCurrentWindow().show(); }, []); return ; diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index cdb311ab..1ab560c1 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,73 +1,61 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { ChannelPane } from "@/app/ChannelPane"; -import { useActiveChannelHeader } from "@/app/useActiveChannelHeader"; -import { useChannelPaneHandlers } from "@/app/useChannelPaneHandlers"; -import { AgentsView } from "@/features/agents/ui/AgentsView"; -import { ForumView } from "@/features/forum/ui/ForumView"; -import { WorkflowsView } from "@/features/workflows/ui/WorkflowsView"; -import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import { + AppShellOverlays, + type BrowseDialogType, +} from "@/app/AppShellOverlays"; +import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; import { channelsQueryKey, + useChannelsQuery, useCreateChannelMutation, useHideDmMutation, useOpenDmMutation, - useChannelsQuery, useSelectedChannel, } from "@/features/channels/hooks"; import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; -import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; -import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; -import { HomeView } from "@/features/home/ui/HomeView"; -import { - useChannelMessagesQuery, - mergeMessages, - useDeleteMessageMutation, - useEditMessageMutation, - useSendMessageMutation, - useChannelSubscription, - useToggleReactionMutation, -} from "@/features/messages/hooks"; -import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; -import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; -import { - collectMessageAuthorPubkeys, - formatTimelineMessages, -} from "@/features/messages/lib/formatTimelineMessages"; -import { useChannelTyping } from "@/features/messages/useChannelTyping"; -import { - getChannelIdFromTags, - getThreadReference, -} from "@/features/messages/lib/threading"; -import { usePresenceSession } from "@/features/presence/hooks"; -import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; +import { HomeScreen } from "@/features/home/ui/HomeScreen"; import { useHomeFeedNotifications } from "@/features/notifications/hooks"; -import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; -import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; -import { ChannelBrowserDialog } from "@/features/channels/ui/ChannelBrowserDialog"; -import { SearchDialog } from "@/features/search/ui/SearchDialog"; -import { - DEFAULT_SETTINGS_SECTION, - SettingsView, - type SettingsSection, -} from "@/features/settings/ui/SettingsView"; +import { usePresenceSession } from "@/features/presence/hooks"; +import { useProfileQuery } from "@/features/profile/hooks"; +import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { relayClient } from "@/shared/api/relayClient"; -import { getEventById, joinChannel } from "@/shared/api/tauri"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { getEventById, joinChannel } from "@/shared/api/tauri"; import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; import { SidebarInset, SidebarProvider, SidebarTrigger, } from "@/shared/ui/sidebar"; -import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; type AppView = "home" | "channel" | "agents" | "workflows"; +const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile"; + +const AgentsScreen = React.lazy(async () => { + const module = await import("@/features/agents/ui/AgentsScreen"); + return { default: module.AgentsScreen }; +}); +const ChannelScreen = React.lazy(async () => { + const module = await import("@/features/channels/ui/ChannelScreen"); + return { default: module.ChannelScreen }; +}); +const SettingsScreen = React.lazy(async () => { + const module = await import("@/features/settings/ui/SettingsScreen"); + return { default: module.SettingsScreen }; +}); +const WorkflowsScreen = React.lazy(async () => { + const module = await import("@/features/workflows/ui/WorkflowsScreen"); + return { default: module.WorkflowsScreen }; +}); + export function AppShell() { useWebviewZoomShortcuts(); + const [selectedView, setSelectedView] = React.useState("home"); const [settingsOpen, setSettingsOpen] = React.useState(false); const [settingsSection, setSettingsSection] = React.useState( @@ -76,12 +64,8 @@ export function AppShell() { const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); - const [browseDialogType, setBrowseDialogType] = React.useState< - "stream" | "forum" | null - >(null); - const handleBrowseDialogOpenChange = React.useCallback((open: boolean) => { - setBrowseDialogType(open ? "stream" : null); - }, []); + const [browseDialogType, setBrowseDialogType] = + React.useState(null); const [searchAnchor, setSearchAnchor] = React.useState( null, ); @@ -90,14 +74,14 @@ export function AppShell() { >(null); const [searchAnchorEvent, setSearchAnchorEvent] = React.useState(null); - const [replyTargetId, setReplyTargetId] = React.useState(null); - const [editTargetId, setEditTargetId] = React.useState(null); const queryClient = useQueryClient(); + const selectView = React.useCallback((view: AppView) => { React.startTransition(() => { setSelectedView(view); }); }, []); + const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); const presenceSession = usePresenceSession(identityQuery.data?.pubkey); @@ -109,6 +93,7 @@ export function AppShell() { const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => { void homeFeedQuery.refetch(); }); + const channelsQuery = useChannelsQuery(); const { refetch: refetchChannels } = channelsQuery; const channels = channelsQuery.data ?? []; @@ -116,181 +101,51 @@ export function AppShell() { () => channels.filter((channel) => channel.isMember), [channels], ); + const availableChannelIds = React.useMemo( + () => new Set(channels.map((channel) => channel.id)), + [channels], + ); const { selectedChannel, setSelectedChannelId } = useSelectedChannel( channels, null, ); - const createChannelMutation = useCreateChannelMutation(); - const createForumMutation = useCreateChannelMutation(); - const openDmMutation = useOpenDmMutation(); - const hideDmMutation = useHideDmMutation(); const activeChannel = selectedView === "channel" ? selectedChannel : null; - const activeChannelId = activeChannel?.id ?? null; - const messagesQuery = useChannelMessagesQuery(activeChannel); - useChannelSubscription(activeChannel); - const { fetchOlder, isFetchingOlder, hasOlderMessages } = - useFetchOlderMessages(activeChannel); - const latestActiveMessage = - messagesQuery.data?.[messagesQuery.data.length - 1]; - const activeReadAt = latestActiveMessage - ? new Date(latestActiveMessage.created_at * 1_000).toISOString() - : (activeChannel?.lastMessageAt ?? null); - const { unreadChannelIds } = useUnreadChannels( + + const { markChannelRead, unreadChannelIds } = useUnreadChannels( channels, activeChannel, - activeReadAt, + // Wait for ChannelScreen to report the latest loaded message before + // advancing unread state for the active channel. + null, { currentPubkey: identityQuery.data?.pubkey, onLiveMention: refetchHomeFeedOnLiveMention, }, ); - const { activeChannelTitle, activeDmPresenceStatus } = useActiveChannelHeader( - activeChannel, - identityQuery.data?.pubkey, - ); - const sendMessageMutation = useSendMessageMutation( - activeChannel, - identityQuery.data, - ); - const toggleReactionMutation = useToggleReactionMutation(); - const deleteMessageMutation = useDeleteMessageMutation(activeChannel); - const editMessageMutation = useEditMessageMutation(activeChannel); - const availableChannelIds = React.useMemo( - () => new Set(channels.map((channel) => channel.id)), - [channels], - ); - const resolvedMessages = React.useMemo(() => { - const currentMessages = messagesQuery.data ?? []; - - if ( - !activeChannel || - !searchAnchorEvent || - searchAnchorChannelId !== activeChannel.id - ) { - return currentMessages; - } - return mergeMessages(currentMessages, searchAnchorEvent); - }, [ - activeChannel, - messagesQuery.data, - searchAnchorChannelId, - searchAnchorEvent, - ]); - const messageAuthorPubkeys = React.useMemo( - () => collectMessageAuthorPubkeys(resolvedMessages), - [resolvedMessages], - ); - const latestMessageEvent = React.useMemo( - () => resolvedMessages[resolvedMessages.length - 1] ?? null, - [resolvedMessages], - ); - const typingPubkeys = useChannelTyping( - activeChannel, - identityQuery.data?.pubkey, - latestMessageEvent, - ); - const messageProfilePubkeys = React.useMemo( - () => [...new Set([...messageAuthorPubkeys, ...typingPubkeys])], - [messageAuthorPubkeys, typingPubkeys], - ); - const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { - enabled: messageProfilePubkeys.length > 0, - }); - const messageProfiles = React.useMemo( - () => - mergeCurrentProfileIntoLookup( - messageProfilesQuery.data?.profiles, - profileQuery.data, - ), - [messageProfilesQuery.data?.profiles, profileQuery.data], - ); - const timelineMessages = React.useMemo( - () => - formatTimelineMessages( - resolvedMessages, - activeChannel, - identityQuery.data?.pubkey, - profileQuery.data?.avatarUrl ?? null, - messageProfiles, - ), - [ - activeChannel, - identityQuery.data?.pubkey, - messageProfiles, - profileQuery.data?.avatarUrl, - resolvedMessages, - ], - ); - const replyTargetMessage = React.useMemo( - () => - timelineMessages.find((message) => message.id === replyTargetId) ?? null, - [replyTargetId, timelineMessages], - ); - const editTargetMessage = React.useMemo( - () => - timelineMessages.find((message) => message.id === editTargetId) ?? null, - [editTargetId, timelineMessages], - ); - - const { - handleCancelEdit, - handleCancelReply, - handleDelete, - handleEdit, - handleEditSave, - handleReply, - handleSend, - handleToggleReaction, - } = useChannelPaneHandlers({ - deleteMessageMutation, - editMessageMutation, - editTargetId, - replyTargetId, - sendMessageMutation, - setEditTargetId, - setReplyTargetId, - toggleReactionMutation, - }); + const createChannelMutation = useCreateChannelMutation(); + const createForumMutation = useCreateChannelMutation(); + const openDmMutation = useOpenDmMutation(); + const hideDmMutation = useHideDmMutation(); + const handleOpenBrowseChannels = React.useCallback(() => { + setBrowseDialogType("stream"); + void refetchChannels(); + }, [refetchChannels]); + const handleOpenBrowseForums = React.useCallback(() => { + setBrowseDialogType("forum"); + void refetchChannels(); + }, [refetchChannels]); + const handleOpenSearch = React.useCallback(() => { + setIsSearchOpen(true); + void refetchChannels(); + }, [refetchChannels]); - const handleTargetReached = React.useCallback((messageId: string) => { - setSearchAnchor((current) => - current?.eventId === messageId ? null : current, - ); + const handleBrowseDialogOpenChange = React.useCallback((open: boolean) => { + if (!open) { + setBrowseDialogType(null); + } }, []); - const canReact = activeChannel !== null && activeChannel.archivedAt === null; - const effectiveToggleReaction = React.useMemo( - () => (canReact ? handleToggleReaction : undefined), - [canReact, handleToggleReaction], - ); - - const channelDescription = activeChannel - ? [ - activeChannel.archivedAt ? "Archived." : null, - !activeChannel.isMember - ? "Read-only until you join this open channel." - : null, - activeChannel.topic, - activeChannel.description, - activeChannel.purpose, - null, - ] - .filter((value) => value && value.trim().length > 0) - .join(" ") || "Channel details and activity." - : "Connect to the relay to browse channels and read messages."; - const shouldLoadTimeline = - activeChannel !== null && activeChannel.channelType !== "forum"; - const isTimelineLoading = - shouldLoadTimeline && - (messagesQuery.isPending || - (messagesQuery.isFetching && resolvedMessages.length === 0)); - - const requestedAncestorIdsRef = React.useRef>(new Set()); - const previousActiveChannelIdRef = React.useRef( - activeChannelId, - ); - const resolveChannel = React.useCallback( async (channelId: string): Promise => { const cachedChannels = @@ -311,6 +166,7 @@ export function AppShell() { }, [channels, queryClient, refetchChannels], ); + const openChannelView = React.useCallback( (channelId: string) => { React.startTransition(() => { @@ -351,9 +207,9 @@ export function AppShell() { try { await hideDmMutation.mutateAsync(channelId); } catch { - // Optimistic rollback handled by onError in the mutation hook. return; } + if (selectedChannel?.id === channelId) { selectView("home"); } @@ -375,6 +231,12 @@ export function AppShell() { setSettingsOpen(false); }, []); + const handleTargetReached = React.useCallback((messageId: string) => { + setSearchAnchor((current) => + current?.eventId === messageId ? null : current, + ); + }, []); + const handleOpenSearchResult = React.useCallback( (hit: SearchHit) => { setSearchAnchor(hit); @@ -424,101 +286,37 @@ export function AppShell() { isCancelled = true; }; }, []); - React.useEffect(() => { - if (previousActiveChannelIdRef.current === activeChannelId) { - return; - } - previousActiveChannelIdRef.current = activeChannelId; - setReplyTargetId(null); - requestedAncestorIdsRef.current.clear(); - }, [activeChannelId]); - React.useEffect(() => { - if (replyTargetId && !replyTargetMessage) { - setReplyTargetId(null); - } - if (editTargetId && !editTargetMessage) { - setEditTargetId(null); - } - }, [editTargetId, editTargetMessage, replyTargetId, replyTargetMessage]); - React.useEffect(() => { - if (!activeChannel || activeChannel.channelType === "forum") { + + React.useLayoutEffect(() => { + if (settingsOpen) { return; } - const knownEvents = new Map( - resolvedMessages.map((message) => [message.id, message]), - ); - const missingAncestorIds = new Set(); - - for (const message of resolvedMessages) { - const thread = getThreadReference(message.tags); - - for (const eventId of [thread.parentId, thread.rootId]) { - if ( - !eventId || - knownEvents.has(eventId) || - requestedAncestorIdsRef.current.has(eventId) - ) { - continue; - } - - missingAncestorIds.add(eventId); + function handleKeyDown(event: KeyboardEvent) { + if (!(event.metaKey || event.ctrlKey) || event.altKey) { + return; } - } - if (missingAncestorIds.size === 0) { - return; - } - - for (const eventId of missingAncestorIds) { - requestedAncestorIdsRef.current.add(eventId); - } + const key = event.key.toLowerCase(); + if (key === "k" && !event.shiftKey) { + event.preventDefault(); + handleOpenSearch(); + return; + } - // Prevent unbounded growth — evict oldest entries when the set exceeds - // a reasonable size. Since Set iteration is insertion-ordered we drop - // the first (oldest) entries. - const MAX_REQUESTED_ANCESTORS = 500; - if (requestedAncestorIdsRef.current.size > MAX_REQUESTED_ANCESTORS) { - const excess = - requestedAncestorIdsRef.current.size - MAX_REQUESTED_ANCESTORS; - let removed = 0; - for (const id of requestedAncestorIdsRef.current) { - if (removed >= excess) break; - requestedAncestorIdsRef.current.delete(id); - removed++; + if (key === "o" && event.shiftKey) { + event.preventDefault(); + handleOpenBrowseChannels(); } } - let isCancelled = false; - - void Promise.all( - [...missingAncestorIds].map(async (eventId) => { - try { - const event = await getEventById(eventId); - - if ( - isCancelled || - getChannelIdFromTags(event.tags) !== activeChannel.id - ) { - return; - } - - queryClient.setQueryData( - channelMessagesKey(activeChannel.id), - (current = []) => mergeMessages(current, event), - ); - } catch (error) { - console.error("Failed to load ancestor event", eventId, error); - } - }), - ); - + window.addEventListener("keydown", handleKeyDown); return () => { - isCancelled = true; + window.removeEventListener("keydown", handleKeyDown); }; - }, [activeChannel, queryClient, resolvedMessages]); + }, [handleOpenBrowseChannels, handleOpenSearch, settingsOpen]); - React.useEffect(() => { + React.useLayoutEffect(() => { function handleKeyDown(event: KeyboardEvent) { const isSettingsShortcut = (event.key === "," || event.code === "Comma") && @@ -566,6 +364,7 @@ export function AppShell() { isCreatingForum={createForumMutation.isPending} isLoading={channelsQuery.isLoading} isOpeningDm={openDmMutation.isPending} + isPresencePending={presenceSession.isPending} selfPresenceStatus={presenceSession.currentStatus} onCreateChannel={async ({ description, name, visibility }) => { const createdChannel = await createChannelMutation.mutateAsync({ @@ -587,35 +386,24 @@ export function AppShell() { openChannelView(createdForum.id); }} - onOpenBrowseChannels={() => { - setBrowseDialogType("stream"); - void refetchChannels(); - }} - onOpenBrowseForums={() => { - setBrowseDialogType("forum"); - void refetchChannels(); - }} - onOpenSearch={() => { - setIsSearchOpen(true); - void refetchChannels(); - }} onHideDm={handleHideDm} + onOpenBrowseChannels={handleOpenBrowseChannels} + onOpenBrowseForums={handleOpenBrowseForums} onOpenDm={async ({ pubkeys }) => { const directMessage = await openDmMutation.mutateAsync({ pubkeys, }); openChannelView(directMessage.id); }} + onOpenSearch={handleOpenSearch} onSelectAgents={() => selectView("agents")} - onSelectWorkflows={() => selectView("workflows")} + onSelectChannel={handleOpenChannel} onSelectHome={() => { selectView("home"); - void homeFeedQuery.refetch(); }} - onSelectChannel={handleOpenChannel} onSelectSettings={handleOpenSettings} + onSelectWorkflows={() => selectView("workflows")} onSetPresenceStatus={(status) => presenceSession.setStatus(status)} - isPresencePending={presenceSession.isPending} profile={profileQuery.data} selectedChannelId={selectedChannel?.id ?? null} selectedView={selectedView} @@ -624,204 +412,92 @@ export function AppShell() { {selectedView === "home" ? ( - ) : selectedView === "agents" ? ( - + } + > + + ) : selectedView === "workflows" ? ( - + } + > + + ) : ( - { - setIsChannelManagementOpen(true); - }} - /> - ) : null - } - channelType={activeChannel?.channelType} - visibility={activeChannel?.visibility} - description={channelDescription} - statusBadge={ - activeChannel?.channelType === "dm" && - activeDmPresenceStatus ? ( - - ) : null - } - title={activeChannelTitle} - /> - )} - -
-
} > - { - void homeFeedQuery.refetch(); + { + setIsChannelManagementOpen(true); }} + onMarkChannelRead={markChannelRead} + onTargetReached={handleTargetReached} + searchAnchor={searchAnchor} + searchAnchorChannelId={searchAnchorChannelId} + searchAnchorEvent={searchAnchorEvent} /> -
-
- -
-
- -
-
- {activeChannel?.channelType === "forum" ? ( - - ) : ( - - )} -
-
+ + )}
- - - - - { + isChannelManagementOpen={isChannelManagementOpen} + isSearchOpen={isSearchOpen} + onBrowseChannelJoin={handleBrowseChannelJoin} + onBrowseDialogOpenChange={handleBrowseDialogOpenChange} + onChannelManagementOpenChange={setIsChannelManagementOpen} + onDeleteActiveChannel={() => { setIsChannelManagementOpen(false); selectView("home"); }} - onOpenChange={setIsChannelManagementOpen} - open={isChannelManagementOpen && activeChannel !== null} + onOpenSearchResult={handleOpenSearchResult} + onSearchOpenChange={setIsSearchOpen} + onSelectChannel={handleOpenChannel} /> - {settingsOpen && ( - - )} + + {settingsOpen ? ( + } + > + + + ) : null} ); diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx new file mode 100644 index 00000000..5311271d --- /dev/null +++ b/desktop/src/app/AppShellOverlays.tsx @@ -0,0 +1,93 @@ +import * as React from "react"; + +import type { Channel, SearchHit } from "@/shared/api/types"; + +const ChannelBrowserDialog = React.lazy(async () => { + const module = await import("@/features/channels/ui/ChannelBrowserDialog"); + return { default: module.ChannelBrowserDialog }; +}); + +const ChannelManagementSheet = React.lazy(async () => { + const module = await import("@/features/channels/ui/ChannelManagementSheet"); + return { default: module.ChannelManagementSheet }; +}); + +const SearchDialog = React.lazy(async () => { + const module = await import("@/features/search/ui/SearchDialog"); + return { default: module.SearchDialog }; +}); + +export type BrowseDialogType = "stream" | "forum" | null; + +type AppShellOverlaysProps = { + activeChannel: Channel | null; + browseDialogType: BrowseDialogType; + channels: Channel[]; + currentPubkey?: string; + isChannelManagementOpen: boolean; + isSearchOpen: boolean; + onBrowseChannelJoin: (channelId: string) => Promise; + onBrowseDialogOpenChange: (open: boolean) => void; + onChannelManagementOpenChange: (open: boolean) => void; + onDeleteActiveChannel: () => void; + onOpenSearchResult: (hit: SearchHit) => void; + onSearchOpenChange: (open: boolean) => void; + onSelectChannel: (channelId: string) => void; +}; + +export function AppShellOverlays({ + activeChannel, + browseDialogType, + channels, + currentPubkey, + isChannelManagementOpen, + isSearchOpen, + onBrowseChannelJoin, + onBrowseDialogOpenChange, + onChannelManagementOpenChange, + onDeleteActiveChannel, + onOpenSearchResult, + onSearchOpenChange, + onSelectChannel, +}: AppShellOverlaysProps) { + return ( + <> + {browseDialogType !== null ? ( + + + + ) : null} + + {isSearchOpen ? ( + + + + ) : null} + + {isChannelManagementOpen && activeChannel !== null ? ( + + + + ) : null} + + ); +} diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index fc05f0e5..3dce9f67 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -151,8 +151,9 @@ export function useRelayAgentsQuery() { }); } -export function useManagedAgentsQuery() { +export function useManagedAgentsQuery(options?: { enabled?: boolean }) { return useQuery({ + enabled: options?.enabled ?? true, queryKey: managedAgentsQueryKey, queryFn: listManagedAgents, staleTime: 5_000, diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx new file mode 100644 index 00000000..a10d394b --- /dev/null +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; + +import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const AgentsView = React.lazy(async () => { + const module = await import("@/features/agents/ui/AgentsView"); + return { default: module.AgentsView }; +}); + +export function AgentsScreen() { + return ( + <> + + + } + > +
+ +
+
+ + ); +} diff --git a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx index 2ef7d040..10f99063 100644 --- a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx +++ b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx @@ -20,7 +20,6 @@ import { import { Input } from "@/shared/ui/input"; import { Button } from "@/shared/ui/button"; -const BROWSE_CHANNELS_SHORTCUT_KEY = "o"; const BROWSE_CHANNELS_SHORTCUT_HINT = "\u21E7\u2318O"; function formatRelativeTime(isoString: string | null) { @@ -141,31 +140,6 @@ export function ChannelBrowserDialog({ [notJoined, joined], ); - React.useEffect(() => { - if (isForumMode) { - return; - } - - function handleKeyDown(event: KeyboardEvent) { - if ( - event.key.toLowerCase() !== BROWSE_CHANNELS_SHORTCUT_KEY || - !(event.metaKey || event.ctrlKey) || - event.altKey || - !event.shiftKey - ) { - return; - } - - event.preventDefault(); - onOpenChange(true); - } - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [isForumMode, onOpenChange]); - React.useEffect(() => { if (!open) { setQuery(""); diff --git a/desktop/src/app/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx similarity index 99% rename from desktop/src/app/ChannelPane.tsx rename to desktop/src/features/channels/ui/ChannelPane.tsx index 4c90a8c7..62550c90 100644 --- a/desktop/src/app/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -71,7 +71,7 @@ export const ChannelPane = React.memo(function ChannelPane({ typingPubkeys, }: ChannelPaneProps) { return ( - + <> - + ); }); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx new file mode 100644 index 00000000..3a66168d --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -0,0 +1,432 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; +import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers"; +import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; +import { + mergeMessages, + useChannelMessagesQuery, + useChannelSubscription, + useDeleteMessageMutation, + useEditMessageMutation, + useSendMessageMutation, + useToggleReactionMutation, +} from "@/features/messages/hooks"; +import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; +import { + collectMessageAuthorPubkeys, + formatTimelineMessages, +} from "@/features/messages/lib/formatTimelineMessages"; +import { + getChannelIdFromTags, + getThreadReference, +} from "@/features/messages/lib/threading"; +import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; +import { useChannelTyping } from "@/features/messages/useChannelTyping"; +import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; +import { getEventById } from "@/shared/api/tauri"; +import type { + Channel, + Identity, + Profile, + RelayEvent, + SearchHit, +} from "@/shared/api/types"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const ChannelPane = React.lazy(async () => { + const module = await import("@/features/channels/ui/ChannelPane"); + return { default: module.ChannelPane }; +}); + +const ForumView = React.lazy(async () => { + const module = await import("@/features/forum/ui/ForumView"); + return { default: module.ForumView }; +}); + +type ChannelScreenProps = { + activeChannel: Channel | null; + currentIdentity?: Identity; + currentProfile?: Profile; + onManageChannel: () => void; + onMarkChannelRead: ( + channelId: string, + readAt: string | null | undefined, + ) => void; + onTargetReached: (messageId: string) => void; + searchAnchor: SearchHit | null; + searchAnchorChannelId: string | null; + searchAnchorEvent: RelayEvent | null; +}; + +export function ChannelScreen({ + activeChannel, + currentIdentity, + currentProfile, + onManageChannel, + onMarkChannelRead, + onTargetReached, + searchAnchor, + searchAnchorChannelId, + searchAnchorEvent, +}: ChannelScreenProps) { + const queryClient = useQueryClient(); + const [replyTargetId, setReplyTargetId] = React.useState(null); + const [editTargetId, setEditTargetId] = React.useState(null); + const currentPubkey = currentIdentity?.pubkey; + const activeChannelId = activeChannel?.id ?? null; + + const messagesQuery = useChannelMessagesQuery(activeChannel); + useChannelSubscription(activeChannel); + const { fetchOlder, hasOlderMessages, isFetchingOlder } = + useFetchOlderMessages(activeChannel); + const latestActiveMessage = + messagesQuery.data?.[messagesQuery.data.length - 1] ?? null; + const activeReadAt = latestActiveMessage + ? new Date(latestActiveMessage.created_at * 1_000).toISOString() + : (activeChannel?.lastMessageAt ?? null); + + React.useEffect(() => { + if (!activeChannelId) { + return; + } + + onMarkChannelRead(activeChannelId, activeReadAt); + }, [activeChannelId, activeReadAt, onMarkChannelRead]); + + const { activeChannelTitle, activeDmPresenceStatus } = useActiveChannelHeader( + activeChannel, + currentPubkey, + ); + const sendMessageMutation = useSendMessageMutation( + activeChannel, + currentIdentity, + ); + const toggleReactionMutation = useToggleReactionMutation(); + const deleteMessageMutation = useDeleteMessageMutation(activeChannel); + const editMessageMutation = useEditMessageMutation(activeChannel); + + const resolvedMessages = React.useMemo(() => { + const currentMessages = messagesQuery.data ?? []; + + if ( + !activeChannel || + !searchAnchorEvent || + searchAnchorChannelId !== activeChannel.id + ) { + return currentMessages; + } + + return mergeMessages(currentMessages, searchAnchorEvent); + }, [ + activeChannel, + messagesQuery.data, + searchAnchorChannelId, + searchAnchorEvent, + ]); + const messageAuthorPubkeys = React.useMemo( + () => collectMessageAuthorPubkeys(resolvedMessages), + [resolvedMessages], + ); + const latestMessageEvent = React.useMemo( + () => resolvedMessages[resolvedMessages.length - 1] ?? null, + [resolvedMessages], + ); + const typingPubkeys = useChannelTyping( + activeChannel, + currentPubkey, + latestMessageEvent, + ); + const messageProfilePubkeys = React.useMemo( + () => [...new Set([...messageAuthorPubkeys, ...typingPubkeys])], + [messageAuthorPubkeys, typingPubkeys], + ); + const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { + enabled: messageProfilePubkeys.length > 0, + }); + const messageProfiles = React.useMemo( + () => + mergeCurrentProfileIntoLookup( + messageProfilesQuery.data?.profiles, + currentProfile, + ), + [currentProfile, messageProfilesQuery.data?.profiles], + ); + const timelineMessages = React.useMemo( + () => + formatTimelineMessages( + resolvedMessages, + activeChannel, + currentPubkey, + currentProfile?.avatarUrl ?? null, + messageProfiles, + ), + [ + activeChannel, + currentProfile?.avatarUrl, + currentPubkey, + messageProfiles, + resolvedMessages, + ], + ); + const replyTargetMessage = React.useMemo( + () => + timelineMessages.find((message) => message.id === replyTargetId) ?? null, + [replyTargetId, timelineMessages], + ); + const editTargetMessage = React.useMemo( + () => + timelineMessages.find((message) => message.id === editTargetId) ?? null, + [editTargetId, timelineMessages], + ); + + const { + handleCancelEdit, + handleCancelReply, + handleDelete, + handleEdit, + handleEditSave, + handleReply, + handleSend, + handleToggleReaction, + } = useChannelPaneHandlers({ + deleteMessageMutation, + editMessageMutation, + editTargetId, + replyTargetId, + sendMessageMutation, + setEditTargetId, + setReplyTargetId, + toggleReactionMutation, + }); + + const canReact = activeChannel !== null && activeChannel.archivedAt === null; + const effectiveToggleReaction = React.useMemo( + () => (canReact ? handleToggleReaction : undefined), + [canReact, handleToggleReaction], + ); + + const channelDescription = activeChannel + ? [ + activeChannel.archivedAt ? "Archived." : null, + !activeChannel.isMember + ? "Read-only until you join this open channel." + : null, + activeChannel.topic, + activeChannel.description, + activeChannel.purpose, + null, + ] + .filter((value) => value && value.trim().length > 0) + .join(" ") || "Channel details and activity." + : "Connect to the relay to browse channels and read messages."; + const shouldLoadTimeline = + activeChannel !== null && activeChannel.channelType !== "forum"; + const isTimelineLoading = + shouldLoadTimeline && + (messagesQuery.isPending || + (messagesQuery.isFetching && resolvedMessages.length === 0)); + const requestedAncestorIdsRef = React.useRef>(new Set()); + const resetComposerTargets = React.useCallback( + (_channelId: string | null) => { + setReplyTargetId(null); + setEditTargetId(null); + }, + [], + ); + const resetRequestedAncestors = React.useCallback( + (_channelId: string | null) => { + requestedAncestorIdsRef.current.clear(); + }, + [], + ); + + React.useEffect(() => { + resetComposerTargets(activeChannelId); + }, [activeChannelId, resetComposerTargets]); + + React.useEffect(() => { + if (replyTargetId && !replyTargetMessage) { + setReplyTargetId(null); + } + if (editTargetId && !editTargetMessage) { + setEditTargetId(null); + } + }, [editTargetId, editTargetMessage, replyTargetId, replyTargetMessage]); + + React.useEffect(() => { + resetRequestedAncestors(activeChannelId); + }, [activeChannelId, resetRequestedAncestors]); + + React.useEffect(() => { + if (!activeChannel || activeChannel.channelType === "forum") { + return; + } + + const knownEvents = new Map( + resolvedMessages.map((message) => [message.id, message]), + ); + const missingAncestorIds = new Set(); + + for (const message of resolvedMessages) { + const thread = getThreadReference(message.tags); + + for (const eventId of [thread.parentId, thread.rootId]) { + if ( + !eventId || + knownEvents.has(eventId) || + requestedAncestorIdsRef.current.has(eventId) + ) { + continue; + } + + missingAncestorIds.add(eventId); + } + } + + if (missingAncestorIds.size === 0) { + return; + } + + for (const eventId of missingAncestorIds) { + requestedAncestorIdsRef.current.add(eventId); + } + + const maxRequestedAncestors = 500; + if (requestedAncestorIdsRef.current.size > maxRequestedAncestors) { + const excess = + requestedAncestorIdsRef.current.size - maxRequestedAncestors; + let removed = 0; + for (const id of requestedAncestorIdsRef.current) { + if (removed >= excess) { + break; + } + requestedAncestorIdsRef.current.delete(id); + removed++; + } + } + + let isCancelled = false; + + void Promise.all( + [...missingAncestorIds].map(async (eventId) => { + try { + const event = await getEventById(eventId); + + if ( + isCancelled || + getChannelIdFromTags(event.tags) !== activeChannel.id + ) { + return; + } + + queryClient.setQueryData( + channelMessagesKey(activeChannel.id), + (current = []) => mergeMessages(current, event), + ); + } catch (error) { + console.error("Failed to load ancestor event", eventId, error); + } + }), + ); + + return () => { + isCancelled = true; + }; + }, [activeChannel, queryClient, resolvedMessages]); + + return ( + <> + + ) : null + } + channelType={activeChannel?.channelType} + visibility={activeChannel?.visibility} + description={channelDescription} + statusBadge={ + activeChannel?.channelType === "dm" && activeDmPresenceStatus ? ( + + ) : null + } + title={activeChannelTitle} + /> + +
+ {activeChannel ? ( + activeChannel.channelType === "forum" ? ( + } + > + + + ) : ( + } + > + + + ) + ) : ( +
+

+ Select a channel to view messages. +

+
+ )} +
+ + ); +} diff --git a/desktop/src/app/useActiveChannelHeader.ts b/desktop/src/features/channels/useActiveChannelHeader.ts similarity index 100% rename from desktop/src/app/useActiveChannelHeader.ts rename to desktop/src/features/channels/useActiveChannelHeader.ts diff --git a/desktop/src/app/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts similarity index 98% rename from desktop/src/app/useChannelPaneHandlers.ts rename to desktop/src/features/channels/useChannelPaneHandlers.ts index 0dee12c4..fa710afd 100644 --- a/desktop/src/app/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -9,7 +9,7 @@ import type { /** * Stable callback references for ChannelPane so that keystroke-driven - * re-renders of AppShell don't cascade into the timeline and composer. + * re-renders of ChannelScreen don't cascade into the timeline and composer. * * Mutation objects from TanStack Query v5 are new references on every render * (especially when `isPending` flips), so we stash `.mutateAsync` in a ref diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index 2ab7fb42..6b1b0258 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -77,7 +77,10 @@ export function useUnreadChannels( const hasInitializedChannelsRef = React.useRef(false); const activeChannelId = activeChannel?.id ?? null; const activeChannelLastMessageAt = activeChannel?.lastMessageAt ?? null; - const effectiveActiveReadAt = activeReadAt ?? activeChannelLastMessageAt; + // Let callers pass `null` to intentionally suppress the optimistic + // channel-metadata fallback until a real timeline position is known. + const effectiveActiveReadAt = + activeReadAt === undefined ? activeChannelLastMessageAt : activeReadAt; const markChannelRead = React.useCallback( (channelId: string, readAt: string | null | undefined) => { diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx new file mode 100644 index 00000000..2d6a4a9d --- /dev/null +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -0,0 +1,45 @@ +import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import { useHomeFeedQuery } from "@/features/home/hooks"; +import { HomeView } from "@/features/home/ui/HomeView"; + +type HomeScreenProps = { + availableChannelIds: ReadonlySet; + currentPubkey?: string; + onOpenChannel: (channelId: string) => void; +}; + +export function HomeScreen({ + availableChannelIds, + currentPubkey, + onOpenChannel, +}: HomeScreenProps) { + const homeFeedQuery = useHomeFeedQuery(); + + return ( + <> + + +
+ { + void homeFeedQuery.refetch(); + }} + /> +
+ + ); +} diff --git a/desktop/src/features/search/ui/SearchDialog.tsx b/desktop/src/features/search/ui/SearchDialog.tsx index ad731686..350f82e6 100644 --- a/desktop/src/features/search/ui/SearchDialog.tsx +++ b/desktop/src/features/search/ui/SearchDialog.tsx @@ -182,27 +182,6 @@ export function SearchDialog({ }; }, [query]); - React.useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if ( - event.key.toLowerCase() !== "k" || - !(event.metaKey || event.ctrlKey) || - event.altKey || - event.shiftKey - ) { - return; - } - - event.preventDefault(); - onOpenChange(true); - } - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [onOpenChange]); - React.useEffect(() => { if (!open) { setQuery(""); diff --git a/desktop/src/features/settings/ui/SettingsScreen.tsx b/desktop/src/features/settings/ui/SettingsScreen.tsx new file mode 100644 index 00000000..af9ccddd --- /dev/null +++ b/desktop/src/features/settings/ui/SettingsScreen.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; + +import type { DesktopNotificationPermissionState } from "@/features/notifications/hooks"; +import type { NotificationSettings } from "@/features/notifications/hooks"; +import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const SettingsView = React.lazy(async () => { + const module = await import("@/features/settings/ui/SettingsView"); + return { default: module.SettingsView }; +}); + +type SettingsScreenProps = { + currentPubkey?: string; + fallbackDisplayName?: string; + isUpdatingDesktopNotifications: boolean; + notificationErrorMessage: string | null; + notificationPermission: DesktopNotificationPermissionState; + notificationSettings: NotificationSettings; + onClose: () => void; + onSectionChange: (section: SettingsSection) => void; + onSetDesktopNotificationsEnabled: (enabled: boolean) => Promise; + onSetHomeBadgeEnabled: (enabled: boolean) => void; + onSetMentionNotificationsEnabled: (enabled: boolean) => void; + onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void; + section: SettingsSection; +}; + +export function SettingsScreen({ + currentPubkey, + fallbackDisplayName, + isUpdatingDesktopNotifications, + notificationErrorMessage, + notificationPermission, + notificationSettings, + onClose, + onSectionChange, + onSetDesktopNotificationsEnabled, + onSetHomeBadgeEnabled, + onSetMentionNotificationsEnabled, + onSetNeedsActionNotificationsEnabled, + section, +}: SettingsScreenProps) { + return ( + } + > + + + ); +} diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 12254c93..1d1f29e4 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { FocusScope } from "@radix-ui/react-focus-scope"; import { X } from "lucide-react"; import { cn } from "@/shared/lib/cn"; @@ -115,93 +114,91 @@ export function SettingsView({ /> {/* Modal container */} - - {/* biome-ignore lint/a11y/useKeyWithClickEvents: Click stops propagation to backdrop; keyboard dismiss handled by Escape key */} -
e.stopPropagation()} - role="dialog" + {/* biome-ignore lint/a11y/useKeyWithClickEvents: Click stops propagation to backdrop; keyboard dismiss handled by Escape key */} +
e.stopPropagation()} + role="dialog" + > + {/* Header with title and close button */} +
- {/* Header with title and close button */} -
-

- Settings -

- -
+ Settings + + +
- {/* Two-column layout */} -
- {/* Sidebar nav */} - - {/* Content area */} -
-
- {renderSettingsSection(section, { - currentPubkey, - fallbackDisplayName, - isUpdatingDesktopNotifications, - notificationErrorMessage, - notificationPermission, - notificationSettings, - onSetDesktopNotificationsEnabled, - onSetHomeBadgeEnabled, - onSetMentionNotificationsEnabled, - onSetNeedsActionNotificationsEnabled, - })} -
-
-
+ {/* Content area */} +
+
+ {renderSettingsSection(section, { + currentPubkey, + fallbackDisplayName, + isUpdatingDesktopNotifications, + notificationErrorMessage, + notificationPermission, + notificationSettings, + onSetDesktopNotificationsEnabled, + onSetHomeBadgeEnabled, + onSetMentionNotificationsEnabled, + onSetNeedsActionNotificationsEnabled, + })} +
+
- +
); } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index a89c413b..f850bd77 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -189,6 +189,40 @@ function useCreateForm( }; } +function useDeferredSidebarLoad( + activateImmediately: boolean, + timeoutMs: number, +) { + const [shouldLoad, setShouldLoad] = React.useState(activateImmediately); + + React.useEffect(() => { + if (shouldLoad || activateImmediately) { + if (!shouldLoad) { + setShouldLoad(true); + } + return; + } + + const load = () => { + setShouldLoad(true); + }; + + if ("requestIdleCallback" in window) { + const idleId = window.requestIdleCallback(load, { timeout: timeoutMs }); + return () => { + window.cancelIdleCallback(idleId); + }; + } + + const timeoutId = globalThis.setTimeout(load, timeoutMs); + return () => { + globalThis.clearTimeout(timeoutId); + }; + }, [activateImmediately, shouldLoad, timeoutMs]); + + return shouldLoad; +} + // --------------------------------------------------------------------------- // SectionHeaderActions — search + create icon buttons for section headers // --------------------------------------------------------------------------- @@ -470,17 +504,31 @@ export function AppSidebar({ const directMessages = channels.filter( (channel) => channel.channelType === "dm", ); + const isSelectedDirectMessage = + selectedView === "channel" && + directMessages.some((channel) => channel.id === selectedChannelId); + const shouldLoadDmMetadata = useDeferredSidebarLoad( + isSelectedDirectMessage, + 400, + ); const { dmChannelLabels, dmParticipantsByChannelId, dmPresenceByChannelId } = useDmSidebarMetadata({ currentPubkey, directMessages, + enabled: shouldLoadDmMetadata, fallbackDisplayName, profileDisplayName: profile?.displayName, }); - const managedAgentsQuery = useManagedAgentsQuery(); + const shouldLoadAgentCount = useDeferredSidebarLoad( + selectedView === "agents", + 250, + ); + const managedAgentsQuery = useManagedAgentsQuery({ + enabled: shouldLoadAgentCount, + }); const totalAgentCount = managedAgentsQuery.data?.length ?? 0; const shouldShowAgentCount = - totalAgentCount > 0 || !managedAgentsQuery.isLoading; + totalAgentCount > 0 || managedAgentsQuery.isFetched; const resolvedDisplayName = profile?.displayName?.trim() || fallbackDisplayName?.trim() || diff --git a/desktop/src/features/sidebar/useDmSidebarMetadata.ts b/desktop/src/features/sidebar/useDmSidebarMetadata.ts index 8e0d9825..52f613f7 100644 --- a/desktop/src/features/sidebar/useDmSidebarMetadata.ts +++ b/desktop/src/features/sidebar/useDmSidebarMetadata.ts @@ -12,11 +12,13 @@ export function useDmSidebarMetadata({ directMessages, fallbackDisplayName, profileDisplayName, + enabled = true, }: { currentPubkey?: string; directMessages: Channel[]; fallbackDisplayName?: string; profileDisplayName?: string | null; + enabled?: boolean; }) { const selfDmLabels = React.useMemo( () => @@ -44,10 +46,10 @@ export function useDmSidebarMetadata({ [currentPubkey, directMessages, selfDmLabels], ); const dmPresenceQuery = usePresenceQuery(dmParticipantPubkeys, { - enabled: directMessages.length > 0, + enabled: enabled && directMessages.length > 0, }); const dmProfilesQuery = useUsersBatchQuery(dmParticipantPubkeys, { - enabled: directMessages.length > 0, + enabled: enabled && directMessages.length > 0, }); const dmProfiles = dmProfilesQuery.data?.profiles; const dmPresenceByChannelId = React.useMemo( diff --git a/desktop/src/features/workflows/ui/WorkflowsScreen.tsx b/desktop/src/features/workflows/ui/WorkflowsScreen.tsx new file mode 100644 index 00000000..a072c9c4 --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowsScreen.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; + +import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import type { Channel } from "@/shared/api/types"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const WorkflowsView = React.lazy(async () => { + const module = await import("@/features/workflows/ui/WorkflowsView"); + return { default: module.WorkflowsView }; +}); + +type WorkflowsScreenProps = { + channels: Channel[]; +}; + +export function WorkflowsScreen({ channels }: WorkflowsScreenProps) { + return ( + <> + + + } + > +
+ +
+
+ + ); +} diff --git a/desktop/src/shared/ui/ViewLoadingFallback.tsx b/desktop/src/shared/ui/ViewLoadingFallback.tsx new file mode 100644 index 00000000..a4edcc0e --- /dev/null +++ b/desktop/src/shared/ui/ViewLoadingFallback.tsx @@ -0,0 +1,11 @@ +type ViewLoadingFallbackProps = { + label: string; +}; + +export function ViewLoadingFallback({ label }: ViewLoadingFallbackProps) { + return ( +
+

{label}

+
+ ); +}