Skip to content

Commit a9b6f7c

Browse files
feat: add session pinning for pinned/recent session groups (#301)
* feat: add session pinning for pinned/recent session groups * refactor: extract buildSessionKey, deduplicate SessionCard rendering, clean up pins on delete
1 parent 217f5bd commit a9b6f7c

21 files changed

Lines changed: 762 additions & 58 deletions

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"build": "bun build src/index.ts --outdir=dist --target=bun",
1010
"typecheck": "tsc --noEmit",
1111
"test": "pnpm run test:bun && pnpm run test:vitest",
12-
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts test/routes/internal-assistant.test.ts src/db/model-state.test.ts src/routes/providers.test.ts src/routes/repos.test.ts",
12+
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts test/routes/internal-assistant.test.ts src/db/model-state.test.ts src/routes/providers.test.ts src/routes/repos.test.ts src/routes/session-pins.test.ts",
1313
"test:vitest": "vitest run",
1414
"test:ui": "vitest --ui",
1515
"test:watch": "vitest --watch",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Migration } from '../migration-runner'
2+
import { ensureSessionPinsTable } from '../session-pins'
3+
4+
const migration: Migration = {
5+
version: 18,
6+
name: 'session-pins',
7+
up(db) {
8+
ensureSessionPinsTable(db)
9+
},
10+
down(db) {
11+
db.run('DROP TABLE IF EXISTS session_pins')
12+
},
13+
}
14+
15+
export default migration

backend/src/db/migrations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import migration014 from './014-repos-add-name'
1616
import migration015 from './015-schedule-worktree-isolation'
1717
import migration016 from './016-schedule-permission-config'
1818
import migration017 from './017-schedule-run-workspace-id'
19+
import migration018 from './018-session-pins'
1920

