diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a8b091..9dcc26f6 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 635ed0f8..c7953981 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 be0e6e80..3364aab2 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 e96ff1e7..7ab18d98 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 245dbe7d..257352ed 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 e81d4a9f..07987df0 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 00000000..9b4b422d --- /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(); + }); +});