mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
[codex] Improve desktop startup shell loading (#241)
This commit is contained in:
@@ -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::<AppState>();
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<dyn std::error::Error> { 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}");
|
||||
}
|
||||
|
||||
@@ -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 <AppShell />;
|
||||
|
||||
@@ -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<AppView>("home");
|
||||
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
||||
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
|
||||
@@ -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<BrowseDialogType>(null);
|
||||
const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
|
||||
null,
|
||||
);
|
||||
@@ -90,14 +74,14 @@ export function AppShell() {
|
||||
>(null);
|
||||
const [searchAnchorEvent, setSearchAnchorEvent] =
|
||||
React.useState<RelayEvent | null>(null);
|
||||
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
|
||||
const [editTargetId, setEditTargetId] = React.useState<string | null>(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;
|
||||
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 handleBrowseDialogOpenChange = React.useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setBrowseDialogType(null);
|
||||
}
|
||||
|
||||
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 handleTargetReached = React.useCallback((messageId: string) => {
|
||||
setSearchAnchor((current) =>
|
||||
current?.eventId === messageId ? null : current,
|
||||
);
|
||||
}, []);
|
||||
|
||||
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<Set<string>>(new Set());
|
||||
const previousActiveChannelIdRef = React.useRef<string | null>(
|
||||
activeChannelId,
|
||||
);
|
||||
|
||||
const resolveChannel = React.useCallback(
|
||||
async (channelId: string): Promise<Channel | null> => {
|
||||
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<string>();
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of resolvedMessages) {
|
||||
const thread = getThreadReference(message.tags);
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "k" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleOpenSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const eventId of [thread.parentId, thread.rootId]) {
|
||||
if (
|
||||
!eventId ||
|
||||
knownEvents.has(eventId) ||
|
||||
requestedAncestorIdsRef.current.has(eventId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
missingAncestorIds.add(eventId);
|
||||
if (key === "o" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleOpenBrowseChannels();
|
||||
}
|
||||
}
|
||||
|
||||
if (missingAncestorIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const eventId of missingAncestorIds) {
|
||||
requestedAncestorIdsRef.current.add(eventId);
|
||||
}
|
||||
|
||||
// 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++;
|
||||
}
|
||||
}
|
||||
|
||||
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<RelayEvent[]>(
|
||||
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() {
|
||||
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
{selectedView === "home" ? (
|
||||
<ChatHeader
|
||||
description="Personalized feed for mentions, reminders, channel activity, and agent work."
|
||||
mode="home"
|
||||
title="Home"
|
||||
<HomeScreen
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onOpenChannel={handleOpenChannel}
|
||||
/>
|
||||
) : selectedView === "agents" ? (
|
||||
<ChatHeader
|
||||
description="Create local ACP workers, mint agent tokens, and monitor the relay-visible agent directory."
|
||||
mode="agents"
|
||||
title="Agents"
|
||||
/>
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading agents..." />}
|
||||
>
|
||||
<AgentsScreen />
|
||||
</React.Suspense>
|
||||
) : selectedView === "workflows" ? (
|
||||
<ChatHeader
|
||||
description="Create, manage, and monitor automated workflows across your channels."
|
||||
mode="workflows"
|
||||
title="Workflows"
|
||||
/>
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading workflows..." />}
|
||||
>
|
||||
<WorkflowsScreen channels={memberChannels} />
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<ChatHeader
|
||||
actions={
|
||||
activeChannel ? (
|
||||
<ChannelMembersBar
|
||||
channel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onManageChannel={() => {
|
||||
setIsChannelManagementOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
channelType={activeChannel?.channelType}
|
||||
visibility={activeChannel?.visibility}
|
||||
description={channelDescription}
|
||||
statusBadge={
|
||||
activeChannel?.channelType === "dm" &&
|
||||
activeDmPresenceStatus ? (
|
||||
<PresenceBadge
|
||||
data-testid="chat-presence-badge"
|
||||
status={activeDmPresenceStatus}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
title={activeChannelTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
selectedView === "home"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "hidden"
|
||||
}
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading channel..." />}
|
||||
>
|
||||
<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();
|
||||
<ChannelScreen
|
||||
activeChannel={activeChannel}
|
||||
currentIdentity={identityQuery.data}
|
||||
currentProfile={profileQuery.data}
|
||||
onManageChannel={() => {
|
||||
setIsChannelManagementOpen(true);
|
||||
}}
|
||||
onMarkChannelRead={markChannelRead}
|
||||
onTargetReached={handleTargetReached}
|
||||
searchAnchor={searchAnchor}
|
||||
searchAnchorChannelId={searchAnchorChannelId}
|
||||
searchAnchorEvent={searchAnchorEvent}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedView === "agents"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
<AgentsView />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedView === "workflows"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
<WorkflowsView channels={memberChannels} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedView !== "home" &&
|
||||
selectedView !== "agents" &&
|
||||
selectedView !== "workflows"
|
||||
? "flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
{activeChannel?.channelType === "forum" ? (
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
/>
|
||||
) : (
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
? {
|
||||
author: editTargetMessage.author,
|
||||
body: editTargetMessage.body,
|
||||
id: editTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelReply={handleCancelReply}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditSave={handleEditSave}
|
||||
onReply={handleReply}
|
||||
onSend={handleSend}
|
||||
onTargetReached={handleTargetReached}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
profiles={messageProfiles}
|
||||
replyTargetId={replyTargetId}
|
||||
replyTargetMessage={replyTargetMessage}
|
||||
targetMessageId={
|
||||
activeChannel &&
|
||||
searchAnchor?.channelId === activeChannel.id
|
||||
? searchAnchor.eventId
|
||||
: null
|
||||
}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</SidebarInset>
|
||||
|
||||
<ChannelBrowserDialog
|
||||
channels={channels}
|
||||
channelTypeFilter={browseDialogType ?? "stream"}
|
||||
onJoinChannel={handleBrowseChannelJoin}
|
||||
onOpenChange={handleBrowseDialogOpenChange}
|
||||
onSelectChannel={handleOpenChannel}
|
||||
open={browseDialogType !== null}
|
||||
/>
|
||||
|
||||
<SearchDialog
|
||||
<AppShellOverlays
|
||||
activeChannel={activeChannel}
|
||||
browseDialogType={browseDialogType}
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onOpenResult={handleOpenSearchResult}
|
||||
onOpenChange={setIsSearchOpen}
|
||||
open={isSearchOpen}
|
||||
/>
|
||||
|
||||
<ChannelManagementSheet
|
||||
channel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onDeleted={() => {
|
||||
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 && (
|
||||
<SettingsView
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
notificationErrorMessage={notificationSettings.errorMessage}
|
||||
notificationPermission={notificationSettings.permission}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={setSettingsSection}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
|
||||
onSetMentionNotificationsEnabled={
|
||||
notificationSettings.setMentionsEnabled
|
||||
}
|
||||
onSetNeedsActionNotificationsEnabled={
|
||||
notificationSettings.setNeedsActionEnabled
|
||||
}
|
||||
section={settingsSection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{settingsOpen ? (
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading settings..." />}
|
||||
>
|
||||
<SettingsScreen
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
notificationErrorMessage={notificationSettings.errorMessage}
|
||||
notificationPermission={notificationSettings.permission}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={setSettingsSection}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
|
||||
onSetMentionNotificationsEnabled={
|
||||
notificationSettings.setMentionsEnabled
|
||||
}
|
||||
onSetNeedsActionNotificationsEnabled={
|
||||
notificationSettings.setNeedsActionEnabled
|
||||
}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</SidebarProvider>
|
||||
</ChannelNavigationProvider>
|
||||
);
|
||||
|
||||
93
desktop/src/app/AppShellOverlays.tsx
Normal file
93
desktop/src/app/AppShellOverlays.tsx
Normal file
@@ -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<void>;
|
||||
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 ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<ChannelBrowserDialog
|
||||
channels={channels}
|
||||
channelTypeFilter={browseDialogType}
|
||||
onJoinChannel={onBrowseChannelJoin}
|
||||
onOpenChange={onBrowseDialogOpenChange}
|
||||
onSelectChannel={onSelectChannel}
|
||||
open={true}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
|
||||
{isSearchOpen ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<SearchDialog
|
||||
channels={channels}
|
||||
currentPubkey={currentPubkey}
|
||||
onOpenResult={onOpenSearchResult}
|
||||
onOpenChange={onSearchOpenChange}
|
||||
open={true}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
|
||||
{isChannelManagementOpen && activeChannel !== null ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<ChannelManagementSheet
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onDeleted={onDeleteActiveChannel}
|
||||
onOpenChange={onChannelManagementOpenChange}
|
||||
open={true}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
29
desktop/src/features/agents/ui/AgentsScreen.tsx
Normal file
29
desktop/src/features/agents/ui/AgentsScreen.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<ChatHeader
|
||||
description="Create local ACP workers, mint agent tokens, and monitor the relay-visible agent directory."
|
||||
mode="agents"
|
||||
title="Agents"
|
||||
/>
|
||||
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading agents..." />}
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<AgentsView />
|
||||
</div>
|
||||
</React.Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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("");
|
||||
|
||||
@@ -71,7 +71,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
typingPubkeys,
|
||||
}: ChannelPaneProps) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<MessageTimeline
|
||||
channelId={activeChannel?.id}
|
||||
activeReplyTargetId={replyTargetId}
|
||||
@@ -144,6 +144,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
});
|
||||
432
desktop/src/features/channels/ui/ChannelScreen.tsx
Normal file
432
desktop/src/features/channels/ui/ChannelScreen.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [editTargetId, setEditTargetId] = React.useState<string | null>(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<Set<string>>(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<string>();
|
||||
|
||||
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<RelayEvent[]>(
|
||||
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 (
|
||||
<>
|
||||
<ChatHeader
|
||||
actions={
|
||||
activeChannel ? (
|
||||
<ChannelMembersBar
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onManageChannel={onManageChannel}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
channelType={activeChannel?.channelType}
|
||||
visibility={activeChannel?.visibility}
|
||||
description={channelDescription}
|
||||
statusBadge={
|
||||
activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
|
||||
<PresenceBadge
|
||||
data-testid="chat-presence-badge"
|
||||
status={activeDmPresenceStatus}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
title={activeChannelTitle}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeChannel ? (
|
||||
activeChannel.channelType === "forum" ? (
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading forum..." />}
|
||||
>
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading channel..." />}
|
||||
>
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
? {
|
||||
author: editTargetMessage.author,
|
||||
body: editTargetMessage.body,
|
||||
id: editTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelReply={handleCancelReply}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditSave={handleEditSave}
|
||||
onReply={handleReply}
|
||||
onSend={handleSend}
|
||||
onTargetReached={onTargetReached}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
profiles={messageProfiles}
|
||||
replyTargetId={replyTargetId}
|
||||
replyTargetMessage={replyTargetMessage}
|
||||
targetMessageId={
|
||||
activeChannel && searchAnchor?.channelId === activeChannel.id
|
||||
? searchAnchor.eventId
|
||||
: null
|
||||
}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a channel to view messages.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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) => {
|
||||
|
||||
45
desktop/src/features/home/ui/HomeScreen.tsx
Normal file
45
desktop/src/features/home/ui/HomeScreen.tsx
Normal file
@@ -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<string>;
|
||||
currentPubkey?: string;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
};
|
||||
|
||||
export function HomeScreen({
|
||||
availableChannelIds,
|
||||
currentPubkey,
|
||||
onOpenChannel,
|
||||
}: HomeScreenProps) {
|
||||
const homeFeedQuery = useHomeFeedQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
description="Personalized feed for mentions, reminders, channel activity, and agent work."
|
||||
mode="home"
|
||||
title="Home"
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<HomeView
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={currentPubkey}
|
||||
errorMessage={
|
||||
homeFeedQuery.error instanceof Error
|
||||
? homeFeedQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
feed={homeFeedQuery.data}
|
||||
isLoading={homeFeedQuery.isLoading}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onRefresh={() => {
|
||||
void homeFeedQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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("");
|
||||
|
||||
67
desktop/src/features/settings/ui/SettingsScreen.tsx
Normal file
67
desktop/src/features/settings/ui/SettingsScreen.tsx
Normal file
@@ -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<boolean>;
|
||||
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 (
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading settings..." />}
|
||||
>
|
||||
<SettingsView
|
||||
currentPubkey={currentPubkey}
|
||||
fallbackDisplayName={fallbackDisplayName}
|
||||
isUpdatingDesktopNotifications={isUpdatingDesktopNotifications}
|
||||
notificationErrorMessage={notificationErrorMessage}
|
||||
notificationPermission={notificationPermission}
|
||||
notificationSettings={notificationSettings}
|
||||
onClose={onClose}
|
||||
onSectionChange={onSectionChange}
|
||||
onSetDesktopNotificationsEnabled={onSetDesktopNotificationsEnabled}
|
||||
onSetHomeBadgeEnabled={onSetHomeBadgeEnabled}
|
||||
onSetMentionNotificationsEnabled={onSetMentionNotificationsEnabled}
|
||||
onSetNeedsActionNotificationsEnabled={
|
||||
onSetNeedsActionNotificationsEnabled
|
||||
}
|
||||
section={section}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
<FocusScope loop>
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click stops propagation to backdrop; keyboard dismiss handled by Escape key */}
|
||||
<div
|
||||
aria-labelledby="settings-title"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
"relative mx-auto flex h-[min(600px,calc(100vh-8rem))] w-[calc(100%-4rem)] max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-background shadow-lg motion-safe:transition-all motion-safe:duration-500 motion-safe:ease-out",
|
||||
isLoaded ? "opacity-100 scale-100" : "opacity-0 scale-95",
|
||||
)}
|
||||
data-testid="settings-view"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click stops propagation to backdrop; keyboard dismiss handled by Escape key */}
|
||||
<div
|
||||
aria-labelledby="settings-title"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
"relative mx-auto flex h-[min(600px,calc(100vh-8rem))] w-[calc(100%-4rem)] max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-background shadow-lg motion-safe:transition-all motion-safe:duration-500 motion-safe:ease-out",
|
||||
isLoaded ? "opacity-100 scale-100" : "opacity-0 scale-95",
|
||||
)}
|
||||
data-testid="settings-view"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
>
|
||||
{/* Header with title and close button */}
|
||||
<header
|
||||
className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{/* Header with title and close button */}
|
||||
<header
|
||||
className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3"
|
||||
data-tauri-drag-region
|
||||
<h2
|
||||
className="text-base font-semibold"
|
||||
data-testid="settings-title"
|
||||
id="settings-title"
|
||||
>
|
||||
<h2
|
||||
className="text-base font-semibold"
|
||||
data-testid="settings-title"
|
||||
id="settings-title"
|
||||
>
|
||||
Settings
|
||||
</h2>
|
||||
<button
|
||||
aria-label="Close settings"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="settings-close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
Settings
|
||||
</h2>
|
||||
<button
|
||||
aria-label="Close settings"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="settings-close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden lg:grid-cols-[220px_minmax(0,1fr)] lg:grid-rows-1">
|
||||
{/* Sidebar nav */}
|
||||
<aside
|
||||
className={cn(
|
||||
"border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-700 motion-safe:ease-out lg:border-b-0 lg:border-r",
|
||||
isLoaded
|
||||
? "opacity-100 translate-x-0"
|
||||
: "opacity-0 -translate-x-2",
|
||||
)}
|
||||
{/* Two-column layout */}
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden lg:grid-cols-[220px_minmax(0,1fr)] lg:grid-rows-1">
|
||||
{/* Sidebar nav */}
|
||||
<aside
|
||||
className={cn(
|
||||
"border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-700 motion-safe:ease-out lg:border-b-0 lg:border-r",
|
||||
isLoaded
|
||||
? "opacity-100 translate-x-0"
|
||||
: "opacity-0 -translate-x-2",
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
className="flex gap-1 overflow-x-auto px-3 py-3 lg:flex-col lg:overflow-y-auto lg:pt-1"
|
||||
>
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
className="flex gap-1 overflow-x-auto px-3 py-3 lg:flex-col lg:overflow-y-auto lg:pt-1"
|
||||
>
|
||||
{settingsSections.map((entry, index) => (
|
||||
<SettingsSectionButton
|
||||
active={entry.value === section}
|
||||
index={index}
|
||||
isLoaded={isLoaded}
|
||||
key={entry.value}
|
||||
onSelect={onSectionChange}
|
||||
section={entry}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
{settingsSections.map((entry, index) => (
|
||||
<SettingsSectionButton
|
||||
active={entry.value === section}
|
||||
index={index}
|
||||
isLoaded={isLoaded}
|
||||
key={entry.value}
|
||||
onSelect={onSectionChange}
|
||||
section={entry}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content area */}
|
||||
<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,
|
||||
isUpdatingDesktopNotifications,
|
||||
notificationErrorMessage,
|
||||
notificationPermission,
|
||||
notificationSettings,
|
||||
onSetDesktopNotificationsEnabled,
|
||||
onSetHomeBadgeEnabled,
|
||||
onSetMentionNotificationsEnabled,
|
||||
onSetNeedsActionNotificationsEnabled,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/* Content area */}
|
||||
<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,
|
||||
isUpdatingDesktopNotifications,
|
||||
notificationErrorMessage,
|
||||
notificationPermission,
|
||||
notificationSettings,
|
||||
onSetDesktopNotificationsEnabled,
|
||||
onSetHomeBadgeEnabled,
|
||||
onSetMentionNotificationsEnabled,
|
||||
onSetNeedsActionNotificationsEnabled,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</FocusScope>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() ||
|
||||
|
||||
@@ -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(
|
||||
|
||||
34
desktop/src/features/workflows/ui/WorkflowsScreen.tsx
Normal file
34
desktop/src/features/workflows/ui/WorkflowsScreen.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<ChatHeader
|
||||
description="Create, manage, and monitor automated workflows across your channels."
|
||||
mode="workflows"
|
||||
title="Workflows"
|
||||
/>
|
||||
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback label="Loading workflows..." />}
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<WorkflowsView channels={channels} />
|
||||
</div>
|
||||
</React.Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
desktop/src/shared/ui/ViewLoadingFallback.tsx
Normal file
11
desktop/src/shared/ui/ViewLoadingFallback.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
type ViewLoadingFallbackProps = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function ViewLoadingFallback({ label }: ViewLoadingFallbackProps) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user