Brings in upstream changes: - Session analysis reports (overview, cost, tokens, tools, git, quality) - Unified cost calculation with LiteLLM pricing data - Custom title bar for Linux with native toggle - Auto-expand AI response groups setting - MoreMenu toolbar component - Various fixes (window drag, Ctrl+R, notification guard) All merge conflicts resolved preserving both fork features (team management, agent language, fullscreen, diff view) and upstream additions.
174 lines
4.8 KiB
TypeScript
174 lines
4.8 KiB
TypeScript
/**
|
|
* SortableTab - A draggable tab item used within SortableContext.
|
|
* Wraps useSortable from @dnd-kit for tab reordering and cross-pane movement.
|
|
*/
|
|
|
|
import { useCallback, useState } from 'react';
|
|
|
|
import { useSortable } from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import { useStore } from '@renderer/store';
|
|
import {
|
|
Activity,
|
|
Bell,
|
|
FileText,
|
|
LayoutDashboard,
|
|
Pin,
|
|
Search,
|
|
Settings,
|
|
Users,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
|
|
import type { Tab } from '@renderer/types/tabs';
|
|
|
|
interface SortableTabProps {
|
|
tab: Tab;
|
|
paneId: string;
|
|
isActive: boolean;
|
|
isSelected: boolean;
|
|
onTabClick: (tabId: string, e: React.MouseEvent) => void;
|
|
onMouseDown: (tabId: string, e: React.MouseEvent) => void;
|
|
onContextMenu: (tabId: string, e: React.MouseEvent) => void;
|
|
onClose: (tabId: string) => void;
|
|
setRef: (tabId: string, el: HTMLDivElement | null) => void;
|
|
}
|
|
|
|
const TAB_ICONS = {
|
|
dashboard: LayoutDashboard,
|
|
notifications: Bell,
|
|
settings: Settings,
|
|
session: FileText,
|
|
teams: Users,
|
|
team: Users,
|
|
report: Activity,
|
|
} as const;
|
|
|
|
export const SortableTab = ({
|
|
tab,
|
|
paneId,
|
|
isActive,
|
|
isSelected,
|
|
onTabClick,
|
|
onMouseDown,
|
|
onContextMenu,
|
|
onClose,
|
|
setRef,
|
|
}: SortableTabProps): React.JSX.Element => {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
|
|
const isPinned = useStore(
|
|
useShallow((s) =>
|
|
tab.type === 'session' && tab.sessionId ? s.pinnedSessionIds.includes(tab.sessionId) : false
|
|
)
|
|
);
|
|
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
id: tab.id,
|
|
data: {
|
|
type: 'tab',
|
|
tabId: tab.id,
|
|
paneId,
|
|
},
|
|
});
|
|
|
|
const style = {
|
|
WebkitAppRegion: 'no-drag',
|
|
transform: CSS.Transform.toString(transform),
|
|
transition: isDragging ? 'none' : transition,
|
|
opacity: isDragging ? 0.3 : 1,
|
|
backgroundColor: isActive
|
|
? 'var(--color-surface-raised)'
|
|
: isHovered
|
|
? 'var(--color-surface-overlay)'
|
|
: 'transparent',
|
|
color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)',
|
|
outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none',
|
|
outlineOffset: '-1px',
|
|
};
|
|
|
|
const Icon = TAB_ICONS[tab.type];
|
|
|
|
const handleRef = useCallback(
|
|
(el: HTMLDivElement | null) => {
|
|
setNodeRef(el);
|
|
setRef(tab.id, el);
|
|
},
|
|
[setNodeRef, setRef, tab.id]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={handleRef}
|
|
// eslint-disable-next-line react/jsx-props-no-spreading -- @dnd-kit useSortable requires prop spreading
|
|
{...attributes}
|
|
// eslint-disable-next-line react/jsx-props-no-spreading -- @dnd-kit useSortable requires prop spreading
|
|
{...listeners}
|
|
role="tab"
|
|
tabIndex={0}
|
|
aria-selected={isActive}
|
|
className="group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
|
|
style={style}
|
|
onClick={(e) => onTabClick(tab.id, e)}
|
|
onMouseDown={(e) => onMouseDown(tab.id, e)}
|
|
onContextMenu={(e) => onContextMenu(tab.id, e)}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onTabClick(tab.id, e as unknown as React.MouseEvent);
|
|
}
|
|
}}
|
|
>
|
|
<Icon className="size-4 shrink-0" />
|
|
{tab.fromSearch && (
|
|
<span title="Opened from search">
|
|
<Search className="size-3 shrink-0 text-amber-400" />
|
|
</span>
|
|
)}
|
|
{isPinned && (
|
|
<span title="Pinned session">
|
|
<Pin className="size-3 shrink-0 text-blue-400" />
|
|
</span>
|
|
)}
|
|
<span className="truncate text-sm">{tab.label}</span>
|
|
<button
|
|
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
|
|
style={{ backgroundColor: 'transparent' }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClose(tab.id);
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
title="Close tab"
|
|
>
|
|
<X className="size-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* DragOverlayTab - Semi-transparent ghost of a tab shown during drag.
|
|
*/
|
|
export const DragOverlayTab = ({ tab }: { tab: Tab }): React.JSX.Element => {
|
|
const Icon = TAB_ICONS[tab.type];
|
|
|
|
return (
|
|
<div
|
|
className="flex min-w-0 max-w-[200px] items-center gap-2 rounded-md border-2 px-3 py-1.5"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface-raised)',
|
|
borderColor: 'var(--color-accent, #6366f1)',
|
|
color: 'var(--color-text)',
|
|
opacity: 0.9,
|
|
cursor: 'grabbing',
|
|
}}
|
|
>
|
|
<Icon className="size-4 shrink-0" />
|
|
<span className="truncate text-sm">{tab.label}</span>
|
|
</div>
|
|
);
|
|
};
|