From 481911e576e24f0a4f0479810d9b42f32d0ecfb9 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 10 Aug 2026 14:58:18 -0500 Subject: [PATCH 1/6] Render file-aware pane minimap Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/tauriPhysicalPanes.test.ts | 72 ++++++++++++- native/macos/psyche-build-tauri/web/main.js | 100 +++++++++++++----- .../macos/psyche-build-tauri/web/styles.css | 4 + 3 files changed, 146 insertions(+), 30 deletions(-) diff --git a/__tests__/tauriPhysicalPanes.test.ts b/__tests__/tauriPhysicalPanes.test.ts index 1c80efdf..762faf8d 100644 --- a/__tests__/tauriPhysicalPanes.test.ts +++ b/__tests__/tauriPhysicalPanes.test.ts @@ -987,10 +987,74 @@ describe('Tauri physical terminal panes', () => { }; } - it('cycles tiled → full column → full row → tiled without editing the tiled tree', () => { - const layout: Layout = { root: tree(), focusedLeafId: 'leaf-a' }; - const snapshot = JSON.stringify(layout.root); - const helpers = compileModeHelpers(layout); + it('lists the active file before pane entries in the minimap helper', () => { + const threads = new Map([ + ['thread-a', { id: 'thread-a', name: 'Agent', status: 'running' }], + [ + 'thread-b', + { + id: 'thread-b', + name: 'Tests', + status: 'running', + needsAttention: true, + attentionReason: 'waiting-on-user', + }, + ], + ]); + const layout: Layout = { + root: PsychePanes.insertBelow( + PsychePanes.createLeaf('leaf-a', 'thread-a'), + 'leaf-a', + PsychePanes.createLeaf('leaf-b', 'thread-b'), + 'split-a', + ), + focusedLeafId: 'leaf-a', + }; + const paneMinimapItems = compileFunction< + (value: Layout, activeFile: { id: string; name: string; rel: string } | null) => Array + >(functionSource('paneMinimapItems'), { + scopedPaneRoot: (value: Layout) => value.root, + PsychePanes, + findThread: (id: string) => threads.get(id) || null, + PsycheSessions: { attentionLabel: () => 'Waiting for you' }, + }); + + expect(paneMinimapItems(layout, { + id: 'file-a', + name: 'Button.tsx', + rel: 'src/Button.tsx', + })).toEqual([ + { + kind: 'file', + id: 'file-a', + label: 'Button.tsx', + detail: 'src/Button.tsx', + current: true, + thread: null, + }, + { + kind: 'pane', + id: 'thread-a', + label: 'Agent', + detail: 'running', + current: false, + thread: threads.get('thread-a'), + }, + { + kind: 'pane', + id: 'thread-b', + label: 'Tests', + detail: 'running · Waiting for you', + current: false, + thread: threads.get('thread-b'), + }, + ]); + }); + + it('cycles tiled → full column → full row → tiled without editing the tiled tree', () => { + const layout: Layout = { root: tree(), focusedLeafId: 'leaf-a' }; + const snapshot = JSON.stringify(layout.root); + const helpers = compileModeHelpers(layout); expect(helpers.effectivePaneRoot(layout)).toBe(layout.root); diff --git a/native/macos/psyche-build-tauri/web/main.js b/native/macos/psyche-build-tauri/web/main.js index 3ead9121..a0744874 100644 --- a/native/macos/psyche-build-tauri/web/main.js +++ b/native/macos/psyche-build-tauri/web/main.js @@ -2841,7 +2841,7 @@ syncThreadPaneMetadata(thread); }); renderSetPickBar(); - renderPaneMinimap(layout); + renderPaneMinimap(layout, findOpenFile(state.activeFileId)); scheduleVisiblePaneFit(); requestAnimationFrame(syncBrowserBounds); } @@ -2935,10 +2935,43 @@ * Focus mode hides every pane but one, so the minimap is how the others stay * reachable — a carousel of the panes the canvas is no longer drawing. */ - function renderPaneMinimap(layout) { + function paneMinimapItems(layout, activeFile) { + var items = []; + if (activeFile) { + items.push({ + kind: "file", + id: activeFile.id, + label: activeFile.name, + detail: activeFile.rel, + current: true, + thread: null, + }); + } + if (!layout || !layout.root) return items; + + PsychePanes.leafIds(scopedPaneRoot(layout)).forEach(function (leafId) { + var leaf = PsychePanes.findLeafById(layout.root, leafId); + var thread = leaf && findThread(leaf.threadId); + if (!thread) return; + items.push({ + kind: "pane", + id: thread.id, + label: thread.name, + detail: (thread.status || "") + + (thread.needsAttention + ? " · " + PsycheSessions.attentionLabel(thread.attentionReason) + : ""), + current: !activeFile && layout.maximizedLeafId === leafId, + thread: thread, + }); + }); + return items; + } + + function renderPaneMinimap(layout, activeFile) { if (!terminalArea) return; var rail = terminalArea.querySelector(".pane-minimap"); - if (!layout || !layout.maximizedLeafId) { + if (!activeFile && (!layout || !layout.maximizedLeafId)) { if (rail) rail.remove(); return; } @@ -2949,33 +2982,28 @@ terminalArea.appendChild(rail); } rail.replaceChildren(); - var scopedIds = PsychePanes.leafIds(scopedPaneRoot(layout)); - scopedIds.forEach(function (leafId) { - var leaf = PsychePanes.findLeafById(layout.root, leafId); - var thread = leaf && findThread(leaf.threadId); - if (!thread) return; + paneMinimapItems(layout, activeFile).forEach(function (item) { var entry = document.createElement("button"); entry.type = "button"; - entry.dataset.threadId = thread.id; entry.className = "minimap-pane" + - (layout.maximizedLeafId === leafId ? " is-current" : ""); - // Focus mode is exactly when a waiting pane is easiest to lose, so the - // minimap says it in the label too, not just in the dot's colour. - entry.title = thread.name + " — " + (thread.status || "") + - (thread.needsAttention - ? " · " + PsycheSessions.attentionLabel(thread.attentionReason) - : "") + - " · click to focus this pane"; + (item.kind === "file" ? " is-file" : "") + + (item.current ? " is-current" : ""); + if (item.kind === "pane") entry.dataset.threadId = item.thread.id; + entry.title = item.kind === "file" + ? item.detail + " · current file" + : item.label + " — " + item.detail + " · click to focus this pane"; entry.setAttribute("aria-label", entry.title); var head = document.createElement("span"); head.className = "minimap-head"; var glyph = document.createElement("span"); glyph.className = "minimap-glyph"; - glyph.textContent = paneGlyphFor(thread.kind); + glyph.textContent = item.kind === "file" ? "F" : paneGlyphFor(item.thread.kind); var dot = document.createElement("span"); - dot.className = "minimap-dot " + sessionStatusClass(thread) + - (thread.needsAttention ? " attention" : ""); + dot.className = item.kind === "file" + ? "minimap-dot file" + : "minimap-dot " + sessionStatusClass(item.thread) + + (item.thread.needsAttention ? " attention" : ""); head.appendChild(glyph); head.appendChild(dot); @@ -2983,16 +3011,36 @@ body.className = "minimap-body"; var name = document.createElement("span"); name.className = "minimap-name"; - name.textContent = thread.name; + name.textContent = item.label; entry.appendChild(head); entry.appendChild(body); entry.appendChild(name); - entry.addEventListener("click", function () { - layout.maximizedLeafId = leafId; - layout.focusedLeafId = leafId; - focusThread(thread.id); - }); + if (item.kind === "file") { + entry.addEventListener("click", function () { + restoreFileEditorFocus(); + }); + } else { + entry.addEventListener("click", async function () { + var leaf = layout && layout.root + ? PsychePanes.findLeafByThreadId(layout.root, item.thread.id) + : null; + if (!leaf) return; + var previousMaximizedLeafId = layout.maximizedLeafId; + var previousFocusedLeafId = layout.focusedLeafId; + layout.maximizedLeafId = leaf.id; + layout.focusedLeafId = leaf.id; + if (activeFile) { + if (!(await focusThread(item.thread.id))) { + layout.maximizedLeafId = previousMaximizedLeafId; + layout.focusedLeafId = previousFocusedLeafId; + renderPaneMinimap(layout, findOpenFile(state.activeFileId)); + } + } else { + focusThread(item.thread.id); + } + }); + } rail.appendChild(entry); }); } diff --git a/native/macos/psyche-build-tauri/web/styles.css b/native/macos/psyche-build-tauri/web/styles.css index 608dc73b..c57c6956 100644 --- a/native/macos/psyche-build-tauri/web/styles.css +++ b/native/macos/psyche-build-tauri/web/styles.css @@ -1697,6 +1697,10 @@ body.is-pane-dragging .terminal-pane { transition: opacity var(--transition-fast .minimap-dot.running { background: var(--ok); } .minimap-dot.starting { background: var(--warn); } .minimap-dot.exited { background: var(--error); } +.minimap-dot.file { + background: var(--accent); + box-shadow: 0 0 7px var(--accent-glow); +} /* Focus mode draws one pane and hides the rest, so the minimap is the only place a waiting pane can still be seen. It wins over the status colour and grows a ring, because at 5px a hue swap alone is not a signal. */ From 68055b79f055469ff825ecf263db2019260ba017 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 10 Aug 2026 15:06:51 -0500 Subject: [PATCH 2/6] Implement file focus return flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/tauriPhysicalPanes.test.ts | 40 ++++++ .../tauriWorkspaceEditorIntegration.test.ts | 90 +++++++++++++- native/macos/psyche-build-tauri/web/main.js | 117 ++++++++++++++---- 3 files changed, 218 insertions(+), 29 deletions(-) diff --git a/__tests__/tauriPhysicalPanes.test.ts b/__tests__/tauriPhysicalPanes.test.ts index 762faf8d..a527d321 100644 --- a/__tests__/tauriPhysicalPanes.test.ts +++ b/__tests__/tauriPhysicalPanes.test.ts @@ -987,6 +987,46 @@ describe('Tauri physical terminal panes', () => { }; } + it('resolves the recorded return pane, then focused pane, then first pane', () => { + const layout: Layout = { root: tree(), focusedLeafId: 'leaf-b' }; + const threads = new Map([ + ['thread-a', { + id: 'thread-a', projectId: 'project', worktreePath: '/repo', hidden: false, + }], + ['thread-b', { + id: 'thread-b', projectId: 'project', worktreePath: '/repo', hidden: false, + }], + ]); + const project = { id: 'project' }; + const fileFocusThreadIsAvailable = compileFunction< + ( + thread: Record | null, + root: Record, + value: typeof project, + workspaceRoot: string, + ) => boolean + >(functionSource('fileFocusThreadIsAvailable'), { PsychePanes }); + const resolveFileFocusThreadId = compileFunction< + (preferredId?: string | null) => string | null + >(functionSource('resolveFileFocusThreadId'), { + activeProject: () => project, + activeWorkspaceRoot: () => '/repo', + activePaneLayout: () => layout, + scopedPaneRoot: (value: Layout) => value.root, + findThread: (id: string) => threads.get(id) || null, + PsychePanes, + fileFocusThreadIsAvailable, + }); + + expect(resolveFileFocusThreadId('thread-a')).toBe('thread-a'); + threads.get('thread-a')!.hidden = true; + expect(resolveFileFocusThreadId('thread-a')).toBe('thread-b'); + layout.focusedLeafId = 'leaf-missing'; + expect(resolveFileFocusThreadId('thread-a')).toBe('thread-b'); + threads.get('thread-b')!.hidden = true; + expect(resolveFileFocusThreadId('thread-a')).toBeNull(); + }); + it('lists the active file before pane entries in the minimap helper', () => { const threads = new Map([ ['thread-a', { id: 'thread-a', name: 'Agent', status: 'running' }], diff --git a/__tests__/tauriWorkspaceEditorIntegration.test.ts b/__tests__/tauriWorkspaceEditorIntegration.test.ts index 446dbf87..09936ac7 100644 --- a/__tests__/tauriWorkspaceEditorIntegration.test.ts +++ b/__tests__/tauriWorkspaceEditorIntegration.test.ts @@ -22,7 +22,18 @@ function extractFunctionSource(source: string, name: string) { const syncStart = source.indexOf(`function ${name}(`); const start = asyncStart === -1 ? syncStart : asyncStart; if (start === -1) throw new Error(`missing function ${name}`); - const bodyStart = source.indexOf('{', start); + const paramsStart = source.indexOf('(', start); + let paramsDepth = 0; + let bodyStart = -1; + for (let index = paramsStart; index < source.length; index += 1) { + if (source[index] === '(') paramsDepth += 1; + if (source[index] === ')') paramsDepth -= 1; + if (paramsDepth === 0) { + bodyStart = source.indexOf('{', index); + break; + } + } + if (bodyStart === -1) throw new Error(`missing function body ${name}`); let depth = 0; for (let index = bodyStart; index < source.length; index += 1) { if (source[index] === '{') depth += 1; @@ -589,6 +600,12 @@ describe('native CodeMirror workspace editor surface', () => { expect(stylesCss).toMatch(/\.file-decision-dialog::backdrop\s*\{/); }); + it('stops dirty-dialog Escape before it reaches fullscreen file return', () => { + expect(extractFunctionSource(mainJs, 'showFileDecision')).toMatch( + /event\.key === "Escape"[\s\S]*event\.preventDefault\(\);[\s\S]*event\.stopPropagation\(\);[\s\S]*settle\(fallback\)/ + ); + }); + it('guards dirty files with save, discard, and cancel semantics', async () => { const source = extractFunctionSource(mainJs, 'guardDirtyFile'); const decisions = ['cancel', 'discard', 'save']; @@ -789,10 +806,8 @@ describe('native CodeMirror workspace editor surface', () => { state, refreshTabs: () => undefined, activateFileTabNow: () => undefined, - fileViewEl: { hidden: false }, - terminalHost: { hidden: true }, - requestAnimationFrame: () => undefined, - scheduleVisiblePaneFit: () => undefined, + clearFileFocusPresentation: () => undefined, + renderPaneWorkspace: () => undefined, }); const closing = closeFileTab(file.id); @@ -1099,6 +1114,69 @@ describe('native CodeMirror workspace editor surface', () => { ); }); + it('keeps file focus intact when dirty-file navigation is cancelled', async () => { + const state = { activeFileId: 'file-a' }; + const fileFocus = { returnThreadId: 'thread-a' }; + let clearCalls = 0; + const showTerminalView = compileFunction<() => Promise>( + extractFunctionSource(mainJs, 'showTerminalView'), + { + state, + fileNavigationInFlight: false, + fileDecisionInFlight: null, + guardDirtyFile: async () => false, + findOpenFile: () => ({ id: 'file-a', dirty: true }), + clearFileFocusPresentation: () => { clearCalls += 1; }, + refreshTabs: () => undefined, + requestAnimationFrame: () => undefined, + scheduleVisiblePaneFit: () => undefined, + }, + ); + + await expect(showTerminalView()).resolves.toBe(false); + expect(state.activeFileId).toBe('file-a'); + expect(fileFocus.returnThreadId).toBe('thread-a'); + expect(clearCalls).toBe(0); + }); + + it('routes Escape through guarded file return before pane maximize', () => { + expect(mainJs).toMatch( + /document\.addEventListener\("keydown", async function \(event\)[\s\S]*if \(state\.activeFileId\) \{[\s\S]*event\.preventDefault\(\);[\s\S]*await returnFromFileFocus\(\);[\s\S]*if \(!typing && exitPaneMaximize\(\)\)/ + ); + expect(extractFunctionSource(mainJs, 'renderPaneMinimap')).toMatch( + /await returnFromFileFocus\(item\.thread\.id, true\)/ + ); + }); + + it('restores the pane workspace after the last active file closes', async () => { + const file = { id: 'f1', projectId: 'p1', dirty: false, savePromise: null }; + const state = { activeFileId: file.id, activeProjectId: 'p1', openFiles: [file] }; + let cleared = 0; + let rendered = 0; + const closeFileTab = compileFunction< + (id: string) => Promise + >(extractFunctionSource(mainJs, 'closeFileTab'), { + findOpenFile: () => file, + fileNavigationInFlight: false, + fileDecisionInFlight: null, + guardDirtyFile: async () => true, + projectFiles: () => state.openFiles, + state, + refreshTabs: () => undefined, + activateFileTabNow: () => undefined, + clearFileFocusPresentation: () => { + cleared += 1; + state.activeFileId = null; + }, + renderPaneWorkspace: () => { rendered += 1; }, + }); + + await expect(closeFileTab(file.id)).resolves.toBe(true); + expect(state.openFiles).toEqual([]); + expect(state.activeFileId).toBeNull(); + expect({ cleared, rendered }).toEqual({ cleared: 1, rendered: 1 }); + }); + it('reserves the focus-mode minimap column for the fullscreen file editor', () => { expect(stylesCss).toMatch( /\.terminal-area\.is-file-focused \.file-view\s*\{[^}]*grid-column:\s*1;/ @@ -1123,7 +1201,7 @@ describe('native CodeMirror workspace editor surface', () => { /await guardDirtyFiles\([\s\S]*if \(!canRemove\) return false;[\s\S]*state\.projects =/ ); expect(extractFunctionSource(mainJs, 'showTerminalView')).toMatch( - /await guardDirtyFile\([\s\S]*if \(!canShowTerminal\) return false;[\s\S]*state\.activeFileId = null/ + /await guardDirtyFile\([\s\S]*if \(!canShowTerminal\) return false;[\s\S]*clearFileFocusPresentation\(\)/ ); expect(mainJs).toContain('window.__TAURI__.window.getCurrentWindow()'); expect(mainJs).toContain('onCloseRequested'); diff --git a/native/macos/psyche-build-tauri/web/main.js b/native/macos/psyche-build-tauri/web/main.js index a0744874..f29bcd82 100644 --- a/native/macos/psyche-build-tauri/web/main.js +++ b/native/macos/psyche-build-tauri/web/main.js @@ -3022,23 +3022,15 @@ }); } else { entry.addEventListener("click", async function () { - var leaf = layout && layout.root - ? PsychePanes.findLeafByThreadId(layout.root, item.thread.id) - : null; + var leaf = PsychePanes.findLeafByThreadId(layout.root, item.thread.id); if (!leaf) return; - var previousMaximizedLeafId = layout.maximizedLeafId; - var previousFocusedLeafId = layout.focusedLeafId; + if (state.activeFileId) { + await returnFromFileFocus(item.thread.id, true); + return; + } layout.maximizedLeafId = leaf.id; layout.focusedLeafId = leaf.id; - if (activeFile) { - if (!(await focusThread(item.thread.id))) { - layout.maximizedLeafId = previousMaximizedLeafId; - layout.focusedLeafId = previousFocusedLeafId; - renderPaneMinimap(layout, findOpenFile(state.activeFileId)); - } - } else { - focusThread(item.thread.id); - } + focusThread(item.thread.id); }); } rail.appendChild(entry); @@ -4424,6 +4416,7 @@ bind(dirtyFileDialogEl, "keydown", function (event) { if (event.key === "Escape") { event.preventDefault(); + event.stopPropagation(); settle(fallback); } }); @@ -4446,6 +4439,44 @@ return state.openFiles.filter(function (f) { return f.id === id; })[0] || null; } + function fileFocusThreadIsAvailable(thread, root, project, workspaceRoot) { + return !!thread && + !thread.hidden && + thread.projectId === project.id && + thread.worktreePath === workspaceRoot && + !!PsychePanes.findLeafByThreadId(root, thread.id); + } + + function resolveFileFocusThreadId(preferredId) { + var project = activeProject(); + var layout = activePaneLayout(); + if (!project || !layout || !layout.root) return null; + var root = scopedPaneRoot(layout); + var workspaceRoot = activeWorkspaceRoot(project); + var preferred = preferredId ? findThread(preferredId) : null; + if (fileFocusThreadIsAvailable(preferred, root, project, workspaceRoot)) { + return preferred.id; + } + + var focused = layout.focusedLeafId + ? PsychePanes.findLeafById(root, layout.focusedLeafId) + : null; + var focusedThread = focused ? findThread(focused.threadId) : null; + if (fileFocusThreadIsAvailable(focusedThread, root, project, workspaceRoot)) { + return focusedThread.id; + } + + var leafIds = PsychePanes.leafIds(root); + for (var i = 0; i < leafIds.length; i++) { + var leaf = PsychePanes.findLeafById(root, leafIds[i]); + var thread = leaf ? findThread(leaf.threadId) : null; + if (fileFocusThreadIsAvailable(thread, root, project, workspaceRoot)) { + return thread.id; + } + } + return null; + } + function enterFileFocus(file) { if (!file) return false; if (!state.activeFileId) { @@ -4459,6 +4490,45 @@ return true; } + function clearFileFocusPresentation() { + state.activeFileId = null; + fileFocus.returnThreadId = null; + terminalArea.classList.remove("is-file-focused"); + fileViewEl.hidden = true; + terminalHost.hidden = false; + } + + async function returnFromFileFocus(explicitThreadId, maximizeDestination) { + if (!state.activeFileId) return false; + var activeFile = findOpenFile(state.activeFileId); + var destinationId = resolveFileFocusThreadId( + explicitThreadId || fileFocus.returnThreadId + ); + if (destinationId) { + var layout = activePaneLayout(); + var leaf = layout && layout.root + ? PsychePanes.findLeafByThreadId(layout.root, destinationId) + : null; + var previousMaximizedLeafId = layout ? layout.maximizedLeafId : null; + var previousFocusedLeafId = layout ? layout.focusedLeafId : null; + if (maximizeDestination && layout && leaf) { + layout.maximizedLeafId = leaf.id; + layout.focusedLeafId = leaf.id; + } + var focused = await focusThread(destinationId); + if (!focused && maximizeDestination && layout) { + layout.maximizedLeafId = previousMaximizedLeafId; + layout.focusedLeafId = previousFocusedLeafId; + renderPaneMinimap(layout, activeFile); + } + return focused; + } + if (!(await showTerminalView())) return false; + renderPaneWorkspace(); + refreshSidebar(); + return true; + } + async function openFileTab(path, project) { project = project || activeProject(); if (!project) return; @@ -4585,9 +4655,7 @@ fileNavigationInFlight = false; } if (!canShowTerminal) return false; - state.activeFileId = null; - if (fileViewEl) fileViewEl.hidden = true; - if (terminalHost) terminalHost.hidden = false; + clearFileFocusPresentation(); refreshTabs(); requestAnimationFrame(function () { scheduleVisiblePaneFit(); }); return true; @@ -4613,11 +4681,9 @@ var next = remaining[Math.min(idx, remaining.length - 1)]; if (next) activateFileTabNow(next.id); else { - state.activeFileId = null; - if (fileViewEl) fileViewEl.hidden = true; - if (terminalHost) terminalHost.hidden = false; + clearFileFocusPresentation(); refreshTabs(); - requestAnimationFrame(function () { scheduleVisiblePaneFit(); }); + renderPaneWorkspace(); } return true; } @@ -6188,13 +6254,13 @@ } // `?` is only a shortcut when nothing text-like has focus. - document.addEventListener("keydown", function (event) { + document.addEventListener("keydown", async function (event) { var tag = (event.target && event.target.tagName ? event.target.tagName : "").toLowerCase(); var typing = tag === "input" || tag === "textarea" || tag === "select" || (event.target && event.target.isContentEditable); // Esc cascade — one key, most-transient layer first, so it never skips // past something the user is looking at to undo something they aren't: - // help → menus → set picking → armed confirm → focus mode. + // help → menus → set picking → armed confirm → file return → focus mode. if (event.key === "Escape") { if (helpOverlayEl && !helpOverlayEl.hidden) { setHelpOpen(false); return; } var menuWasOpen = (newPaneMenuEl && !newPaneMenuEl.hidden) || @@ -6207,6 +6273,11 @@ // A call is the most transient thing on screen after a menu, and ending // it is always safe: nothing is transmitting. if (endCall()) return; + if (state.activeFileId) { + event.preventDefault(); + await returnFromFileFocus(); + return; + } if (!typing && exitPaneMaximize()) return; return; } From a2b2e1a0a6508493f9bd45c7b7d977ece6f3949e Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 10 Aug 2026 15:15:10 -0500 Subject: [PATCH 3/6] Document fullscreen file escape shortcut Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/tauriWorkspaceEditorIntegration.test.ts | 6 +++++- native/macos/psyche-build-tauri/web/main.js | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/__tests__/tauriWorkspaceEditorIntegration.test.ts b/__tests__/tauriWorkspaceEditorIntegration.test.ts index 09936ac7..5bb7b953 100644 --- a/__tests__/tauriWorkspaceEditorIntegration.test.ts +++ b/__tests__/tauriWorkspaceEditorIntegration.test.ts @@ -1148,9 +1148,13 @@ describe('native CodeMirror workspace editor surface', () => { ); }); + it('documents Escape as the way to leave a fullscreen file', () => { + expect(mainJs).toContain('["Leave a fullscreen file", "esc"]'); + }); + it('restores the pane workspace after the last active file closes', async () => { const file = { id: 'f1', projectId: 'p1', dirty: false, savePromise: null }; - const state = { activeFileId: file.id, activeProjectId: 'p1', openFiles: [file] }; + const state = { activeFileId: file.id as string | null, activeProjectId: 'p1', openFiles: [file] }; let cleared = 0; let rendered = 0; const closeFileTab = compileFunction< diff --git a/native/macos/psyche-build-tauri/web/main.js b/native/macos/psyche-build-tauri/web/main.js index f29bcd82..93c9994e 100644 --- a/native/macos/psyche-build-tauri/web/main.js +++ b/native/macos/psyche-build-tauri/web/main.js @@ -6229,6 +6229,7 @@ ["Rename a session", "double-click"], ["Cycle file tabs", "⌘[ · ⌘]"], ["Save the open file", "⌘S"], + ["Leave a fullscreen file", "esc"], ["This overlay", "?"], ]; function renderHelpRows() { From 4ae2c46d076fc69a19e3ffd8800b22d4b687df1a Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 10 Aug 2026 15:29:43 -0500 Subject: [PATCH 4/6] Fix terminal minimap refresh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tauriWorkspaceEditorIntegration.test.ts | 28 +++++++++++++++++++ native/macos/psyche-build-tauri/web/main.js | 1 + 2 files changed, 29 insertions(+) diff --git a/__tests__/tauriWorkspaceEditorIntegration.test.ts b/__tests__/tauriWorkspaceEditorIntegration.test.ts index 5bb7b953..9295b746 100644 --- a/__tests__/tauriWorkspaceEditorIntegration.test.ts +++ b/__tests__/tauriWorkspaceEditorIntegration.test.ts @@ -1139,6 +1139,34 @@ describe('native CodeMirror workspace editor surface', () => { expect(clearCalls).toBe(0); }); + it('refreshes the pane minimap immediately after leaving file focus', async () => { + const calls: string[] = []; + const layout = { root: { type: 'leaf', id: 'leaf-a', threadId: 'thread-a' } }; + const showTerminalView = compileFunction<() => Promise>( + extractFunctionSource(mainJs, 'showTerminalView'), + { + state: { activeFileId: 'file-a' }, + fileNavigationInFlight: false, + fileDecisionInFlight: null, + guardDirtyFile: async () => true, + findOpenFile: () => ({ id: 'file-a', dirty: false }), + clearFileFocusPresentation: () => { calls.push('clear'); }, + activePaneLayout: () => layout, + renderPaneMinimap: (value: unknown, file: unknown) => { + expect(value).toBe(layout); + expect(file).toBeNull(); + calls.push('minimap'); + }, + refreshTabs: () => { calls.push('tabs'); }, + requestAnimationFrame: (callback: () => void) => callback(), + scheduleVisiblePaneFit: () => { calls.push('fit'); }, + }, + ); + + await expect(showTerminalView()).resolves.toBe(true); + expect(calls).toEqual(['clear', 'minimap', 'tabs', 'fit']); + }); + it('routes Escape through guarded file return before pane maximize', () => { expect(mainJs).toMatch( /document\.addEventListener\("keydown", async function \(event\)[\s\S]*if \(state\.activeFileId\) \{[\s\S]*event\.preventDefault\(\);[\s\S]*await returnFromFileFocus\(\);[\s\S]*if \(!typing && exitPaneMaximize\(\)\)/ diff --git a/native/macos/psyche-build-tauri/web/main.js b/native/macos/psyche-build-tauri/web/main.js index 93c9994e..44685404 100644 --- a/native/macos/psyche-build-tauri/web/main.js +++ b/native/macos/psyche-build-tauri/web/main.js @@ -4656,6 +4656,7 @@ } if (!canShowTerminal) return false; clearFileFocusPresentation(); + renderPaneMinimap(activePaneLayout(), null); refreshTabs(); requestAnimationFrame(function () { scheduleVisiblePaneFit(); }); return true; From 521bfdcfae89361635b46ed2a8ba3a5c1cc65900 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 10 Aug 2026 15:48:14 -0500 Subject: [PATCH 5/6] Retain file focus on pane removal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/tauriCovenLaunch.test.ts | 3 + __tests__/tauriPhysicalPanes.test.ts | 140 ++++++++++++++++++++ native/macos/psyche-build-tauri/web/main.js | 19 ++- 3 files changed, 160 insertions(+), 2 deletions(-) diff --git a/__tests__/tauriCovenLaunch.test.ts b/__tests__/tauriCovenLaunch.test.ts index 2273c85d..52f90ce0 100644 --- a/__tests__/tauriCovenLaunch.test.ts +++ b/__tests__/tauriCovenLaunch.test.ts @@ -894,6 +894,7 @@ describe('native Coven launch routing', () => { forgetThreadInSets: () => undefined, findThread: () => thread, detachThreadPane: () => null, + retainFileFocusAfterThreadRemoval: () => false, pendingDataBuffers: new Map(), stopThreadPty, state, @@ -926,6 +927,7 @@ describe('native Coven launch routing', () => { forgetThreadInSets: () => undefined, findThread: () => thread, detachThreadPane: () => null, + retainFileFocusAfterThreadRemoval: () => false, pendingDataBuffers: new Map(), stopThreadPty: () => { stopCalls += 1; return Promise.resolve(true); }, state, @@ -1001,6 +1003,7 @@ describe('native Coven launch routing', () => { forgetThreadInSets: () => undefined, findThread: () => state.threads.find((value) => value.id === thread.id) || null, detachThreadPane: () => null, + retainFileFocusAfterThreadRemoval: () => false, pendingDataBuffers: new Map(), stopThreadPty, state, diff --git a/__tests__/tauriPhysicalPanes.test.ts b/__tests__/tauriPhysicalPanes.test.ts index a527d321..9de9ede1 100644 --- a/__tests__/tauriPhysicalPanes.test.ts +++ b/__tests__/tauriPhysicalPanes.test.ts @@ -426,6 +426,38 @@ describe('Tauri physical terminal panes', () => { expect(stylesCss).toMatch(/\.terminal-pane-body/); }); + it('refreshes the minimap in the empty-layout branch while a file stays active', () => { + const calls: string[] = []; + const activeFile = { id: 'file-a' }; + const terminalHost = { + children: ['stale-pane'], + replaceChildren: () => { + terminalHost.children = []; + calls.push('clear'); + }, + }; + const renderPaneWorkspace = compileFunction<() => void>(functionSource('renderPaneWorkspace'), { + terminalHost, + stageBrowserSurface: () => { calls.push('stage'); }, + activePaneLayout: () => null, + renderTerminalEmptyState: () => { calls.push('empty'); }, + renderPaneMinimap: (layout: unknown, file: unknown) => { + expect(layout).toBeNull(); + expect(file).toBe(activeFile); + calls.push('minimap'); + }, + findOpenFile: (id: string | null) => { + expect(id).toBe('file-a'); + return activeFile; + }, + state: { activeFileId: 'file-a' }, + }); + + renderPaneWorkspace(); + expect(terminalHost.children).toEqual([]); + expect(calls).toEqual(['stage', 'clear', 'empty', 'minimap']); + }); + it('renders file tabs without depending on terminal thread visibility', () => { expect(functionSource('refreshTabs')).not.toMatch(/activeProjectThreads/); }); @@ -649,6 +681,7 @@ describe('Tauri physical terminal panes', () => { forgetThreadInSets: () => undefined, findThread: () => thread, detachThreadPane: () => null, + retainFileFocusAfterThreadRemoval: () => false, pendingDataBuffers, stopThreadPty: () => { stops += 1; return Promise.resolve(true); }, state, @@ -717,6 +750,7 @@ describe('Tauri physical terminal panes', () => { forgetThreadInSets: () => undefined, findThread: () => thread, detachThreadPane: () => null, + retainFileFocusAfterThreadRemoval: () => false, pendingDataBuffers, stopThreadPty, state, @@ -737,6 +771,112 @@ describe('Tauri physical terminal panes', () => { expect(pendingDataBuffers.has(thread.id)).toBe(false); }); + it('retains file focus when closing the active underlying pane', () => { + const project = { id: 'project' }; + const threadA = { + id: 'thread-a', + kind: 'shell', + projectId: project.id, + worktreePath: '/repo', + closeStarted: false, + closing: false, + startInFlight: false, + term: { dispose: () => undefined }, + }; + const threadB = { + id: 'thread-b', + kind: 'shell', + projectId: project.id, + worktreePath: '/repo', + closeStarted: false, + closing: false, + startInFlight: false, + term: { dispose: () => undefined }, + }; + const state = { + threads: [threadA, threadB], + activeThreadId: threadA.id as string | null, + activeFileId: 'file-a', + }; + const fileFocus = { returnThreadId: threadA.id as string | null }; + const retainFileFocusAfterThreadRemoval = compileFunction< + (removedThreadId: string, nextThreadId: string | null) => boolean + >(functionSource('retainFileFocusAfterThreadRemoval'), { state, fileFocus }); + let renders = 0; + let focused = 0; + const closeThread = compileFunction<(id: string) => boolean>(functionSource('closeThread'), { + forgetThreadInSets: () => undefined, + findThread: (id: string) => state.threads.find((thread) => thread.id === id) || null, + detachThreadPane: () => threadB.id, + retainFileFocusAfterThreadRemoval, + pendingDataBuffers: new Map(), + stopThreadPty: () => Promise.resolve(true), + state, + fileFocus, + renderPaneWorkspace: () => { renders += 1; }, + setProjectStatus: () => undefined, + findProject: () => project, + refreshSidebar: () => undefined, + refreshTabs: () => undefined, + focusThread: () => { focused += 1; }, + }); + + expect(closeThread(threadA.id)).toBe(true); + expect(focused).toBe(0); + expect(state.activeFileId).toBe('file-a'); + expect(state.activeThreadId).toBe(threadB.id); + expect(fileFocus.returnThreadId).toBe(threadB.id); + expect(renders).toBe(1); + expect(state.threads).toEqual([threadB]); + }); + + it('retains file focus when hiding the active underlying pane', () => { + const threadA = { + id: 'thread-a', + kind: 'shell', + projectId: 'project', + worktreePath: '/repo', + hidden: false, + }; + const threadB = { + id: 'thread-b', + kind: 'shell', + projectId: 'project', + worktreePath: '/repo', + hidden: false, + }; + const state = { + threads: [threadA, threadB], + activeThreadId: threadA.id as string | null, + activeFileId: 'file-a', + }; + const fileFocus = { returnThreadId: threadA.id as string | null }; + const retainFileFocusAfterThreadRemoval = compileFunction< + (removedThreadId: string, nextThreadId: string | null) => boolean + >(functionSource('retainFileFocusAfterThreadRemoval'), { state, fileFocus }); + let renders = 0; + let focused = 0; + const hideThread = compileFunction<(id: string) => boolean>(functionSource('hideThread'), { + findThread: (id: string) => state.threads.find((thread) => thread.id === id) || null, + detachThreadPane: () => threadB.id, + retainFileFocusAfterThreadRemoval, + state, + fileFocus, + focusThread: () => { focused += 1; }, + renderPaneWorkspace: () => { renders += 1; }, + refreshSidebar: () => undefined, + refreshTabs: () => undefined, + }); + + expect(hideThread(threadA.id)).toBe(true); + expect(focused).toBe(0); + expect(state.activeFileId).toBe('file-a'); + expect(state.activeThreadId).toBe(threadB.id); + expect(fileFocus.returnThreadId).toBe(threadB.id); + expect(threadA.hidden).toBe(true); + expect(renders).toBe(1); + }); + it('guards inactive-project hidden-session reopen behind dirty-file cancellation', async () => { const state = { activeProjectId: 'active-project' }; const project = { id: 'inactive-project', selectedWorktreePath: '/old' }; diff --git a/native/macos/psyche-build-tauri/web/main.js b/native/macos/psyche-build-tauri/web/main.js index 44685404..45c81217 100644 --- a/native/macos/psyche-build-tauri/web/main.js +++ b/native/macos/psyche-build-tauri/web/main.js @@ -2817,6 +2817,7 @@ var layout = activePaneLayout(); if (!layout || !layout.root) { renderTerminalEmptyState(); + renderPaneMinimap(layout, findOpenFile(state.activeFileId)); return; } var root = effectivePaneRoot(layout); @@ -3130,6 +3131,15 @@ return closed; } + function retainFileFocusAfterThreadRemoval(removedThreadId, nextThreadId) { + if (!state.activeFileId) return false; + state.activeThreadId = nextThreadId || null; + if (fileFocus.returnThreadId === removedThreadId) { + fileFocus.returnThreadId = nextThreadId || null; + } + return true; + } + function closeThread(id, options) { var thread = findThread(id); if (!thread || thread.closeStarted) return false; @@ -3153,7 +3163,10 @@ // Prefer the next thread in the same project so closing a tab doesn't // teleport the user into a different project. state.activeThreadId = null; - if (nextThreadId && (!options || options.focus !== false)) { + if (retainFileFocusAfterThreadRemoval(id, nextThreadId)) { + renderPaneWorkspace(); + if (!nextThreadId) setProjectStatus(findProject(closingProjectId), ""); + } else if (nextThreadId && (!options || options.focus !== false)) { focusThread(nextThreadId); } else { renderPaneWorkspace(); @@ -3174,7 +3187,9 @@ thread.hidden = true; if (state.activeThreadId === id) { state.activeThreadId = null; - if (nextThreadId) focusThread(nextThreadId); + if (!retainFileFocusAfterThreadRemoval(id, nextThreadId) && nextThreadId) { + focusThread(nextThreadId); + } } renderPaneWorkspace(); refreshSidebar(); From 9b39625c50bea7b34602aa930b45ef407ac4e972 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 10 Aug 2026 15:58:33 -0500 Subject: [PATCH 6/6] Fix file-focus project restore metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/tauriPhysicalPanes.test.ts | 94 +++++++++++++++++++-- native/macos/psyche-build-tauri/web/main.js | 14 ++- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/__tests__/tauriPhysicalPanes.test.ts b/__tests__/tauriPhysicalPanes.test.ts index 9de9ede1..c8a0a459 100644 --- a/__tests__/tauriPhysicalPanes.test.ts +++ b/__tests__/tauriPhysicalPanes.test.ts @@ -772,7 +772,11 @@ describe('Tauri physical terminal panes', () => { }); it('retains file focus when closing the active underlying pane', () => { - const project = { id: 'project' }; + const project = { + id: 'project', + lastActiveThreadId: 'thread-a', + selectedWorktreePath: '/repo', + }; const threadA = { id: 'thread-a', kind: 'shell', @@ -787,7 +791,7 @@ describe('Tauri physical terminal panes', () => { id: 'thread-b', kind: 'shell', projectId: project.id, - worktreePath: '/repo', + worktreePath: '/repo-next', closeStarted: false, closing: false, startInFlight: false, @@ -800,8 +804,13 @@ describe('Tauri physical terminal panes', () => { }; const fileFocus = { returnThreadId: threadA.id as string | null }; const retainFileFocusAfterThreadRemoval = compileFunction< - (removedThreadId: string, nextThreadId: string | null) => boolean - >(functionSource('retainFileFocusAfterThreadRemoval'), { state, fileFocus }); + (removedThreadId: string, nextThreadId: string | null, projectId: string | null) => boolean + >(functionSource('retainFileFocusAfterThreadRemoval'), { + state, + fileFocus, + findProject: (id: string) => (id === project.id ? project : null), + findThread: (id: string) => state.threads.find((thread) => thread.id === id) || null, + }); let renders = 0; let focused = 0; const closeThread = compileFunction<(id: string) => boolean>(functionSource('closeThread'), { @@ -826,23 +835,30 @@ describe('Tauri physical terminal panes', () => { expect(state.activeFileId).toBe('file-a'); expect(state.activeThreadId).toBe(threadB.id); expect(fileFocus.returnThreadId).toBe(threadB.id); + expect(project.lastActiveThreadId).toBe(threadB.id); + expect(project.selectedWorktreePath).toBe(threadB.worktreePath); expect(renders).toBe(1); expect(state.threads).toEqual([threadB]); }); it('retains file focus when hiding the active underlying pane', () => { + const project = { + id: 'project', + lastActiveThreadId: 'thread-a', + selectedWorktreePath: '/repo', + }; const threadA = { id: 'thread-a', kind: 'shell', - projectId: 'project', + projectId: project.id, worktreePath: '/repo', hidden: false, }; const threadB = { id: 'thread-b', kind: 'shell', - projectId: 'project', - worktreePath: '/repo', + projectId: project.id, + worktreePath: '/repo-next', hidden: false, }; const state = { @@ -852,8 +868,13 @@ describe('Tauri physical terminal panes', () => { }; const fileFocus = { returnThreadId: threadA.id as string | null }; const retainFileFocusAfterThreadRemoval = compileFunction< - (removedThreadId: string, nextThreadId: string | null) => boolean - >(functionSource('retainFileFocusAfterThreadRemoval'), { state, fileFocus }); + (removedThreadId: string, nextThreadId: string | null, projectId: string | null) => boolean + >(functionSource('retainFileFocusAfterThreadRemoval'), { + state, + fileFocus, + findProject: (id: string) => (id === project.id ? project : null), + findThread: (id: string) => state.threads.find((thread) => thread.id === id) || null, + }); let renders = 0; let focused = 0; const hideThread = compileFunction<(id: string) => boolean>(functionSource('hideThread'), { @@ -873,10 +894,65 @@ describe('Tauri physical terminal panes', () => { expect(state.activeFileId).toBe('file-a'); expect(state.activeThreadId).toBe(threadB.id); expect(fileFocus.returnThreadId).toBe(threadB.id); + expect(project.lastActiveThreadId).toBe(threadB.id); + expect(project.selectedWorktreePath).toBe(threadB.worktreePath); expect(threadA.hidden).toBe(true); expect(renders).toBe(1); }); + it('clears file-focus project metadata when there is no replacement pane', () => { + const project = { + id: 'project', + lastActiveThreadId: 'thread-a', + selectedWorktreePath: '/repo', + }; + const threadA = { + id: 'thread-a', + kind: 'shell', + projectId: project.id, + worktreePath: '/repo', + closeStarted: false, + closing: false, + startInFlight: false, + term: { dispose: () => undefined }, + }; + const state = { + threads: [threadA], + activeThreadId: threadA.id as string | null, + activeFileId: 'file-a', + }; + const fileFocus = { returnThreadId: threadA.id as string | null }; + const retainFileFocusAfterThreadRemoval = compileFunction< + (removedThreadId: string, nextThreadId: string | null, projectId: string | null) => boolean + >(functionSource('retainFileFocusAfterThreadRemoval'), { + state, + fileFocus, + findProject: (id: string) => (id === project.id ? project : null), + findThread: (id: string) => state.threads.find((thread) => thread.id === id) || null, + }); + const closeThread = compileFunction<(id: string) => boolean>(functionSource('closeThread'), { + forgetThreadInSets: () => undefined, + findThread: (id: string) => state.threads.find((thread) => thread.id === id) || null, + detachThreadPane: () => null, + retainFileFocusAfterThreadRemoval, + pendingDataBuffers: new Map(), + stopThreadPty: () => Promise.resolve(true), + state, + renderPaneWorkspace: () => undefined, + setProjectStatus: () => undefined, + findProject: (id: string) => (id === project.id ? project : null), + refreshSidebar: () => undefined, + refreshTabs: () => undefined, + focusThread: () => undefined, + }); + + expect(closeThread(threadA.id)).toBe(true); + expect(state.activeThreadId).toBeNull(); + expect(fileFocus.returnThreadId).toBeNull(); + expect(project.lastActiveThreadId).toBeNull(); + expect(project.selectedWorktreePath).toBe('/repo'); + }); + it('guards inactive-project hidden-session reopen behind dirty-file cancellation', async () => { const state = { activeProjectId: 'active-project' }; const project = { id: 'inactive-project', selectedWorktreePath: '/old' }; diff --git a/native/macos/psyche-build-tauri/web/main.js b/native/macos/psyche-build-tauri/web/main.js index 45c81217..d5457d03 100644 --- a/native/macos/psyche-build-tauri/web/main.js +++ b/native/macos/psyche-build-tauri/web/main.js @@ -3131,12 +3131,20 @@ return closed; } - function retainFileFocusAfterThreadRemoval(removedThreadId, nextThreadId) { + function retainFileFocusAfterThreadRemoval(removedThreadId, nextThreadId, projectId) { if (!state.activeFileId) return false; state.activeThreadId = nextThreadId || null; if (fileFocus.returnThreadId === removedThreadId) { fileFocus.returnThreadId = nextThreadId || null; } + var project = findProject(projectId); + if (project) { + project.lastActiveThreadId = nextThreadId || null; + if (nextThreadId) { + var nextThread = findThread(nextThreadId); + if (nextThread) project.selectedWorktreePath = nextThread.worktreePath; + } + } return true; } @@ -3163,7 +3171,7 @@ // Prefer the next thread in the same project so closing a tab doesn't // teleport the user into a different project. state.activeThreadId = null; - if (retainFileFocusAfterThreadRemoval(id, nextThreadId)) { + if (retainFileFocusAfterThreadRemoval(id, nextThreadId, closingProjectId)) { renderPaneWorkspace(); if (!nextThreadId) setProjectStatus(findProject(closingProjectId), ""); } else if (nextThreadId && (!options || options.focus !== false)) { @@ -3187,7 +3195,7 @@ thread.hidden = true; if (state.activeThreadId === id) { state.activeThreadId = null; - if (!retainFileFocusAfterThreadRemoval(id, nextThreadId) && nextThreadId) { + if (!retainFileFocusAfterThreadRemoval(id, nextThreadId, thread.projectId) && nextThreadId) { focusThread(nextThreadId); } }