diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index ce255c2f5..47e810674 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -11,6 +11,23 @@ const MAX_TITLE_FETCH_BYTES: usize = 256 * 1024; const TITLE_FETCH_TIMEOUT: Duration = Duration::from_secs(4); const GITHUB_API_TIMEOUT: Duration = Duration::from_secs(8); +/// Shared HTTP client for GitHub API commands: the PR monitor polls on +/// short intervals, and building a fresh client (connection pool + TLS +/// session cache) per call threw away keep-alive reuse on every tick. +fn github_client() -> Result<&'static reqwest::Client, String> { + static CLIENT: std::sync::OnceLock> = std::sync::OnceLock::new(); + CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .pool_idle_timeout(Duration::from_secs(90)) + .pool_max_idle_per_host(2) + .build() + .ok() + }) + .as_ref() + .ok_or_else(|| "github client failed to initialize".to_string()) +} + /// Live pull-request details for the rich GitHub PR link card. #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -82,11 +99,7 @@ pub async fn fetch_github_pull_request( return Err("invalid GitHub repository reference".to_string()); } - let client = reqwest::Client::builder() - .pool_idle_timeout(Duration::from_secs(10)) - .pool_max_idle_per_host(1) - .build() - .map_err(|error| format!("github client failed: {error}"))?; + let client = github_client()?; let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls/{number}"); let mut request = client @@ -149,11 +162,7 @@ pub async fn fetch_github_check_summary( return Err("invalid GitHub check reference".to_string()); } - let client = reqwest::Client::builder() - .pool_idle_timeout(Duration::from_secs(10)) - .pool_max_idle_per_host(1) - .build() - .map_err(|error| format!("github client failed: {error}"))?; + let client = github_client()?; let url = format!( "https://api.github.com/repos/{owner}/{repo}/commits/{sha}/check-runs?per_page=100" @@ -235,11 +244,7 @@ pub async fn fetch_github_pr_comment_state( return Err("invalid GitHub repository reference".to_string()); } - let client = reqwest::Client::builder() - .pool_idle_timeout(Duration::from_secs(10)) - .pool_max_idle_per_host(1) - .build() - .map_err(|error| format!("github client failed: {error}"))?; + let client = github_client()?; let base = format!("https://api.github.com/repos/{owner}/{repo}"); let build = |url: String| { @@ -357,11 +362,7 @@ pub async fn find_github_pr_for_branch( return Ok(None); } - let client = reqwest::Client::builder() - .pool_idle_timeout(Duration::from_secs(10)) - .pool_max_idle_per_host(1) - .build() - .map_err(|error| format!("github client failed: {error}"))?; + let client = github_client()?; let build = |url: String| { let mut request = client .get(url) diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index 0adc0992e..0035c5e9f 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -99,7 +99,20 @@ export function useChatWorkAutomation(chatId: string): ChatWorkAutomation { ); React.useEffect(() => { - const refresh = () => setState(readChatWorkAutomation(chatId)); + // Content-compare: a fresh object per storage event would re-run every + // consumer effect (and re-render every panel) even when nothing changed. + const refresh = () => + setState((current) => { + const next = readChatWorkAutomation(chatId); + return current.autoFixCi === next.autoFixCi && + current.addressComments === next.addressComments && + current.lastCiNudgeSha === next.lastCiNudgeSha && + current.lastCommentNudgeCount === next.lastCommentNudgeCount && + current.lastCiNudgeAt === next.lastCiNudgeAt && + current.lastCommentNudgeAt === next.lastCommentNudgeAt + ? current + : next; + }); refresh(); window.addEventListener(STORAGE_EVENT, refresh); window.addEventListener("storage", refresh); diff --git a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx index fd26c9b43..c5311653e 100644 --- a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx +++ b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx @@ -87,47 +87,52 @@ function isSetupLifecycleItem(item: TranscriptItem) { ); } -export function ChatActivityTranscript({ - activeTurnIds, - agent, - blocks, - identityPubkey, - profiles, - showAgentIdentity = true, -}: { - /** Turn ids currently live in this channel — drives per-turn rendering. */ - activeTurnIds?: ReadonlySet; - agent: ManagedAgent | null; - blocks: ChatActivityRenderBlock[]; - identityPubkey?: string; - profiles?: UserProfileLookup; - /** Hidden in solo chats so agent replies read as part of the stream. */ - showAgentIdentity?: boolean; -}) { - if (blocks.length === 0) { - return null; - } +// Memoized: rendered once per anchored message; placement recomputes on +// every transcript event, but rows whose props didn't change must not +// re-render their Markdown trees. +export const ChatActivityTranscript = React.memo( + function ChatActivityTranscript({ + activeTurnIds, + agent, + blocks, + identityPubkey, + profiles, + showAgentIdentity = true, + }: { + /** Turn ids currently live in this channel — drives per-turn rendering. */ + activeTurnIds?: ReadonlySet; + agent: ManagedAgent | null; + blocks: ChatActivityRenderBlock[]; + identityPubkey?: string; + profiles?: UserProfileLookup; + /** Hidden in solo chats so agent replies read as part of the stream. */ + showAgentIdentity?: boolean; + }) { + if (blocks.length === 0) { + return null; + } - return ( - <> - {blocks.map((renderBlock) => ( - - ))} - - ); -} + return ( + <> + {blocks.map((renderBlock) => ( + + ))} + + ); + }, +); function ChatActivityBlockView({ agent, diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index 3bae8289b..59a2962e4 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -35,7 +35,10 @@ function profileName( ); } -export function ChatMessageRow({ +// Memoized: ChatDetail re-renders on every observer frame during a live +// turn, and without this every persisted message re-renders its whole +// Markdown tree each time. All props are identity-stable between events. +export const ChatMessageRow = React.memo(function ChatMessageRow({ event, isAgent, isOwn, @@ -138,7 +141,7 @@ export function ChatMessageRow({ ); -} +}); // Following the stream is owned entirely by the MessageScroller's built-in // autoScroll (content mutation + resize observers). This anchor only handles diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 9544e782d..5aceb466a 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -125,15 +125,22 @@ export function ChatDetail({ // Every active managed agent, not just the default: a chat can have // several agents working and all of their activity must render. const managedAgentsQuery = useManagedAgentsQuery(); - const activeAgentPubkeys = React.useMemo(() => { + // Key-stabilized: the managed-agents query refetches on a 30s interval and + // a fresh array identity would resubscribe the transcript store each time + // even when the active set is unchanged. + const activeAgentPubkeysKey = React.useMemo(() => { const pubkeys = (managedAgentsQuery.data ?? []) .filter(isManagedAgentActive) .map((agent) => normalizePubkey(agent.pubkey)); if (defaultAgent && isManagedAgentActive(defaultAgent)) { pubkeys.push(normalizePubkey(defaultAgent.pubkey)); } - return [...new Set(pubkeys)].sort(); + return [...new Set(pubkeys)].sort().join(","); }, [defaultAgent, managedAgentsQuery.data]); + const activeAgentPubkeys = React.useMemo( + () => activeAgentPubkeysKey.split(",").filter(Boolean), + [activeAgentPubkeysKey], + ); const hasObserver = activeAgentPubkeys.length > 0; const activeChannelTurns = useActiveAgentTurnsByChannel(); // Per-turn ids, not a channel-wide boolean: while a new turn runs, older