diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py index 4143570..9fb286d 100644 --- a/navi/api/routes/sessions.py +++ b/navi/api/routes/sessions.py @@ -114,26 +114,36 @@ limit: Annotated[int | None, Query(ge=1, le=100)] = None, offset: Annotated[int | None, Query(ge=0)] = None, profile_id: str | None = None, + search: str | None = Query(None), ) -> dict | list[dict]: is_admin = user.role == "admin" or user.has_permission("navi.sessions.read_all") if limit is None and offset is None: sessions = await store.list_all(user_id=user.id, is_admin=is_admin) - if profile_id: + if profile_id and not search: sessions = [s for s in sessions if s.profile_id == profile_id] - return [_session_summary(s) for s in sessions] + return [_session_summary(s, search=search) for s in sessions] page_limit = limit or 30 page_offset = offset or 0 - sessions = await store.list_page( - limit=page_limit + 1, - offset=page_offset, - profile_id=profile_id, - user_id=user.id, - is_admin=is_admin, - ) + if search: + sessions = await store.search_list( + limit=page_limit + 1, + offset=page_offset, + user_id=user.id, + is_admin=is_admin, + search=search, + ) + else: + sessions = await store.list_page( + limit=page_limit + 1, + offset=page_offset, + profile_id=profile_id, + user_id=user.id, + is_admin=is_admin, + ) items = sessions[:page_limit] return { - "items": [_session_summary(s) for s in items], + "items": [_session_summary(s, search=search) for s in items], "limit": page_limit, "offset": page_offset, "has_more": len(sessions) > page_limit, @@ -141,8 +151,8 @@ } -def _session_summary(session) -> dict: - return { +def _session_summary(session, search: str | None = None) -> dict: + summary = { "session_id": session.id, "profile_id": session.profile_id, "name": session.name, @@ -152,6 +162,9 @@ "created_at": session.created_at.isoformat(), "last_active": session.last_active.isoformat(), } + if search: + summary["match_indices"], summary["match_preview"] = _match_info(session, search) + return summary def _preview(session) -> str: @@ -162,6 +175,28 @@ return "" +def _match_info(session, search: str) -> tuple[list[int], str]: + """Return message indices that contain the search term and a preview snippet.""" + q = search.lower() + indices = [] + preview = "" + for i, msg in enumerate(session.messages): + content = msg.content or "" + if q in content.lower(): + indices.append(i) + if not preview: + idx = content.lower().find(q) + start = max(0, idx - 30) + end = min(len(content), idx + len(search) + 30) + snippet = content[start:end] + if start > 0: + snippet = "…" + snippet + if end < len(content): + snippet = snippet + "…" + preview = snippet + return indices, preview + + @router.get("/{session_id}") async def get_session( session_id: str, diff --git a/navi/core/pg_session_store.py b/navi/core/pg_session_store.py index 54e6684..d7f3b01 100644 --- a/navi/core/pg_session_store.py +++ b/navi/core/pg_session_store.py @@ -197,7 +197,7 @@ if search: like = f"%{search}%" conditions.append( - f"(id ILIKE {add_param(like)} OR name ILIKE {add_param(like)} OR user_id ILIKE {add_param(like)} OR profile_id ILIKE {add_param(like)})" + f"(id ILIKE {add_param(like)} OR name ILIKE {add_param(like)} OR user_id ILIKE {add_param(like)} OR profile_id ILIKE {add_param(like)} OR messages ILIKE {add_param(like)})" ) where = "WHERE " + " AND ".join(conditions) if conditions else "" @@ -232,7 +232,7 @@ if search: like = f"%{search}%" conditions.append( - f"(id ILIKE {add_param(like)} OR name ILIKE {add_param(like)} OR user_id ILIKE {add_param(like)} OR profile_id ILIKE {add_param(like)})" + f"(id ILIKE {add_param(like)} OR name ILIKE {add_param(like)} OR user_id ILIKE {add_param(like)} OR profile_id ILIKE {add_param(like)} OR messages ILIKE {add_param(like)})" ) where = "WHERE " + " AND ".join(conditions) if conditions else "" diff --git a/navi/core/session.py b/navi/core/session.py index 969e9d2..e0e263e 100644 --- a/navi/core/session.py +++ b/navi/core/session.py @@ -154,6 +154,7 @@ or (s.name and q in s.name.lower()) or (s.user_id and q in s.user_id.lower()) or (s.profile_id and q in s.profile_id.lower()) + or any(q in (m.content or "").lower() for m in s.messages) ] key_map = { "created_at": lambda s: s.created_at, diff --git a/webclient/src/api/index.js b/webclient/src/api/index.js index a9f9da6..67d59f7 100644 --- a/webclient/src/api/index.js +++ b/webclient/src/api/index.js @@ -53,12 +53,13 @@ } // ─── Sessions ────────────────────────────────────────────────────────────── -export function getSessions({ limit = 30, offset = 0, profileId = null } = {}) { +export function getSessions({ limit = 30, offset = 0, profileId = null, search = null } = {}) { const params = new URLSearchParams({ limit: String(limit), offset: String(offset), }) if (profileId) params.set('profile_id', profileId) + if (search) params.set('search', search) return request('GET', `/sessions?${params.toString()}`) } diff --git a/webclient/src/components/chat/MessageList.vue b/webclient/src/components/chat/MessageList.vue index 308dbc4..93dc607 100644 --- a/webclient/src/components/chat/MessageList.vue +++ b/webclient/src/components/chat/MessageList.vue @@ -4,9 +4,10 @@
@@ -40,8 +41,21 @@ const containerEl = ref(null) const contentVisible = ref(false) const showScrollBtn = ref(false) +const flashIndex = ref(null) let userScrolledUp = false +function scrollToMessage(index) { + const el = containerEl.value + if (!el) return + const target = el.querySelector(`[data-msg-index="${index}"]`) + if (!target) return + target.scrollIntoView({ behavior: 'smooth', block: 'center' }) + flashIndex.value = index + setTimeout(() => { + if (flashIndex.value === index) flashIndex.value = null + }, 1500) +} + function resolveComponent(msg) { if (msg.role === 'user') return UserMessage if (msg.type === 'summary') return SummaryCard @@ -96,14 +110,19 @@ } ) -// When session finishes loading: scroll to bottom while invisible, then reveal +// When session finishes loading: scroll to target message or bottom, then reveal watch( () => chat.loading, (loading) => { if (!loading) { contentVisible.value = false afterLayout(() => { - scrollToBottom() + if (chat.scrollToMessageIndex != null) { + scrollToMessage(chat.scrollToMessageIndex) + chat.scrollToMessageIndex = null + } else { + scrollToBottom() + } contentVisible.value = true }) } diff --git a/webclient/src/components/sidebar/AppSidebar.vue b/webclient/src/components/sidebar/AppSidebar.vue index 884c22e..2ad005c 100644 --- a/webclient/src/components/sidebar/AppSidebar.vue +++ b/webclient/src/components/sidebar/AppSidebar.vue @@ -27,9 +27,43 @@
+ +