From a3813f57e9d90ebb19bd1c730f4db6154f1d8a65 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 17:07:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(header):=20session=20tabs=20=E2=80=94=20a?= =?UTF-8?q?=20horizontal=20working=20set=20of=20open=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web/desktop header replaces the single session title with a strip of soft pill tabs, one per session the user has opened (sidebar, palette or deep link — opening anywhere adds a tab once). The active tab is the familiar title block — rename, meta row and the full session menu — inside a gently selected pill; a brand-new draft shows as a transient pill until its session exists. Inactive tabs show the title with a hover-revealed "..." menu (close tab, close other tabs, copy id) that nudges the text like sidebar rows, and close by middle-click too. Tabs drag to reorder, scroll behind the right-side header buttons with soft fade edges, respect the reserved window-controls inset, and persist across reloads. Closing the active tab activates its neighbour (or opens a new draft when it was the last). Tab ids whose session is not in the loaded list stay stored but hidden, so a partial session list never destroys the working set. VS Code keeps the plain title; mobile is untouched. --- packages/ui/src/components/layout/Header.tsx | 152 +++++++- .../components/layout/SessionTabsStrip.tsx | 326 ++++++++++++++++++ packages/ui/src/lib/i18n/messages/de.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 + .../ui/src/stores/useSessionTabsStore.test.ts | 43 +++ packages/ui/src/stores/useSessionTabsStore.ts | 81 +++++ 15 files changed, 645 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/layout/SessionTabsStrip.tsx create mode 100644 packages/ui/src/stores/useSessionTabsStore.test.ts create mode 100644 packages/ui/src/stores/useSessionTabsStore.ts diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 3d99604b..82f3b5c1 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -49,6 +49,7 @@ import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; +import { SessionTabsStrip } from './SessionTabsStrip'; import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop'; import { desktopHostsGet, redactSensitiveUrl } from '@/lib/desktopHosts'; import { @@ -1572,7 +1573,7 @@ export const Header: React.FC = () => { ) : null} - ) : ( + ) : isVSCode ? (
{!isSidebarOpen ? ( @@ -1585,6 +1586,154 @@ export const Header: React.FC = () => { ) : null} +
+ {isRenamingHeaderSession ? ( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + void saveHeaderSessionRename(); + }} + > + setHeaderSessionTitleDraft(event.target.value)} + autoFocus + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === 'Escape') { + setIsRenamingHeaderSession(false); + } + }} + placeholder={t('sessions.sidebar.session.menu.rename')} + className="min-w-0 flex-1 bg-transparent typography-ui-label text-[14px] font-normal leading-tight outline-none placeholder:text-muted-foreground" + /> + + +
+ ) : ( + + {isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle} + + )} + {showHeaderMetaRow ? ( + + {activeProjectLabel ? {activeProjectLabel} : null} + {currentBranchLabel ? ( + + + {currentBranchLabel} + + ) : null} + {!isNewSessionDraftOpen && worktreeBadgeKind ? ( + + + {worktreeBadge} + + ) : null} + + ) : null} +
+
+ {currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? ( + { + if (!open && pendingHeaderRenameRef.current) { + pendingHeaderRenameRef.current = false; + beginHeaderSessionRename(); + } + }} + > + + + + + { pendingHeaderRenameRef.current = true; }}>{t('sessions.sidebar.session.menu.rename')} + {t('sessions.sidebar.session.menu.copyId')} + + {currentSession?.shareUrl ? ( + <> + {t('sessions.sidebar.session.menu.copyLink')} + void unshareCurrentSession()}>{t('sessions.sidebar.session.menu.unshare')} + + ) : ( + void shareCurrentSession()}>{t('sessions.sidebar.session.menu.share')} + )} + void exportCurrentSession()}>{t('sessions.sidebar.session.menu.exportMarkdown')} + {!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? ( + + + + + + {t('sessions.sidebar.session.menu.moveToWorktree')} + + + + + {isCurrentSessionMovingToWorktree + ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') + : isCurrentSessionActive + ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') + : t('sessions.sidebar.session.moveToWorktree.tooltip')} + + + ) : null} + + setPendingHeaderRetentionAction('archive')}>{t('sessions.sidebar.bulkActions.archive')} + setPendingHeaderRetentionAction('delete')}>{t('sessions.sidebar.bulkActions.delete')} + + + ) : null} +
+
+ ) : ( +
+ {!isSidebarOpen ? ( + + + + ) : null} +
{isRenamingHeaderSession ? (
{ ) : null}
+
)} diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx new file mode 100644 index 00000000..18e7528f --- /dev/null +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -0,0 +1,326 @@ +import React from 'react'; +import { + DndContext, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, + type Modifier, +} from '@dnd-kit/core'; +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS as DndCSS } from '@dnd-kit/utilities'; +import type { Session } from '@opencode-ai/sdk/v2'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Icon } from '@/components/icon/Icon'; +import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/i18n'; +import { copyTextToClipboard } from '@/lib/clipboard'; +import { useSessionTabsStore } from '@/stores/useSessionTabsStore'; +import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; + +const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 }); + +/** + * Sortable shell for the active tab: the pill itself drags, while the + * interactive content inside (rename form, menu) stops pointer-down so a text + * selection or menu click never starts a drag. + */ +const ActiveTabShell: React.FC<{ id: string; children: React.ReactNode }> = ({ id, children }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + return ( +
+
+ {children} +
+
+ ); +}; + +type SessionTab = { id: string; session: Session }; + +/** + * One inactive tab: a soft pill with the session title. The "..." menu trigger + * has no reserved footprint — it appears at the tab's end on hover (or while + * its menu is open), nudging the title, mirroring the sidebar row mechanic. + * The reveal itself is opacity-only; the layout change is instant. + */ +const InactiveSessionTab: React.FC<{ + tab: SessionTab; + onSelect: (tab: SessionTab) => void; + onClose: (id: string) => void; + onCloseOthers: (id: string) => void; +}> = ({ tab, onSelect, onClose, onCloseOthers }) => { + const { t } = useI18n(); + const [menuOpen, setMenuOpen] = React.useState(false); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id }); + + const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled'); + + return ( +
+
onSelect(tab)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(tab); + } + }} + onAuxClick={(event) => { + if (event.button === 1) { + event.preventDefault(); + onClose(tab.id); + } + }} + className={cn( + 'group/session-tab flex h-8 max-w-[200px] cursor-pointer touch-none select-none items-center rounded-[10px] px-3', + 'text-muted-foreground transition-colors duration-150 hover:bg-interactive-hover/40 hover:text-foreground', + menuOpen && 'bg-interactive-hover/40 text-foreground', + )} + title={title} + > + + {title} + + + + + + + onClose(tab.id)}> + + {t('header.sessionTabs.closeTab')} + + onCloseOthers(tab.id)}> + + {t('header.sessionTabs.closeOtherTabs')} + + + void copyTextToClipboard(tab.id)}> + + {t('sessions.sidebar.session.menu.copyId')} + + + +
+
+ ); +}; + +/** + * The header's horizontal working set of sessions (web/desktop only). + * + * Every session the user opens joins the strip once; the tab whose session is + * current renders `children` — the header's existing title block with rename, + * meta row and the full session menu — inside a softly selected pill. Closing + * a tab only removes it from the strip; closing the active one activates its + * neighbour. Ids whose session has not loaded (or was archived/deleted) stay + * in the store but do not render, so a partial session list never destroys + * the working set. + */ +export const SessionTabsStrip: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { t } = useI18n(); + const tabIds = useSessionTabsStore((state) => state.tabIds); + const ensureTab = useSessionTabsStore((state) => state.ensureTab); + const closeTab = useSessionTabsStore((state) => state.closeTab); + const closeOtherTabs = useSessionTabsStore((state) => state.closeOtherTabs); + const reorderTabs = useSessionTabsStore((state) => state.reorderTabs); + + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const activeSessions = useGlobalSessionsStore((state) => state.activeSessions); + + // Opening a session anywhere (sidebar, palette, deep link) adds its tab. + React.useEffect(() => { + if (currentSessionId) ensureTab(currentSessionId); + }, [currentSessionId, ensureTab]); + + const sessionsById = React.useMemo(() => { + const map = new Map(); + for (const session of activeSessions) map.set(session.id, session); + return map; + }, [activeSessions]); + + // Only tabs with a known live session render; unknown ids stay stored. + const tabs = React.useMemo(() => { + const list: SessionTab[] = []; + for (const id of tabIds) { + const session = sessionsById.get(id); + if (session) list.push({ id, session }); + } + return list; + }, [tabIds, sessionsById]); + + const handleSelect = React.useCallback((tab: SessionTab) => { + setCurrentSession(tab.id, resolveGlobalSessionDirectory(tab.session)); + }, [setCurrentSession]); + + const activateNeighbour = React.useCallback((closedId: string) => { + const index = tabs.findIndex((tab) => tab.id === closedId); + const neighbour = tabs[index + 1] ?? tabs[index - 1] ?? null; + if (neighbour) { + handleSelect(neighbour); + } else { + openNewSessionDraft(); + } + }, [tabs, handleSelect, openNewSessionDraft]); + + const handleClose = React.useCallback((id: string) => { + if (id === currentSessionId) activateNeighbour(id); + closeTab(id); + }, [activateNeighbour, closeTab, currentSessionId]); + + const handleCloseOthers = React.useCallback((id: string) => { + closeOtherTabs(id); + if (currentSessionId && currentSessionId !== id) { + const kept = tabs.find((tab) => tab.id === id); + if (kept) handleSelect(kept); + } + }, [closeOtherTabs, currentSessionId, handleSelect, tabs]); + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }), + ); + + const handleDragEnd = React.useCallback((event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + reorderTabs(String(active.id), String(over.id)); + } + }, [reorderTabs]); + + // Soft fade at the edges while more tabs hide behind them. + const scrollRef = React.useRef(null); + const [edges, setEdges] = React.useState({ left: false, right: false }); + const updateEdges = React.useCallback(() => { + const node = scrollRef.current; + if (!node) return; + const left = node.scrollLeft > 2; + const right = node.scrollLeft + node.clientWidth < node.scrollWidth - 2; + setEdges((prev) => (prev.left === left && prev.right === right ? prev : { left, right })); + }, []); + React.useEffect(() => { + updateEdges(); + const node = scrollRef.current; + if (!node || !globalThis.ResizeObserver) return; + const observer = new ResizeObserver(updateEdges); + observer.observe(node); + return () => observer.disconnect(); + }, [updateEdges, tabs.length]); + + // Keep the active tab in view when it changes. + React.useEffect(() => { + scrollRef.current + ?.querySelector('[data-active-session-tab]') + ?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }, [currentSessionId]); + + const maskImage = edges.left && edges.right + ? 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)' + : edges.left + ? 'linear-gradient(to right, transparent, black 24px)' + : edges.right + ? 'linear-gradient(to right, black calc(100% - 24px), transparent)' + : undefined; + + const tabIdsInOrder = React.useMemo(() => tabs.map((tab) => tab.id), [tabs]); + + const renderTab = (tab: SessionTab) => { + if (tab.id === currentSessionId) { + return {children}; + } + return ( + + ); + }; + + // A brand-new draft (no session yet) shows as a transient active pill after + // the tabs; it becomes a real tab once the first message creates the session. + const showDraftPill = !currentSessionId || !tabs.some((tab) => tab.id === currentSessionId); + + return ( +
+
+ + + {tabs.map(renderTab)} + + + {showDraftPill ? ( +
+ {children} +
+ ) : null} +
+
+ ); +}; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 4f0f3bc2..44b048d4 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -419,6 +419,10 @@ export const dict = { 'sessions.sidebar.activity.chatsEmpty': 'Noch keine Chats.', 'chat.chatInput.chooseProject': 'Projekt auswählen', 'sessions.switcher.openAria': 'Sitzungswechsler öffnen', + 'header.sessionTabs.stripAria': 'Offene Sitzungen', + 'header.sessionTabs.tabMenuAria': 'Aktionen für den Sitzungs-Tab', + 'header.sessionTabs.closeTab': 'Tab schließen', + 'header.sessionTabs.closeOtherTabs': 'Andere Tabs schließen', 'sessions.switcher.empty': 'Keine kürzlichen Sitzungen', 'sessions.switcher.draftTitle': 'Neue Sitzung', 'sessions.sidebar.updateCheck.errorTitle': 'Fehler beim Prüfen auf Aktualisierungen', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index fc9a2717..a4840295 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -481,6 +481,10 @@ export const dict = { 'sessions.archivePage.deleteSessionAria': 'Delete {title}', 'sessions.archivePage.restoreSessionAria': 'Restore {title}', 'sessions.switcher.openAria': 'Open session switcher', + 'header.sessionTabs.stripAria': 'Open sessions', + 'header.sessionTabs.tabMenuAria': 'Session tab actions', + 'header.sessionTabs.closeTab': 'Close tab', + 'header.sessionTabs.closeOtherTabs': 'Close other tabs', 'sessions.switcher.empty': 'No recent sessions', 'sessions.switcher.draftTitle': 'New session', 'sessions.sidebar.updateCheck.errorTitle': 'Failed to check for updates', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 17fec35e..1f6b829e 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -482,6 +482,10 @@ export const dict: Record = { "sessions.archivePage.deleteSessionAria": "Eliminar {title}", "sessions.archivePage.restoreSessionAria": "Restaurar {title}", "sessions.switcher.openAria": "Abrir selector de sesiones", + "header.sessionTabs.stripAria": "Sesiones abiertas", + "header.sessionTabs.tabMenuAria": "Acciones de la pestaña de sesión", + "header.sessionTabs.closeTab": "Cerrar pestaña", + "header.sessionTabs.closeOtherTabs": "Cerrar las demás pestañas", "sessions.switcher.empty": "No hay sesiones recientes", "sessions.switcher.draftTitle": "Nueva sesión", "sessions.sidebar.updateCheck.errorTitle": "No se pudo comprobar actualizaciones", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 0cf10e61..1b8ccd3a 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -312,6 +312,10 @@ export const dict = { 'sessions.archivePage.deleteSessionAria': 'Supprimer {title}', 'sessions.archivePage.restoreSessionAria': 'Restaurer {title}', 'sessions.switcher.openAria': 'Sélecteur de session ouvert', + 'header.sessionTabs.stripAria': 'Sessions ouvertes', + 'header.sessionTabs.tabMenuAria': 'Actions de l\'onglet de session', + 'header.sessionTabs.closeTab': 'Fermer l\'onglet', + 'header.sessionTabs.closeOtherTabs': 'Fermer les autres onglets', 'sessions.switcher.empty': 'Aucune session récente', 'sessions.switcher.draftTitle': 'Nouvelle session', 'sessions.sidebar.updateCheck.errorTitle': 'Échec de la vérification des mises à jour', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 5796a5b0..fe7f1868 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -482,6 +482,10 @@ export const dict: Record = { 'sessions.archivePage.deleteSessionAria': '{title} を削除', 'sessions.archivePage.restoreSessionAria': '{title} を復元', 'sessions.switcher.openAria': 'セッションスイッチャーを開く', + 'header.sessionTabs.stripAria': '開いているセッション', + 'header.sessionTabs.tabMenuAria': 'セッションタブの操作', + 'header.sessionTabs.closeTab': 'タブを閉じる', + 'header.sessionTabs.closeOtherTabs': '他のタブを閉じる', 'sessions.switcher.empty': '最近のセッションはありません', 'sessions.switcher.draftTitle': '新しいセッション', 'sessions.sidebar.updateCheck.errorTitle': '更新の確認に失敗しました', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 80d4f60e..a2b73918 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -482,6 +482,10 @@ export const dict: Record = { 'sessions.archivePage.deleteSessionAria': '{title} 삭제', 'sessions.archivePage.restoreSessionAria': '{title} 복원', 'sessions.switcher.openAria': '세션 전환기 열기', + 'header.sessionTabs.stripAria': '열린 세션', + 'header.sessionTabs.tabMenuAria': '세션 탭 작업', + 'header.sessionTabs.closeTab': '탭 닫기', + 'header.sessionTabs.closeOtherTabs': '다른 탭 닫기', 'sessions.switcher.empty': '최근 세션 없음', 'sessions.switcher.draftTitle': '새 세션', 'sessions.sidebar.updateCheck.errorTitle': '업데이트 확인 실패', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index c29a1dfa..2f3ef7b6 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -293,6 +293,10 @@ export const dict: Record = { 'sessions.archivePage.deleteSessionAria': 'Usuń {title}', 'sessions.archivePage.restoreSessionAria': 'Przywróć {title}', 'sessions.switcher.openAria': 'Otwórz przełącznik sesji', + 'header.sessionTabs.stripAria': 'Otwarte sesje', + 'header.sessionTabs.tabMenuAria': 'Akcje karty sesji', + 'header.sessionTabs.closeTab': 'Zamknij kartę', + 'header.sessionTabs.closeOtherTabs': 'Zamknij pozostałe karty', 'sessions.switcher.empty': 'Brak ostatnich sesji', 'sessions.switcher.draftTitle': 'Nowa sesja', 'sessions.sidebar.updateCheck.errorTitle': 'Nie udało się sprawdzić aktualizacji', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 5e988ce3..2723fd6e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -482,6 +482,10 @@ export const dict: Record = { "sessions.archivePage.deleteSessionAria": "Excluir {title}", "sessions.archivePage.restoreSessionAria": "Restaurar {title}", "sessions.switcher.openAria": "Abrir seletor de sessões", + "header.sessionTabs.stripAria": "Sessões abertas", + "header.sessionTabs.tabMenuAria": "Ações da aba de sessão", + "header.sessionTabs.closeTab": "Fechar aba", + "header.sessionTabs.closeOtherTabs": "Fechar outras abas", "sessions.switcher.empty": "Nenhuma sessão recente", "sessions.switcher.draftTitle": "Nova sessão", "sessions.sidebar.updateCheck.errorTitle": "Não foi possível verificar atualizações", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 1f10374f..90c85f21 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -482,6 +482,10 @@ export const dict: Record = { "sessions.archivePage.deleteSessionAria": "Видалити {title}", "sessions.archivePage.restoreSessionAria": "Відновити {title}", "sessions.switcher.openAria": "Відкрити перемикач сесій", + "header.sessionTabs.stripAria": "Відкриті сесії", + "header.sessionTabs.tabMenuAria": "Дії вкладки сесії", + "header.sessionTabs.closeTab": "Закрити вкладку", + "header.sessionTabs.closeOtherTabs": "Закрити інші вкладки", "sessions.switcher.empty": "Немає недавніх сесій", "sessions.switcher.draftTitle": "Нова сесія", "sessions.sidebar.updateCheck.errorTitle": "Не вдалося перейти на наявність оновлень", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2b0c7263..007a243b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -482,6 +482,10 @@ export const dict: Record = { 'sessions.archivePage.deleteSessionAria': '删除 {title}', 'sessions.archivePage.restoreSessionAria': '还原 {title}', 'sessions.switcher.openAria': '打开会话切换器', + 'header.sessionTabs.stripAria': '打开的会话', + 'header.sessionTabs.tabMenuAria': '会话标签页操作', + 'header.sessionTabs.closeTab': '关闭标签页', + 'header.sessionTabs.closeOtherTabs': '关闭其他标签页', 'sessions.switcher.empty': '没有最近会话', 'sessions.switcher.draftTitle': '新会话', 'sessions.sidebar.updateCheck.errorTitle': '检查更新失败', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 842450c1..1e4750e3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -495,6 +495,10 @@ export const dict: Record = { 'sessions.archivePage.deleteSessionAria': '刪除 {title}', 'sessions.archivePage.restoreSessionAria': '還原 {title}', 'sessions.switcher.openAria': '開啟會話切換器', + 'header.sessionTabs.stripAria': '開啟的會話', + 'header.sessionTabs.tabMenuAria': '工作階段分頁動作', + 'header.sessionTabs.closeTab': '關閉分頁', + 'header.sessionTabs.closeOtherTabs': '關閉其他分頁', 'sessions.switcher.empty': '沒有最近會話', 'sessions.switcher.draftTitle': '新會話', 'sessions.sidebar.updateCheck.errorTitle': '檢查更新失敗', diff --git a/packages/ui/src/stores/useSessionTabsStore.test.ts b/packages/ui/src/stores/useSessionTabsStore.test.ts new file mode 100644 index 00000000..5e9d9547 --- /dev/null +++ b/packages/ui/src/stores/useSessionTabsStore.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { useSessionTabsStore } from './useSessionTabsStore'; + +describe('useSessionTabsStore', () => { + beforeEach(() => { + useSessionTabsStore.setState({ tabIds: [] }); + }); + + test('ensureTab appends once and preserves order', () => { + const store = useSessionTabsStore.getState(); + store.ensureTab('a'); + store.ensureTab('b'); + store.ensureTab('a'); + expect(useSessionTabsStore.getState().tabIds).toEqual(['a', 'b']); + }); + + test('closeTab removes only the given id; closeOtherTabs keeps only it', () => { + useSessionTabsStore.setState({ tabIds: ['a', 'b', 'c'] }); + useSessionTabsStore.getState().closeTab('b'); + expect(useSessionTabsStore.getState().tabIds).toEqual(['a', 'c']); + useSessionTabsStore.getState().closeOtherTabs('c'); + expect(useSessionTabsStore.getState().tabIds).toEqual(['c']); + }); + + test('reorderTabs moves by id and ignores unknown ids', () => { + useSessionTabsStore.setState({ tabIds: ['a', 'b', 'c'] }); + useSessionTabsStore.getState().reorderTabs('c', 'a'); + expect(useSessionTabsStore.getState().tabIds).toEqual(['c', 'a', 'b']); + const before = useSessionTabsStore.getState().tabIds; + useSessionTabsStore.getState().reorderTabs('x', 'a'); + expect(useSessionTabsStore.getState().tabIds).toBe(before); + }); + + test('removeTabs drops only confirmed-gone ids and no-ops otherwise', () => { + useSessionTabsStore.setState({ tabIds: ['a', 'b'] }); + const before = useSessionTabsStore.getState().tabIds; + useSessionTabsStore.getState().removeTabs(['x']); + expect(useSessionTabsStore.getState().tabIds).toBe(before); + useSessionTabsStore.getState().removeTabs(['a']); + expect(useSessionTabsStore.getState().tabIds).toEqual(['b']); + }); +}); diff --git a/packages/ui/src/stores/useSessionTabsStore.ts b/packages/ui/src/stores/useSessionTabsStore.ts new file mode 100644 index 00000000..b2c6b3d2 --- /dev/null +++ b/packages/ui/src/stores/useSessionTabsStore.ts @@ -0,0 +1,81 @@ +import { create } from 'zustand'; +import { devtools, persist } from 'zustand/middleware'; + +import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage'; + +/** + * The header's working set of sessions, shown as tabs on web/desktop. + * + * Only session ids and their order are owned here — titles, directories and + * liveness come from the session stores at render time. Tabs are a per-client + * projection: opening a session anywhere adds it once, closing a tab only + * removes it from the strip and never touches the session itself. Ids whose + * session is unknown are kept (a partially loaded global list must not + * destroy the working set) and simply do not render until the session loads. + */ +interface SessionTabsStore { + tabIds: string[]; + + ensureTab: (sessionId: string) => void; + closeTab: (sessionId: string) => void; + closeOtherTabs: (sessionId: string) => void; + reorderTabs: (activeId: string, overId: string) => void; + /** Drop ids the caller has authoritatively confirmed no longer exist. */ + removeTabs: (sessionIds: readonly string[]) => void; +} + +type PersistedSessionTabs = { tabIds: string[] }; + +export const useSessionTabsStore = create()( + devtools( + persist( + (set, get) => ({ + tabIds: [], + + ensureTab: (sessionId) => { + if (!sessionId) return; + const { tabIds } = get(); + if (tabIds.includes(sessionId)) return; + set({ tabIds: [...tabIds, sessionId] }); + }, + + closeTab: (sessionId) => { + const { tabIds } = get(); + if (!tabIds.includes(sessionId)) return; + set({ tabIds: tabIds.filter((id) => id !== sessionId) }); + }, + + closeOtherTabs: (sessionId) => { + const { tabIds } = get(); + if (!tabIds.includes(sessionId)) return; + if (tabIds.length === 1) return; + set({ tabIds: [sessionId] }); + }, + + reorderTabs: (activeId, overId) => { + const { tabIds } = get(); + const from = tabIds.indexOf(activeId); + const to = tabIds.indexOf(overId); + if (from < 0 || to < 0 || from === to) return; + const next = [...tabIds]; + next.splice(to, 0, ...next.splice(from, 1)); + set({ tabIds: next }); + }, + + removeTabs: (sessionIds) => { + if (sessionIds.length === 0) return; + const gone = new Set(sessionIds); + const { tabIds } = get(); + const next = tabIds.filter((id) => !gone.has(id)); + if (next.length === tabIds.length) return; + set({ tabIds: next }); + }, + }), + { + name: 'session-tabs-store', + storage: createDeferredSafeJSONStorage(), + partialize: (state) => ({ tabIds: state.tabIds }), + }, + ), + ), +);