diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 457207a5..aef863da 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -2753,6 +2753,7 @@ export const en = { header: { conversation: "Chat", titleAria: "Conversation title", + switchSession: "Switch session", pickCwd: "Click to choose a working directory", cwdUnset: "Working directory not set", cwdMissing: "Folder is gone", @@ -2823,6 +2824,8 @@ export const en = { stop: "Stop", actions: "More actions", history: "Search history", + prevSession: "Previous session", + nextSession: "Next session", model: "Change model", newChat: "New chat", overview: "Shortcut overview", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 20348e9d..c0819a82 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -2733,6 +2733,7 @@ export const zh = { header: { conversation: "对话", titleAria: "会话标题", + switchSession: "切换会话", pickCwd: "点击选择工作目录", cwdUnset: "未设置工作目录", cwdMissing: "目录已不存在", @@ -2803,6 +2804,8 @@ export const zh = { stop: "停止", actions: "更多操作", history: "搜索历史会话", + prevSession: "上一条会话", + nextSession: "下一条会话", model: "换模型", newChat: "新建对话", overview: "快捷键一览", diff --git a/src/pages/chat/ChatSessionHeader.tsx b/src/pages/chat/ChatSessionHeader.tsx index c4576f17..4b903059 100644 --- a/src/pages/chat/ChatSessionHeader.tsx +++ b/src/pages/chat/ChatSessionHeader.tsx @@ -1,15 +1,32 @@ import { useEffect, useRef, useState } from 'react'; -import { Copy, FolderOpen, PanelLeftOpen, Settings2, ShieldAlert, Terminal } from 'lucide-react'; +import { + ChevronDown, + ChevronUp, + ChevronsUpDown, + Copy, + FolderOpen, + PanelLeftOpen, + Settings2, + ShieldAlert, + Terminal, +} from 'lucide-react'; import { ChromeActions } from '@/components/layout/ChromeActions'; import { pageRhythm } from '@/components/layout/page-rhythm'; import { copyTextToClipboard } from '@/components/shared/CopyTextButton'; import { useI18n } from '@/components/shared/LanguageProvider'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Hint } from '@/components/ui/tooltip'; import { Input } from '@/components/ui/input'; import { useToast } from '@/components/ui/toast'; import type { Conversation } from '@/lib/types'; import { cn } from '@/lib/utils'; +import { sessionSwitchNeighbors } from './chat-session-switch'; import { isKiroChatAgent } from './chat-kiro-model'; import { chatConnectLabelKey, @@ -32,8 +49,11 @@ export function ChatSessionHeader({ active, railOpen, recordText, + sessions, + sendingConversationIds = [], onExpandRail, onRename, + onFocus, onOpenSettings, onPickWorkingDirectory, runtimeLocked = false, @@ -44,8 +64,11 @@ export function ChatSessionHeader({ active: Conversation | null; railOpen: boolean; recordText?: string; + sessions: readonly Conversation[]; + sendingConversationIds?: readonly string[]; onExpandRail: () => void; onRename: (next: string) => Promise; + onFocus: (id: string) => void; onOpenSettings: () => void; onPickWorkingDirectory: () => void; runtimeLocked?: boolean; @@ -114,7 +137,14 @@ export function ChatSessionHeader({ )}
- {active && editing ? ( + {!railOpen && sessions.length > 0 ? ( + + ) : active && editing ? ( + ); +} + +function ChatSessionSwitcher({ + sessions, + active, + sendingConversationIds, + onFocus, +}: { + sessions: readonly Conversation[]; + active: Conversation | null; + sendingConversationIds: readonly string[]; + onFocus: (id: string) => void; +}) { + const { t } = useI18n(); + const neighbors = sessionSwitchNeighbors(sessions, active?.id ?? null); + const title = active ? conversationTitle(t, active.title) : t('chat.header.conversation'); + const cwdLabel = active ? cwdShortName(active.cwd, t) : t('chat.cwd.unset'); + const sendingHere = Boolean(active && sendingConversationIds.includes(active.id)); + + return ( +
+ + + + + + + {sessions.map((session) => { + const selected = active?.id === session.id; + const sending = sendingConversationIds.includes(session.id); + return ( + onFocus(session.id)} + > + + + {conversationTitle(t, session.title)} + + {cwdShortName(session.cwd, t)} + + + + ); + })} + + + +
+ ); +} diff --git a/src/pages/chat/chat-session-switch.test.ts b/src/pages/chat/chat-session-switch.test.ts new file mode 100644 index 00000000..dc295d55 --- /dev/null +++ b/src/pages/chat/chat-session-switch.test.ts @@ -0,0 +1,145 @@ +import { createElement, type ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import type { Conversation } from '@/lib/types'; +import { ChatSessionHeader } from './ChatSessionHeader'; +import { + adjacentSessionId, + chatSessionSwitchShortcutAction, + sessionSwitchNeighbors, +} from './chat-session-switch'; + +vi.mock('@/components/shared/LanguageProvider', async () => { + const { createTranslator } = await import('@/lib/i18n'); + const t = createTranslator('zh'); + return { + useI18n: () => ({ lang: 'zh', setLanguage: () => undefined, t }), + }; +}); + +vi.mock('@/components/ui/toast', () => ({ + useToast: () => ({ toast: () => undefined }), +})); + +vi.mock('@/components/layout/ChromeActions', () => ({ + ChromeActions: () => null, +})); + +const sessions = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + +describe('adjacentSessionId', () => { + it('returns null for an empty list', () => { + expect(adjacentSessionId([], 'a', 'next')).toBeNull(); + expect(adjacentSessionId([], null, 'prev')).toBeNull(); + }); + + it('cannot switch a single-item list that is already focused', () => { + expect(adjacentSessionId([{ id: 'a' }], 'a', 'next')).toBeNull(); + expect(adjacentSessionId([{ id: 'a' }], 'a', 'prev')).toBeNull(); + }); + + it('focuses the only session when the current id is missing', () => { + expect(adjacentSessionId([{ id: 'a' }], null, 'next')).toBe('a'); + expect(adjacentSessionId([{ id: 'a' }], 'gone', 'prev')).toBe('a'); + }); + + it('walks the flat list and wraps at both ends', () => { + expect(adjacentSessionId(sessions, 'a', 'next')).toBe('b'); + expect(adjacentSessionId(sessions, 'b', 'next')).toBe('c'); + expect(adjacentSessionId(sessions, 'c', 'next')).toBe('a'); + expect(adjacentSessionId(sessions, 'a', 'prev')).toBe('c'); + expect(adjacentSessionId(sessions, 'b', 'prev')).toBe('a'); + expect(adjacentSessionId(sessions, 'c', 'prev')).toBe('b'); + }); + + it('lands on the first or last session when the current id is not in the list', () => { + expect(adjacentSessionId(sessions, null, 'next')).toBe('a'); + expect(adjacentSessionId(sessions, 'gone', 'prev')).toBe('c'); + }); +}); + +describe('sessionSwitchNeighbors', () => { + it('exposes both wrapped neighbors', () => { + expect(sessionSwitchNeighbors(sessions, 'b')).toEqual({ prevId: 'a', nextId: 'c' }); + expect(sessionSwitchNeighbors(sessions, 'a')).toEqual({ prevId: 'c', nextId: 'b' }); + expect(sessionSwitchNeighbors([{ id: 'a' }], 'a')).toEqual({ prevId: null, nextId: null }); + }); +}); + +describe('chatSessionSwitchShortcutAction', () => { + const base = { + key: '', + altKey: true, + metaKey: false, + ctrlKey: false, + shiftKey: false, + overlayOpen: false, + }; + + it('maps Alt+ArrowUp / Alt+ArrowDown', () => { + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp' })).toBe('prev'); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowDown' })).toBe('next'); + expect( + chatSessionSwitchShortcutAction({ ...base, key: 'Unidentified', code: 'ArrowUp' }), + ).toBe('prev'); + }); + + it('ignores chords that already belong to Chat', () => { + expect(chatSessionSwitchShortcutAction({ ...base, key: 'k', ctrlKey: true, altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'n', ctrlKey: true, altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'Enter', altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'Escape', altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: '/', altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp', altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp', ctrlKey: true })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp', overlayOpen: true })).toBeNull(); + }); +}); + +function conv(partial?: Partial): Conversation { + return { + id: 'a', + title: '修登录', + agentIds: ['claude'], + cwd: 'D:\\work\\agenthub', + allowDangerous: false, + createdAt: '2026-09-20T00:00:00.000Z', + updatedAt: '2026-09-20T00:00:00.000Z', + ...partial, + }; +} + +function header(partial?: Partial[0]>): ReactElement { + const active = conv(); + return createElement(ChatSessionHeader, { + active, + railOpen: false, + sessions: [active, conv({ id: 'b', title: '下一场', cwd: 'D:\\work\\other' })], + sendingConversationIds: ['a'], + onExpandRail: () => undefined, + onRename: async () => true, + onFocus: () => undefined, + onOpenSettings: () => undefined, + onPickWorkingDirectory: () => undefined, + ...partial, + }); +} + +describe('collapsed session switcher', () => { + it('shows the current title, working-directory short name, and a sending dot', () => { + const html = renderToStaticMarkup(createElement(TooltipProvider, null, header())); + expect(html).toContain('data-help="chat-session-switch"'); + expect(html).toContain('修登录'); + expect(html).toContain('agenthub'); + expect(html).toContain('data-sending=""'); + expect(html).toContain('上一条会话'); + expect(html).toContain('下一条会话'); + }); + + it('keeps the rename title when the history rail is open', () => { + const html = renderToStaticMarkup(createElement(TooltipProvider, null, header({ railOpen: true }))); + expect(html).not.toContain('data-help="chat-session-switch"'); + expect(html).toContain('修登录'); + }); +}); diff --git a/src/pages/chat/chat-session-switch.ts b/src/pages/chat/chat-session-switch.ts new file mode 100644 index 00000000..4afee8f3 --- /dev/null +++ b/src/pages/chat/chat-session-switch.ts @@ -0,0 +1,58 @@ +export type SessionSwitchDirection = 'prev' | 'next'; + +export type SessionSwitchNeighbors = { + prevId: string | null; + nextId: string | null; +}; + +/** + * Previous / next session in the current filtered list. + * Wraps at both ends. A single-item list cannot switch. + */ +export function adjacentSessionId( + sessions: readonly { id: string }[], + currentId: string | null, + direction: SessionSwitchDirection, +): string | null { + if (sessions.length === 0) return null; + const index = currentId == null ? -1 : sessions.findIndex((item) => item.id === currentId); + let nextIndex: number; + if (index < 0) { + nextIndex = direction === 'next' ? 0 : sessions.length - 1; + } else if (sessions.length === 1) { + return null; + } else { + const delta = direction === 'next' ? 1 : -1; + nextIndex = (index + delta + sessions.length) % sessions.length; + } + const target = sessions[nextIndex]?.id ?? null; + return target && target !== currentId ? target : null; +} + +export function sessionSwitchNeighbors( + sessions: readonly { id: string }[], + currentId: string | null, +): SessionSwitchNeighbors { + return { + prevId: adjacentSessionId(sessions, currentId, 'prev'), + nextId: adjacentSessionId(sessions, currentId, 'next'), + }; +} + +/** Alt+↑ / Alt+↓. Leaves Ctrl+K, Ctrl+N, Enter, Esc, and / alone. */ +export function chatSessionSwitchShortcutAction(input: { + key: string; + code?: string; + altKey: boolean; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + overlayOpen: boolean; +}): SessionSwitchDirection | null { + if (input.overlayOpen || !input.altKey || input.metaKey || input.ctrlKey || input.shiftKey) { + return null; + } + if (input.key === 'ArrowUp' || input.code === 'ArrowUp') return 'prev'; + if (input.key === 'ArrowDown' || input.code === 'ArrowDown') return 'next'; + return null; +} diff --git a/src/pages/chat/chat-shortcuts.test.ts b/src/pages/chat/chat-shortcuts.test.ts index 55f20e34..d9cd3923 100644 --- a/src/pages/chat/chat-shortcuts.test.ts +++ b/src/pages/chat/chat-shortcuts.test.ts @@ -14,15 +14,23 @@ describe('chat shortcut overview', () => { 'stop', 'actions', 'history', + 'prevSession', + 'nextSession', 'model', 'newChat', 'overview', ]); expect(CHAT_SHORTCUT_ROWS.find((row) => row.id === 'newChat')?.keys).toBe('Ctrl+N'); + expect(CHAT_SHORTCUT_ROWS.find((row) => row.id === 'prevSession')?.keys).toBe('Alt+↑'); + expect(CHAT_SHORTCUT_ROWS.find((row) => row.id === 'nextSession')?.keys).toBe('Alt+↓'); expect(CHAT_SHORTCUT_ROWS.find((row) => row.id === 'overview')?.keys).toBe('?'); expect(CHAT_SHORTCUT_ROWS.find((row) => row.id === 'stop')?.keys).toBe('Esc'); expect(translate('zh', 'chat.shortcuts.stop')).toBe('停止'); expect(translate('en', 'chat.shortcuts.stop')).toBe('Stop'); + expect(translate('zh', 'chat.shortcuts.prevSession')).toBe('上一条会话'); + expect(translate('en', 'chat.shortcuts.prevSession')).toBe('Previous session'); + expect(translate('zh', 'chat.shortcuts.nextSession')).toBe('下一条会话'); + expect(translate('en', 'chat.shortcuts.nextSession')).toBe('Next session'); }); it('shows Cmd on macOS and Ctrl elsewhere', () => { diff --git a/src/pages/chat/chat-shortcuts.ts b/src/pages/chat/chat-shortcuts.ts index d87c5577..b348a802 100644 --- a/src/pages/chat/chat-shortcuts.ts +++ b/src/pages/chat/chat-shortcuts.ts @@ -14,6 +14,8 @@ export const CHAT_SHORTCUT_ROWS: readonly ChatShortcutRow[] = [ { id: 'stop', keys: 'Esc', actionKey: 'chat.shortcuts.stop' }, { id: 'actions', keys: '/', actionKey: 'chat.shortcuts.actions' }, { id: 'history', keys: 'Ctrl+K', actionKey: 'chat.shortcuts.history' }, + { id: 'prevSession', keys: 'Alt+↑', actionKey: 'chat.shortcuts.prevSession' }, + { id: 'nextSession', keys: 'Alt+↓', actionKey: 'chat.shortcuts.nextSession' }, { id: 'model', keys: 'Ctrl+Shift+I', actionKey: 'chat.shortcuts.model' }, { id: 'newChat', keys: 'Ctrl+N', actionKey: 'chat.shortcuts.newChat' }, { id: 'overview', keys: '?', actionKey: 'chat.shortcuts.overview' }, diff --git a/src/pages/chat/index.tsx b/src/pages/chat/index.tsx index 50b0c08c..7cf8b7ef 100644 --- a/src/pages/chat/index.tsx +++ b/src/pages/chat/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { MessagesSquare } from 'lucide-react'; import { pageRhythm } from '@/components/layout/page-rhythm'; @@ -31,8 +31,10 @@ import { chatMainColumnClass, chatStageClass, composerNativeEditChord, + filterConversations, } from './chat-model'; import { subscribeChatShortcutKeydown } from './chat-shortcuts'; +import { useChatSessionSwitch } from './use-chat-session-switch'; import { chatModShiftIShouldOpenModel } from './chat-model-labels'; import { formatChatSessionRecord, processUserPromptPreview, type TurnGroup } from './chat-format'; import { chatBusySendMode, grokLegacyContinueKind } from './chat-grok-follow-up'; @@ -242,6 +244,16 @@ export default function ChatPage() { }; }, [page.runChatAction]); + const switchSessions = useMemo( + () => filterConversations(page.conversations, page.railQuery), + [page.conversations, page.railQuery], + ); + useChatSessionSwitch({ + sessions: switchSessions, + currentId: page.activeId, + onFocus: page.focusConversation, + }); + if (page.error && page.conversations.length === 0 && !page.listLoading) { return (
@@ -303,8 +315,11 @@ export default function ChatPage() { active={page.active} railOpen={page.railOpen} recordText={formatChatSessionRecord(page.turns, t('common.you'))} + sessions={switchSessions} + sendingConversationIds={page.sendingConversationIds} onExpandRail={() => page.setRailOpen(true)} onRename={page.renameTitle} + onFocus={page.focusConversation} onOpenSettings={() => page.setSettingsOpen(true)} onPickWorkingDirectory={() => void page.pickWorkingDirectory()} runtimeLocked={page.runtimeLocked || page.sendingHere} diff --git a/src/pages/chat/use-chat-session-switch.ts b/src/pages/chat/use-chat-session-switch.ts new file mode 100644 index 00000000..f569d2e3 --- /dev/null +++ b/src/pages/chat/use-chat-session-switch.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { hasEscPriorityOverlay } from '@/lib/skills/preview-keys'; +import { + adjacentSessionId, + chatSessionSwitchShortcutAction, +} from './chat-session-switch'; +import { subscribeChatShortcutKeydown } from './chat-shortcuts'; + +export function useChatSessionSwitch(input: { + sessions: readonly { id: string }[]; + currentId: string | null; + onFocus: (id: string) => void; +}): void { + const { sessions, currentId, onFocus } = input; + const idsKey = sessions.map((item) => item.id).join('\n'); + + useEffect(() => { + const list = idsKey === '' ? [] : idsKey.split('\n').map((id) => ({ id })); + return subscribeChatShortcutKeydown((event) => { + if (event.isComposing) return; + const action = chatSessionSwitchShortcutAction({ + key: event.key, + code: event.code, + altKey: event.altKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + overlayOpen: hasEscPriorityOverlay(), + }); + if (!action) return; + const target = adjacentSessionId(list, currentId, action); + if (!target) return; + event.preventDefault(); + event.stopPropagation(); + onFocus(target); + }); + }, [currentId, idsKey, onFocus]); +}