Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down
30 changes: 30 additions & 0 deletions docs/STATE_MANAGEMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
192 changes: 142 additions & 50 deletions frontend/src/components/ProjectSessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,13 @@ function SessionRow({
}: SessionRowProps) {
const [localGitStatus, setLocalGitStatus] = useState<GitStatus | undefined>(session.gitStatus);
const initialGitStatusRequestRef = useRef<string | null>(null);
// `null` means "not renaming"; any string (including '') is the in-progress draft.
const [renameDraft, setRenameDraft] = useState<string | null>(null);
const isRenaming = renameDraft !== null;
const renameInputRef = useRef<HTMLInputElement | null>(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);
Expand Down Expand Up @@ -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<HTMLInputElement>) => {
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 (
<div
className={cn(
Expand All @@ -769,59 +841,79 @@ function SessionRow({
>
{/* Always-present left accent bar reflecting the agent status. */}
<StatusAccentBar status={agentDisplayStatus} />
<Tooltip
content={<SessionDetailTooltip session={session} gitStatus={localGitStatus} showName showDiffStats={false} globalIndex={globalIndex} />}
side="right"
interactive
>
<button
type="button"
onClick={onClick}
aria-current={isActive ? 'page' : undefined}
aria-label={accessibleName}
className="absolute inset-0 z-0 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-interactive"
/>
</Tooltip>
<div className="pointer-events-none contents">
<SessionRowContent
session={session}
gs={gs}
iconColor={iconColor}
hasDiff={hasDiff}
adds={adds}
dels={dels}
displayName={accessibleName}
showActivity={showActivity}
showUnviewedCompleted={hasUnviewedCompletedActivity && !isActive && !showActivity}
rowLayout={rowLayout}
{isRenaming ? (
<input
ref={focusRenameInput}
type="text"
data-testid={`session-rename-input-${session.id}`}
aria-label={`Rename pane ${accessibleName}`}
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleRenameBlur}
className={cn(
'relative z-10 min-w-0 flex-1 rounded border border-border-primary bg-bg-primary px-1 text-sm font-medium text-text-primary outline-none',
'focus:border-border-focus focus:ring-1 focus:ring-border-focus',
)}
/>

<div className="relative z-10 pointer-events-auto flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onArchive(); }}
className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-text-muted hover:text-status-error hover:bg-surface-hover transition-all opacity-0 group-hover/session:opacity-100"
title="Archive"
aria-label={`Archive ${accessibleName}`}
) : (
<>
<Tooltip
content={<SessionDetailTooltip session={session} gitStatus={localGitStatus} showName showDiffStats={false} globalIndex={globalIndex} />}
side="right"
interactive
>
<Archive className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={onClick}
onDoubleClick={startRename}
aria-current={isActive ? 'page' : undefined}
aria-label={accessibleName}
className="absolute inset-0 z-0 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-interactive"
/>
</Tooltip>
<div className="pointer-events-none contents">
<SessionRowContent
session={session}
gs={gs}
iconColor={iconColor}
hasDiff={hasDiff}
adds={adds}
dels={dels}
displayName={accessibleName}
showActivity={showActivity}
showUnviewedCompleted={hasUnviewedCompletedActivity && !isActive && !showActivity}
rowLayout={rowLayout}
/>

<div className="relative z-10 pointer-events-auto flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onArchive(); }}
className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-text-muted hover:text-status-error hover:bg-surface-hover transition-all opacity-0 group-hover/session:opacity-100"
title="Archive"
aria-label={`Archive ${accessibleName}`}
>
<Archive className="w-3.5 h-3.5" />
</button>

<button
type="button"
onClick={(e) => { e.stopPropagation(); onTogglePinned(); }}
className={`inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded transition-all ${
session.isFavorite
? 'text-text-muted hover:text-text-tertiary hover:bg-surface-hover opacity-100'
: 'text-text-muted hover:text-text-tertiary hover:bg-surface-hover opacity-0 group-hover/session:opacity-100'
}`}
title={session.isFavorite ? 'Unpin' : 'Pin'}
aria-label={`${session.isFavorite ? 'Unpin' : 'Pin'} ${accessibleName}`}
>
<Pin className="w-3.5 h-3.5 rotate-45" />
</button>
</div>
</div>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onTogglePinned(); }}
className={`inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded transition-all ${
session.isFavorite
? 'text-text-muted hover:text-text-tertiary hover:bg-surface-hover opacity-100'
: 'text-text-muted hover:text-text-tertiary hover:bg-surface-hover opacity-0 group-hover/session:opacity-100'
}`}
title={session.isFavorite ? 'Unpin' : 'Pin'}
aria-label={`${session.isFavorite ? 'Unpin' : 'Pin'} ${accessibleName}`}
>
<Pin className="w-3.5 h-3.5 rotate-45" />
</button>
</div>
</div>
</>
)}
</div>
);
}
Expand Down
34 changes: 17 additions & 17 deletions frontend/src/stores/sessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,21 +110,20 @@ export const useSessionStore = create<SessionStore>((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++) {
Expand All @@ -140,10 +139,11 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
break;
}
}

return {
...state,
sessions: newSessions
sessions: newSessions,
activeMainRepoSession: newActiveMainRepoSession
};
}),

Expand Down
12 changes: 12 additions & 0 deletions tests/electronApiMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Array<{ channel: string; args: unknown[] }>>();
let sessionsGetCount = 0;

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -1141,6 +1150,9 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc
getSessionFavoriteToggleCalls() {
return clone(sessionFavoriteToggleCalls);
},
getSessionRenameCalls() {
return clone(sessionRenameCalls);
},
getDiffManifestCalls() {
return clone(diffManifestCalls);
},
Expand Down
Loading