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 = '
No conversations yet
'; return; } listEl.innerHTML = sessions.map(s => { - const active = s.session_id === currentId ? ' active' : ''; - const preview = esc(s.preview || 'No messages yet'); - const name = esc(s.profile_name || s.profile_id); - const time = timeLabel(s.last_active); + const active = s.session_id === currentId ? ' active' : ''; + const pinned = s.pinned ? ' pinned' : ''; + const preview = esc(s.preview || 'No messages yet'); + const name = esc(s.profile_name || s.profile_id); + const time = timeLabel(s.last_active); + const pinIcon = s.pinned ? '📌' : '📍'; + const pinTitle = s.pinned ? 'Unpin' : 'Pin'; return ` -
+
-
${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), )