diff --git a/client/js/api.js b/client/js/api.js
index 0c660b2..c523bbc 100644
--- a/client/js/api.js
+++ b/client/js/api.js
@@ -14,10 +14,11 @@
}
export const api = {
- getProfiles: () => request('GET', '/agents/profiles'),
- getSessions: () => request('GET', '/sessions'),
- getSession: (id) => request('GET', `/sessions/${id}`),
- createSession: (profileId) => request('POST', '/sessions', { profile_id: profileId }),
- deleteSession: (id) => request('DELETE', `/sessions/${id}`),
- sendMessage: (id, text) => request('POST', `/sessions/${id}/messages`, { content: text }),
+ getProfiles: () => request('GET', '/agents/profiles'),
+ getSessions: () => request('GET', '/sessions'),
+ getSession: (id) => request('GET', `/sessions/${id}`),
+ createSession: (profileId) => request('POST', '/sessions', { profile_id: profileId }),
+ deleteSession: (id) => request('DELETE', `/sessions/${id}`),
+ pinSession: (id, pinned) => request('PATCH', `/sessions/${id}/pin`, { pinned }),
+ sendMessage: (id, text) => request('POST', `/sessions/${id}/messages`, { content: text }),
};
diff --git a/client/js/app.js b/client/js/app.js
index 09fe036..33e0ec7 100644
--- a/client/js/app.js
+++ b/client/js/app.js
@@ -118,6 +118,15 @@
rerenderSidebar();
}
+async function pinSession(sessionId, pinned) {
+ await api.pinSession(sessionId, pinned).catch(console.error);
+ const s = sessions.find(s => s.session_id === sessionId);
+ if (s) s.pinned = pinned;
+ // Re-sort: pinned first
+ sessions.sort((a, b) => (b.pinned - a.pinned) || (b.last_active > a.last_active ? 1 : -1));
+ rerenderSidebar();
+}
+
// ── WebSocket ─────────────────────────────────────────────────────────────────
function connectWs(sessionId) {
@@ -202,6 +211,7 @@
renderSessions(sessionListEl, sessions, currentId, {
onSelect: (id) => { if (id !== currentId) openSession(id); },
onDelete: deleteSession,
+ onPin: pinSession,
});
}
diff --git a/client/js/sidebar.js b/client/js/sidebar.js
index 23f2f9c..abff932 100644
--- a/client/js/sidebar.js
+++ b/client/js/sidebar.js
@@ -23,31 +23,43 @@
.join('');
}
-export function renderSessions(listEl, sessions, currentId, { onSelect, onDelete }) {
+export function renderSessions(listEl, sessions, currentId, { onSelect, onDelete, onPin }) {
if (!sessions.length) {
listEl.innerHTML = '
+
-
${name}
+
${s.pinned ? '📌 ' : ''}${name}
${preview}
${time}
-
+
+
+
+
`;
}).join('');
listEl.querySelectorAll('.session-item').forEach(el =>
el.addEventListener('click', () => onSelect(el.dataset.id))
);
+ listEl.querySelectorAll('.btn-pin').forEach(btn =>
+ btn.addEventListener('click', e => {
+ e.stopPropagation();
+ onPin(btn.dataset.id, btn.dataset.pinned !== 'true');
+ })
+ );
listEl.querySelectorAll('.btn-delete').forEach(btn =>
btn.addEventListener('click', e => { e.stopPropagation(); onDelete(btn.dataset.id); })
);
diff --git a/client/style.css b/client/style.css
index 4c38303..4fd0434 100644
--- a/client/style.css
+++ b/client/style.css
@@ -109,26 +109,36 @@
.session-item .s-body { flex: 1; min-width: 0; }
.session-item { display: flex; align-items: center; gap: 6px; }
+.session-item.pinned { border-left: 2px solid var(--accent); padding-left: 10px; }
-.btn-delete {
+.s-actions {
+ display: flex;
+ gap: 2px;
flex-shrink: 0;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+.session-item:hover .s-actions { opacity: 1; }
+
+.btn-pin, .btn-delete {
width: 22px;
height: 22px;
background: none;
border: none;
border-radius: 5px;
- color: var(--text-muted);
- font-size: 15px;
+ font-size: 13px;
line-height: 1;
cursor: pointer;
- opacity: 0;
- transition: opacity 0.15s, background 0.15s, color 0.15s;
display: flex;
align-items: center;
justify-content: center;
+ transition: background 0.15s;
+ color: var(--text-muted);
}
-.session-item:hover .btn-delete { opacity: 1; }
+.btn-pin:hover { background: #1a2540; }
.btn-delete:hover { background: #3d1a1a; color: var(--error-text); }
+/* always show pin icon for pinned sessions */
+.session-item.pinned .s-actions { opacity: 1; }
.empty-sessions { padding: 20px 12px; color: var(--text-muted); font-size: 13px; text-align: center; }
@@ -208,8 +218,8 @@
.prose code { font-family: "Fira Code", "Cascadia Code", ui-monospace, monospace; font-size: 0.85em;
background: #2a2a2a; color: #e2b97e; padding: 1px 5px; border-radius: 4px; }
.prose pre { margin: 0.6em 0; border-radius: 8px; overflow: hidden; }
-.prose pre code { background: none; color: inherit; padding: 0; border-radius: 0; font-size: 0.82em; }
-.prose pre .hljs { padding: 12px 16px; border-radius: 8px; }
+.prose pre code { background: none; color: inherit; padding: 0; border-radius: 0; font-size: 0.9em; }
+.prose pre .hljs { padding: 12px 16px; border-radius: 8px; font-size: 0.9em; }
.prose blockquote { border-left: 3px solid #444; margin: 0.5em 0; padding: 0.2em 0 0.2em 0.8em; color: var(--text-muted); }
.prose table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 0.9em; }
.prose th,.prose td { border: 1px solid #333; padding: 5px 10px; text-align: left; }
diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py
index a667ad2..473e1a6 100644
--- a/navi/api/routes/sessions.py
+++ b/navi/api/routes/sessions.py
@@ -16,6 +16,10 @@
profile_id: str
+class PinSessionRequest(BaseModel):
+ pinned: bool
+
+
@router.post("", status_code=201)
async def create_session(
body: CreateSessionRequest,
@@ -46,6 +50,7 @@
"profile_id": s.profile_id,
"message_count": len(s.messages),
"preview": _preview(s),
+ "pinned": s.pinned,
"created_at": s.created_at.isoformat(),
"last_active": s.last_active.isoformat(),
}
@@ -78,6 +83,18 @@
}
+@router.patch("/{session_id}/pin")
+async def pin_session(
+ session_id: str,
+ body: PinSessionRequest,
+ store: Annotated[SessionStore, Depends(get_session_store)],
+) -> dict:
+ ok = await store.set_pinned(session_id, body.pinned)
+ if not ok:
+ raise HTTPException(status_code=404, detail="Session not found")
+ return {"session_id": session_id, "pinned": body.pinned}
+
+
@router.delete("/{session_id}", status_code=204)
async def delete_session(
session_id: str,
diff --git a/navi/core/session.py b/navi/core/session.py
index 9a703a5..69004a0 100644
--- a/navi/core/session.py
+++ b/navi/core/session.py
@@ -13,6 +13,7 @@
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
profile_id: str
messages: list[Message] = Field(default_factory=list)
+ pinned: bool = False
created_at: datetime = Field(default_factory=datetime.utcnow)
last_active: datetime = Field(default_factory=datetime.utcnow)
@@ -33,6 +34,9 @@
@abstractmethod
async def delete(self, session_id: str) -> bool: ...
+ @abstractmethod
+ async def set_pinned(self, session_id: str, pinned: bool) -> bool: ...
+
class InMemorySessionStore(SessionStore):
def __init__(self) -> None:
@@ -51,10 +55,21 @@
self._sessions[session.id] = session
async def list_all(self) -> list[Session]:
- return list(self._sessions.values())
+ return sorted(
+ self._sessions.values(),
+ key=lambda s: (not s.pinned, s.last_active),
+ reverse=False,
+ )
async def delete(self, session_id: str) -> bool:
if session_id in self._sessions:
del self._sessions[session_id]
return True
return False
+
+ async def set_pinned(self, session_id: str, pinned: bool) -> bool:
+ s = self._sessions.get(session_id)
+ if s is None:
+ return False
+ s.pinned = pinned
+ return True
diff --git a/navi/core/sqlite_session_store.py b/navi/core/sqlite_session_store.py
index b6d4eb0..d68a2d8 100644
--- a/navi/core/sqlite_session_store.py
+++ b/navi/core/sqlite_session_store.py
@@ -15,6 +15,7 @@
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
messages TEXT NOT NULL DEFAULT '[]',
+ pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_active TEXT NOT NULL
)
@@ -24,17 +25,21 @@
class SqliteSessionStore(SessionStore):
def __init__(self, db_path: str = "navi.db") -> None:
self._db_path = db_path
- # Create table synchronously so it's ready before any async call
with sqlite3.connect(db_path) as conn:
conn.execute(_CREATE_TABLE)
+ # Migrate: add pinned column to existing tables that don't have it
+ try:
+ conn.execute("ALTER TABLE sessions ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0")
+ except sqlite3.OperationalError:
+ pass # column already exists
conn.commit()
async def create(self, profile_id: str) -> Session:
session = Session(profile_id=profile_id)
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
- "INSERT INTO sessions (id, profile_id, messages, created_at, last_active) "
- "VALUES (?, ?, '[]', ?, ?)",
+ "INSERT INTO sessions (id, profile_id, messages, pinned, created_at, last_active) "
+ "VALUES (?, ?, '[]', 0, ?, ?)",
(session.id, session.profile_id,
session.created_at.isoformat(), session.last_active.isoformat()),
)
@@ -44,7 +49,7 @@
async def get(self, session_id: str) -> Session | None:
async with aiosqlite.connect(self._db_path) as db:
async with db.execute(
- "SELECT id, profile_id, messages, created_at, last_active "
+ "SELECT id, profile_id, messages, pinned, created_at, last_active "
"FROM sessions WHERE id = ?",
(session_id,),
) as cur:
@@ -64,11 +69,20 @@
)
await db.commit()
+ async def set_pinned(self, session_id: str, pinned: bool) -> bool:
+ async with aiosqlite.connect(self._db_path) as db:
+ cur = await db.execute(
+ "UPDATE sessions SET pinned = ? WHERE id = ?",
+ (1 if pinned else 0, session_id),
+ )
+ await db.commit()
+ return cur.rowcount > 0
+
async def list_all(self) -> list[Session]:
async with aiosqlite.connect(self._db_path) as db:
async with db.execute(
- "SELECT id, profile_id, messages, created_at, last_active "
- "FROM sessions ORDER BY last_active DESC"
+ "SELECT id, profile_id, messages, pinned, created_at, last_active "
+ "FROM sessions ORDER BY pinned DESC, last_active DESC"
) as cur:
rows = await cur.fetchall()
return [self._row_to_session(r) for r in rows]
@@ -80,12 +94,13 @@
return cur.rowcount > 0
def _row_to_session(self, row: tuple) -> Session:
- id_, profile_id, messages_json, created_at, last_active = row
+ id_, profile_id, messages_json, pinned, created_at, last_active = row
messages = [Message.model_validate(m) for m in json.loads(messages_json)]
return Session(
id=id_,
profile_id=profile_id,
messages=messages,
+ pinned=bool(pinned),
created_at=datetime.fromisoformat(created_at),
last_active=datetime.fromisoformat(last_active),
)