feat(UI): Introduce new UI components for file attachments and related views (#191)

Add file management API and UI components
Implement directory listing, search and CRUD operations in desktop backend
Expose new Files API on frontend to list, search, and modify files
This commit is contained in:
Bohdan Triapitsyn
2026-01-22 12:21:19 +02:00
committed by GitHub
parent 1f23b63c0b
commit d97181bcd2
22 changed files with 1611 additions and 1223 deletions
+55 -90
View File
@@ -59,7 +59,7 @@ export const FixedSessionsButton: React.FC = () => {
}
}, [isMobile, setSessionSwitcherOpen, toggleSidebar]);
const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground';
const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-md gap-2 p-2 typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-secondary/50 transition-colors';
if (isMobile || !isDesktopApp || !isMacPlatform) {
return null;
@@ -89,7 +89,6 @@ export const Header: React.FC = () => {
const toggleCommandPalette = useUIStore((state) => state.toggleCommandPalette);
const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog);
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
@@ -168,12 +167,11 @@ export const Header: React.FC = () => {
setSettingsDialogOpen(true);
}, [blurActiveElement, isMobile, setSessionSwitcherOpen, setSettingsDialogOpen]);
const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground';
const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 rounded-md typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-secondary/50 transition-colors';
const desktopPaddingClass = React.useMemo(() => {
if (isDesktopApp && isMacPlatform) {
return isSidebarOpen ? 'pl-0' : 'pl-[8.0rem]';
return isSidebarOpen ? 'pl-2' : 'pl-[8.0rem]';
}
return 'pl-3';
}, [isDesktopApp, isMacPlatform, isSidebarOpen]);
@@ -221,15 +219,12 @@ export const Header: React.FC = () => {
}, [updateHeaderHeight, isMobile]);
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) {
return;
}
if (e.button !== 0) {
return;
}
if (isDesktopApp) {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
@@ -242,11 +237,9 @@ export const Header: React.FC = () => {
}, [isDesktopApp]);
const handleActiveTabDragStart = React.useCallback(async (e: React.MouseEvent) => {
if (e.button !== 0) {
return;
}
if (isDesktopApp) {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
@@ -278,7 +271,6 @@ export const Header: React.FC = () => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= tabs.length) {
@@ -287,82 +279,58 @@ export const Header: React.FC = () => {
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [tabs, setActiveMainTab]);
const renderTab = (tab: TabConfig, isLast: boolean) => {
const renderTab = (tab: TabConfig) => {
const isActive = activeMainTab === tab.id;
const Icon = tab.icon;
const isChatTab = tab.id === 'chat';
const isGitTab = tab.id === 'git';
return (
<React.Fragment key={tab.id}>
<button
type="button"
onClick={() => setActiveMainTab(tab.id)}
onMouseDown={isActive ? handleActiveTabDragStart : undefined}
className={cn(
'relative flex h-full items-center gap-2 px-4 typography-ui-label font-medium transition-colors',
isActive ? 'app-region-drag' : 'app-region-no-drag',
'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
isActive ? 'text-foreground' : 'text-muted-foreground',
isActive && 'after:absolute after:bottom-[-1px] after:left-0 after:right-0 after:h-[2px] after:bg-background',
isActive && isChatTab && isSidebarOpen && 'before:absolute before:bottom-[-1px] before:right-full before:h-px before:bg-[var(--interactive-border)] before:w-[var(--sidebar-w)]',
isChatTab && !(isDesktopApp && isMacPlatform && isSidebarOpen) && 'border-l',
isGitTab && 'border-r',
isChatTab && !isMobile && 'min-w-[165px] max-[1024px]:min-w-0'
)}
style={{
...(isActive && isChatTab && isSidebarOpen ? {
['--sidebar-w' as string]: (isDesktopApp && isMacPlatform) ? `${sidebarWidth}px` : '64px'
} : {}),
...((isChatTab && !(isDesktopApp && isMacPlatform && isSidebarOpen)) || isGitTab ? { borderColor: 'var(--interactive-border)' } : {}),
}}
aria-label={tab.label}
aria-selected={isActive}
role="tab"
>
{isMobile ? (
<Icon size={20} />
) : (
<>
<Icon size={16} />
<span className="header-tab-label">{tab.label}</span>
</>
)}
{}
{isChatTab && !isMobile && contextUsage && contextUsage.totalTokens > 0 && (
<span className="ml-1">
<ContextUsageDisplay
totalTokens={contextUsage.totalTokens}
percentage={contextUsage.percentage}
contextLimit={contextUsage.contextLimit}
outputLimit={contextUsage.outputLimit ?? 0}
size="compact"
/>
</span>
)}
{}
{tab.badge !== undefined && tab.badge > 0 && (
<span className="text-xs font-semibold text-primary">
{tab.badge}
</span>
)}
</button>
{}
{!isLast && (
<div className="h-full w-px bg-border" aria-hidden="true" />
<button
key={tab.id}
type="button"
onClick={() => setActiveMainTab(tab.id)}
onMouseDown={isActive ? handleActiveTabDragStart : undefined}
className={cn(
'relative flex h-8 items-center gap-2 px-3 rounded-md typography-ui-label font-medium transition-colors',
isActive ? 'app-region-drag bg-secondary text-foreground shadow-sm' : 'app-region-no-drag text-muted-foreground hover:bg-secondary/50 hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isChatTab && !isMobile && 'min-w-[100px] justify-center'
)}
</React.Fragment>
aria-label={tab.label}
aria-selected={isActive}
role="tab"
>
{isMobile ? (
<Icon size={20} />
) : (
<>
<Icon size={16} />
<span className="header-tab-label">{tab.label}</span>
</>
)}
{isChatTab && !isMobile && contextUsage && contextUsage.totalTokens > 0 && (
<span className="ml-1">
<ContextUsageDisplay
totalTokens={contextUsage.totalTokens}
percentage={contextUsage.percentage}
contextLimit={contextUsage.contextLimit}
outputLimit={contextUsage.outputLimit ?? 0}
size="compact"
/>
</span>
)}
{tab.badge !== undefined && tab.badge > 0 && (
<span className="ml-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/10 px-1 text-[10px] font-bold text-primary">
{tab.badge}
</span>
)}
</button>
);
};
@@ -376,29 +344,25 @@ export const Header: React.FC = () => {
role="tablist"
aria-label="Main navigation"
>
{}
{!(isDesktopApp && isMacPlatform) && (
<>
<button
type="button"
onClick={handleOpenSessionSwitcher}
aria-label="Open sessions"
className={`${headerIconButtonClass} mr-2.5`}
className={`${headerIconButtonClass} mr-2`}
>
<RiLayoutLeftLine className="h-5 w-5" />
</button>
</>
)}
{}
<div className="flex h-full items-center">
{tabs.map((tab, index) => renderTab(tab, index === tabs.length - 1))}
<div className="flex items-center gap-1 p-1 bg-background/50 rounded-lg">
{tabs.map((tab) => renderTab(tab))}
</div>
{}
<div className="flex-1" />
{}
<div className="flex items-center gap-1 pr-3">
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
@@ -442,7 +406,7 @@ export const Header: React.FC = () => {
<div className="flex items-center gap-2">
<button
onClick={handleOpenSessionSwitcher}
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md active:bg-secondary"
aria-label="Open sessions"
>
<RiPlayListAddLine className="h-5 w-5" />
@@ -459,9 +423,10 @@ export const Header: React.FC = () => {
)}
</div>
<div className="app-region-no-drag flex items-center gap-1.5">
<div className="app-region-no-drag flex items-center gap-1">
<div className="flex items-center gap-0.5" role="tablist" aria-label="Main navigation">
<div className="flex items-center" role="tablist" aria-label="Main navigation">
{tabs.map((tab) => {
const isActive = activeMainTab === tab.id;
const Icon = tab.icon;
@@ -482,7 +447,7 @@ export const Header: React.FC = () => {
className={cn(
headerIconButtonClass,
'relative',
isActive && 'text-foreground'
isActive && 'text-foreground bg-secondary'
)}
>
<Icon className="h-5 w-5" />
@@ -535,7 +500,7 @@ export const Header: React.FC = () => {
);
const headerClassName = cn(
'header-safe-area border-b relative z-10',
'header-safe-area border-b border-border/50 relative z-10',
isDesktopApp ? 'bg-background' : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
);
@@ -543,7 +508,7 @@ export const Header: React.FC = () => {
<header
ref={headerRef}
className={headerClassName}
style={{ borderColor: 'var(--interactive-border)', ['--padding-scale' as string]: '1' } as React.CSSProperties}
style={{ ['--padding-scale' as string]: '1' } as React.CSSProperties}
>
{isMobile ? renderMobile() : renderDesktop()}
</header>
+30 -31
View File
@@ -168,19 +168,17 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
return (
<aside
className={cn(
'relative flex h-full overflow-hidden border-r',
'relative flex h-full overflow-hidden border-r border-border',
isDesktopApp
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
? 'bg-sidebar/95 backdrop-blur supports-[backdrop-filter]:bg-sidebar/80'
: 'bg-sidebar',
isResizing ? 'transition-none' : '',
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
!isOpen && 'border-r-0'
)}
style={{
width: `${appliedWidth}px`,
minWidth: `${appliedWidth}px`,
maxWidth: `${appliedWidth}px`,
pointerEvents: !isOpen ? 'none' : undefined,
borderColor: 'var(--interactive-border)',
overflowX: 'clip',
}}
aria-hidden={!isOpen || appliedWidth === 0}
@@ -188,8 +186,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
{isOpen && (
<div
className={cn(
'absolute right-0 top-0 z-20 h-full w-[6px] -mr-[3px] cursor-col-resize',
isResizing ? 'bg-primary/30' : 'bg-transparent hover:bg-primary/20'
'absolute right-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
isResizing && 'bg-primary'
)}
onPointerDown={handlePointerDown}
role="separator"
@@ -199,7 +197,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
)}
<div
className={cn(
'relative z-10 flex h-full flex-col transition-opacity duration-200 ease-in-out',
'relative z-10 flex h-full flex-col transition-opacity duration-300 ease-in-out',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
style={{ width: `${appliedWidth}px`, overflowX: 'hidden' }}
@@ -216,44 +214,45 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
<div className="flex-1 overflow-hidden">
<ErrorBoundary>{children}</ErrorBoundary>
</div>
<div className="flex-shrink-0 border-t border-border p-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-shrink-0 border-t border-border h-12 px-2 bg-sidebar-accent/10">
<div className="flex h-full items-center justify-between gap-2">
<button
onClick={() => setSettingsDialogOpen(true)}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2',
'text-sm font-semibold text-muted-foreground',
'hover:text-foreground',
'transition-colors'
'flex h-8 items-center gap-2 rounded-md px-2',
'text-sm font-semibold text-sidebar-foreground/90',
'hover:text-sidebar-foreground hover:bg-sidebar-accent',
'transition-all duration-200'
)}
>
<RiSettings3Line className="h-4 w-4" />
<span>Settings</span>
</button>
{(available || downloaded) ? (
<button
onClick={() => setUpdateDialogOpen(true)}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1.5',
'text-xs font-medium',
'bg-primary/10 text-primary',
'hover:bg-primary/20',
'transition-colors'
)}
>
<RiDownloadLine className="h-3.5 w-3.5" />
<span>Update</span>
</button>
<button
onClick={() => setUpdateDialogOpen(true)}
className={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1',
'text-xs font-semibold',
'bg-primary/10 text-primary',
'hover:bg-primary/20',
'transition-colors'
)}
>
<RiDownloadLine className="h-3.5 w-3.5" />
<span>Update</span>
</button>
) : !isDesktopApp && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setAboutDialogOpen(true)}
className={cn(
'flex items-center justify-center rounded-md p-1.5',
'text-muted-foreground',
'hover:text-foreground hover:bg-muted/50',
'transition-colors'
'flex h-8 w-8 items-center justify-center rounded-md',
'text-sidebar-foreground/70',
'hover:text-sidebar-foreground hover:bg-sidebar-accent',
'transition-all duration-200'
)}
>
<RiInformationLine className="h-4 w-4" />