diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 149b1e08cc..ff880dc069 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -153,7 +153,10 @@ const overrides = new Map([ // importIdentity, persistCurrentIdentity) moved to tauriIdentity.ts; // limit ratcheted down 1380 → 1360 to bank the headroom (absorbs main-side // growth landed between the split and the rebase). - ["src/shared/api/tauri.ts", 1360], + // mention-alias fix: profile wrappers (RawProfile/RawUserProfileSummary types, + // getProfile/updateProfile/getUserProfile/getUsersBatch/searchUsers) moved to + // tauriProfiles.ts; limit ratcheted down 1360 → 1241 to bank the headroom. + ["src/shared/api/tauri.ts", 1241], // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog // shows required markers and credential amber rows (parity with diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index e4c52066a2..54b52bd197 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, Manager}; use crate::{ app_state::AppState, diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index b0143daef7..8eac36fca8 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -33,7 +33,7 @@ fn directory_cursor_keeps_same_second_tiebreaker() { let event = ev_at(39000, "{}", vec![], timestamp); let mut filter = serde_json::json!({"kinds": [39000], "limit": DIRECTORY_PAGE_SIZE}); - advance_directory_cursor(&mut filter, &[event.clone()]); + advance_directory_cursor(&mut filter, std::slice::from_ref(&event)); assert_eq!(filter["until"], serde_json::json!(timestamp.as_secs())); assert_eq!(filter["before_id"], serde_json::json!(event.id.to_hex())); diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index ef8fc665fe..abd5c9596b 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -436,7 +436,7 @@ const DEV_MIGRATION_MARKER: &str = "_dev_migration_v1"; /// /// On subsequent boots (marker already present): /// 1. One `dst.load_all_readonly()` — dev blob read (1 keychain prompt) -/// Returns immediately — prod keyring is NEVER accessed. +/// Returns immediately — prod keyring is NEVER accessed. /// /// Idempotency: keys already present in `dst` are not overwritten (the agent /// may have rotated their key in the dev service after initial migration). diff --git a/desktop/src-tauri/src/migration_databricks_tests.rs b/desktop/src-tauri/src/migration_databricks_tests.rs index 2e50fae17d..842507ec83 100644 --- a/desktop/src-tauri/src/migration_databricks_tests.rs +++ b/desktop/src-tauri/src/migration_databricks_tests.rs @@ -32,7 +32,7 @@ fn reconcile_databricks_v1_to_v2_rewrites_v1_provider_on_block_build() { // Stale V1 model must be cleared so the baked DATABRICKS_MODEL is not // shadowed by BUZZ_AGENT_MODEL at spawn time (last-write-wins in Command::env). assert!( - records[0].get("model").map_or(true, |v| v.is_null()), + records[0].get("model").is_none_or(|v| v.is_null()), "stale V1 model field must be cleared when provider is rewritten to V2" ); } @@ -98,12 +98,12 @@ fn reconcile_databricks_v1_to_v2_clears_model_on_provider_rewrite() { // V1 records: provider migrated, model cleared. assert_eq!(records[0]["provider"], "databricks_v2"); assert!( - records[0].get("model").map_or(true, |v| v.is_null()), + records[0].get("model").is_none_or(|v| v.is_null()), "model must be cleared for V1→V2 migrated record A" ); assert_eq!(records[1]["provider"], "databricks_v2"); assert!( - records[1].get("model").map_or(true, |v| v.is_null()), + records[1].get("model").is_none_or(|v| v.is_null()), "model must be cleared for V1→V2 migrated record B" ); // V2 record: model untouched. diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 881cd11a35..8f002fdb1d 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -37,6 +37,11 @@ pub struct ProfileInfo { #[derive(Serialize, Deserialize)] pub struct UserProfileSummaryInfo { pub display_name: Option, + /// Kind-0 `name` field, carried separately from `display_name` so clients + /// can match @mention text against either alias (agents and the CLI + /// resolve mentions server-side against `display_name` *or* `name`). + #[serde(default)] + pub name: Option, pub avatar_url: Option, pub nip05_handle: Option, pub owner_pubkey: Option, diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index c1db2a1b59..ec4970e0c9 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -339,6 +339,7 @@ pub fn users_batch_from_events( .and_then(Value::as_str) .or_else(|| v.get("name").and_then(Value::as_str)) .map(str::to_string), + name: v.get("name").and_then(Value::as_str).map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), is_agent: owner_pubkey.is_some(), diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index ac3eded07e..5d1b0e7d63 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -14,10 +14,8 @@ import { } from "@/features/channels/readState/readStateFormat"; import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState"; import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader"; -import { - ChannelPane, - ForumView, -} from "@/features/channels/ui/ChannelScreenLazyViews"; +import { ChannelPane } from "@/features/channels/ui/ChannelScreenLazyViews"; +import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; import { useManagedAgentsQuery, @@ -839,19 +837,27 @@ export function ChannelScreen({ > {activeChannel ? ( activeChannel.channelType === "forum" ? ( - <> - {channelHeader} - }> - - - + ) : ( } diff --git a/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts b/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts index 4e4fc0f2af..86619a3c57 100644 --- a/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts +++ b/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts @@ -9,3 +9,8 @@ export const ForumView = React.lazy(async () => { const module = await import("@/features/forum/ui/ForumView"); return { default: module.ForumView }; }); + +export const UserProfilePanel = React.lazy(async () => { + const module = await import("@/features/profile/ui/UserProfilePanel"); + return { default: module.UserProfilePanel }; +}); diff --git a/desktop/src/features/channels/ui/ForumChannelContent.tsx b/desktop/src/features/channels/ui/ForumChannelContent.tsx new file mode 100644 index 0000000000..428413c597 --- /dev/null +++ b/desktop/src/features/channels/ui/ForumChannelContent.tsx @@ -0,0 +1,121 @@ +import * as React from "react"; + +import { + ForumView, + UserProfilePanel, +} from "@/features/channels/ui/ChannelScreenLazyViews"; +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import type { + ProfilePanelTab, + ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; +import type { Channel } from "@/shared/api/types"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +type ForumChannelContentProps = { + canResetPanelWidth: boolean; + channel: Channel; + currentPubkey?: string; + header: React.ReactNode; + onClosePost: () => void; + onCloseProfilePanel: () => void; + onOpenDm?: (pubkeys: string[]) => Promise | void; + onOpenProfilePanel: (pubkey: string) => void; + onPanelResizeStart: (event: React.PointerEvent) => void; + onProfilePanelTabChange: ( + tab: ProfilePanelTab, + options?: { replace?: boolean }, + ) => void; + onProfilePanelViewChange: ( + view: ProfilePanelView, + options?: { replace?: boolean }, + ) => void; + onResetPanelWidth: () => void; + onSelectPost: (postId: string) => void; + panelWidthPx: number; + profilePanelPubkey?: string | null; + profilePanelTab: ProfilePanelTab; + profilePanelView: ProfilePanelView; + selectedPostId: string | null; + targetReplyId: string | null; +}; + +/** + * Forum-channel body for ChannelScreen: the post list/thread plus the + * user-profile auxiliary pane. Forums replace ChannelPane (which hosts the + * profile panel for message channels), so without this host, opening a + * profile from a mention chip, avatar, or the members sidebar would set + * state that never renders. + */ +export function ForumChannelContent({ + canResetPanelWidth, + channel, + currentPubkey, + header, + onClosePost, + onCloseProfilePanel, + onOpenDm, + onOpenProfilePanel, + onPanelResizeStart, + onProfilePanelTabChange, + onProfilePanelViewChange, + onResetPanelWidth, + onSelectPost, + panelWidthPx, + profilePanelPubkey, + profilePanelTab, + profilePanelView, + selectedPostId, + targetReplyId, +}: ForumChannelContentProps) { + return ( + <> + {header} +
+
+ }> + + +
+ {profilePanelPubkey ? ( + + + + + + ) : null} +
+ + ); +} diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 314d28f41c..48a04b5918 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -511,18 +511,15 @@ export function MembersSidebar({ useFeedbackToasts(actionNoticeMessage, actionErrorMessage); const { openProfilePanel } = useProfilePanel(); - // UserProfilePanel only renders inside ChannelPane, which forums replace - // with ForumView — opening there would close the sheet and show nothing. - const isForumChannel = channel?.channelType === "forum"; const handleOpenProfile = React.useMemo( () => - openProfilePanel && !isForumChannel + openProfilePanel ? (pubkey: string) => { onOpenChange(false); openProfilePanel(pubkey); } : undefined, - [isForumChannel, onOpenChange, openProfilePanel], + [onOpenChange, openProfilePanel], ); const [editRespondToAgent, setEditRespondToAgent] = diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 4bd5f0f087..311df0f97f 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -10,7 +10,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { ForumPost } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { parseImetaTags } from "@/features/messages/lib/parseImeta"; -import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; +import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; import { formatRelativeTime } from "../lib/time"; @@ -44,7 +44,10 @@ export function ForumPostCard({ preferResolvedSelfLabel: true, }); const avatarUrl = profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null; - const mentionNames = resolveMentionNames(post.tags, profiles); + const { mentionNames, mentionPubkeysByName } = resolveMentionProps( + post.tags, + profiles, + ); // Memoize the imeta map: `parseImetaTags` builds a fresh object each render, // and the `Markdown` memo compares `imetaByUrl` by reference. Without this, // the post's Markdown (and the FileCard