fix(mobile): sync non-markdown editor files to mobile session tabs (#5582)

* fix(mobile): sync non-markdown editor files to mobile session tabs

A regression stopped non-markdown files opened on desktop from reaching the
mobile session-tab list: the group projection order surfaced markdown tabs but
dropped plain file tabs. Append any open editor file for the worktree that the
projection didn't emit, keyed by relativePath, so plain files show up on mobile
again (markdown still syncs via the projection).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mobile): preserve recovered editor session tab identity

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): tolerate legacy active tab metadata

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
gsxdsm 2026-06-19 09:34:14 +08:00 committed by GitHub
parent f534cd5bc3
commit b8f2f95100
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 675 additions and 9 deletions

View File

@ -21,6 +21,8 @@ function makeState(overrides: Partial<AppState> = {}): AppState {
tabBarOrderByWorktree: {},
activeFileId: null,
activeFileIdByWorktree: {},
activeTabType: 'terminal',
activeTabTypeByWorktree: {},
activeBrowserTabIdByWorktree: {},
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
@ -53,6 +55,8 @@ function makeSharedOverrides(): Partial<AppState> {
unifiedTabsByWorktree: {},
tabBarOrderByWorktree: {},
activeFileIdByWorktree: {},
activeTabType: 'terminal',
activeTabTypeByWorktree: {},
activeBrowserTabIdByWorktree: {},
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
@ -606,6 +610,448 @@ describe('buildMobileSessionTabSnapshots', () => {
expect(tab).not.toHaveProperty('diffSource')
})
it('publishes a missing non-markdown editor with its unified tab id and split group', () => {
const fileId = '/repo/src/app.ts'
const state = makeState({
activeGroupIdByWorktree: { 'wt-1': 'group-left' },
groupsByWorktree: {
'wt-1': [
{
id: 'group-left',
activeTabId: 'browser-tab-1',
tabOrder: ['browser-tab-1'],
recentTabIds: ['browser-tab-1']
},
{
id: 'group-right',
activeTabId: 'editor-tab-1',
tabOrder: [],
recentTabIds: []
}
]
} as unknown as AppState['groupsByWorktree'],
layoutByWorktree: {
'wt-1': {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' }
}
} as unknown as AppState['layoutByWorktree'],
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'browser-tab-1',
groupId: 'group-left',
contentType: 'browser',
entityId: 'browser-1',
title: 'Docs'
},
{
id: 'editor-tab-1',
groupId: 'group-right',
contentType: 'editor',
entityId: fileId,
title: 'app.ts'
}
]
} as unknown as AppState['unifiedTabsByWorktree'],
browserTabsByWorktree: {
'wt-1': [
{
id: 'browser-1',
worktreeId: 'wt-1',
activePageId: 'page-1',
pageIds: ['page-1'],
url: 'https://example.test',
title: 'Docs',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
} as unknown as AppState['browserTabsByWorktree'],
browserPagesByWorkspace: {
'browser-1': [
{
id: 'page-1',
workspaceId: 'browser-1',
worktreeId: 'wt-1',
url: 'https://example.test',
title: 'Docs',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
} as unknown as AppState['browserPagesByWorkspace'],
openFiles: [
{
id: fileId,
filePath: fileId,
relativePath: 'src/app.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit',
isDirty: false
}
]
})
const snapshot = buildMobileSessionTabSnapshots(state)[0]
expect(snapshot?.tabs.map((tab) => tab.id)).toEqual(['browser-tab-1', 'editor-tab-1'])
expect(snapshot?.tabs.at(-1)).toMatchObject({
type: 'file',
id: 'editor-tab-1',
relativePath: 'src/app.ts',
isActive: false
})
expect(snapshot?.activeTabId).toBe('browser-tab-1')
expect(snapshot?.tabGroups).toEqual([
{
id: 'group-left',
activeTabId: 'browser-tab-1',
tabOrder: ['browser-tab-1'],
recentTabIds: ['browser-tab-1']
},
{
id: 'group-right',
activeTabId: 'editor-tab-1',
tabOrder: ['editor-tab-1'],
recentTabIds: []
}
])
expect(snapshot?.tabGroupLayout).toEqual({
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' }
})
})
it('does not conflate same-path edit and diff editor tabs in the fallback', () => {
const fileId = '/repo/src/app.ts'
const diffId = 'wt-1::diff::unstaged::src/app.ts'
const state = makeState({
activeGroupIdByWorktree: { 'wt-1': 'group-1' },
groupsByWorktree: {
'wt-1': [
{
id: 'group-1',
activeTabId: 'editor-tab-1',
tabOrder: ['editor-tab-1'],
recentTabIds: ['editor-tab-1']
}
]
} as unknown as AppState['groupsByWorktree'],
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'editor-tab-1',
groupId: 'group-1',
contentType: 'editor',
entityId: fileId,
title: 'app.ts'
}
]
} as unknown as AppState['unifiedTabsByWorktree'],
openFiles: [
{
id: fileId,
filePath: fileId,
relativePath: 'src/app.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit',
isDirty: false
},
{
id: diffId,
filePath: fileId,
relativePath: 'src/app.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'diff',
diffSource: 'unstaged',
isDirty: false
}
]
})
const snapshot = buildMobileSessionTabSnapshots(state)[0]
expect(snapshot?.tabs).toMatchObject([
{ type: 'file', id: 'editor-tab-1', mode: 'edit', relativePath: 'src/app.ts' },
{ type: 'file', id: diffId, mode: 'diff', diffSource: 'unstaged', relativePath: 'src/app.ts' }
])
expect(snapshot?.tabGroups).toEqual([
{
id: 'group-1',
activeTabId: 'editor-tab-1',
tabOrder: ['editor-tab-1', diffId],
recentTabIds: ['editor-tab-1']
}
])
})
it('recovers a duplicate split editor tab for an already-emitted file id', () => {
const fileId = '/repo/src/app.ts'
const state = makeState({
activeGroupIdByWorktree: { 'wt-1': 'group-left' },
groupsByWorktree: {
'wt-1': [
{
id: 'group-left',
activeTabId: 'editor-left',
tabOrder: ['editor-left'],
recentTabIds: ['editor-left']
},
{
id: 'group-right',
activeTabId: 'editor-right',
tabOrder: [],
recentTabIds: []
}
]
} as unknown as AppState['groupsByWorktree'],
layoutByWorktree: {
'wt-1': {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' }
}
} as unknown as AppState['layoutByWorktree'],
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'editor-left',
groupId: 'group-left',
contentType: 'editor',
entityId: fileId,
title: 'app.ts'
},
{
id: 'editor-right',
groupId: 'group-right',
contentType: 'editor',
entityId: fileId,
title: 'app.ts'
}
]
} as unknown as AppState['unifiedTabsByWorktree'],
openFiles: [
{
id: fileId,
filePath: fileId,
relativePath: 'src/app.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit',
isDirty: false
}
]
})
const snapshot = buildMobileSessionTabSnapshots(state)[0]
expect(snapshot?.tabs).toMatchObject([
{ type: 'file', id: 'editor-left', relativePath: 'src/app.ts' },
{ type: 'file', id: 'editor-right', relativePath: 'src/app.ts' }
])
expect(snapshot?.tabGroups).toEqual([
{
id: 'group-left',
activeTabId: 'editor-left',
tabOrder: ['editor-left'],
recentTabIds: ['editor-left']
},
{
id: 'group-right',
activeTabId: 'editor-right',
tabOrder: ['editor-right'],
recentTabIds: []
}
])
expect(snapshot?.tabGroupLayout).toEqual({
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' }
})
})
it('uses unified editor ids in legacy no-group order without duplicating file ids', () => {
const fileId = '/repo/src/app.ts'
const state = makeState({
tabBarOrderByWorktree: { 'wt-1': [fileId] },
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'editor-tab-1',
groupId: 'group-1',
contentType: 'editor',
entityId: fileId,
title: 'app.ts'
}
]
} as unknown as AppState['unifiedTabsByWorktree'],
openFiles: [
{
id: fileId,
filePath: fileId,
relativePath: 'src/app.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit',
isDirty: false
}
]
})
const snapshot = buildMobileSessionTabSnapshots(state)[0]
expect(snapshot?.tabs).toMatchObject([
{ type: 'file', id: 'editor-tab-1', relativePath: 'src/app.ts' }
])
expect(snapshot?.tabs).toHaveLength(1)
})
it('recovers a missing diff unified tab in its split group', () => {
const diffId = 'wt-1::diff::unstaged::src/app.ts'
const state = makeState({
activeGroupIdByWorktree: { 'wt-1': 'group-left' },
groupsByWorktree: {
'wt-1': [
{
id: 'group-left',
activeTabId: 'terminal-left',
tabOrder: [],
recentTabIds: []
},
{
id: 'group-right',
activeTabId: 'diff-tab-right',
tabOrder: [],
recentTabIds: []
}
]
} as unknown as AppState['groupsByWorktree'],
layoutByWorktree: {
'wt-1': {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-left' },
second: { type: 'leaf', groupId: 'group-right' }
}
} as unknown as AppState['layoutByWorktree'],
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'diff-tab-right',
groupId: 'group-right',
contentType: 'diff',
entityId: diffId,
title: 'app.ts'
}
]
} as unknown as AppState['unifiedTabsByWorktree'],
openFiles: [
{
id: diffId,
filePath: '/repo/src/app.ts',
relativePath: 'src/app.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'diff',
diffSource: 'unstaged',
isDirty: false
}
]
})
const snapshot = buildMobileSessionTabSnapshots(state)[0]
expect(snapshot?.tabs).toMatchObject([
{
type: 'file',
id: 'diff-tab-right',
mode: 'diff',
diffSource: 'unstaged',
relativePath: 'src/app.ts'
}
])
expect(snapshot?.tabGroups).toEqual([
{
id: 'group-right',
activeTabId: 'diff-tab-right',
tabOrder: ['diff-tab-right'],
recentTabIds: []
}
])
expect(snapshot?.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-right' })
})
it('gates fallback editor active state on the worktree active tab type', () => {
const fileId = '/repo/src/app.ts'
const state = {
activeFileId: '/repo/other-worktree.ts',
activeFileIdByWorktree: { 'wt-1': fileId },
groupsByWorktree: {
'wt-1': [
{
id: 'group-1',
activeTabId: fileId,
tabOrder: [],
recentTabIds: []
}
]
} as unknown as AppState['groupsByWorktree'],
openFiles: [
{
id: fileId,
filePath: fileId,
relativePath: 'src/app.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit',
isDirty: false
}
]
} satisfies Partial<AppState>
const terminalSnapshot = buildMobileSessionTabSnapshots(
makeState({
...state,
activeTabTypeByWorktree: { 'wt-1': 'terminal' }
})
)[0]
const editorSnapshot = buildMobileSessionTabSnapshots(
makeState({
...state,
activeTabTypeByWorktree: { 'wt-1': 'editor' }
})
)[0]
expect(terminalSnapshot?.tabs).toMatchObject([
{ type: 'file', id: fileId, relativePath: 'src/app.ts', isActive: false }
])
expect(terminalSnapshot?.activeTabId).toBeNull()
expect(terminalSnapshot?.activeTabType).toBeNull()
expect(editorSnapshot?.tabs).toMatchObject([
{ type: 'file', id: fileId, relativePath: 'src/app.ts', isActive: true }
])
expect(editorSnapshot?.activeTabId).toBe(fileId)
expect(editorSnapshot?.activeTabType).toBe('file')
})
it('keeps duplicate file ids scoped to their worktree', () => {
const sharedRemotePath = '/home/dev/project/README.md'
const previewId = `markdown-preview::${sharedRemotePath}`

View File

@ -26,6 +26,7 @@ import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { isClaudeManagementTitle } from '../../../shared/agent-detection'
import type {
Tab,
TabGroup,
TabGroupLayoutNode,
TerminalLayoutSnapshot,
@ -53,6 +54,10 @@ type OpenFileIndexes = {
byWorktreeAndId: OpenFileByWorktreeAndId
idsByWorktree: Map<string, string[]>
}
type FallbackEditorTabTarget = {
tabId: string
groupId: string | null
}
type TabsProjectionCacheEntry = {
tabs: NonNullable<AppState['tabsByWorktree'][string]>
worktreeIdJson: string
@ -184,6 +189,8 @@ export type RuntimeMobileSessionSyncKey = {
tabBarOrderByWorktree: AppState['tabBarOrderByWorktree']
activeFileId: AppState['activeFileId']
activeFileIdByWorktree: AppState['activeFileIdByWorktree']
activeTabType: AppState['activeTabType']
activeTabTypeByWorktree: AppState['activeTabTypeByWorktree']
activeTabId: AppState['activeTabId']
activeBrowserTabIdByWorktree: AppState['activeBrowserTabIdByWorktree']
agentStatusEpoch: number
@ -222,6 +229,8 @@ export function canSkipRuntimeMobileSessionSyncKeyBuild(
state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree &&
state.activeFileId === previousState.activeFileId &&
state.activeFileIdByWorktree === previousState.activeFileIdByWorktree &&
state.activeTabType === previousState.activeTabType &&
state.activeTabTypeByWorktree === previousState.activeTabTypeByWorktree &&
state.browserTabsByWorktree === previousState.browserTabsByWorktree &&
state.browserPagesByWorkspace === previousState.browserPagesByWorkspace &&
state.activeBrowserTabIdByWorktree === previousState.activeBrowserTabIdByWorktree &&
@ -274,6 +283,8 @@ export function getRuntimeMobileSessionSyncKey(
tabBarOrderByWorktree: state.tabBarOrderByWorktree,
activeFileId: state.activeFileId,
activeFileIdByWorktree: state.activeFileIdByWorktree,
activeTabType: state.activeTabType,
activeTabTypeByWorktree: state.activeTabTypeByWorktree,
activeTabId: state.activeTabId,
activeBrowserTabIdByWorktree:
state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE,
@ -478,6 +489,8 @@ export function runtimeMobileSessionSyncKeysEqual(
a.tabBarOrderByWorktree === b.tabBarOrderByWorktree &&
a.activeFileId === b.activeFileId &&
a.activeFileIdByWorktree === b.activeFileIdByWorktree &&
a.activeTabType === b.activeTabType &&
a.activeTabTypeByWorktree === b.activeTabTypeByWorktree &&
a.activeTabId === b.activeTabId &&
a.activeBrowserTabIdByWorktree === b.activeBrowserTabIdByWorktree &&
a.agentStatusEpoch === b.agentStatusEpoch &&
@ -670,6 +683,8 @@ export function buildMobileSessionTabSnapshots(
browserIds: [...browserWorkspaceByIdForWorktree.keys()]
})
const tabs: RuntimeMobileSessionSnapshotTab[] = []
const emittedEditorFileIds = new Set<string>()
const emittedEditorTabIds = new Set<string>()
for (const item of groupProjection.order) {
if (item.type === 'terminal') {
@ -706,6 +721,8 @@ export function buildMobileSessionTabSnapshots(
} else {
tabs.push(buildMobileFileTab(state, file, item.tabId))
}
emittedEditorFileIds.add(file.id)
emittedEditorTabIds.add(item.tabId ?? item.id)
} else if (item.type === 'browser') {
const workspace = browserWorkspaceByIdForWorktree.get(item.id)
if (!workspace) {
@ -715,7 +732,75 @@ export function buildMobileSessionTabSnapshots(
}
}
// Why: split-group projection can miss plain editor files during hydration.
// Publish the missing file so paired mobile/web clients still mirror it.
const fallbackEditorTabs: FallbackEditorTabTarget[] = []
const openFilesForWorktree = openFileIndexes.byWorktreeAndId.get(worktreeId)
if (openFilesForWorktree) {
const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(state, worktreeId)
const unifiedEditorFileIds = new Set(unifiedEditorTabs.map((tab) => tab.entityId))
for (const unifiedTab of unifiedEditorTabs) {
if (emittedEditorTabIds.has(unifiedTab.id)) {
continue
}
const file = openFilesForWorktree.get(unifiedTab.entityId)
if (!file) {
continue
}
const markdown = buildMobileMarkdownTab(
state,
openFileIndexes.byWorktreeAndId,
editorDraftVersionByFileId,
file,
unifiedTab.id
)
const fallbackTab = markdown ?? buildMobileFileTab(state, file, unifiedTab.id)
tabs.push(fallbackTab)
fallbackEditorTabs.push({
tabId: fallbackTab.id,
groupId: unifiedTab.groupId
})
emittedEditorTabIds.add(unifiedTab.id)
}
for (const file of openFilesForWorktree.values()) {
if (emittedEditorFileIds.has(file.id)) {
continue
}
if (unifiedEditorFileIds.has(file.id)) {
emittedEditorFileIds.add(file.id)
continue
}
const markdown = buildMobileMarkdownTab(
state,
openFileIndexes.byWorktreeAndId,
editorDraftVersionByFileId,
file
)
const fallbackTab = markdown ?? buildMobileFileTab(state, file)
tabs.push(fallbackTab)
fallbackEditorTabs.push({
tabId: fallbackTab.id,
groupId: null
})
emittedEditorFileIds.add(file.id)
}
}
const active = tabs.find((tab) => tab.isActive) ?? null
const tabGroups = appendFallbackEditorTabsToGroups(
groupProjection.tabGroups,
state.groupsByWorktree[worktreeId] ?? [],
activeGroupId,
fallbackEditorTabs,
active?.id ?? null
)
const tabGroupLayout =
tabGroups && tabGroups.length > 0
? pruneTabGroupLayout(
(state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE)[worktreeId],
new Set(tabGroups.map((group) => group.id))
)
: groupProjection.tabGroupLayout
snapshots.push({
worktree: worktreeId,
publicationEpoch: mobileSessionPublicationEpoch,
@ -723,10 +808,8 @@ export function buildMobileSessionTabSnapshots(
activeGroupId,
activeTabId: active?.id ?? null,
activeTabType: active?.type ?? null,
...(groupProjection.tabGroups && groupProjection.tabGroups.length > 0
? { tabGroups: groupProjection.tabGroups }
: {}),
...(groupProjection.tabGroupLayout ? { tabGroupLayout: groupProjection.tabGroupLayout } : {}),
...(tabGroups && tabGroups.length > 0 ? { tabGroups } : {}),
...(tabGroupLayout ? { tabGroupLayout } : {}),
tabs
})
}
@ -734,6 +817,125 @@ export function buildMobileSessionTabSnapshots(
return snapshots
}
function isEditorSurfaceTab(tab: Pick<Tab, 'contentType'>): boolean {
// Why: mobile file snapshots can faithfully mirror ordinary edit/diff files;
// conflict review and check-details tabs require metadata this contract lacks.
return tab.contentType === 'editor' || tab.contentType === 'diff'
}
function getEditorUnifiedTabsForWorktree(
state: Pick<AppState, 'unifiedTabsByWorktree'>,
worktreeId: string
): Tab[] {
return (state.unifiedTabsByWorktree[worktreeId] ?? []).filter(isEditorSurfaceTab)
}
function applyUnifiedEditorTabIdsToLegacyOrder(
order: readonly VisibleTabRef[],
state: Pick<AppState, 'unifiedTabsByWorktree'>,
worktreeId: string
): VisibleTabRef[] {
const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(state, worktreeId)
if (unifiedEditorTabs.length === 0) {
return [...order]
}
const firstUnifiedTabByFileId = new Map<string, string>()
for (const tab of unifiedEditorTabs) {
if (!firstUnifiedTabByFileId.has(tab.entityId)) {
firstUnifiedTabByFileId.set(tab.entityId, tab.id)
}
}
return order.map((item) => {
if (item.type !== 'editor' || item.tabId) {
return item
}
const tabId = firstUnifiedTabByFileId.get(item.id)
return tabId ? { ...item, tabId } : item
})
}
function appendFallbackEditorTabsToGroups(
tabGroups: RuntimeMobileSessionTabGroup[] | undefined,
sourceGroups: readonly TabGroup[],
activeGroupId: string | null,
fallbackTabs: readonly FallbackEditorTabTarget[],
activeTabId: string | null
): RuntimeMobileSessionTabGroup[] | undefined {
if (fallbackTabs.length === 0) {
return tabGroups
}
const result = [...(tabGroups ?? [])]
const sourceGroupsById = new Map(sourceGroups.map((group) => [group.id, group]))
const groupIndexById = new Map(result.map((group, index) => [group.id, index]))
const firstTargetGroupId =
result[0]?.id ??
(activeGroupId && sourceGroupsById.has(activeGroupId) ? activeGroupId : null) ??
sourceGroups[0]?.id ??
null
const fallbackTabIdSet = new Set(fallbackTabs.map((tab) => tab.tabId))
for (const fallback of fallbackTabs) {
const targetGroupId =
fallback.groupId ??
(activeGroupId && (groupIndexById.has(activeGroupId) || sourceGroupsById.has(activeGroupId))
? activeGroupId
: firstTargetGroupId)
if (!targetGroupId) {
continue
}
let targetIndex = groupIndexById.get(targetGroupId)
if (targetIndex === undefined) {
const sourceGroup = sourceGroupsById.get(targetGroupId)
const group: RuntimeMobileSessionTabGroup = {
id: targetGroupId,
activeTabId: sourceGroup?.activeTabId ?? null,
tabOrder: [],
recentTabIds: sourceGroup?.recentTabIds ?? []
}
targetIndex = result.length
groupIndexById.set(targetGroupId, targetIndex)
result.push(group)
}
const group = result[targetIndex]!
if (!group.tabOrder.includes(fallback.tabId)) {
result[targetIndex] = {
...group,
tabOrder: [...group.tabOrder, fallback.tabId]
}
}
}
if (result.length === 0) {
return tabGroups
}
const activeFallbackTabId = activeTabId && fallbackTabIdSet.has(activeTabId) ? activeTabId : null
return result.map((group) => {
const tabOrder = [...group.tabOrder]
const tabOrderSet = new Set(tabOrder)
const activeFallbackTabIdForGroup =
activeFallbackTabId && tabOrderSet.has(activeFallbackTabId) ? activeFallbackTabId : null
const activeTabIdForGroup =
activeFallbackTabIdForGroup ??
(group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null)
const recentTabIds = (group.recentTabIds ?? []).filter((tabId) => tabOrderSet.has(tabId))
if (
activeFallbackTabId &&
tabOrderSet.has(activeFallbackTabId) &&
!recentTabIds.includes(activeFallbackTabId)
) {
recentTabIds.push(activeFallbackTabId)
}
return {
...group,
activeTabId: activeTabIdForGroup,
tabOrder,
recentTabIds
}
})
}
function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean {
return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null
}
@ -859,9 +1061,13 @@ function buildMobileSessionGroupProjection(
const groups = state.groupsByWorktree[worktreeId] ?? []
if (groups.length === 0) {
return {
order: getActiveTabNavOrder(state, worktreeId, {
editorIds: ids.editorIds
})
order: applyUnifiedEditorTabIdsToLegacyOrder(
getActiveTabNavOrder(state, worktreeId, {
editorIds: ids.editorIds
}),
state,
worktreeId
)
}
}
@ -1137,7 +1343,7 @@ function buildMobileMarkdownTab(
isDirty: file.isDirty || sourceFile.isDirty,
isActive: unifiedTabId
? isUnifiedTabActiveInActiveGroup(state, file.worktreeId, unifiedTabId)
: state.activeFileId === file.id,
: isFileActiveEditorSurface(state, file),
sourceFileId: sourceFile.id,
sourceFilePath: sourceFile.filePath,
sourceRelativePath: sourceFile.relativePath,
@ -1165,10 +1371,24 @@ function buildMobileFileTab(
isDirty: file.isDirty,
isActive: unifiedTabId
? isUnifiedTabActiveInActiveGroup(state, file.worktreeId, unifiedTabId)
: state.activeFileId === file.id
: isFileActiveEditorSurface(state, file)
}
}
function isFileActiveEditorSurface(
state: Pick<
AppState,
'activeFileId' | 'activeFileIdByWorktree' | 'activeTabType' | 'activeTabTypeByWorktree'
>,
file: Pick<AppState['openFiles'][number], 'id' | 'worktreeId'>
): boolean {
const activeType = state.activeTabTypeByWorktree?.[file.worktreeId] ?? state.activeTabType
return (
activeType === 'editor' &&
(state.activeFileIdByWorktree?.[file.worktreeId] ?? state.activeFileId) === file.id
)
}
function isMobileFileDiffSource(
diffSource: AppState['openFiles'][number]['diffSource']
): diffSource is 'staged' | 'unstaged' {