diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 6f6f2b73a36..1a06723e90c 100644 Binary files a/src/main/github/project-view.ts and b/src/main/github/project-view.ts differ diff --git a/src/main/ipc/sidekick.ts b/src/main/ipc/sidekick.ts index 1812eb9a9ef..f7de7f10e45 100644 --- a/src/main/ipc/sidekick.ts +++ b/src/main/ipc/sidekick.ts @@ -46,7 +46,11 @@ function isSafeId(id: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) } -function resolveSidekickFile(id: string, fileName: string, kind: 'image' | 'bundle'): string | null { +function resolveSidekickFile( + id: string, + fileName: string, + kind: 'image' | 'bundle' +): string | null { if (!isSafeId(id)) { return null } @@ -288,8 +292,7 @@ export function registerSidekickHandlers(): void { // Why: NTFS/macOS HFS+ default volumes are case-insensitive — a path like // `BUNDLE\sheet.png` is still inside `bundle\`. Compare lowercased on // Windows so the prefix check isn't bypassed by case differences. - const cmp = - process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s + const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) { throw new Error('spritesheetPath escapes the bundle.') } @@ -441,12 +444,7 @@ export function registerSidekickHandlers(): void { ipcMain.handle( 'sidekick:delete', - async ( - _event, - id: string, - fileName: string, - kind?: 'image' | 'bundle' - ): Promise => { + async (_event, id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => { // Why: validate IPC inputs before any path logic. let parsed: z.infer try { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7dcdff43c35..30560b8dab3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,13 +3,10 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants' import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react' -import { - FOCUS_TERMINAL_PANE_EVENT, - SYNC_FIT_PANES_EVENT, - TOGGLE_TERMINAL_PANE_EXPAND_EVENT -} from '@/constants/terminal' +import { SYNC_FIT_PANES_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { buildAppFontFamily } from '@/lib/app-font-family' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { toast } from 'sonner' import { Toaster } from '@/components/ui/sonner' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -844,22 +841,7 @@ function App(): React.JSX.Element { className="titlebar-agent-hovercard-agent" onClick={() => { activateAndRevealWorktree(worktreeId) - useAppStore.getState().setActiveTab(agent.tabId) - if (agent.paneId !== null) { - // Why: a split-terminal tab can host multiple - // agents. After selecting the tab, wait one - // frame so the active TerminalPane can mount - // and then focus the specific pane the user - // clicked instead of leaving whichever pane - // was previously active highlighted. - requestAnimationFrame(() => { - window.dispatchEvent( - new CustomEvent(FOCUS_TERMINAL_PANE_EVENT, { - detail: { tabId: agent.tabId, paneId: agent.paneId } - }) - ) - }) - } + activateTabAndFocusPane(agent.tabId, agent.paneId) }} > diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index 8cfd6d82397..d6b9a4b7c85 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -19,6 +19,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { SearchableSetting } from './SearchableSetting' import { useAppStore } from '../../store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' type ConfirmKind = 'killOne' @@ -121,7 +122,6 @@ export function ManageSessionsSection(): React.JSX.Element { // entry points can't drift on what "go to this session" means. const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) - const setActiveTab = useAppStore((s) => s.setActiveTab) const setActiveView = useAppStore((s) => s.setActiveView) const closeSettingsPage = useAppStore((s) => s.closeSettingsPage) @@ -151,17 +151,20 @@ export function ManageSessionsSection(): React.JSX.Element { if (worktreeId) { // Why: match SessionsStatusSegment — route through the shared // activation path before switching tab so the worktree container - // is mounted by the time setActiveTab runs. + // is mounted by the time activateTabAndFocusPane runs. activateAndRevealWorktree(worktreeId) } setActiveView('terminal') - setActiveTab(tabId) + // Why: rows here only carry ptyId, and there's no selector that maps + // ptyId → numeric paneId for an unmounted tab. Pass null so the helper + // degrades to tab-only activation (no worse than prior behavior). + activateTabAndFocusPane(tabId, null) // Why: the status-bar version doesn't need this because it's already // rendered over the terminal surface; from the Settings pane the user // would otherwise land on their pane with the modal still covering it. closeSettingsPage() }, - [tabIdToWorktreeId, setActiveView, setActiveTab, closeSettingsPage] + [tabIdToWorktreeId, setActiveView, closeSettingsPage] ) useEffect(() => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 6f731c56bc7..094e568aaa7 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react' import { ChevronDown } from 'lucide-react' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' import { useNow } from '@/components/dashboard/useNow' import { useWorktreeAgentRows } from './useWorktreeAgentRows' @@ -51,7 +52,6 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ }: BodyProps) { const dropAgentStatus = useAppStore((s) => s.dropAgentStatus) const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent) - const setActiveTab = useAppStore((s) => s.setActiveTab) const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents) // Why: per-worktree collapse is session-only UI state. Single-primitive @@ -87,6 +87,18 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ const handleActivateAgentTab = useCallback( (tabId: string, paneKey: string) => { acknowledgeAgents([paneKey]) + const colon = paneKey.indexOf(':') + const tail = colon > 0 ? paneKey.slice(colon + 1) : '' + const parsed = /^\d+$/.test(tail) ? Number.parseInt(tail, 10) : NaN + let paneId: number | null = null + if (Number.isFinite(parsed) && parsed > 0) { + paneId = parsed + } else { + // Why: paneKey for sidebar agent rows is always ${tabId}:${paneId} + // with a positive integer paneId; anything else (empty, zero, + // non-numeric) means upstream row construction drifted. + console.warn('[WorktreeCardAgents] malformed paneKey, skipping pane focus', paneKey) + } // Why: route through activateAndRevealWorktree so cross-repo clicks also // set activeRepoId, record a nav-history entry, clear sidebar filters, // reveal the card, and stamp focus recency — per the design doc rule @@ -97,10 +109,10 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ activateAndRevealWorktree(worktreeId) const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] if (tabs.some((t) => t.id === tabId)) { - setActiveTab(tabId) + activateTabAndFocusPane(tabId, paneId) } }, - [worktreeId, setActiveTab, acknowledgeAgents] + [worktreeId, acknowledgeAgents] ) const handleToggleCollapsed = useCallback( diff --git a/src/renderer/src/components/sidekick/SidekickOverlay.tsx b/src/renderer/src/components/sidekick/SidekickOverlay.tsx index 0dd7c31a3b4..7baf8b870b8 100644 --- a/src/renderer/src/components/sidekick/SidekickOverlay.tsx +++ b/src/renderer/src/components/sidekick/SidekickOverlay.tsx @@ -84,9 +84,13 @@ function DetectedSpriteFrame({ useEffect(() => { const canvas = canvasRef.current - if (!canvas) {return} + if (!canvas) { + return + } const ctx = canvas.getContext('2d') - if (!ctx) {return} + if (!ctx) { + return + } canvas.width = maxSize canvas.height = maxSize // Why: reset playback when the underlying sprite changes so the new @@ -121,7 +125,9 @@ function DetectedSpriteFrame({ raf = requestAnimationFrame(tick) } return () => { - if (raf) {cancelAnimationFrame(raf)} + if (raf) { + cancelAnimationFrame(raf) + } } }, [detected, animate, maxSize]) @@ -337,7 +343,12 @@ export function SidekickOverlay(): React.JSX.Element { ) : detected ? ( ) : ( - + )} diff --git a/src/renderer/src/components/sidekick/sidekick-blob-cache.ts b/src/renderer/src/components/sidekick/sidekick-blob-cache.ts index 33dfe9f93ad..f78cf92a33d 100644 --- a/src/renderer/src/components/sidekick/sidekick-blob-cache.ts +++ b/src/renderer/src/components/sidekick/sidekick-blob-cache.ts @@ -95,7 +95,9 @@ async function processBundleSheet( const rejected = results.some((r) => r.status === 'rejected') if (rejected) { for (const r of results) { - if (r.status === 'fulfilled') {r.value.close()} + if (r.status === 'fulfilled') { + r.value.close() + } } return null } @@ -121,9 +123,13 @@ function magentaScore(r: number, g: number, b: number): number { // 0 = not magenta, 1 = pure magenta key. Requires both red and blue to // dominate green (the signature of any magenta shade), and rejects greys // / dark pixels which can otherwise have low-but-equal channels. - if (r < 100 || b < 100) {return 0} + if (r < 100 || b < 100) { + return 0 + } const minRB = Math.min(r, b) - if (g >= minRB) {return 0} + if (g >= minRB) { + return 0 + } const dom = (minRB - g) / 255 // how much R and B dominate green return Math.max(0, Math.min(1, dom * 1.4)) } @@ -131,7 +137,9 @@ function magentaScore(r: number, g: number, b: number): number { function keyMagenta(px: Uint8ClampedArray): void { for (let i = 0; i < px.length; i += 4) { const score = magentaScore(px[i], px[i + 1], px[i + 2]) - if (score <= 0) {continue} + if (score <= 0) { + continue + } if (score >= 0.5) { px[i + 3] = 0 px[i] = 0 @@ -164,7 +172,9 @@ export function revokeCustomSidekickBlobUrl(id: string): void { } const detected = detectedSpriteCache.get(id) if (detected) { - for (const bmp of detected.bitmaps) {bmp.close()} + for (const bmp of detected.bitmaps) { + bmp.close() + } detectedSpriteCache.delete(id) } } diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 91da475cd11..c7191f05b05 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -30,6 +30,7 @@ import { } from '@/components/ui/dialog' import { cn } from '@/lib/utils' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { useAppStore } from '../../store' import { useWorktreeMap } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' @@ -306,13 +307,13 @@ function SessionRow({ }: { session: UnifiedSessionRow worktreeId: string - onNavigate: (tabId: string) => void + onNavigate: (tabId: string, paneKey: string | null) => void onKill: (session: UnifiedSessionRow) => void }): React.JSX.Element { const clickable = session.tabId !== null && session.bound const handleClick = (): void => { if (clickable && session.tabId) { - onNavigate(session.tabId) + onNavigate(session.tabId, session.paneKey) } } @@ -394,7 +395,7 @@ function WorktreeRow({ onSleep: () => void onDelete: () => void onKillSession: (session: UnifiedSessionRow) => void - navigateToTab: (tabId: string) => void + navigateToTab: (tabId: string, paneKey: string | null) => void }): React.JSX.Element { const hasSessions = worktree.sessions.length > 0 // Why: synthetic buckets (orphan/unattributed) have no sidebar target to @@ -549,7 +550,7 @@ function ResourceTree({ collapsedWorktrees: Set toggleWorktree: (worktreeId: string) => void navigateToWorktree: (worktreeId: string) => void - navigateToTab: (tabId: string) => void + navigateToTab: (tabId: string, paneKey: string | null) => void onSleep: (worktreeId: string) => void onDelete: (worktreeId: string) => void onKillSession: (session: UnifiedSessionRow) => void @@ -648,7 +649,6 @@ export function ResourceUsageStatusSegment({ const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) - const setActiveTab = useAppStore((s) => s.setActiveTab) const setActiveView = useAppStore((s) => s.setActiveView) const repos = useAppStore((s) => s.repos) @@ -835,7 +835,7 @@ export function ResourceUsageStatusSegment({ }, []) const navigateToTab = useCallback( - (tabId: string) => { + (tabId: string, paneKey: string | null) => { // Resolve the tab → worktree from the store so we can also reveal the // worktree in the sidebar before flipping the active tab. for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { @@ -845,9 +845,19 @@ export function ResourceUsageStatusSegment({ } } setActiveView('terminal') - setActiveTab(tabId) + // Why: snapshot-derived rows carry a `${tabId}:${paneId}` paneKey from + // the main-process pty registry — parse the paneId tail so split-tab + // clicks land focus on the *clicked* pane rather than whichever pane + // was last active. Daemon-only rows have paneKey=null and degrade to + // tab-only activation. + const colon = paneKey ? paneKey.indexOf(':') : -1 + const tail = colon > 0 && paneKey ? paneKey.slice(colon + 1) : '' + const parsed = /^\d+$/.test(tail) ? Number.parseInt(tail, 10) : NaN + const paneId = + Number.isFinite(parsed) && parsed > 0 && paneKey?.slice(0, colon) === tabId ? parsed : null + activateTabAndFocusPane(tabId, paneId) }, - [tabsByWorktree, setActiveTab, setActiveView] + [tabsByWorktree, setActiveView] ) const deleteWorktree = useCallback((worktreeId: string): void => { @@ -979,11 +989,11 @@ export function ResourceUsageStatusSegment({ sideOffset={8} className="w-[26rem] p-0" onOpenAutoFocus={(event) => event.preventDefault()} - // Why: clicking a terminal row calls setActiveTab, which causes - // xterm to programmatically focus the terminal DOM node. Radix - // would interpret that as a focus-outside event and close the - // popover. Suppress focus-driven closes; the popover still closes - // on outside-click (onPointerDownOutside default) and Escape. + // Why: clicking a terminal row activates a tab, which causes xterm + // to programmatically focus the terminal DOM node. Radix would + // interpret that as a focus-outside event and close the popover. + // Suppress focus-driven closes; the popover still closes on + // outside-click (onPointerDownOutside default) and Escape. onFocusOutside={(event) => event.preventDefault()} > {/* Why: header strip — title on the left, daemon-control icons on diff --git a/src/renderer/src/lib/activate-tab-and-focus-pane.ts b/src/renderer/src/lib/activate-tab-and-focus-pane.ts new file mode 100644 index 00000000000..9d91e11d119 --- /dev/null +++ b/src/renderer/src/lib/activate-tab-and-focus-pane.ts @@ -0,0 +1,18 @@ +import { useAppStore } from '@/store' +import { FOCUS_TERMINAL_PANE_EVENT, type FocusTerminalPaneDetail } from '@/constants/terminal' + +export function activateTabAndFocusPane(tabId: string, paneId: number | null): void { + useAppStore.getState().setActiveTab(tabId) + if (paneId === null) { + return + } + // Why: defer one frame so the new TerminalPane has mounted its + // FOCUS_TERMINAL_PANE_EVENT listener before we dispatch. + requestAnimationFrame(() => { + window.dispatchEvent( + new CustomEvent(FOCUS_TERMINAL_PANE_EVENT, { + detail: { tabId, paneId }, + }) + ) + }) +} diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 0acce050518..df60ce89b8c 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -30,7 +30,9 @@ import { revokeCustomSidekickBlobUrl } from '../../components/sidekick/sidekick- import { isGitRepoKind } from '../../../../shared/repo-kind' function clampSidekickSize(size: number): number { - if (!Number.isFinite(size)) {return SIDEKICK_SIZE_DEFAULT} + if (!Number.isFinite(size)) { + return SIDEKICK_SIZE_DEFAULT + } return Math.max(SIDEKICK_SIZE_MIN, Math.min(SIDEKICK_SIZE_MAX, Math.round(size))) } @@ -639,7 +641,9 @@ export const createUISlice: StateCreator = (set, get) // index no longer references it. window.api.sidekick.delete(id, target.fileName, target.kind).catch(console.error) const partial: Partial = { customSidekicks: next } - if (fallback !== s.sidekickId) {partial.sidekickId = fallback} + if (fallback !== s.sidekickId) { + partial.sidekickId = fallback + } return partial }),