diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 9111bb5be97..34f96e34f93 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -78,6 +78,7 @@ import { releaseBrowserAutomationVisibility } from '@/components/browser-pane/browser-automation-visibility' import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' +import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' import { createRuntimeClientEventsSync } from './runtime-client-events-sync' @@ -1606,7 +1607,10 @@ export function useIpcEvents(): void { guardPinnedTabClose({ isPinned: isPinnedSessionTab(store, worktreeId, tabId), tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), - onClose: () => useAppStore.getState().closeUnifiedTab(tabId) + onClose: () => { + const currentStore = useAppStore.getState() + closeMobileSessionTabInStore(currentStore, worktreeId, tabId) + } }) }) ) diff --git a/src/renderer/src/runtime/mobile-session-tab-close.ts b/src/renderer/src/runtime/mobile-session-tab-close.ts new file mode 100644 index 00000000000..3d97fe02c4b --- /dev/null +++ b/src/renderer/src/runtime/mobile-session-tab-close.ts @@ -0,0 +1,29 @@ +import type { AppState } from '../store/types' + +const EDITOR_SESSION_CONTENT_TYPES = new Set(['editor', 'diff', 'conflict-review', 'check-details']) + +export function closeMobileSessionTabInStore( + store: Pick, + worktreeId: string, + tabId: string +): boolean { + const unifiedTab = (store.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.id === tabId || tab.entityId === tabId + ) + if (unifiedTab && EDITOR_SESSION_CONTENT_TYPES.has(unifiedTab.contentType)) { + store.closeFile(unifiedTab.entityId) + return true + } + + const fallbackFile = store.openFiles.find( + (file) => file.worktreeId === worktreeId && file.id === tabId + ) + if (fallbackFile) { + // Why: mobile may receive fallback file-id tabs from openFiles after the + // unified tab wrapper has already closed; close the source file too. + store.closeFile(fallbackFile.id) + return true + } + + return store.closeUnifiedTab(tabId) !== null +} diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index f7823486df9..792c722f558 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Tab, TabGroup } from '../../../../shared/types' import type * as AgentStatusModule from '@/lib/agent-status' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultUIState } from '../../../../shared/constants' +import { buildMobileSessionTabSnapshots } from '../../runtime/sync-runtime-graph' +import { closeMobileSessionTabInStore } from '../../runtime/mobile-session-tab-close' // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -102,7 +104,7 @@ const mockApi = { // @ts-expect-error -- mock globalThis.window = { api: mockApi } -import { createTestStore } from './store-test-helpers' +import { createTestStore, makeOpenFile, makeTabGroup, makeUnifiedTab } from './store-test-helpers' const WT = 'repo1::/tmp/feature' @@ -295,6 +297,133 @@ describe('TabsSlice', () => { expect(result).toBeNull() }) + it('removes a mobile-closed markdown tab from open files so it is not republished', () => { + const groupId = 'editor-group' + const file = makeOpenFile({ + id: '/tmp/feature/README.md', + filePath: '/tmp/feature/README.md', + relativePath: 'README.md', + language: 'markdown', + worktreeId: WT + }) + const tab = makeUnifiedTab({ + id: 'readme-unified', + entityId: file.id, + contentType: 'editor', + label: 'README.md', + worktreeId: WT, + groupId + }) + store.setState({ + openFiles: [file], + unifiedTabsByWorktree: { [WT]: [tab] }, + groupsByWorktree: { + [WT]: [ + makeTabGroup({ + id: groupId, + worktreeId: WT, + activeTabId: tab.id, + tabOrder: [tab.id], + recentTabIds: [tab.id] + }) + ] + }, + activeGroupIdByWorktree: { [WT]: groupId }, + activeFileId: file.id, + activeFileIdByWorktree: { [WT]: file.id }, + activeWorktreeId: WT, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' } + }) + + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs).toMatchObject([ + { id: tab.id, type: 'markdown', filePath: file.filePath } + ]) + + expect(closeMobileSessionTabInStore(store.getState(), WT, tab.id)).toBe(true) + + expect(store.getState().openFiles).toEqual([]) + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs ?? []).toEqual([]) + }) + + it('removes a mobile-closed regular file tab from open files so fallback closes do not resurrect', () => { + const groupId = 'editor-group' + const file = makeOpenFile({ + id: '/tmp/feature/src/app.ts', + filePath: '/tmp/feature/src/app.ts', + relativePath: 'src/app.ts', + language: 'typescript', + worktreeId: WT + }) + const tab = makeUnifiedTab({ + id: 'app-unified', + entityId: file.id, + contentType: 'editor', + label: 'app.ts', + worktreeId: WT, + groupId + }) + store.setState({ + openFiles: [file], + unifiedTabsByWorktree: { [WT]: [tab] }, + groupsByWorktree: { + [WT]: [ + makeTabGroup({ + id: groupId, + worktreeId: WT, + activeTabId: tab.id, + tabOrder: [tab.id], + recentTabIds: [tab.id] + }) + ] + }, + activeGroupIdByWorktree: { [WT]: groupId }, + activeFileId: file.id, + activeFileIdByWorktree: { [WT]: file.id }, + activeWorktreeId: WT, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' } + }) + + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs).toMatchObject([ + { id: tab.id, type: 'file', filePath: file.filePath } + ]) + + expect(closeMobileSessionTabInStore(store.getState(), WT, tab.id)).toBe(true) + + expect(store.getState().openFiles).toEqual([]) + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs ?? []).toEqual([]) + }) + + it('closes a mobile fallback file-id tab after the unified wrapper is already gone', () => { + const file = makeOpenFile({ + id: '/tmp/feature/src/app.ts', + filePath: '/tmp/feature/src/app.ts', + relativePath: 'src/app.ts', + language: 'typescript', + worktreeId: WT + }) + store.setState({ + openFiles: [file], + unifiedTabsByWorktree: { [WT]: [] }, + groupsByWorktree: { [WT]: [] }, + activeFileId: file.id, + activeFileIdByWorktree: { [WT]: file.id }, + activeWorktreeId: WT, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' } + }) + + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs).toMatchObject([ + { id: file.id, type: 'file', filePath: file.filePath } + ]) + + expect(closeMobileSessionTabInStore(store.getState(), WT, file.id)).toBe(true) + + expect(store.getState().openFiles).toEqual([]) + expect(buildMobileSessionTabSnapshots(store.getState())[0]?.tabs ?? []).toEqual([]) + }) + it('activates the previously-active tab (MRU) instead of the visual neighbor', () => { const t1 = store.getState().createUnifiedTab(WT, 'terminal') const t2 = store.getState().createUnifiedTab(WT, 'terminal')