Skip to content
Merged
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
288 changes: 281 additions & 7 deletions contracts/runpane/contract.json

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions contracts/runpane/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,8 @@
"panes list",
"panes create",
"panes archive",
"panes pin",
"panes unpin",
"panels",
"panels create",
"panels list",
Expand Down Expand Up @@ -401,6 +403,12 @@
"panes archive": {
"$ref": "#/$defs/helpLines"
},
"panes pin": {
"$ref": "#/$defs/helpLines"
},
"panes unpin": {
"$ref": "#/$defs/helpLines"
},
"panels": {
"$ref": "#/$defs/helpLines"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/RUNPANE_CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ Brief tools:
- `panes list`: List Pane sessions, optionally scoped to a saved repository.
- `panes create`: Create user-visible Panes (Pane sessions) backed by Pane-managed worktrees for feature/PR work and open terminal-backed tool tabs.
- `panes archive`: Archive a Pane exactly like the UI Archive action, including safe removal of its Pane-managed git worktree.
- `panes pin`: Declaratively pin a Pane (the Pane UI's favorite/pin star) without changing focus.
- `panes unpin`: Declaratively unpin a Pane (the Pane UI's favorite/pin star) without changing focus.
- `panels create`: Create reviewer/helper terminal tabs inside an existing Pane; they share that Pane's worktree.
- `panels list`: List tool panels inside a Pane session.
- `panels output`: Read recent terminal output from a panel.
Expand Down Expand Up @@ -298,6 +300,7 @@ These flags are consumed by local daemon-control commands:
--wait-ready
--no-focus
--focus
--pinned
--force
```

Expand Down
59 changes: 59 additions & 0 deletions main/src/database/database.favorite-pinning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { DatabaseService } from './database';

const tempDirs: string[] = [];

afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

describe('session favorite pinning persistence', () => {
it('preserves declarative pin state and timestamp across database reopens', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pane-favorite-pinning-'));
tempDirs.push(tempDir);
const databasePath = path.join(tempDir, 'sessions.db');

const first = new DatabaseService(databasePath);
first.initialize();
const project = first.createProject('Repo', path.join(tempDir, 'repo'));
first.createSession({
id: 'pinned-session',
name: 'Pinned Session',
initial_prompt: '',
worktree_name: 'pinned-session',
worktree_path: path.join(tempDir, 'repo-pinned-session'),
project_id: project.id,
tool_type: 'none',
});

const pinned = first.setSessionFavorite('pinned-session', true);
const pinnedAgain = first.setSessionFavorite('pinned-session', true);
expect(Boolean(pinned?.is_favorite)).toBe(true);
expect(pinned?.favorite_pinned_at).not.toBeNull();
expect(pinnedAgain?.favorite_pinned_at).toBe(pinned?.favorite_pinned_at);
first.close();

const second = new DatabaseService(databasePath);
second.initialize();
expect(Boolean(second.getSession('pinned-session')?.is_favorite)).toBe(true);
expect(second.getSession('pinned-session')?.favorite_pinned_at).toBe(pinned?.favorite_pinned_at);

const unpinned = second.setSessionFavorite('pinned-session', false);
const unpinnedAgain = second.setSessionFavorite('pinned-session', false);
expect(Boolean(unpinned?.is_favorite)).toBe(false);
expect(unpinned?.favorite_pinned_at).toBeNull();
expect(unpinnedAgain?.favorite_pinned_at).toBeNull();
second.close();

const third = new DatabaseService(databasePath);
third.initialize();
expect(Boolean(third.getSession('pinned-session')?.is_favorite)).toBe(false);
expect(third.getSession('pinned-session')?.favorite_pinned_at).toBeNull();
third.close();
});
});
14 changes: 14 additions & 0 deletions main/src/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3129,6 +3129,20 @@ export class DatabaseService {
return this.getSession(id);
}

setSessionFavorite(id: string, pinned: boolean): Session | undefined {
this.db.prepare(`
UPDATE sessions SET
is_favorite = @pinned,
favorite_pinned_at = CASE WHEN @pinned
THEN COALESCE(favorite_pinned_at, CURRENT_TIMESTAMP)
ELSE NULL END,
updated_at = CURRENT_TIMESTAMP
WHERE id = @id
`).run({ id, pinned: pinned ? 1 : 0 });

return this.getSession(id);
}

markSessionAsViewed(id: string): Session | undefined {
this.db
.prepare(
Expand Down
163 changes: 163 additions & 0 deletions main/src/ipc/runpane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ function registerSessionsDeleteStub(

describe('runpane IPC handlers', () => {
beforeEach(() => {
session.isFavorite = undefined;
session.favoritePinnedAt = undefined;
vi.mocked(panelManager.createPanel).mockReset();
vi.mocked(panelManager.getPanel).mockReset();
vi.mocked(panelManager.getPanelsForSession).mockReset();
Expand Down Expand Up @@ -436,6 +438,7 @@ describe('runpane IPC handlers', () => {
repoId: project.id,
repoName: project.name,
panelCount: 1,
pinned: false,
createdAt: '2026-01-01T00:00:00.000Z',
}],
});
Expand Down Expand Up @@ -1152,6 +1155,7 @@ describe('runpane IPC handlers', () => {
projectId: project.id,
baseBranch: 'main',
toolType: 'none',
startPinned: undefined,
activateOnCreate: false,
}, { timeoutMs: 1234 });
expect(panelManager.createPanel).toHaveBeenCalledWith({
Expand Down Expand Up @@ -1187,6 +1191,165 @@ describe('runpane IPC handlers', () => {
});
});

it('creates a pinned pane without focusing it', async () => {
vi.mocked(panelManager.createPanel).mockResolvedValue({
...terminalPanel,
state: { ...terminalPanel.state, isActive: false },
});
const databaseRow = {
id: session.id,
is_favorite: 0,
favorite_pinned_at: null as string | null,
};
const services = createServices({
databaseService: {
...createServices().databaseService,
getSession: vi.fn(() => databaseRow),
} as never,
taskQueue: {
createSessionAndWait: vi.fn(async (request: { startPinned?: boolean }) => {
databaseRow.is_favorite = request.startPinned ? 1 : 0;
databaseRow.favorite_pinned_at = request.startPinned ? '2026-07-21 12:00:00' : null;
session.isFavorite = request.startPinned;
session.favoritePinnedAt = databaseRow.favorite_pinned_at ?? undefined;
return { sessionId: session.id };
}),
} as never,
});
const registry = createRegistry(services);

const result = await registry.invoke('runpane:panes:create', [{
repo: 'active',
noFocus: true,
panes: [{
name: 'pinned-background-pane',
pinned: true,
tool: { agent: 'codex' },
}],
}]);

expect(services.taskQueue?.createSessionAndWait).toHaveBeenCalledWith(
expect.objectContaining({ startPinned: true, activateOnCreate: false }),
expect.any(Object),
);
expect(result).toMatchObject({
ok: true,
items: [{ pinned: true, focused: false }],
});
expect(databaseRow.is_favorite).toBe(1);
expect(databaseRow.favorite_pinned_at).not.toBeNull();
});

it('sets pinned state declaratively and idempotently', async () => {
const databaseRow = {
id: session.id,
is_favorite: 0,
favorite_pinned_at: null as string | null,
};
const setSessionFavorite = vi.fn((_id: string, pinned: boolean) => {
databaseRow.is_favorite = pinned ? 1 : 0;
databaseRow.favorite_pinned_at = pinned
? databaseRow.favorite_pinned_at ?? '2026-07-21 12:00:00'
: null;
return databaseRow;
});
const emit = vi.fn();
const services = createServices({
databaseService: {
...createServices().databaseService,
setSessionFavorite,
} as never,
sessionManager: {
...createServices().sessionManager,
emit,
} as never,
});
const registry = createRegistry(services);

const firstPin = await registry.invoke('runpane:panes:pin', [{ paneId: session.id, pinned: true }]);
const pinTimestamp = databaseRow.favorite_pinned_at;
const secondPin = await registry.invoke('runpane:panes:pin', [{ paneId: session.id, pinned: true }]);
const listedPinned = await registry.invoke('runpane:panes:list', [{ repo: 'active' }]);

expect(firstPin).toEqual({
ok: true,
paneId: session.id,
pinned: true,
favoritePinnedAt: pinTimestamp,
});
expect(secondPin).toEqual(firstPin);
expect(databaseRow.favorite_pinned_at).toBe(pinTimestamp);
expect(listedPinned).toMatchObject({ panes: [{ paneId: session.id, pinned: true }] });

const firstUnpin = await registry.invoke('runpane:panes:pin', [{ paneId: session.id, pinned: false }]);
const secondUnpin = await registry.invoke('runpane:panes:pin', [{ paneId: session.id, pinned: false }]);

expect(firstUnpin).toEqual({ ok: true, paneId: session.id, pinned: false, favoritePinnedAt: undefined });
expect(secondUnpin).toEqual(firstUnpin);
expect(databaseRow).toMatchObject({ is_favorite: 0, favorite_pinned_at: null });
expect(setSessionFavorite).toHaveBeenCalledTimes(4);
expect(emit).toHaveBeenCalledTimes(4);
expect(emit).toHaveBeenLastCalledWith('session-updated', expect.objectContaining({
id: session.id,
isFavorite: false,
favoritePinnedAt: undefined,
}));
});

it('dry-runs pinned state changes without mutating or emitting', async () => {
session.isFavorite = false;
session.favoritePinnedAt = undefined;
const databaseRow = {
id: session.id,
is_favorite: 0,
favorite_pinned_at: null as string | null,
};
const setSessionFavorite = vi.fn((_id: string, pinned: boolean) => {
databaseRow.is_favorite = pinned ? 1 : 0;
databaseRow.favorite_pinned_at = pinned ? '2026-07-21 12:00:00' : null;
return databaseRow;
});
const emit = vi.fn();
const services = createServices({
databaseService: {
...createServices().databaseService,
setSessionFavorite,
} as never,
sessionManager: {
...createServices().sessionManager,
emit,
} as never,
});
const registry = createRegistry(services);

const result = await registry.invoke('runpane:panes:pin', [{
paneId: session.id,
pinned: true,
dryRun: true,
}]);

expect(result).toEqual({
ok: true,
dryRun: true,
paneId: session.id,
pinned: true,
favoritePinnedAt: undefined,
});
expect(databaseRow).toMatchObject({ is_favorite: 0, favorite_pinned_at: null });
expect(session.isFavorite).toBe(false);
expect(setSessionFavorite).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});

it('rejects missing and non-boolean pinned state', async () => {
const registry = createRegistry();

await expect(registry.invoke('runpane:panes:pin', [{ paneId: session.id }]))
.rejects.toThrow('pinned as a boolean');
await expect(registry.invoke('runpane:panes:pin', [{ paneId: session.id, pinned: 'yes' }]))
.rejects.toThrow('pinned as a boolean');
});

it('serializes multi-pane session creation before enqueueing the next pane', async () => {
vi.mocked(panelManager.createPanel).mockResolvedValue({
id: 'panel-1',
Expand Down
Loading
Loading