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:
parent
c793426247
commit
58f06576d3
10 changed files with 565 additions and 254 deletions
|
|
@ -4,6 +4,7 @@ import { ConfirmDialog } from './components/common/ConfirmDialog';
|
|||
import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
|
||||
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
||||
import { TabbedLayout } from './components/layout/TabbedLayout';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { api } from './api';
|
||||
import { initializeNotificationListeners, useStore } from './store';
|
||||
|
|
@ -43,9 +44,11 @@ export const App = (): React.JSX.Element => {
|
|||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ContextSwitchOverlay />
|
||||
<TabbedLayout />
|
||||
<ConfirmDialog />
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<ContextSwitchOverlay />
|
||||
<TabbedLayout />
|
||||
<ConfirmDialog />
|
||||
</TooltipProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ import { KanbanBoard } from './kanban/KanbanBoard';
|
|||
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
||||
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||||
import { MemberList } from './members/MemberList';
|
||||
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||
|
||||
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
||||
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
|
||||
import type { Session } from '@renderer/types/data';
|
||||
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||
|
||||
|
|
@ -134,6 +136,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
);
|
||||
|
||||
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(() => {
|
||||
if (!teamName) {
|
||||
|
|
@ -251,12 +259,31 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
|
||||
const filteredMessages = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (!timeWindow) return data.messages;
|
||||
return data.messages.filter((m) => {
|
||||
const ts = new Date(m.timestamp).getTime();
|
||||
return ts >= timeWindow.start && ts < timeWindow.end;
|
||||
});
|
||||
}, [data, timeWindow]);
|
||||
let list = data.messages;
|
||||
if (timeWindow) {
|
||||
list = list.filter((m) => {
|
||||
const ts = new Date(m.timestamp).getTime();
|
||||
return ts >= timeWindow.start && ts < timeWindow.end;
|
||||
});
|
||||
}
|
||||
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 query = kanbanSearch.trim();
|
||||
|
|
@ -593,20 +620,41 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
badge={filteredMessages.length}
|
||||
defaultOpen
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 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 className="flex items-center gap-2 pl-2">
|
||||
<div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Поиск..."
|
||||
value={messagesSearchQuery}
|
||||
onChange={(e) => setMessagesSearchQuery(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ import { api, isElectronMode } from '@renderer/api';
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
|
|
@ -377,164 +383,177 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="size-full overflow-auto p-4">
|
||||
{renderHeader()}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="size-full overflow-auto p-4">
|
||||
{renderHeader()}
|
||||
|
||||
{filteredTeams.length === 0 && searchQuery.trim() ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
|
||||
No teams matching "{searchQuery.trim()}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredTeams.map((team) => {
|
||||
const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns);
|
||||
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
|
||||
return (
|
||||
<div
|
||||
key={team.teamName}
|
||||
role="button"
|
||||
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)]"
|
||||
style={
|
||||
teamColorSet
|
||||
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
|
||||
: undefined
|
||||
}
|
||||
onClick={() => openTeamTab(team.teamName, team.projectPath)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openTeamTab(team.teamName, team.projectPath);
|
||||
{filteredTeams.length === 0 && searchQuery.trim() ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
|
||||
No teams matching "{searchQuery.trim()}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredTeams.map((team) => {
|
||||
const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns);
|
||||
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
|
||||
return (
|
||||
<div
|
||||
key={team.teamName}
|
||||
role="button"
|
||||
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)]"
|
||||
style={
|
||||
teamColorSet
|
||||
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
|
||||
: undefined
|
||||
}
|
||||
}}
|
||||
>
|
||||
{teamColorSet ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
|
||||
style={{ backgroundColor: teamColorSet.badge }}
|
||||
/>
|
||||
) : null}
|
||||
<div className={teamColorSet ? 'relative z-10' : undefined}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
|
||||
{team.displayName}
|
||||
</h3>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
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) => handleCopyTeam(team.teamName, e)}
|
||||
title="Copy team"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<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)}
|
||||
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
|
||||
}
|
||||
onClick={() => openTeamTab(team.teamName, team.projectPath)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openTeamTab(team.teamName, team.projectPath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{teamColorSet ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
|
||||
style={{ backgroundColor: teamColorSet.badge }}
|
||||
/>
|
||||
) : null}
|
||||
<div className={teamColorSet ? 'relative z-10' : undefined}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
|
||||
{team.displayName}
|
||||
</h3>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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) => handleCopyTeam(team.teamName, e)}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
{m.role ? (
|
||||
<span className="text-[9px] text-[var(--color-text-muted)]">
|
||||
{m.role}
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Copy team</TooltipContent>
|
||||
</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>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||
Members: {team.memberCount}
|
||||
</Badge>
|
||||
)}
|
||||
{(() => {
|
||||
const tc = taskCountsByTeam.get(team.teamName);
|
||||
if (!tc || (tc.pending === 0 && tc.inProgress === 0 && tc.completed === 0)) {
|
||||
{m.role ? (
|
||||
<span className="text-[9px] text-[var(--color-text-muted)]">
|
||||
{m.role}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||
Members: {team.memberCount}
|
||||
</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 (
|
||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||
Tasks: 0
|
||||
</Badge>
|
||||
<>
|
||||
{tc.inProgress > 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">
|
||||
{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 (
|
||||
<>
|
||||
{tc.inProgress > 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">
|
||||
{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 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>
|
||||
{(() => {
|
||||
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>
|
||||
)}
|
||||
{createDialogElement}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{createDialogElement}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import {
|
||||
|
|
@ -205,29 +206,40 @@ const SessionRow = ({
|
|||
</button>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
title={isSelected ? 'Remove filter' : 'Filter by this session'}
|
||||
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();
|
||||
onToggleFilter();
|
||||
}}
|
||||
>
|
||||
{isSelected ? <FilterX size={12} /> : <Filter 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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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();
|
||||
onToggleFilter();
|
||||
}}
|
||||
>
|
||||
{isSelected ? <FilterX size={12} /> : <Filter size={12} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{isSelected ? 'Remove filter' : 'Filter by this session'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||
|
|
@ -180,13 +181,18 @@ export const SendMessageDialog = ({
|
|||
|
||||
{quote ? (
|
||||
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setQuote(undefined)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setQuote(undefined)}
|
||||
>
|
||||
<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)]">
|
||||
Replying to @{quote.from}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react';
|
|||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
|
@ -156,13 +157,18 @@ export const TaskCommentsSection = ({
|
|||
{replyTo.text}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
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)]"
|
||||
onClick={() => setReplyTo(null)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
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)]"
|
||||
onClick={() => setReplyTo(null)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Cancel reply</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Columns3, LayoutGrid } from 'lucide-react';
|
||||
|
||||
|
|
@ -138,36 +139,44 @@ export const KanbanBoard = ({
|
|||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-r-none px-2',
|
||||
viewMode === 'grid'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
aria-label="Grid view"
|
||||
title="Grid"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
|
||||
viewMode === 'columns'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => setViewMode('columns')}
|
||||
aria-label="Columns view"
|
||||
title="Columns"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-r-none px-2',
|
||||
viewMode === 'grid'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Grid view</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
|
||||
viewMode === 'columns'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => setViewMode('columns')}
|
||||
aria-label="Columns view"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Columns view</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useMemo } 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { Crown, Filter } from 'lucide-react';
|
||||
|
||||
import type { Session } from '@renderer/types/data';
|
||||
|
|
@ -57,22 +58,26 @@ export const KanbanFilterPopover = ({
|
|||
|
||||
return (
|
||||
<Popover>
|
||||
<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 tasks"
|
||||
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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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 tasks"
|
||||
>
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Filter tasks</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="w-72 p-0">
|
||||
{/* Session section */}
|
||||
<div className="border-b border-[var(--color-border)] p-3">
|
||||
|
|
|
|||
171
src/renderer/components/team/messages/MessagesFilterPopover.tsx
Normal file
171
src/renderer/components/team/messages/MessagesFilterPopover.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/renderer/components/ui/tooltip.tsx
Normal file
32
src/renderer/components/ui/tooltip.tsx
Normal 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 */
|
||||
Loading…
Reference in a new issue