feat: integrate tooltip functionality across various components

- Added TooltipProvider and Tooltip components to enhance user experience with contextual information.
- Wrapped existing components such as TeamListView, TeamDetailView, and SendMessageDialog with tooltips for better interaction feedback.
- Implemented MessagesFilterPopover to allow filtering messages by sender and recipient, improving message management.
- Updated KanbanBoard and KanbanFilterPopover to include tooltips for view mode buttons, enhancing usability.

These changes aim to provide users with clearer guidance and improve overall interaction within the application.
This commit is contained in:
iliya 2026-02-23 12:09:23 +02:00 committed by Илия
parent c793426247
commit 58f06576d3
10 changed files with 565 additions and 254 deletions

View file

@ -4,6 +4,7 @@ import { ConfirmDialog } from './components/common/ConfirmDialog';
import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay'; import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
import { ErrorBoundary } from './components/common/ErrorBoundary'; import { ErrorBoundary } from './components/common/ErrorBoundary';
import { TabbedLayout } from './components/layout/TabbedLayout'; import { TabbedLayout } from './components/layout/TabbedLayout';
import { TooltipProvider } from './components/ui/tooltip';
import { useTheme } from './hooks/useTheme'; import { useTheme } from './hooks/useTheme';
import { api } from './api'; import { api } from './api';
import { initializeNotificationListeners, useStore } from './store'; import { initializeNotificationListeners, useStore } from './store';
@ -43,9 +44,11 @@ export const App = (): React.JSX.Element => {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ContextSwitchOverlay /> <TooltipProvider delayDuration={300}>
<TabbedLayout /> <ContextSwitchOverlay />
<ConfirmDialog /> <TabbedLayout />
<ConfirmDialog />
</TooltipProvider>
</ErrorBoundary> </ErrorBoundary>
); );
}; };

View file

