Эх сурвалжийг харах

feat: 会话置顶优先与向下分页

samien 2 сар өмнө
parent
commit
f12f12919b

+ 17 - 1
src/client/components/app/ResourceSidebar.vue

@@ -16,6 +16,8 @@ const props = withDefaults(defineProps<{
   emptyTitle?: string
   emptyDescription?: string
   singleLine?: boolean
+  hasMore?: boolean
+  loadingMore?: boolean
 }>(), {
   activeId: '',
   loading: false,
@@ -25,12 +27,15 @@ const props = withDefaults(defineProps<{
   emptyTitle: '暂无内容',
   emptyDescription: '新建一项开始使用。',
   singleLine: false,
+  hasMore: false,
+  loadingMore: false,
 })
 
 const emit = defineEmits<{
   select: [id: string]
   search: [value: string]
   more: [id: string]
+  loadMore: []
 }>()
 
 const searchInput = ref<HTMLInputElement | null>(null)
@@ -69,6 +74,12 @@ function closeSearch(clear = false) {
 
 function handleSearchRequest() { void focusSearch() }
 function handleSearchCloseRequest() { searchOpen.value = false }
+function handleListScroll(event: Event) {
+  const element = event.currentTarget as HTMLElement
+  if (props.hasMore && !props.loadingMore && element.scrollHeight - element.scrollTop - element.clientHeight < 96) {
+    emit('loadMore')
+  }
+}
 
 watch(() => props.search, value => {
   if (value) searchOpen.value = true
@@ -102,7 +113,7 @@ defineExpose({ focusSearch })
         />
       </label>
     </Transition>
-    <div class="sidebar-list" role="listbox" aria-label="资源列表">
+    <div class="sidebar-list" role="listbox" aria-label="资源列表" @scroll.passive="handleListScroll">
       <div v-if="loading && !items.length" class="sidebar-loading" aria-label="正在加载">
         <span v-for="n in 7" :key="n" class="sidebar-skeleton" :style="{ opacity: 1 - n * .08 }" />
       </div>
@@ -140,6 +151,9 @@ defineExpose({ focusSearch })
           </div>
         </template>
       </template>
+      <button v-if="hasMore" class="sidebar-load-more" type="button" :disabled="loadingMore" @click="emit('loadMore')">
+        {{ loadingMore ? '正在加载更多…' : '继续加载会话' }}
+      </button>
       <div v-if="!loading && !items.length" class="sidebar-empty">
         <span><AppIcon name="chat" :size="20" /></span>
         <strong>{{ emptyTitle }}</strong>
@@ -185,6 +199,8 @@ defineExpose({ focusSearch })
 .sidebar-item__row b { display: grid; place-items: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--accent); color: var(--text-on-solid); font-size: 8px; }
 .sidebar-item__more { display: none; position: absolute; right: 5px; top: 7px; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 7px; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; box-shadow: 0 1px 5px rgba(0,0,0,.07); }
 .sidebar-item:hover .sidebar-item__more, .sidebar-item:focus-within .sidebar-item__more { display: grid; }
+.sidebar-load-more { display: block; width: calc(100% - 14px); min-height: 32px; margin: 8px 7px 2px; border: 0; border-radius: 8px; background: var(--surface-soft); color: var(--text-secondary); cursor: pointer; font: 10px var(--font-ui); }
+.sidebar-load-more:hover:not(:disabled) { background: var(--surface-hover); color: var(--text-primary); }.sidebar-load-more:disabled { cursor: progress; opacity: .7; }
 .sidebar-loading { display: flex; flex-direction: column; gap: 4px; padding: 2px; }
 .sidebar-skeleton { height: 38px; border-radius: 8px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }
 @keyframes shimmer { to { background-position: -200% 0; } }

+ 39 - 17
src/client/stores/chat.ts

@@ -14,6 +14,7 @@ import { ScopedCache } from '@/utils/cache'
 import { createId, routeKey } from '@/utils/id'
 import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
 import { bool, normalizeChatMessage, number, record, string, values } from '@/utils/normalize'
+import { appendSessionPage, pinnedSessionsFirst } from '@/utils/sessionOrder'
 import { useAuthStore } from './auth'
 
 interface CachedHistory { messages: ChatMessage[]; total: number; savedAt: number }
@@ -42,6 +43,8 @@ export const useChatStore = defineStore('chat', () => {
   const connectionState = ref<RealtimeConnectionState>('idle')
   const historySynced = computed(() => activeRouteState.value?.historySynced ?? false)
   const isLoading = ref(false)
+  const isLoadingMoreSessions = ref(false)
+  const hasMoreSessions = ref(false)
   const isSending = ref(false)
   const error = ref<string>()
   const models = ref<ModelOption[]>([])
@@ -57,6 +60,7 @@ export const useChatStore = defineStore('chat', () => {
   let reconnectTimer: number | undefined
   let reconnectAttempt = 0
   let sessionLoadGeneration = 0
+  let nextSessionCursor: string | undefined
   let modelLoadGeneration = 0
   let unreadLoadGeneration = 0
   let reconnectResumePromise: Promise<unknown> | undefined
@@ -203,11 +207,17 @@ export const useChatStore = defineStore('chat', () => {
     disconnect()
     activeSessionId.value = undefined
     activeProfileName.value = profile
+    nextSessionCursor = undefined
+    hasMoreSessions.value = false
+    isLoadingMoreSessions.value = false
     error.value = undefined
   }
 
   function clearAccountState(): void {
     sessions.value = []
+    nextSessionCursor = undefined
+    hasMoreSessions.value = false
+    isLoadingMoreSessions.value = false
     activeSessionId.value = undefined
     activeProfileName.value = undefined
     runtimeRoutes.clear()
@@ -229,23 +239,14 @@ export const useChatStore = defineStore('chat', () => {
     isLoading.value = true
     error.value = undefined
     try {
-      const loaded: SessionSummary[] = []
-      const seen = new Set<string>()
-      let cursor: string | undefined
-      do {
-        const key = cursor ?? '<first-page>'
-        if (seen.has(key)) break
-        seen.add(key)
-        const page = await getSessions(requestedProfile, cursor, 100)
-        loaded.push(...page.items)
-        cursor = page.nextCursor ?? undefined
-      } while (cursor && loaded.length < 1_000)
+      const page = await getSessions(requestedProfile, undefined, 100)
       if (loadGeneration !== sessionLoadGeneration
         || (requestedProfile && auth.activeProfile?.name !== requestedProfile)) return
-      // 9119 owns both the list order and pin placement. Do not re-sort with a
-      // browser timestamp: it can move a conversation before the server has
-      // accepted or persisted the operation.
-      sessions.value = loaded
+      // 9119 owns recency within each bucket. Pinning is an explicit user
+      // command, so pinned sessions always form the visible top partition.
+      sessions.value = pinnedSessionsFirst(page.items)
+      nextSessionCursor = page.nextCursor ?? undefined
+      hasMoreSessions.value = Boolean(nextSessionCursor)
     } catch (cause) {
       if (loadGeneration === sessionLoadGeneration) error.value = errorMessage(cause)
       throw cause
@@ -254,6 +255,27 @@ export const useChatStore = defineStore('chat', () => {
     }
   }
 
+  async function loadMoreSessions(profile = auth.activeProfile?.name): Promise<void> {
+    const cursor = nextSessionCursor
+    const requestedProfile = profile || undefined
+    if (!cursor || isLoadingMoreSessions.value) return
+    const loadGeneration = sessionLoadGeneration
+    isLoadingMoreSessions.value = true
+    try {
+      const page = await getSessions(requestedProfile, cursor, 100)
+      if (loadGeneration !== sessionLoadGeneration
+        || (requestedProfile && auth.activeProfile?.name !== requestedProfile)) return
+      sessions.value = appendSessionPage(sessions.value, page.items)
+      nextSessionCursor = page.nextCursor ?? undefined
+      hasMoreSessions.value = Boolean(nextSessionCursor)
+    } catch (cause) {
+      if (loadGeneration === sessionLoadGeneration) error.value = errorMessage(cause)
+      throw cause
+    } finally {
+      if (loadGeneration === sessionLoadGeneration) isLoadingMoreSessions.value = false
+    }
+  }
+
   async function loadUnread(profile?: string): Promise<void> {
     const loadGeneration = ++unreadLoadGeneration
     const loaded = await getSessionUnread(profile)
@@ -698,9 +720,9 @@ export const useChatStore = defineStore('chat', () => {
 
   return {
     sessions, activeSessionId, activeProfileName, activeSession, routes, activeRouteState, messages,
-    connectionState, historySynced, hasMoreBefore, isLoading, isSending, isStreaming, isQueued,
+    connectionState, historySynced, hasMoreBefore, isLoading, isLoadingMoreSessions, hasMoreSessions, isSending, isStreaming, isQueued,
     error, models, selectedModel, reasoningEffort, fastMode, contextUsage, pendingApproval, pendingClarification, unreadCounts,
-    loadSessions, selectSession, loadOlder, createSession, connect, disconnect, send, interrupt,
+    loadSessions, loadMoreSessions, selectSession, loadOlder, createSession, connect, disconnect, send, interrupt,
     respondToApproval, respondToClarification, branchSession, renameSession, setSessionPinned, removeSession,
     loadModels, setModel, refreshContextUsage, loadUnread, markRead, switchProfile,
   }

+ 19 - 0
src/client/utils/sessionOrder.ts

@@ -0,0 +1,19 @@
+import type { SessionSummary } from '@shared/types'
+
+/** Keeps 9119's relative order inside each partition while honoring a pin. */
+export function pinnedSessionsFirst(sessions: SessionSummary[]): SessionSummary[] {
+  const pinned: SessionSummary[] = []
+  const regular: SessionSummary[] = []
+  for (const session of sessions) (session.pinned ? pinned : regular).push(session)
+  return [...pinned, ...regular]
+}
+
+export function appendSessionPage(existing: SessionSummary[], next: SessionSummary[]): SessionSummary[] {
+  const seen = new Set(existing.map(session => `${session.profile}\u0000${session.id}`))
+  return pinnedSessionsFirst([...existing, ...next.filter(session => {
+    const key = `${session.profile}\u0000${session.id}`
+    if (seen.has(key)) return false
+    seen.add(key)
+    return true
+  })])
+}

+ 3 - 0
src/client/views/ChatView.vue

@@ -193,10 +193,13 @@ watch(() => chat.activeSessionId, async id => {
         :loading="chat.isLoading"
         :search="search"
         single-line
+        :has-more="chat.hasMoreSessions"
+        :loading-more="chat.isLoadingMoreSessions"
         search-placeholder="搜索会话"
         empty-title="还没有会话"
         empty-description="新建会话后,从输入框开始聊天。"
         @search="search = $event"
+        @load-more="chat.loadMoreSessions(auth.activeProfile?.name)"
         @select="chooseSession"
         @more="openSessionActions"
       />

+ 19 - 0
tests/client/sessionOrder.test.ts

@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import type { SessionSummary } from '@shared/types'
+import { appendSessionPage, pinnedSessionsFirst } from '@/utils/sessionOrder'
+
+function session(id: string, pinned = false): SessionSummary {
+  return { id, profile: 'yaoyao', source: 'web', title: id, pinned, messageCount: 0, toolCallCount: 0, startedAt: 0, updatedAt: 0 }
+}
+
+describe('session sidebar order', () => {
+  it('puts pinned conversations first without reordering either server bucket', () => {
+    expect(pinnedSessionsFirst([session('recent'), session('pinned-one', true), session('older'), session('pinned-two', true)])
+      .map(value => value.id)).toEqual(['pinned-one', 'pinned-two', 'recent', 'older'])
+  })
+
+  it('appends a page without duplicating sessions and promotes newly discovered pins', () => {
+    expect(appendSessionPage([session('recent')], [session('pinned', true), session('recent'), session('older')])
+      .map(value => value.id)).toEqual(['pinned', 'recent', 'older'])
+  })
+})

+ 17 - 1
tests/e2e/workspace.spec.ts

@@ -14,7 +14,7 @@ test('navigates every 9119 workspace without blank transitions', async ({ page }
   const consoleErrors: string[] = []
   page.on('console', message => { if (message.type() === 'error') consoleErrors.push(message.text()) })
 
-  await expect(page.getByRole('option').first()).toContainText('第二个会话')
+  await expect(page.getByRole('option').first()).toContainText('夭夭 Web 验收会话')
   await expect(page.getByRole('option').first()).toContainText('夭夭')
   await expect(page.locator('.desktop-sidebar').getByText('已置顶 1', { exact: true })).toBeVisible()
   await page.getByRole('button', { name: '群聊' }).click()
@@ -39,6 +39,22 @@ test('navigates every 9119 workspace without blank transitions', async ({ page }
   expect(consoleErrors).toEqual([])
 })
 
+test('keeps pins first and loads the next session page at the list bottom', async ({ page }) => {
+  const sidebar = page.locator('.desktop-sidebar')
+  const items = sidebar.locator('.sidebar-list [role="option"]')
+  await expect(items).toHaveCount(100)
+  await expect(items.first()).toContainText('夭夭 Web 验收会话')
+  await expect(sidebar.getByRole('button', { name: '继续加载会话' })).toBeVisible()
+
+  const list = sidebar.locator('.sidebar-list')
+  await list.evaluate(element => {
+    element.scrollTop = element.scrollHeight
+    element.dispatchEvent(new Event('scroll'))
+  })
+  await expect(items).toHaveCount(103)
+  await expect(sidebar.getByRole('button', { name: '继续加载会话' })).toHaveCount(0)
+})
+
 test('restores a routed conversation even when unread-count refresh fails', async ({ page }) => {
   await page.route('**/api/app/sessions/unread*', async route => {
     await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'unread unavailable' }) })

+ 6 - 1
tests/fixtures/fake-hermes.mjs

@@ -37,6 +37,7 @@ const profiles = [
 const sessions = [
   { id: 'session-second', profile: 'yaoyao', source: 'web', title: '第二个会话', preview: '用于验证列表和切换', message_count: 2, tool_call_count: 0, started_at: now() - 7200, last_active_at: now() - 600 },
   { id: 'session-demo', profile: 'yaoyao', source: 'web', title: '夭夭 Web 验收会话', preview: '文件库与群聊已经就绪', message_count: 4, tool_call_count: 1, started_at: now() - 3600, last_active_at: now(), pinned: true, model: 'gpt-5.6', provider: 'openai' },
+  ...Array.from({ length: 101 }, (_, index) => ({ id: `session-page-${index + 1}`, profile: 'yaoyao', source: 'web', title: `分页会话 ${index + 1}`, message_count: 0, tool_call_count: 0, started_at: now() - 10_000 - index, last_active_at: now() - 10_000 - index })),
 ]
 const messages = [
   { id: 'message-user', role: 'user', content: '请检查今天生成的产物。MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 180 },
@@ -71,7 +72,11 @@ const server = createServer(async (request, response) => {
     { name: 'yaoer', label: '瑶儿', agentName: '瑶儿', isDefault: false },
   ] })
   if (url.pathname === '/api/model/options') return json(response, 200, { model: 'gpt-5.6', provider: 'openai', providers: [{ slug: 'openai', name: 'OpenAI', models: ['gpt-5.6', 'gpt-5.5'] }] })
-  if (url.pathname === '/api/profiles/sessions' || url.pathname === '/api/sessions') return json(response, 200, { sessions, total: sessions.length, offset: 0, limit: 100 })
+  if (url.pathname === '/api/profiles/sessions' || url.pathname === '/api/sessions') {
+    const offset = Math.max(0, Number(url.searchParams.get('offset') || 0))
+    const limit = Math.max(1, Number(url.searchParams.get('limit') || 100))
+    return json(response, 200, { sessions: sessions.slice(offset, offset + limit), total: sessions.length, offset, limit })
+  }
   if (/^\/api\/sessions\/[^/]+\/messages$/.test(url.pathname)) return json(response, 200, { messages, pagination: { total: messages.length, returned: messages.length, limit: 150, hasMore: false } })
   if (/^\/api\/sessions\/[^/]+$/.test(url.pathname)) return json(response, 200, sessions[0])
   if (url.pathname === '/api/session-unread') return json(response, 200, { items: { 'session-demo': 0 } })