diff --git a/webclient/src/App.vue b/webclient/src/App.vue index 164f304..6320732 100644 --- a/webclient/src/App.vue +++ b/webclient/src/App.vue @@ -109,8 +109,13 @@ const documentTitle = computed(() => { if (route.value === 'settings') return 'Navi — Settings' if (!chatStore.currentId) return 'Navi' - const session = sessionsStore.sessions.find(s => s.session_id === chatStore.currentId) - return session?.name || chatStore.currentId.slice(0, 8) + const id = chatStore.currentId + // nameById persists beyond the loaded list page (the list holds only ~30) + return ( + sessionsStore.nameById[id] || + sessionsStore.sessions.find(s => s.session_id === id)?.name || + id.slice(0, 8) + ) }) watch(documentTitle, (title) => { @@ -203,7 +208,7 @@ background: rgba(0, 0, 0, 0.6); z-index: 99; - @media (max-width: 1280px) { + @media (max-width: 1279.98px) { display: block; } } diff --git a/webclient/src/components/settings/SettingsView.vue b/webclient/src/components/settings/SettingsView.vue index d30ad7e..b6eb541 100644 --- a/webclient/src/components/settings/SettingsView.vue +++ b/webclient/src/components/settings/SettingsView.vue @@ -42,7 +42,7 @@ .settings-sidebar-toggle { display: none; - @media (max-width: 1280px) { + @media (max-width: 1279.98px) { display: flex; } } diff --git a/webclient/src/components/sidebar/AppSidebar.vue b/webclient/src/components/sidebar/AppSidebar.vue index aaa6152..4a3ea5d 100644 --- a/webclient/src/components/sidebar/AppSidebar.vue +++ b/webclient/src/components/sidebar/AppSidebar.vue @@ -282,7 +282,7 @@ .sidebar-close-btn { display: none; - @media (max-width: 1280px) { + @media (max-width: 1279.98px) { display: flex; margin-left: auto; } diff --git a/webclient/src/components/sidebar/SessionItem.vue b/webclient/src/components/sidebar/SessionItem.vue index cd7866f..e1d2f20 100644 --- a/webclient/src/components/sidebar/SessionItem.vue +++ b/webclient/src/components/sidebar/SessionItem.vue @@ -4,7 +4,7 @@ :class="{ 'is-active': active, 'is-pinned': session.pinned, 'has-recall': session.has_pending_recall }" @click="emit('select')" > -
+
diff --git a/webclient/src/components/ui/WelcomeScreen.vue b/webclient/src/components/ui/WelcomeScreen.vue index 4308da6..207735c 100644 --- a/webclient/src/components/ui/WelcomeScreen.vue +++ b/webclient/src/components/ui/WelcomeScreen.vue @@ -85,7 +85,7 @@ top: 12px; left: 12px; - @media (max-width: 1280px) { + @media (max-width: 1279.98px) { display: flex; } } diff --git a/webclient/src/stores/chat.js b/webclient/src/stores/chat.js index 5a7877f..a23b88b 100644 --- a/webclient/src/stores/chat.js +++ b/webclient/src/stores/chat.js @@ -19,6 +19,7 @@ } export const useChatStore = defineStore('chat', () => { + const sessionsStore = useSessionsStore() const currentId = ref(null) const currentProfileId = ref(null) const messages = ref([]) @@ -86,6 +87,9 @@ // Ignore stale responses — user may have switched to a different session while this loaded if (_lastLoadId !== id) return currentProfileId.value = session.profile_id ?? null + // The paged session list may not include this session — keep its name + // in the persistent map for document.title lookups + sessionsStore.rememberName(id, session.name) messages.value = buildMessageList(page.items ?? []) // Set archive state from the page itself — hot messages below the tail // are NOT archived yet, so archive_threshold alone can't tell. diff --git a/webclient/src/stores/sessions.js b/webclient/src/stores/sessions.js index b7ec085..2073007 100644 --- a/webclient/src/stores/sessions.js +++ b/webclient/src/stores/sessions.js @@ -14,6 +14,20 @@ const searchActive = ref(false) const searchCache = ref(null) const hasPendingRecallFilter = ref(false) + // Persistent session_id → name map. The paged list only holds ~30 loaded + // items, so App's document.title lookup needs a map that survives paging; + // it is filled by fetchSessions/fetchMoreSessions/loadSession and never + // shrinks on refetch. + const nameById = ref({}) + + function rememberName(id, name) { + if (id == null || !name) return + if (nameById.value[id] !== name) nameById.value[id] = name + } + + function rememberNames(items) { + for (const s of items) rememberName(s.session_id, s.name) + } async function fetchSessions(profileId = null) { currentProfileId.value = profileId @@ -38,6 +52,7 @@ } return it }) + rememberNames(sessions.value) hasMore.value = Array.isArray(page) ? false : page.has_more nextOffset.value = Array.isArray(page) ? items.length : page.next_offset if (searchActive.value) { @@ -69,6 +84,7 @@ ...sessions.value, ...items.filter(s => !known.has(s.session_id)), ] + rememberNames(sessions.value) hasMore.value = Array.isArray(page) ? false : page.has_more nextOffset.value = Array.isArray(page) ? sessions.value.length : page.next_offset if (searchActive.value) { @@ -113,8 +129,13 @@ async function createSession(profileId) { const session = await api.createSession(profileId) - // Add a complete placeholder so SessionItem renders correctly immediately - sessions.value.unshift({ + // Add a complete placeholder so SessionItem renders correctly immediately. + // Insert at the position the server list would give it (pinned first, then + // last_active desc — a brand-new session is the newest unpinned one), so a + // refetch doesn't visibly move it. + const firstUnpinned = sessions.value.findIndex(s => !s.pinned) + const at = firstUnpinned === -1 ? sessions.value.length : firstUnpinned + sessions.value.splice(at, 0, { session_id: session.session_id, profile_id: session.profile_id, created_at: session.created_at, @@ -154,6 +175,7 @@ } function updateName(id, name) { + rememberName(id, name) const session = sessions.value.find(s => s.session_id === id) if (session) session.name = name } @@ -164,6 +186,7 @@ }) return { + nameById, sessions, filteredSessions, loading, @@ -175,6 +198,7 @@ hasPendingRecallFilter, fetchSessions, fetchMoreSessions, + rememberName, createSession, deleteSession, pinSession, diff --git a/webclient/src/styles/app.scss b/webclient/src/styles/app.scss index 80ff462..31ffda4 100644 --- a/webclient/src/styles/app.scss +++ b/webclient/src/styles/app.scss @@ -2,7 +2,9 @@ // ─── Breakpoints ─────────────────────────────────────────────────────────── $bp-mobile: 768px; -$bp-sidebar-drawer: 1280px; +// Drawer mode starts strictly *below* 1280px: use an .02px-exclusive bound +// so a viewport of exactly 1280px gets the desktop sidebar, not the drawer. +$bp-sidebar-drawer: 1279.98px; // ─── App shell ───────────────────────────────────────────────────────────── $sidebar-width: 380px; diff --git a/webclient/tests/unit/stores/chat.test.js b/webclient/tests/unit/stores/chat.test.js index 028c555..98262c3 100644 --- a/webclient/tests/unit/stores/chat.test.js +++ b/webclient/tests/unit/stores/chat.test.js @@ -6,6 +6,7 @@ useSessionsStore: () => ({ updatePreview: vi.fn(), updateName: vi.fn(), + rememberName: vi.fn(), sessions: [], }), })) diff --git a/webclient/tests/unit/stores/sessions.test.js b/webclient/tests/unit/stores/sessions.test.js index 107bed7..ba734f9 100644 --- a/webclient/tests/unit/stores/sessions.test.js +++ b/webclient/tests/unit/stores/sessions.test.js @@ -173,4 +173,44 @@ expect(store.sessions[0]).toBe(ref2) expect(store.sessions[0].pinned).toBe(true) }) + + it('createSession places the placeholder where the server list would (below pinned)', async () => { + api.createSession.mockResolvedValue({ + session_id: 'new1', + profile_id: 'developer', + created_at: '2024-01-02T00:00:00Z', + }) + const store = useSessionsStore() + store.sessions = [{ session_id: 's1', pinned: true }] + await store.createSession('developer') + // placeholder lands after the pinned run, matching server's + // pinned DESC, last_active DESC ordering + expect(store.sessions.map(s => s.session_id)).toEqual(['s1', 'new1']) + }) + + it('rememberName persists names beyond the loaded page; fetchSessions fills the map', async () => { + api.getSessions.mockResolvedValueOnce({ + items: [{ session_id: 's1', name: 'Work', preview: '', pinned: false }], + has_more: false, + next_offset: 1, + }) + const store = useSessionsStore() + await store.fetchSessions('developer') + expect(store.nameById['s1']).toBe('Work') + + store.rememberName('s2', 'Deep session') + expect(store.nameById['s2']).toBe('Deep session') + + api.getSessions.mockResolvedValueOnce({ + items: [{ session_id: 's3', name: null, preview: '', pinned: false }], + has_more: false, + next_offset: 1, + }) + await store.fetchSessions('developer') + // null name must not wipe remembered names + expect(store.nameById['s2']).toBe('Deep session') + + store.updateName('s3', 'Named now') + expect(store.nameById['s3']).toBe('Named now') + }) })