diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1f307d94..3f881a11 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 ( - - - + + + + + ); }; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 258df23e..7a496cf1 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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({ + 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={ - +
+
+ + 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" + /> +
+ + +
} > { } return ( -
- {renderHeader()} + +
+ {renderHeader()} - {filteredTeams.length === 0 && searchQuery.trim() ? ( -
- No teams matching "{searchQuery.trim()}" -
- ) : ( -
- {filteredTeams.map((team) => { - const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns); - const teamColorSet = team.color ? getTeamColorSet(team.color) : null; - return ( -
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() ? ( +
+ No teams matching "{searchQuery.trim()}" +
+ ) : ( +
+ {filteredTeams.map((team) => { + const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns); + const teamColorSet = team.color ? getTeamColorSet(team.color) : null; + return ( +
- {teamColorSet ? ( -
- ) : null} -
-
-
-

- {team.displayName} -

- -
-
- - -
-
-

- {team.description || 'No description'} -

-
- {team.members && team.members.length > 0 ? ( - team.members.map((m) => { - const memberColor = m.color ? getTeamColorSet(m.color) : null; - return ( - - openTeamTab(team.teamName, team.projectPath)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openTeamTab(team.teamName, team.projectPath); + } + }} + > + {teamColorSet ? ( +
+ ) : null} +
+
+
+

+ {team.displayName} +

+ +
+
+ + + + + Copy team + + + + + + Delete team + +
+
+

+ {team.description || 'No description'} +

+
+ {team.members && team.members.length > 0 ? ( + team.members.map((m) => { + const memberColor = m.color ? getTeamColorSet(m.color) : null; + return ( + + + {m.name} - ) : null} - - ); - }) - ) : ( - - Members: {team.memberCount} - - )} - {(() => { - const tc = taskCountsByTeam.get(team.teamName); - if (!tc || (tc.pending === 0 && tc.inProgress === 0 && tc.completed === 0)) { + {m.role ? ( + + {m.role} + + ) : null} + + ); + }) + ) : ( + + Members: {team.memberCount} + + )} + {(() => { + const tc = taskCountsByTeam.get(team.teamName); + if ( + !tc || + (tc.pending === 0 && tc.inProgress === 0 && tc.completed === 0) + ) { + return ( + + Tasks: 0 + + ); + } return ( - - Tasks: 0 - + <> + {tc.inProgress > 0 && ( + + {tc.inProgress} active + + )} + {tc.pending > 0 && ( + + {tc.pending} pending + + )} + {tc.completed > 0 && ( + + {tc.completed} done + + )} + ); - } + })()} +
+ {(() => { + const recentPaths = getRecentProjects(team); + if (recentPaths.length === 0) return null; return ( - <> - {tc.inProgress > 0 && ( - - {tc.inProgress} active - - )} - {tc.pending > 0 && ( - - {tc.pending} pending - - )} - {tc.completed > 0 && ( - - {tc.completed} done - - )} - +
+ + + {recentPaths.map((p, i) => ( + + {i === 0 && status === 'running' ? ( + {folderName(p)} + ) : ( + folderName(p) + )} + {i < recentPaths.length - 1 ? ', ' : ''} + + ))} + +
); })()}
- {(() => { - const recentPaths = getRecentProjects(team); - if (recentPaths.length === 0) return null; - return ( -
- - - {recentPaths.map((p, i) => ( - - {i === 0 && status === 'running' ? ( - {folderName(p)} - ) : ( - folderName(p) - )} - {i < recentPaths.length - 1 ? ', ' : ''} - - ))} - -
- ); - })()}
-
- ); - })} -
- )} - {createDialogElement} -
+ ); + })} +
+ )} + {createDialogElement} +
+ ); }; diff --git a/src/renderer/components/team/TeamSessionsSection.tsx b/src/renderer/components/team/TeamSessionsSection.tsx index ba0966ba..cff780c7 100644 --- a/src/renderer/components/team/TeamSessionsSection.tsx +++ b/src/renderer/components/team/TeamSessionsSection.tsx @@ -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 = ({
- - + + + + + + {isSelected ? 'Remove filter' : 'Filter by this session'} + + + + + + + Open session +
); diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index cd827969..120bc8b4 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -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 ? (
- + + + + + Remove quote + Replying to @{quote.from} diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 51467b44..9cc7de3d 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -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}
- + + + + + Cancel reply +
) : null} diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 44289e58..11adde9f 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -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} />
- - + + + + + Grid view + + + + + + Columns view +
diff --git a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx index 024c0945..cfd8a163 100644 --- a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx +++ b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx @@ -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 ( - - - + + + + + + + Filter tasks + {/* Session section */}
diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx new file mode 100644 index 00000000..7037aafc --- /dev/null +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -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; + to: Set; +} + +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(); + 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(); + 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({ 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(), to: new Set() }; + setDraft(empty); + onApply(empty); + }; + + return ( + + + + + +
+

+ Кто писал +

+
+ {fromOptions.length === 0 ? ( +

Нет данных

+ ) : ( + fromOptions.map((name) => ( + + )) + )} +
+
+
+

+ Кому писали +

+
+ {toOptions.length === 0 ? ( +

Нет данных

+ ) : ( + toOptions.map((name) => ( + + )) + )} +
+
+
+ + +
+
+
+ ); +}; diff --git a/src/renderer/components/ui/tooltip.tsx b/src/renderer/components/ui/tooltip.tsx new file mode 100644 index 00000000..5513e36e --- /dev/null +++ b/src/renderer/components/ui/tooltip.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger }; +/* eslint-enable react/jsx-props-no-spreading */