2021
export const allMigrations: Migration[] = [
2122
migration001,
@@ -35,4 +36,5 @@ export const allMigrations: Migration[] = [
3536
migration015,
3637
migration016,
3738
migration017,
39+
migration018,
3840
]
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { Database } from 'bun:sqlite'
3+
import { migrate } from './migration-runner'
4+
import { allMigrations } from './migrations'
5+
import { listSessionPins, setSessionPin, ensureSessionPinsTable } from './session-pins'
6+
7+
function createTestDb(): Database {
8+
const db = new Database(':memory:')
9+
migrate(db, allMigrations)
10+
return db
11+
}
12+
13+
describe('session-pins', () => {
14+
let db: Database
15+
16+
beforeEach(() => {
17+
db = createTestDb()
18+
})
19+
20+
describe('listSessionPins', () => {
21+
it('returns [] on a fresh db', () => {
22+
expect(listSessionPins(db)).toEqual([])
23+
})
24+
})
25+
26+
describe('setSessionPin', () => {
27+
it('pinning returns array with the pin', () => {
28+
const pins = setSessionPin(db, 'ses_1', '/w/a', true)
29+
expect(pins).toHaveLength(1)
30+
expect(pins[0]!.sessionId).toBe('ses_1')
31+
expect(pins[0]!.directory).toBe('/w/a')
32+
expect(pins[0]!.pinnedAt).toEqual(expect.any(Number))
33+
})
34+
35+
it('pinning the same (sessionId, directory) twice does not create a duplicate', async () => {
36+
setSessionPin(db, 'ses_1', '/w/a', true)
37+
const first = listSessionPins(db)
38+
const firstPinnedAt = first[0]!.pinnedAt
39+
40+
// Delay to guarantee a different pinnedAt timestamp
41+
await new Promise(r => setTimeout(r, 5))
42+
const second = setSessionPin(db, 'ses_1', '/w/a', true)
43+
expect(second).toHaveLength(1)
44+
expect(second[0]!.pinnedAt).toBeGreaterThan(firstPinnedAt)
45+
})
46+
47+
it('same sessionId with different directory produces two distinct pins', () => {
48+
setSessionPin(db, 'ses_1', '/w/a', true)
49+
const pins = setSessionPin(db, 'ses_1', '/w/b', true)
50+
expect(pins).toHaveLength(2)
51+
expect(pins.map(p => p.directory).sort()).toEqual(['/w/a', '/w/b'])
52+
})
53+
54+
it('unpinning removes only that pin', () => {
55+
setSessionPin(db, 'ses_1', '/w/a', true)
56+
setSessionPin(db, 'ses_1', '/w/b', true)
57+
58+
const afterUnpin = setSessionPin(db, 'ses_1', '/w/a', false)
59+
expect(afterUnpin).toHaveLength(1)
60+
expect(afterUnpin[0]!.directory).toBe('/w/b')
61+
})
62+
})
63+
64+
describe('ensureSessionPinsTable', () => {
65+
it('recreates the table if dropped', () => {
66+
db.run('DROP TABLE session_pins')
67+
68+
ensureSessionPinsTable(db)
69+
const table = db
70+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_pins'")
71+
.get() as { name: string } | undefined
72+
73+
expect(table).toBeTruthy()
74+
expect(table!.name).toBe('session_pins')
75+
})
76+
})
77+
})

backend/src/db/session-pins.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Database } from 'bun:sqlite'
2+
3+
export interface SessionPinRecord {
4+
sessionId: string
5+
directory: string
6+
pinnedAt: number
7+
}
8+
9+
export function ensureSessionPinsTable(db: Database): void {
10+
db.run(`
11+
CREATE TABLE IF NOT EXISTS session_pins (
12+
session_id TEXT NOT NULL,
13+
directory TEXT NOT NULL,
14+
pinned_at INTEGER NOT NULL,
15+
PRIMARY KEY (session_id, directory)
16+
)
17+
`)
18+
}
19+
20+
export function listSessionPins(db: Database): SessionPinRecord[] {
21+
const rows = db
22+
.prepare('SELECT session_id, directory, pinned_at FROM session_pins ORDER BY pinned_at DESC')
23+
.all() as { session_id: string; directory: string; pinned_at: number }[]
24+
return rows.map(r => ({ sessionId: r.session_id, directory: r.directory, pinnedAt: r.pinned_at }))
25+
}
26+
27+
export function setSessionPin(
28+
db: Database,
29+
sessionId: string,
30+
directory: string,
31+
pinned: boolean,
32+
): SessionPinRecord[] {
33+
const run = db.transaction(() => {
34+
if (pinned) {
35+
db.prepare(`
36+
INSERT INTO session_pins(session_id, directory, pinned_at)
37+
VALUES(?,?,?)
38+
ON CONFLICT(session_id, directory) DO UPDATE SET pinned_at=excluded.pinned_at
39+
`).run(sessionId, directory, Date.now())
40+
} else {
41+
db.prepare('DELETE FROM session_pins WHERE session_id = ? AND directory = ?').run(sessionId, directory)
42+
}
43+
return listSessionPins(db)
44+
})
45+
return run()
46+
}

backend/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { createAuthRoutes, createAuthInfoRoutes, syncAdminFromEnv } from './rout
3434
import { createAuth } from './auth'
3535
import { createAuthMiddleware } from './auth/middleware'
3636
import { createPromptTemplateRoutes } from './routes/prompt-templates'
37+
import { createSessionPinRoutes } from './routes/session-pins'
3738
import { createInternalRoutes } from './routes/internal'
3839
import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers'
3940
import { createOpenCodeProxyRoutes } from './routes/opencode-proxy'
@@ -355,6 +356,7 @@ protectedApi.route('/sse', createSSERoutes())
355356
protectedApi.route('/ssh', createSSHRoutes(gitAuthService))
356357
protectedApi.route('/notifications', createNotificationRoutes(notificationService))
357358
protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db))
359+
protectedApi.route('/session-pins', createSessionPinRoutes(db))
358360
protectedApi.route('/schedules', createScheduleRoutes(scheduleService))
359361

