From e6f30e4857d02de573d586f71b3b354f878b97a4 Mon Sep 17 00:00:00 2001 From: Lukce Date: Mon, 31 Aug 2026 23:54:57 -0400 Subject: [PATCH] Add inline pane rename from the sidebar Double-click a pane's sidebar row to edit its name: Enter saves, Escape cancels, an empty or unchanged name is discarded, and a click outside commits. The rename reuses the existing sessions:rename IPC, whose frontend handlers were already written but never rendered. Committing on blur (the pattern PanelTabStrip uses for tabs) does not work here: double-clicking also activates the pane, and activation pulls focus to the terminal, so the input committed itself one tick after mounting. Instead a capture-phase pointerdown listener commits on a real outside click, and the first programmatic blur reclaims focus once. Also fixes a pre-existing sessionStore bug this surfaced. A main repo session is held both in the sessions array and in activeMainRepoSession, but updateSession returned early after updating the active copy, so the sidebar kept rendering stale data for that pane. Both copies now update together, matching updateSessionGitStatus. Claude-Session: https://claude.ai/code/session_019yor34qu8TA9jTkkHtvu59 --- CHANGELOG.md | 4 + README.md | 7 +- docs/STATE_MANAGEMENT.md | 30 +++ .../src/components/ProjectSessionList.tsx | 192 +++++++++++++----- frontend/src/stores/sessionStore.ts | 34 ++-- tests/electronApiMock.ts | 12 ++ tests/sidebar-rename-pane.spec.ts | 123 +++++++++++ 7 files changed, 332 insertions(+), 70 deletions(-) create mode 100644 tests/sidebar-rename-pane.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a8b0914..9dcc26f69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,16 @@ All notable changes to Pane will be documented in this file. ## [Unreleased] ### Added +- Inline pane rename: double-click a pane's sidebar row to edit its name, `Enter` to save, `Escape` to cancel. An empty name is discarded rather than saved, and clicking outside the field commits the edit. - Cursor Agent CLI (`cursor-agent`) as a third built-in agent tool: launch pill/menu entries with `mod+alt+5`, prompt-as-argument delivery, chat pre-creation with resume-after-restart, at-a-glance status detection, RunPane `--agent cursor` support with a doctor fallback probe for `~/.local/bin`, and a Cursor option for the Pane Chat orchestrator. Pane supports Cursor in macOS, Linux, and WSL repositories. Native Windows launches stay disabled. ### Changed - Custom-command keyboard shortcuts moved from `mod+alt+5..9` to `mod+alt+6..9` to make room for the Cursor slot. - Cursor Agent is now available inside WSL repositories. +### Fixed +- Session store updates for the active main repo pane now reach both copies of the session. Previously an update to that pane (name, status, favorite, or git metadata) refreshed only `activeMainRepoSession` and left the sidebar's copy stale. + ## [1.1.123] - 2026-04-25 ### Added diff --git a/README.md b/README.md index 635ed0f84..c79539817 100644 --- a/README.md +++ b/README.md @@ -336,9 +336,10 @@ irm https://runpane.com/install.ps1 | iex 1. **Open Pane** and create or select a project (any git repository) 2. **Create a pane** — enter a prompt and pick your agent 3. **Add tabs** — launch a Claude, Codex, or Cursor terminal, diff viewer, file explorer, or any CLI tool -4. **Work in parallel** — create multiple panes for different approaches -5. **Review diffs** — see what changed with the built-in diff viewer -6. **Ship** — commit, rebase, and merge from keyboard shortcuts +4. **Rename a pane** — double-click its sidebar row, type a new name, press Enter (Escape cancels) +5. **Work in parallel** — create multiple panes for different approaches +6. **Review diffs** — see what changed with the built-in diff viewer +7. **Ship** — commit, rebase, and merge from keyboard shortcuts --- diff --git a/docs/STATE_MANAGEMENT.md b/docs/STATE_MANAGEMENT.md index be0e6e807..3364aab2e 100644 --- a/docs/STATE_MANAGEMENT.md +++ b/docs/STATE_MANAGEMENT.md @@ -39,6 +39,36 @@ const handleSessionCreated = (newSession: Session) => { }; ``` +### Main Repo Sessions Are Stored Twice + +A repository's main repo session lives in **two** places in `sessionStore`: in the +`sessions` array (which the sidebar renders) and in `activeMainRepoSession` (which +the project view reads) while it is active. Any writer that touches a session must +update **both** copies, or one surface renders stale data. + +```typescript +// ❌ BAD: returns early, leaving the sidebar's copy stale +if (state.activeMainRepoSession?.id === updated.id) { + return { ...state, activeMainRepoSession: { ...state.activeMainRepoSession, ...updated } }; +} +// ...never reached for the main repo session +return { ...state, sessions: updateInList(state.sessions, updated) }; + +// ✅ GOOD: both copies move together +const newActiveMainRepoSession = state.activeMainRepoSession?.id === updated.id + ? { ...state.activeMainRepoSession, ...updated } + : state.activeMainRepoSession; +return { + ...state, + sessions: updateInList(state.sessions, updated), + activeMainRepoSession: newActiveMainRepoSession, +}; +``` + +`updateSession` and `updateSessionGitStatus` both follow the second shape. Regression +coverage lives in `tests/sidebar-rename-pane.spec.ts`, which renames an active main +repo pane and asserts the sidebar label changes. + ### Project Updates ```typescript diff --git a/frontend/src/components/ProjectSessionList.tsx b/frontend/src/components/ProjectSessionList.tsx index e96ff1e71..7ab18d981 100644 --- a/frontend/src/components/ProjectSessionList.tsx +++ b/frontend/src/components/ProjectSessionList.tsx @@ -694,6 +694,13 @@ function SessionRow({ }: SessionRowProps) { const [localGitStatus, setLocalGitStatus] = useState(session.gitStatus); const initialGitStatusRequestRef = useRef(null); + // `null` means "not renaming"; any string (including '') is the in-progress draft. + const [renameDraft, setRenameDraft] = useState(null); + const isRenaming = renameDraft !== null; + const renameInputRef = useRef(null); + // Double-clicking the row also activates the pane, and activation pulls focus + // to the terminal. Reclaim it once so the rename input keeps the keystrokes. + const reclaimedRenameFocusRef = useRef(false); const hasUnviewedCompletedActivity = usePanelStore(s => Boolean(s.unviewedCompletedActivity[session.id])); const agentDisplayStatus = useSessionAgentDisplayStatus(session.id); @@ -759,6 +766,71 @@ function SessionRow({ const showActivity = agentDisplayStatus === 'working'; const accessibleName = displayName || gs?.prTitle || session.name || 'Untitled'; + // --- Inline rename (double-click the row) --- + // The draft seeds from the stored pane name, not the row label, so a pane + // showing a PR title is still edited against its own name. + const startRename = useCallback(() => { + reclaimedRenameFocusRef.current = false; + setRenameDraft(session.name ?? ''); + }, [session.name]); + const cancelRename = useCallback(() => setRenameDraft(null), []); + + const submitRename = useCallback(async () => { + const nextName = renameDraft?.trim() ?? ''; + setRenameDraft(null); + if (!nextName || nextName === session.name) return; + try { + const response = await API.sessions.rename(session.id, nextName); + if (!response.success) { + console.error('Failed to rename pane:', response.error); + } + } catch (error) { + console.error('Failed to rename pane:', error); + } + }, [renameDraft, session.id, session.name]); + + const handleRenameKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + void submitRename(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + cancelRename(); + } + }, [submitRename, cancelRename]); + + const focusRenameInput = useCallback((el: HTMLInputElement | null) => { + renameInputRef.current = el; + if (el) { + el.focus(); + el.select(); + } + }, []); + + // Commit on a click outside rather than on blur, so the commit is driven by + // the user's pointer instead of whichever element grabs focus next. + useEffect(() => { + if (!isRenaming) return; + const handlePointerDown = (event: PointerEvent) => { + const input = renameInputRef.current; + if (input && event.target instanceof Node && !input.contains(event.target)) { + void submitRename(); + } + }; + document.addEventListener('pointerdown', handlePointerDown, true); + return () => document.removeEventListener('pointerdown', handlePointerDown, true); + }, [isRenaming, submitRename]); + + const handleRenameBlur = useCallback(() => { + if (!reclaimedRenameFocusRef.current) { + reclaimedRenameFocusRef.current = true; + renameInputRef.current?.focus(); + return; + } + void submitRename(); + }, [submitRename]); + return (
{/* Always-present left accent bar reflecting the agent status. */} - } - side="right" - interactive - > - + - -
- + + + + + )} ); } diff --git a/frontend/src/stores/sessionStore.ts b/frontend/src/stores/sessionStore.ts index 245dbe7de..257352ed2 100644 --- a/frontend/src/stores/sessionStore.ts +++ b/frontend/src/stores/sessionStore.ts @@ -110,21 +110,20 @@ export const useSessionStore = create((set, get) => ({ updateSession: (updatedSession) => set((state) => { const normalizedUpdatedSession = normalizeSession(updatedSession); - // If this is the active main repo session, update it - if (state.activeMainRepoSession && state.activeMainRepoSession.id === normalizedUpdatedSession.id) { - const newActiveSession = { - ...state.activeMainRepoSession, - ...normalizedUpdatedSession, - output: state.activeMainRepoSession.output, - jsonMessages: state.activeMainRepoSession.jsonMessages - }; - return { - ...state, - activeMainRepoSession: newActiveSession - }; - } - - // Otherwise update in regular sessions + // A main repo session is held in activeMainRepoSession AND listed in + // sessions, so both copies have to move together — updating only the + // active copy leaves the sidebar rendering a stale name/status. + const newActiveMainRepoSession = + state.activeMainRepoSession && state.activeMainRepoSession.id === normalizedUpdatedSession.id + ? { + ...state.activeMainRepoSession, + ...normalizedUpdatedSession, + output: state.activeMainRepoSession.output, + jsonMessages: state.activeMainRepoSession.jsonMessages + } + : state.activeMainRepoSession; + + // Update in regular sessions // Performance: Only clone array if session exists let newSessions = state.sessions; for (let i = 0; i < state.sessions.length; i++) { @@ -140,10 +139,11 @@ export const useSessionStore = create((set, get) => ({ break; } } - + return { ...state, - sessions: newSessions + sessions: newSessions, + activeMainRepoSession: newActiveMainRepoSession }; }), diff --git a/tests/electronApiMock.ts b/tests/electronApiMock.ts index e81d4a9f8..07987df01 100644 --- a/tests/electronApiMock.ts +++ b/tests/electronApiMock.ts @@ -266,6 +266,7 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc const preferenceWrites: Array<{ key: string; value: string }> = []; const sessionDeleteCalls: string[] = []; const sessionFavoriteToggleCalls: string[] = []; + const sessionRenameCalls: Array<{ sessionId: string; name: string }> = []; const invokeCalls = new Map>(); let sessionsGetCount = 0; @@ -779,6 +780,14 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc sessionFavoriteToggleCalls.push(sessionId); return success(); }, + rename: (sessionId: string, name: string) => { + sessionRenameCalls.push({ sessionId, name }); + const renamed = mockSessions.find((session) => session.id === sessionId); + if (!renamed) return Promise.resolve({ success: false as const, error: 'Session not found' }); + renamed.name = name; + emit('session:updated', clone(renamed)); + return success(clone(renamed)); + }, getAll: () => { sessionsGetCount += 1; return success(clone(mockSessions)); @@ -1141,6 +1150,9 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc getSessionFavoriteToggleCalls() { return clone(sessionFavoriteToggleCalls); }, + getSessionRenameCalls() { + return clone(sessionRenameCalls); + }, getDiffManifestCalls() { return clone(diffManifestCalls); }, diff --git a/tests/sidebar-rename-pane.spec.ts b/tests/sidebar-rename-pane.spec.ts new file mode 100644 index 000000000..9b4b422df --- /dev/null +++ b/tests/sidebar-rename-pane.spec.ts @@ -0,0 +1,123 @@ +import { expect, test, type Page } from '@playwright/test'; +import type { JsonObject } from '../shared/validation/boundaryDecoder'; +import { installElectronApiMock } from './electronApiMock'; + +const projects = [ + { + id: 1, + name: 'Alpha', + path: '/tmp/alpha', + active: true, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }, +]; + +function session(id: string, name: string, overrides: JsonObject = {}) { + return { + id, + name, + projectId: 1, + worktreePath: `/tmp/${id}`, + prompt: '', + status: 'stopped', + createdAt: '2026-01-01T00:00:00.000Z', + lastActivity: '2026-01-01T00:00:00.000Z', + output: [], + jsonMessages: [], + permissionMode: 'ignore', + toolType: 'none', + archived: false, + isHidden: false, + isFavorite: false, + ...overrides, + }; +} + +async function openSidebarWithPane(page: Page, name: string, overrides: JsonObject = {}) { + await installElectronApiMock(page, { + initialConfig: { theme: 'night-owl' }, + initialProjects: projects, + initialSessions: [session('pane-rename', name, overrides)], + initialUiState: { + expandedProjects: [1], + pinnedSectionExpanded: true, + repositoriesSectionExpanded: true, + }, + }); + + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('button', { name, exact: true })).toBeVisible({ timeout: 30_000 }); +} + +async function renameCalls(page: Page) { + // SAFETY: installElectronApiMock defines this test-only bridge before the page loads. + return page.evaluate(() => ( + window as typeof window & { + __paneTestElectronMock: { getSessionRenameCalls: () => Array<{ sessionId: string; name: string }> }; + } + ).__paneTestElectronMock.getSessionRenameCalls()); +} + +test.describe('sidebar pane rename', () => { + test('double-click renames the pane and Enter commits the new name', async ({ page }) => { + await openSidebarWithPane(page, 'Old pane name'); + + await page.getByRole('button', { name: 'Old pane name', exact: true }).dblclick(); + + const input = page.getByTestId('session-rename-input-pane-rename'); + await expect(input).toBeFocused(); + await expect(input).toHaveValue('Old pane name'); + + await input.fill('New pane name'); + await input.press('Enter'); + + await expect(input).toHaveCount(0); + expect(await renameCalls(page)).toEqual([{ sessionId: 'pane-rename', name: 'New pane name' }]); + await expect(page.getByRole('button', { name: 'New pane name', exact: true })).toBeVisible(); + }); + + test('Escape cancels the rename without calling the backend', async ({ page }) => { + await openSidebarWithPane(page, 'Old pane name'); + + await page.getByRole('button', { name: 'Old pane name', exact: true }).dblclick(); + + const input = page.getByTestId('session-rename-input-pane-rename'); + await input.fill('Discarded name'); + await input.press('Escape'); + + await expect(input).toHaveCount(0); + expect(await renameCalls(page)).toEqual([]); + await expect(page.getByRole('button', { name: 'Old pane name', exact: true })).toBeVisible(); + }); + + // The main repo session is held twice in the session store (in the sessions + // list and as activeMainRepoSession), so a rename has to reach both copies. + test('renaming the active main repo pane updates the sidebar label', async ({ page }) => { + await openSidebarWithPane(page, 'Old pane name', { isMainRepo: true }); + + const row = page.getByRole('button', { name: 'Old pane name', exact: true }); + await row.click(); + await row.dblclick(); + + const input = page.getByTestId('session-rename-input-pane-rename'); + await input.fill('New pane name'); + await input.press('Enter'); + + await expect(page.getByRole('button', { name: 'New pane name', exact: true })).toBeVisible(); + }); + + test('an empty name is discarded instead of clearing the pane title', async ({ page }) => { + await openSidebarWithPane(page, 'Old pane name'); + + await page.getByRole('button', { name: 'Old pane name', exact: true }).dblclick(); + + const input = page.getByTestId('session-rename-input-pane-rename'); + await input.fill(' '); + await input.press('Enter'); + + await expect(input).toHaveCount(0); + expect(await renameCalls(page)).toEqual([]); + await expect(page.getByRole('button', { name: 'Old pane name', exact: true })).toBeVisible(); + }); +});