Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/lib/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/lib/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2733,6 +2733,7 @@ export const zh = {
header: {
conversation: "对话",
titleAria: "会话标题",
switchSession: "切换会话",
pickCwd: "点击选择工作目录",
cwdUnset: "未设置工作目录",
cwdMissing: "目录已不存在",
Expand Down Expand Up @@ -2803,6 +2804,8 @@ export const zh = {
stop: "停止",
actions: "更多操作",
history: "搜索历史会话",
prevSession: "上一条会话",
nextSession: "下一条会话",
model: "换模型",
newChat: "新建对话",
overview: "快捷键一览",
Expand Down
131 changes: 129 additions & 2 deletions src/pages/chat/ChatSessionHeader.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -32,8 +49,11 @@ export function ChatSessionHeader({
active,
railOpen,
recordText,
sessions,
sendingConversationIds = [],
onExpandRail,
onRename,
onFocus,
onOpenSettings,
onPickWorkingDirectory,
runtimeLocked = false,
Expand All @@ -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<boolean>;
onFocus: (id: string) => void;
onOpenSettings: () => void;
onPickWorkingDirectory: () => void;
runtimeLocked?: boolean;
Expand Down Expand Up @@ -114,7 +137,14 @@ export function ChatSessionHeader({
</Button>
)}
<div className="min-w-0 flex-1">
{active && editing ? (
{!railOpen && sessions.length > 0 ? (
<ChatSessionSwitcher
sessions={sessions}
active={active}
sendingConversationIds={sendingConversationIds}
onFocus={onFocus}
/>
) : active && editing ? (
<Input
ref={inputRef}
value={draftTitle}
Expand Down Expand Up @@ -311,3 +341,100 @@ export function ChatSessionHeader({
function shortenId(id: string, max: number): string {
return id.length <= max ? id : `${id.slice(0, max - 1)}…`;
}

function SendingDot({ sending }: { sending: boolean }) {
if (!sending) return null;
return (
<span aria-hidden className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-accent" data-sending="" />
);
}

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 (
<div className="flex min-w-0 items-center gap-0.5" data-help="chat-session-switch">
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted"
disabled={!neighbors.prevId}
title={t('chat.shortcuts.prevSession')}
aria-label={t('chat.shortcuts.prevSession')}
aria-keyshortcuts="Alt+ArrowUp"
onClick={() => {
if (neighbors.prevId) onFocus(neighbors.prevId);
}}
>
<ChevronUp className="h-4 w-4" />
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className="min-w-0 max-w-xs justify-start px-1.5"
aria-label={t('chat.header.switchSession')}
>
<SendingDot sending={sendingHere} />
<span className="min-w-0 truncate font-semibold text-primary">{title}</span>
<span className="min-w-0 truncate text-meta font-normal text-muted">{cwdLabel}</span>
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-72 min-w-[16rem] overflow-y-auto">
{sessions.map((session) => {
const selected = active?.id === session.id;
const sending = sendingConversationIds.includes(session.id);
return (
<DropdownMenuItem
key={session.id}
className={cn('items-start', selected && 'font-medium')}
onSelect={() => onFocus(session.id)}
>
<SendingDot sending={sending} />
<span className="min-w-0 flex-1">
<span className="block truncate">{conversationTitle(t, session.title)}</span>
<span className="block truncate text-meta text-muted">
{cwdShortName(session.cwd, t)}
</span>
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted"
disabled={!neighbors.nextId}
title={t('chat.shortcuts.nextSession')}
aria-label={t('chat.shortcuts.nextSession')}
aria-keyshortcuts="Alt+ArrowDown"
onClick={() => {
if (neighbors.nextId) onFocus(neighbors.nextId);
}}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
);
}
145 changes: 145 additions & 0 deletions src/pages/chat/chat-session-switch.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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<Parameters<typeof ChatSessionHeader>[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('修登录');
});
});
Loading
Loading