@ -19,11 +19,13 @@ import { KanbanBoard } from './kanban/KanbanBoard';
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover'; import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
import { MemberDetailDialog } from './members/MemberDetailDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog';
import { MemberList } from './members/MemberList'; import { MemberList } from './members/MemberList';
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection'; import { TeamSessionsSection } from './TeamSessionsSection';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
import type { ResolvedTeamMember, TeamTask } from '@shared/types'; import type { ResolvedTeamMember, TeamTask } from '@shared/types';
@ -134,6 +136,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
); );
const [kanbanSearch, setKanbanSearch] = useState(''); const [kanbanSearch, setKanbanSearch] = useState('');
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
from: new Set(),
to: new Set(),
});
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!teamName) { if (!teamName) {
@ -251,12 +259,31 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const filteredMessages = useMemo(() => { const filteredMessages = useMemo(() => {
if (!data) return []; if (!data) return [];
if (!timeWindow) return data.messages; let list = data.messages;
return data.messages.filter((m) => { if (timeWindow) {
const ts = new Date(m.timestamp).getTime(); list = list.filter((m) => {
return ts >= timeWindow.start && ts < timeWindow.end; const ts = new Date(m.timestamp).getTime();
}); return ts >= timeWindow.start && ts < timeWindow.end;
}, [data, timeWindow]); });
}
if (messagesFilter.from.size > 0) {
list = list.filter((m) => m.from?.trim() && messagesFilter.from.has(m.from.trim()));
}
if (messagesFilter.to.size > 0) {
list = list.filter((m) => m.to?.trim() && messagesFilter.to.has(m.to.trim()));
}
const q = messagesSearchQuery.trim().toLowerCase();
if (q) {
list = list.filter((m) => {
const text = (m.text ?? '').toLowerCase();
const summary = (m.summary ?? '').toLowerCase();
const from = (m.from ?? '').toLowerCase();
const to = (m.to ?? '').toLowerCase();
return text.includes(q) || summary.includes(q) || from.includes(q) || to.includes(q);
});
}
return list;
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
const kanbanDisplayTasks = useMemo(() => { const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim(); const query = kanbanSearch.trim();
@ -593,20 +620,41 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
badge={filteredMessages.length} badge={filteredMessages.length}
defaultOpen defaultOpen
action={ action={
<Button <div className="flex items-center gap-2 pl-2">
variant="outline" <div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
size="sm" <Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
className="h-7 gap-1.5 px-2.5 text-xs font-medium text-[var(--color-text)]" <input
onClick={(e) => { type="search"
e.stopPropagation(); placeholder="Поиск..."
setSendDialogRecipient(undefined); value={messagesSearchQuery}
setReplyQuote(undefined); onChange={(e) => setMessagesSearchQuery(e.target.value)}
setSendDialogOpen(true); onPointerDown={(e) => e.stopPropagation()}
}} onClick={(e) => e.stopPropagation()}
> className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
<MessageSquare size={12} /> />
Message </div>
</Button> <MessagesFilterPopover
filter={messagesFilter}
messages={data?.messages ?? []}
open={messagesFilterOpen}
onOpenChange={setMessagesFilterOpen}
onApply={setMessagesFilter}
/>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs font-medium text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setSendDialogRecipient(undefined);
setReplyQuote(undefined);
setSendDialogOpen(true);
}}
>
<MessageSquare size={12} />
Message
</Button>
</div>
} }
> >
<ActivityTimeline <ActivityTimeline

View file

@ -4,6 +4,12 @@ import { api, isElectronMode } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
@ -377,164 +383,177 @@ export const TeamListView = (): React.JSX.Element => {
} }
return ( return (
<div className="size-full overflow-auto p-4"> <TooltipProvider delayDuration={300}>
{renderHeader()} <div className="size-full overflow-auto p-4">
{renderHeader()}
{filteredTeams.length === 0 && searchQuery.trim() ? ( {filteredTeams.length === 0 && searchQuery.trim() ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]"> <div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
No teams matching &quot;{searchQuery.trim()}&quot; No teams matching &quot;{searchQuery.trim()}&quot;
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredTeams.map((team) => { {filteredTeams.map((team) => {
const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns); const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns);
const teamColorSet = team.color ? getTeamColorSet(team.color) : null; const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
return ( return (
<div <div
key={team.teamName} key={team.teamName}
role="button" role="button"
tabIndex={0} tabIndex={0}
className="group relative cursor-pointer overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]" className="group relative cursor-pointer overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
style={ style={
teamColorSet teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border } ? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
: undefined : undefined
}
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName, team.projectPath);
} }
}} onClick={() => openTeamTab(team.teamName, team.projectPath)}
> onKeyDown={(e) => {
{teamColorSet ? ( if (e.key === 'Enter' || e.key === ' ') {
<div e.preventDefault();
className="pointer-events-none absolute inset-0 z-0 rounded-lg" openTeamTab(team.teamName, team.projectPath);
style={{ backgroundColor: teamColorSet.badge }} }
/> }}
) : null} >
<div className={teamColorSet ? 'relative z-10' : undefined}> {teamColorSet ? (
<div className="flex items-start justify-between"> <div
<div className="flex min-w-0 flex-1 items-center gap-2"> className="pointer-events-none absolute inset-0 z-0 rounded-lg"
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]"> style={{ backgroundColor: teamColorSet.badge }}
{team.displayName} />
</h3> ) : null}
<StatusBadge status={status} /> <div className={teamColorSet ? 'relative z-10' : undefined}>
</div> <div className="flex items-start justify-between">
<div className="flex shrink-0 gap-1"> <div className="flex min-w-0 flex-1 items-center gap-2">
<button <h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
type="button" {team.displayName}
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100" </h3>
onClick={(e) => handleCopyTeam(team.teamName, e)} <StatusBadge status={status} />
title="Copy team" </div>
> <div className="flex shrink-0 gap-1">
<Copy size={14} /> <Tooltip>
</button> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100" className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)} onClick={(e) => handleCopyTeam(team.teamName, e)}
title="Delete team"
>
<Trash2 size={14} />
</button>
</div>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
team.members.map((m) => {
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<span key={m.name} className="inline-flex items-center gap-1">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={
memberColor
? {
backgroundColor: memberColor.badge,
color: memberColor.text,
border: `1px solid ${memberColor.border}40`,
}
: undefined
}
> >
{m.name} <Copy size={14} />
</span> </button>
{m.role ? ( </TooltipTrigger>
<span className="text-[9px] text-[var(--color-text-muted)]"> <TooltipContent side="bottom">Copy team</TooltipContent>
{m.role} </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</TooltipContent>
</Tooltip>
</div>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
team.members.map((m) => {
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<span key={m.name} className="inline-flex items-center gap-1">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={
memberColor
? {
backgroundColor: memberColor.badge,
color: memberColor.text,
border: `1px solid ${memberColor.border}40`,
}
: undefined
}
>
{m.name}
</span> </span>
) : null} {m.role ? (
</span> <span className="text-[9px] text-[var(--color-text-muted)]">
); {m.role}
}) </span>
) : ( ) : null}
<Badge variant="secondary" className="text-[10px] font-normal"> </span>
Members: {team.memberCount} );
</Badge> })
)} ) : (
{(() => { <Badge variant="secondary" className="text-[10px] font-normal">
const tc = taskCountsByTeam.get(team.teamName); Members: {team.memberCount}
if (!tc || (tc.pending === 0 && tc.inProgress === 0 && tc.completed === 0)) { </Badge>
)}
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
if (
!tc ||
(tc.pending === 0 && tc.inProgress === 0 && tc.completed === 0)
) {
return (
<Badge variant="secondary" className="text-[10px] font-normal">
Tasks: 0
</Badge>
);
}
return ( return (
<Badge variant="secondary" className="text-[10px] font-normal"> <>
Tasks: 0 {tc.inProgress > 0 && (
</Badge> <span className="inline-flex items-center rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
{tc.inProgress} active
</span>
)}
{tc.pending > 0 && (
<span className="inline-flex items-center rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
{tc.pending} pending
</span>
)}
{tc.completed > 0 && (
<span className="inline-flex items-center rounded-full bg-green-500/15 px-1.5 py-0.5 text-[10px] font-medium text-green-400">
{tc.completed} done
</span>
)}
</>
); );
} })()}
</div>
{(() => {
const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null;
return ( return (
<> <div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
{tc.inProgress > 0 && ( <FolderOpen size={10} className="shrink-0" />
<span className="inline-flex items-center rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-400"> <span className="truncate">
{tc.inProgress} active {recentPaths.map((p, i) => (
</span> <span key={p} title={p}>
)} {i === 0 && status === 'running' ? (
{tc.pending > 0 && ( <span className="text-emerald-400">{folderName(p)}</span>
<span className="inline-flex items-center rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400"> ) : (
{tc.pending} pending folderName(p)
</span> )}
)} {i < recentPaths.length - 1 ? ', ' : ''}
{tc.completed > 0 && ( </span>
<span className="inline-flex items-center rounded-full bg-green-500/15 px-1.5 py-0.5 text-[10px] font-medium text-green-400"> ))}
{tc.completed} done </span>
</span> </div>
)}
</>
); );
})()} })()}
</div> </div>
{(() => {
const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null;
return (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{recentPaths.map((p, i) => (
<span key={p} title={p}>
{i === 0 && status === 'running' ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < recentPaths.length - 1 ? ', ' : ''}
</span>
))}
</span>
</div>
);
})()}
</div> </div>
</div> );
); })}
})} </div>
</div> )}
)} {createDialogElement}
{createDialogElement} </div>
</div> </TooltipProvider>
); );
}; };

