[codex] Improve desktop startup shell loading (#241)

This commit is contained in:
Wes
2026-04-06 09:19:47 -07:00
committed by GitHub
parent e10ca5193c
commit 53c2223ba5
20 changed files with 1064 additions and 643 deletions

View File

@@ -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}");
}

View File

@@ -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 />;

View File

@@ -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>
);

View 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}
</>
);
}

View File

@@ -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,

View 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>
</>
);
}

View File

@@ -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("");

View File

@@ -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>
</>
);
});

View 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>
</>
);
}

View File

@@ -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

View File

@@ -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) => {

View 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>
</>
);
}

View File

@@ -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("");

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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() ||

View File

@@ -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(

View 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>
</>
);
}

View 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>
);
}