Przeglądaj źródła

WIP: 收紧会话列表并改用右键浮动菜单

[gstack-context]
Decisions: 单行会话高度 31px、标题常规字重;右键和省略号共用 208px 定位菜单,重命名内联完成
Remaining: 全量测试、重载 8800、真实页面视觉复验
Skill: /frontend-skill
[/gstack-context]
samien 2 miesięcy temu
rodzic
commit
3eb05d2f1e

+ 8 - 6
src/client/components/app/ResourceSidebar.vue

@@ -34,7 +34,8 @@ const props = withDefaults(defineProps<{
 const emit = defineEmits<{
   select: [id: string]
   search: [value: string]
-  more: [id: string]
+  more: [id: string, event: MouseEvent]
+  contextMenu: [id: string, event: MouseEvent]
   loadMore: []
 }>()
 
@@ -127,6 +128,7 @@ defineExpose({ focusSearch })
             tabindex="0"
             :aria-selected="row.item.id === activeId || row.item.active"
             @click="emit('select', row.item.id)"
+            @contextmenu.prevent.stop="emit('contextMenu', row.item.id, $event)"
             @keydown.enter.prevent="emit('select', row.item.id)"
             @keydown.space.prevent="emit('select', row.item.id)"
           >
@@ -145,7 +147,7 @@ defineExpose({ focusSearch })
                 <b v-if="row.item.unread">{{ row.item.unread > 99 ? '99+' : row.item.unread }}</b>
               </span>
             </span>
-            <button class="sidebar-item__more" type="button" aria-label="更多操作" @click.stop="emit('more', row.item.id)">
+            <button class="sidebar-item__more" type="button" aria-label="更多操作" @click.stop="emit('more', row.item.id, $event)">
               <AppIcon name="dots" :size="16" />
             </button>
           </div>
@@ -179,11 +181,11 @@ defineExpose({ focusSearch })
 .sidebar-item { position: relative; display: flex; align-items: center; gap: 7px; width: 100%; min-height: 42px; padding: 4px 7px; border: 0; border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; transition: background-color 120ms ease, color 120ms ease; }
 .sidebar-item:hover { background: var(--surface-soft); }
 .sidebar-item.active { background: var(--surface-hover); }
-.sidebar-item--single-line { min-height: 34px; padding-block: 3px; }
+.sidebar-item--single-line { min-height: 31px; padding-block: 1px; }
 .sidebar-item--single-line .sidebar-item__copy { display: block; }
-.sidebar-item--single-line .sidebar-item__row { min-height: 28px; }
-.sidebar-item--single-line .sidebar-item__row strong { font-size: 12px; }
-.sidebar-item--single-line .sidebar-item__more { top: 3px; }
+.sidebar-item--single-line .sidebar-item__row { min-height: 27px; }
+.sidebar-item--single-line .sidebar-item__row strong { font-size: 11.5px; font-weight: 450; }
+.sidebar-item--single-line .sidebar-item__more { top: 1px; }
 .sidebar-item__icon { position: relative; display: grid; place-items: center; width: 23px; height: 23px; flex: 0 0 23px; border: 0; border-radius: 7px; color: var(--text-muted); background: transparent; }
 .sidebar-item.active .sidebar-item__icon, .sidebar-item:hover .sidebar-item__icon { color: var(--text-secondary); }
 .sidebar-item__icon--avatar { background: var(--surface-hover); color: var(--text-secondary); font-size: 9px; font-weight: 700; }

+ 2 - 0
src/client/components/app/WorkspaceShell.vue

@@ -515,6 +515,8 @@ onBeforeUnmount(() => {
 .sidebar-context__body :deep(.library-search) { margin-bottom: 10px; }
 .sidebar-context__body :deep(.sidebar-list) { padding-inline: 10px; }
 .sidebar-context__body :deep(.sidebar-item) { min-height: 42px; padding: 5px 9px; }
+.sidebar-context__body :deep(.sidebar-item.sidebar-item--single-line) { min-height: 31px; padding: 1px 7px; }
+.sidebar-context__body :deep(.sidebar-item--single-line .sidebar-item__row strong) { font-size: 11.5px; font-weight: 450; }
 .sidebar-context__body :deep(.sidebar-item__icon) { width: 25px; height: 25px; flex-basis: 25px; border: 0; border-radius: 7px; background: transparent; }
 .sidebar-context__body :deep(.sidebar-item__icon--avatar) { background: var(--surface-soft); color: var(--text-secondary); }
 .sidebar-context__body :deep(.sidebar-item__row strong) { font-size: 12px; }

+ 52 - 23
src/client/views/ChatView.vue

@@ -38,6 +38,7 @@ const queueMode = ref(false)
 const actionSessionId = ref('')
 const renaming = ref(false)
 const renameValue = ref('')
+const actionMenuPosition = ref({ x: 8, y: 8 })
 const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
 const timeline = ref<InstanceType<typeof MessageTimeline> | null>(null)
 const restoringRouteProfile = ref('')
@@ -82,6 +83,7 @@ const reasoningComposerOptions = computed<ComposerOption[]>(() => reasoningOptio
   detail: option.description,
 })))
 const reasoningLabel = computed(() => reasoningOptions.find(option => option.id === (chat.reasoningEffort || ''))?.label || '默认')
+const actionMenuStyle = computed(() => ({ left: `${actionMenuPosition.value.x}px`, top: `${actionMenuPosition.value.y}px` }))
 
 function routeProfile(): string {
   const value = typeof route.query.profile === 'string' ? route.query.profile : ''
@@ -193,25 +195,49 @@ function selectReasoning(id: string) {
   chat.reasoningEffort = id || undefined
 }
 
-function openSessionActions(id: string) {
+function openSessionActions(id: string, event?: MouseEvent) {
   actionSessionId.value = id
   const session = chat.sessions.find(item => item.id === id)
   renameValue.value = session?.title || ''
   renaming.value = false
+  const menuWidth = 208
+  const menuHeight = 176
+  const inset = 8
+  const anchor = event?.currentTarget instanceof HTMLElement ? event.currentTarget.getBoundingClientRect() : null
+  const requestedX = event?.type === 'contextmenu' ? event.clientX : anchor ? anchor.right - menuWidth : window.innerWidth - menuWidth - 14
+  const requestedY = event?.type === 'contextmenu' ? event.clientY : anchor ? anchor.bottom + 5 : 46
+  actionMenuPosition.value = {
+    x: Math.max(inset, Math.min(requestedX, window.innerWidth - menuWidth - inset)),
+    y: Math.max(inset, Math.min(requestedY, window.innerHeight - menuHeight - inset)),
+  }
+}
+
+function closeSessionActions() {
+  actionSessionId.value = ''
+  renaming.value = false
+}
+
+function handleSessionActionPointer(event: PointerEvent) {
+  if (!actionSessionId.value || (event.target as HTMLElement).closest('.session-actions')) return
+  closeSessionActions()
+}
+
+function handleSessionActionKey(event: KeyboardEvent) {
+  if (event.key === 'Escape' && actionSessionId.value) closeSessionActions()
 }
 
 async function saveRename() {
   const session = chat.sessions.find(item => item.id === actionSessionId.value)
   if (!session || !renameValue.value.trim()) return
   await chat.renameSession(session.id, renameValue.value.trim(), session.profile)
-  actionSessionId.value = ''
+  closeSessionActions()
 }
 
 async function deleteSession() {
   const session = chat.sessions.find(item => item.id === actionSessionId.value)
   if (!session) return
   await chat.removeSession(session.id, session.profile)
-  actionSessionId.value = ''
+  closeSessionActions()
   if (route.params.sessionId === session.id) await router.replace('/chat')
 }
 
@@ -233,6 +259,8 @@ async function revealSourceMessage() {
 }
 
 onMounted(async () => {
+  document.addEventListener('pointerdown', handleSessionActionPointer)
+  document.addEventListener('keydown', handleSessionActionKey)
   await refreshSessions()
   await Promise.allSettled([chat.loadModels(auth.activeProfile?.name), chat.connect()])
   await revealSourceMessage()
@@ -240,7 +268,11 @@ onMounted(async () => {
   if (pendingFile) composer.value?.attachFiles([pendingFile])
 })
 
-onBeforeUnmount(() => chat.disconnect())
+onBeforeUnmount(() => {
+  document.removeEventListener('pointerdown', handleSessionActionPointer)
+  document.removeEventListener('keydown', handleSessionActionKey)
+  chat.disconnect()
+})
 
 watch(() => auth.activeProfile?.name, async (profile, previous) => {
   if (!profile || profile === previous) return
@@ -290,6 +322,7 @@ watch(() => chat.activeSessionId, async id => {
         @load-more="chat.loadMoreSessions(auth.activeProfile?.name)"
         @select="chooseSession"
         @more="openSessionActions"
+        @context-menu="openSessionActions"
       />
     </template>
 
@@ -319,7 +352,7 @@ watch(() => chat.activeSessionId, async id => {
         @clarify="interaction && chat.respondToClarification(interaction.id, $event)"
       >
         <template #header-actions>
-          <button v-if="activeSession" class="icon-button header-session-menu" type="button" title="会话操作" aria-label="会话操作" @click="openSessionActions(activeSession.id)"><AppIcon name="dots" :size="18" /></button>
+          <button v-if="activeSession" class="icon-button header-session-menu" type="button" title="会话操作" aria-label="会话操作" @click="openSessionActions(activeSession.id, $event)"><AppIcon name="dots" :size="18" /></button>
         </template>
       </MessageTimeline>
       <p v-if="chat.error" class="chat-error" role="alert"><AppIcon name="alert" :size="13" />{{ chat.error }}</p>
@@ -363,20 +396,17 @@ watch(() => chat.activeSessionId, async id => {
   <ChoiceDialog :open="modelDialog" title="选择模型" :options="modelOptions" :selected-id="selectedModelId" @close="modelDialog = false" @select="selectModel" />
 
   <Teleport to="body">
-    <Transition name="choice-fade">
-      <div v-if="actionSessionId" class="session-action-layer" @mousedown.self="actionSessionId = ''">
-        <section class="session-actions" role="dialog" aria-modal="true" aria-label="会话操作">
-          <header><strong>会话操作</strong><button class="icon-button" type="button" aria-label="关闭" @click="actionSessionId = ''"><AppIcon name="close" /></button></header>
-          <template v-if="renaming">
-            <label>会话名称<input v-model="renameValue" maxlength="120" autofocus @keydown.enter="saveRename" /></label>
-            <div><button class="quiet-button" type="button" @click="renaming = false">取消</button><button class="solid-button" type="button" @click="saveRename">保存</button></div>
-          </template>
-          <template v-else>
-            <button class="action-row" type="button" @click="renaming = true"><AppIcon name="edit" :size="15" />重命名</button>
-            <button class="action-row danger" type="button" @click="deleteSession"><AppIcon name="trash" :size="15" />删除会话</button>
-          </template>
-        </section>
-      </div>
+    <Transition name="session-menu">
+      <section v-if="actionSessionId" class="session-actions" :style="actionMenuStyle" role="menu" aria-label="会话操作" @contextmenu.prevent>
+        <template v-if="renaming">
+          <label>会话名称<input v-model="renameValue" maxlength="120" autofocus @keydown.enter="saveRename" /></label>
+          <div><button class="quiet-button" type="button" @click="renaming = false">取消</button><button class="solid-button" type="button" @click="saveRename">保存</button></div>
+        </template>
+        <template v-else>
+          <button class="action-row" role="menuitem" type="button" @click="renaming = true"><AppIcon name="edit" :size="14" />重命名</button>
+          <button class="action-row danger" role="menuitem" type="button" @click="deleteSession"><AppIcon name="trash" :size="14" />删除会话</button>
+        </template>
+      </section>
     </Transition>
   </Teleport>
 </template>
@@ -388,8 +418,7 @@ watch(() => chat.activeSessionId, async id => {
 .sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
 .header-session-menu { color: var(--text-muted); background: transparent; }.header-session-menu:hover { color: var(--text-primary); background: var(--surface-soft); }
 .chat-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
-.session-action-layer { position: fixed; z-index: 205; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(4px); }
-.session-actions { width: min(340px, 100%); padding: 12px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface-raised); box-shadow: var(--shadow-float); }.session-actions header { display: flex; min-height: 40px; align-items: center; justify-content: space-between; padding: 0 2px 7px 7px; }.session-actions header strong { font-size: 13px; }.action-row { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 0 9px; border: 0; border-radius: 9px; background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; }.action-row:hover { background: var(--surface-soft); color: var(--text-primary); }.action-row.danger { color: var(--danger); }.session-actions label { display: flex; flex-direction: column; gap: 6px; color: var(--text-muted); font-size: 10px; }.session-actions input { height: 38px; padding: 0 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface-soft); color: var(--text-primary); }.session-actions input:focus { border-color: var(--line-strong); }.session-actions > div { display: flex; justify-content: flex-end; gap: 7px; margin-top: 13px; }
-@media (max-width: 600px) { .session-action-layer { place-items: end center; padding: 0; }.session-actions { width: 100%; border-radius: 17px 17px 0 0; padding-bottom: max(12px, env(safe-area-inset-bottom)); } }
-@media (prefers-reduced-motion: reduce) { .sidebar-primary-action { transition: none; } }
+.session-actions { position: fixed; z-index: 205; width: 208px; box-sizing: border-box; padding: 6px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-raised); box-shadow: 0 12px 34px rgba(0,0,0,.16); }.action-row { display: flex; width: 100%; min-height: 34px; align-items: center; gap: 8px; padding: 0 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 11px; text-align: left; }.action-row:hover, .action-row:focus-visible { outline: 0; background: var(--surface-soft); color: var(--text-primary); }.action-row.danger { color: var(--danger); }.session-actions label { display: flex; flex-direction: column; gap: 6px; padding: 4px; color: var(--text-muted); font-size: 9px; }.session-actions input { width: 100%; height: 32px; box-sizing: border-box; padding: 0 8px; border: 1px solid var(--line); border-radius: 7px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font-size: 11px; }.session-actions input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.session-actions > div { display: flex; justify-content: flex-end; gap: 5px; margin-top: 7px; padding: 0 4px 3px; }.session-actions :deep(.quiet-button), .session-actions :deep(.solid-button) { min-height: 29px; padding-inline: 10px; font-size: 10px; }
+.session-menu-enter-active, .session-menu-leave-active { transition: opacity 100ms ease, transform 120ms var(--ease-out); transform-origin: top right; }.session-menu-enter-from, .session-menu-leave-to { opacity: 0; transform: translateY(-3px) scale(.98); }
+@media (prefers-reduced-motion: reduce) { .sidebar-primary-action, .session-menu-enter-active, .session-menu-leave-active { transition: none; } }
 </style>

+ 22 - 2
tests/e2e/workspace.spec.ts

@@ -191,9 +191,29 @@ test('keeps the canonical logo and yaoyao-webui composer geometry', async ({ pag
   await expect(page.locator('.message--user .message__meta')).toHaveCSS('display', 'none')
   await expect(page.getByRole('button', { name: '会话操作' })).toBeVisible()
   await page.getByRole('button', { name: '会话操作' }).click()
-  await expect(page.getByText('重命名', { exact: true })).toBeVisible()
-  await expect(page.getByText('删除会话', { exact: true })).toBeVisible()
+  const sessionMenu = page.getByRole('menu', { name: '会话操作' })
+  await expect(sessionMenu.getByText('重命名', { exact: true })).toBeVisible()
+  await expect(sessionMenu.getByText('删除会话', { exact: true })).toBeVisible()
   await expect(page.getByText('置顶会话', { exact: true })).toHaveCount(0)
+  const menuBox = await sessionMenu.evaluate(element => element.getBoundingClientRect().toJSON())
+  expect(menuBox.width).toBeLessThanOrEqual(220)
+  expect(menuBox.height).toBeLessThan(100)
+  await expect(page.locator('.session-action-layer')).toHaveCount(0)
+  await page.keyboard.press('Escape')
+
+  const compactSession = page.locator('.desktop-sidebar .sidebar-item--single-line').first()
+  const compactMetrics = await compactSession.evaluate(element => {
+    const title = element.querySelector('strong')
+    return {
+      height: element.getBoundingClientRect().height,
+      weight: title ? Number(getComputedStyle(title).fontWeight) : 0,
+    }
+  })
+  expect(compactMetrics.height).toBeLessThanOrEqual(31)
+  expect(compactMetrics.weight).toBeLessThan(500)
+  await compactSession.click({ button: 'right' })
+  await expect(sessionMenu).toBeVisible()
+  await page.keyboard.press('Escape')
   const composer = page.locator('.composer-shell')
   await expect(composer).toBeVisible()
   const geometry = await composer.evaluate(element => {