View file

@ -1,5 +1,6 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatDistanceToNowStrict } from 'date-fns'; import { formatDistanceToNowStrict } from 'date-fns';
import { import {
@ -205,29 +206,40 @@ const SessionRow = ({
</button> </button>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<button <Tooltip>
type="button" <TooltipTrigger asChild>
title={isSelected ? 'Remove filter' : 'Filter by this session'} <button
className={`rounded p-0.5 text-[var(--color-text-muted)] transition-opacity hover:text-blue-400 ${ type="button"
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100' className={`rounded p-0.5 text-[var(--color-text-muted)] transition-opacity hover:text-blue-400 ${
}`} isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
onClick={(e) => { }`}
e.stopPropagation(); onClick={(e) => {
onToggleFilter(); e.stopPropagation();
}} onToggleFilter();
> }}
{isSelected ? <FilterX size={12} /> : <Filter size={12} />} >
</button> {isSelected ? <FilterX size={12} /> : <Filter size={12} />}
<button </button>
type="button" </TooltipTrigger>
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text)] group-hover:opacity-100" <TooltipContent side="left">
onClick={(e) => { {isSelected ? 'Remove filter' : 'Filter by this session'}
e.stopPropagation(); </TooltipContent>
onClick(); </Tooltip>
}} <Tooltip>
> <TooltipTrigger asChild>
<ExternalLink size={12} /> <button
</button> type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text)] group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<ExternalLink size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Open session</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
); );