360362
app.route('/api', protectedApi)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2+
import { Hono } from 'hono'
3+
import { Database } from 'bun:sqlite'
4+
import { migrate } from '../db/migration-runner'
5+
import { allMigrations } from '../db/migrations'
6+
import { createSessionPinRoutes } from './session-pins'
7+
8+
function createTestApp(db: Database): Hono {
9+
const app = new Hono()
10+
app.route('/session-pins', createSessionPinRoutes(db))
11+
return app
12+
}
13+
14+
function createTestDb(): Database {
15+
const db = new Database(':memory:')
16+
migrate(db, allMigrations)
17+
return db
18+
}
19+
20+
describe('session pins routes', () => {
21+
let db: Database
22+
let app: Hono
23+
24+
beforeEach(() => {
25+
db = createTestDb()
26+
app = createTestApp(db)
27+
})
28+
29+
afterEach(() => {
30+
db.close()
31+
})
32+
33+
it('GET / returns empty pins list initially', async () => {
34+
const res = await app.request('/session-pins')
35+
expect(res.status).toBe(200)
36+
const data = await res.json() as { pins: unknown[] }
37+
expect(data.pins).toEqual([])
38+
})
39+
40+
it('PUT / creates a pin and returns updated list', async () => {
41+
const res = await app.request('/session-pins', {
42+
method: 'PUT',
43+
headers: { 'Content-Type': 'application/json' },
44+
body: JSON.stringify({ sessionId: 'ses_1', directory: '/w/a', pinned: true }),
45+
})
46+
expect(res.status).toBe(200)
47+
const data = await res.json() as { pins: Array<{ sessionId: string; directory: string }> }
48+
expect(data.pins).toHaveLength(1)
49+
expect(data.pins[0]).toMatchObject({ sessionId: 'ses_1', directory: '/w/a' })
50+
})
51+
52+
it('GET / returns pins after creation', async () => {
53+
await app.request('/session-pins', {
54+
method: 'PUT',
55+
headers: { 'Content-Type': 'application/json' },
56+
body: JSON.stringify({ sessionId: 'ses_1', directory: '/w/a', pinned: true }),
57+
})
58+
59+
const res = await app.request('/session-pins')
60+
expect(res.status).toBe(200)
61+
const data = await res.json() as { pins: unknown[] }
62+
expect(data.pins).toHaveLength(1)
63+
})
64+
65+
it('PUT / with pinned: false removes the pin', async () => {
66+
await app.request('/session-pins', {
67+
method: 'PUT',
68+
headers: { 'Content-Type': 'application/json' },
69+
body: JSON.stringify({ sessionId: 'ses_1', directory: '/w/a', pinned: true }),
70+
})
71+
72+
const res = await app.request('/session-pins', {
73+
method: 'PUT',
74+
headers: { 'Content-Type': 'application/json' },
75+
body: JSON.stringify({ sessionId: 'ses_1', directory: '/w/a', pinned: false }),
76+
})
77+
expect(res.status).toBe(200)
78+
const data = await res.json() as { pins: unknown[] }
79+
expect(data.pins).toHaveLength(0)
80+
})
81+
82+
it('PUT / with invalid body returns 400', async () => {
83+
const res = await app.request('/session-pins', {
84+
method: 'PUT',
85+
headers: { 'Content-Type': 'application/json' },
86+
body: JSON.stringify({ sessionId: '', directory: '/w/a', pinned: true }),
87+
})
88+
expect(res.status).toBe(400)
89+
const data = await res.json() as { error: string }
90+
expect(data.error).toBe('Invalid request')
91+
})
92+
})