View file

@ -19,6 +19,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
@ -180,13 +181,18 @@ export const SendMessageDialog = ({
{quote ? ( {quote ? (
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5"> <div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]" <button
onClick={() => setQuote(undefined)} type="button"
> className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
<X size={12} /> onClick={() => setQuote(undefined)}
</button> >
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Remove quote</TooltipContent>
</Tooltip>
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]"> <span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to @{quote.from} Replying to @{quote.from}
</span> </span>

View file

@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
@ -156,13 +157,18 @@ export const TaskCommentsSection = ({
{replyTo.text} {replyTo.text}
</div> </div>
</div> </div>
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]" <button
onClick={() => setReplyTo(null)} type="button"
> className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
<X size={12} /> onClick={() => setReplyTo(null)}
</button> >
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Cancel reply</TooltipContent>
</Tooltip>
</div> </div>
) : null} ) : null}

View file

@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { Columns3, LayoutGrid } from 'lucide-react'; import { Columns3, LayoutGrid } from 'lucide-react';
@ -138,36 +139,44 @@ export const KanbanBoard = ({
onFilterChange={onFilterChange} onFilterChange={onFilterChange}
/> />
<div className="inline-flex rounded-md border border-[var(--color-border)]"> <div className="inline-flex rounded-md border border-[var(--color-border)]">
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="sm" <Button
className={cn( variant="ghost"
'h-7 rounded-r-none px-2', size="sm"
viewMode === 'grid' className={cn(
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' 'h-7 rounded-r-none px-2',
: 'text-[var(--color-text-muted)]' viewMode === 'grid'
)} ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
onClick={() => setViewMode('grid')} : 'text-[var(--color-text-muted)]'
aria-label="Grid view" )}
title="Grid" onClick={() => setViewMode('grid')}
> aria-label="Grid view"
<LayoutGrid size={14} /> >
</Button> <LayoutGrid size={14} />
<Button </Button>
variant="ghost" </TooltipTrigger>
size="sm" <TooltipContent side="bottom">Grid view</TooltipContent>
className={cn( </Tooltip>
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2', <Tooltip>
viewMode === 'columns' <TooltipTrigger asChild>
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' <Button
: 'text-[var(--color-text-muted)]' variant="ghost"
)} size="sm"
onClick={() => setViewMode('columns')} className={cn(
aria-label="Columns view" 'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
title="Columns" viewMode === 'columns'
> ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
<Columns3 size={14} /> : 'text-[var(--color-text-muted)]'
</Button> )}
onClick={() => setViewMode('columns')}
aria-label="Columns view"
>
<Columns3 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Columns view</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>

View file

@ -3,6 +3,7 @@ import { useMemo } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Crown, Filter } from 'lucide-react'; import { Crown, Filter } from 'lucide-react';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
@ -57,22 +58,26 @@ export const KanbanFilterPopover = ({
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant="ghost" <PopoverTrigger asChild>
size="sm" <Button
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]" variant="ghost"
aria-label="Filter tasks" size="sm"
title="Filter" className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
> aria-label="Filter tasks"
<Filter size={14} /> >
{activeCount > 0 && ( <Filter size={14} />
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white"> {activeCount > 0 && (
{activeCount} <span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
</span> {activeCount}
)} </span>
</Button> )}
</PopoverTrigger> </Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Filter tasks</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-72 p-0"> <PopoverContent align="end" className="w-72 p-0">
{/* Session section */} {/* Session section */}
<div className="border-b border-[var(--color-border)] p-3"> <div className="border-b border-[var(--color-border)] p-3">

View file

@ -0,0 +1,171 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Filter } from 'lucide-react';
import type { InboxMessage } from '@shared/types';
export interface MessagesFilterState {
from: Set<string>;
to: Set<string>;
}
interface MessagesFilterPopoverProps {
filter: MessagesFilterState;
messages: InboxMessage[];
open: boolean;
onOpenChange: (open: boolean) => void;
onApply: (filter: MessagesFilterState) => void;
}
function collectFromOptions(messages: InboxMessage[]): string[] {
const set = new Set<string>();
for (const m of messages) {
if (m.from?.trim()) set.add(m.from.trim());
}
return Array.from(set).sort();
}
function collectToOptions(messages: InboxMessage[]): string[] {
const set = new Set<string>();
for (const m of messages) {
if (m.to?.trim()) set.add(m.to.trim());
}
return Array.from(set).sort();
}
export const MessagesFilterPopover = ({
filter,
messages,
open,
onOpenChange,
onApply,
}: MessagesFilterPopoverProps): React.JSX.Element => {
const [draft, setDraft] = useState<MessagesFilterState>({ from: new Set(), to: new Set() });
useEffect(() => {
if (open) {
setDraft({
from: new Set(filter.from),
to: new Set(filter.to),
});
}
}, [open, filter.from, filter.to]);
const fromOptions = useMemo(() => collectFromOptions(messages), [messages]);
const toOptions = useMemo(() => collectToOptions(messages), [messages]);
const activeCount = (filter.from.size > 0 ? 1 : 0) + (filter.to.size > 0 ? 1 : 0);
const draftCount = (draft.from.size > 0 ? 1 : 0) + (draft.to.size > 0 ? 1 : 0);
const toggleFrom = (name: string): void => {
setDraft((prev) => {
const next = new Set(prev.from);
if (next.has(name)) next.delete(name);
else next.add(name);
return { ...prev, from: next };
});
};
const toggleTo = (name: string): void => {
setDraft((prev) => {
const next = new Set(prev.to);
if (next.has(name)) next.delete(name);
else next.add(name);
return { ...prev, to: next };
});
};
const handleSave = (): void => {
onApply(draft);
onOpenChange(false);
};
const handleReset = (): void => {
const empty = { from: new Set<string>(), to: new Set<string>() };
setDraft(empty);
onApply(empty);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Filter messages"
title="Filter"
>
<Filter size={14} />
{activeCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
{activeCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-0">
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Кто писал
</p>
<div className="max-h-40 space-y-1 overflow-y-auto">
{fromOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">Нет данных</p>
) : (
fromOptions.map((name) => (
<label
key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox
checked={draft.from.has(name)}
onCheckedChange={() => toggleFrom(name)}
/>
{name}
</label>
))
)}
</div>
</div>
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Кому писали
</p>
<div className="max-h-40 space-y-1 overflow-y-auto">
{toOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">Нет данных</p>
) : (
toOptions.map((name) => (
<label
key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} />
{name}
</label>
))
)}
</div>
</div>
<div className="flex justify-between gap-2 p-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={draftCount === 0}
onClick={handleReset}
>
Сбросить
</Button>
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={handleSave}>
Сохранить
</Button>
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -0,0 +1,32 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard Radix/shadcn pattern */
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@renderer/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipPortal = TooltipPrimitive.Portal;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 max-w-[var(--radix-tooltip-content-available-width)] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger };
/* eslint-enable react/jsx-props-no-spreading */