backend/src/routes/session-pins.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Hono } from 'hono'
2+
import type { Database } from 'bun:sqlite'
3+
import { z } from 'zod'
4+
import { ToggleSessionPinRequestSchema } from '@opencode-manager/shared/schemas'
5+
import { listSessionPins, setSessionPin } from '../db/session-pins'
6+
import { getErrorMessage } from '../utils/error-utils'
7+
import { logger } from '../utils/logger'
8+
9+
export function createSessionPinRoutes(database: Database) {
10+
const app = new Hono()
11+
12+
app.get('/', (c) => {
13+
try {
14+
return c.json({ pins: listSessionPins(database) })
15+
} catch (error) {
16+
logger.error('Failed to list session pins', error)
17+
return c.json({ error: getErrorMessage(error) }, 500)
18+
}
19+
})
20+
21+
app.put('/', async (c) => {
22+
try {
23+
const body = await c.req.json()
24+
const input = ToggleSessionPinRequestSchema.parse(body)
25+
const pins = setSessionPin(database, input.sessionId, input.directory, input.pinned)
26+
return c.json({ pins })
27+
} catch (error) {
28+
if (error instanceof z.ZodError) {
29+
return c.json({ error: 'Invalid request' }, 400)
30+
}
31+
logger.error('Failed to toggle session pin', error)
32+
return c.json({ error: getErrorMessage(error) }, 500)
33+
}
34+
})
35+
36+
return app
37+
}

backend/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default defineConfig({
2020
'src/db/model-state.test.ts',
2121
'src/routes/providers.test.ts',
2222
'src/routes/repos.test.ts',
23+
'src/routes/session-pins.test.ts',
2324
],
2425
coverage: {
2526
provider: 'v8',
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { listSessionPins, toggleSessionPin } from './sessionPins'
3+
4+
describe('sessionPins', () => {
5+
const fetchMock = vi.fn()
6+
7+
beforeEach(() => {
8+
fetchMock.mockReset()
9+
vi.stubGlobal('fetch', fetchMock)
10+
})
11+
12+
afterEach(() => {
13+
vi.unstubAllGlobals()
14+
})
15+
16+
describe('listSessionPins', () => {
17+
it('calls GET /api/session-pins and returns pins array', async () => {
18+
const expectedPins = [
19+
{ sessionId: 's1', directory: '/a', pinnedAt: 100 },
20+
{ sessionId: 's2', directory: '/b', pinnedAt: 200 },
21+
]
22+
fetchMock.mockResolvedValue(
23+
new Response(JSON.stringify({ pins: expectedPins }), { status: 200 }),
24+
)
25+
26+
const result = await listSessionPins()
27+
28+
expect(result).toEqual(expectedPins)
29+
30+
const callUrl = fetchMock.mock.calls[0][0]
31+
expect(callUrl).toEqual(expect.stringContaining('/api/session-pins'))
32+
33+
const callOptions = fetchMock.mock.calls[0][1]
34+
expect(callOptions.method).toBeUndefined()
35+
})
36+
})
37+
38+
describe('toggleSessionPin', () => {
39+
it('calls PUT /api/session-pins with JSON body and returns pins', async () => {
40+
const input = { sessionId: 's1', directory: '/a', pinned: true }
41+
const expectedPins = [
42+
{ sessionId: 's1', directory: '/a', pinnedAt: 100 },
43+
]
44+
fetchMock.mockResolvedValue(
45+
new Response(JSON.stringify({ pins: expectedPins }), { status: 200 }),
46+
)
47+
48+
const result = await toggleSessionPin(input)
49+
50+
expect(result).toEqual(expectedPins)
51+
52+
const callUrl = fetchMock.mock.calls[0][0]
53+
expect(callUrl).toEqual(expect.stringContaining('/api/session-pins'))
54+
55+
const callOptions = fetchMock.mock.calls[0][1]
56+
expect(callOptions.method).toBe('PUT')
57+
expect(callOptions.headers['Content-Type']).toBe('application/json')
58+
59+
const body = JSON.parse(callOptions.body)
60+
expect(body).toEqual(input)
61+
})
62+
})
63+
})

0 commit comments

Comments
 (0)