From 9d2062c8c09c662a84b90250b232e8d3b4aa6d75 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 10 Mar 2026 23:34:58 +0200 Subject: [PATCH 01/72] feat: overhaul UI components and enhance task management features - Updated CLAUDE.md to reflect new AI agent team capabilities and features, including real-time task management and built-in code editor. - Refactored PaneContainer to simplify drag-and-drop functionality by lifting DnD context to TabbedLayout. - Removed SidebarHeader component and integrated its functionality into Sidebar for a cleaner layout. - Enhanced Sidebar with a collapse button and improved task/session navigation. - Updated TabBar to streamline tab management and improve user interaction. - Introduced new drag-and-drop handling in TabbedLayout for better user experience across panes. --- CLAUDE.md | 23 +- src/main/ipc/teams.ts | 1 + .../chat/viewers/MarkdownViewer.tsx | 22 ++ .../components/layout/PaneContainer.tsx | 146 +----------- src/renderer/components/layout/PaneView.tsx | 18 +- src/renderer/components/layout/Sidebar.tsx | 34 ++- .../components/layout/SidebarHeader.tsx | 72 ------ src/renderer/components/layout/TabBar.tsx | 214 ++---------------- .../components/layout/TabBarActions.tsx | 172 ++++++++++++++ src/renderer/components/layout/TabBarRow.tsx | 91 ++++++++ .../components/layout/TabbedLayout.tsx | 169 +++++++++++++- .../components/team/TeamDetailView.tsx | 22 ++ .../team/attachments/ImageLightbox.tsx | 4 + .../team/messages/MessageComposer.tsx | 8 +- src/renderer/store/slices/tabSlice.ts | 15 ++ src/renderer/store/slices/teamSlice.ts | 105 +++++++++ 16 files changed, 670 insertions(+), 446 deletions(-) delete mode 100644 src/renderer/components/layout/SidebarHeader.tsx create mode 100644 src/renderer/components/layout/TabBarActions.tsx create mode 100644 src/renderer/components/layout/TabBarRow.tsx diff --git a/CLAUDE.md b/CLAUDE.md index beb60a43..61d3d26c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,24 @@ # Claude Agent Teams UI -Electron app that visualizes Claude Code session execution +A new approach to task management with AI agent teams. Assemble agent teams with different roles that work autonomously in parallel, communicate with each other, create and manage their own tasks, review code, and collaborate across teams. You manage everything through a kanban board — like a CTO with an AI engineering team. + +Key capabilities: +- **Agent Teams** — create teams with roles, agents work autonomously in parallel +- **Cross-team communication** — agents message each other within and across teams +- **Kanban board** — tasks change status in real-time as agents work +- **Code review** — diff view per task (accept/reject/comment), similar to Cursor +- **Solo mode** — single agent with self-managed tasks, expandable to full team +- **Live process section** — see running agents, open URLs in browser +- **Direct messaging** — send messages to any agent, comment on tasks, add quick actions on kanban cards +- **Deep session analysis** — bash commands, reasoning, subprocesses breakdown +- **Context monitoring** — token usage by category (CLAUDE.md, tool outputs, thinking, team coordination) +- **Built-in code editor** — edit files with Git support without leaving the app +- **MCP integration** — built-in mcp-server for external tools and agent plugins +- **Post-compact context recovery** — restores team-management instructions after context compaction +- **Notification system** — alerts on task completion, agent attention needed, errors +- **Zero-setup onboarding** — built-in Claude Code installation and authentication + +100% free, open source. No API keys. No configuration. Runs entirely locally. ## Tech Stack Electron 28.x, React 18.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x @@ -24,6 +42,9 @@ When running build/typecheck/test commands, pipe through `tail -20` to avoid flo - `pnpm test:semantic` - Semantic step extraction tests - `pnpm test:noise` - Noise filtering tests - `pnpm test:task-filtering` - Task tool filtering tests +- `pnpm check` - Full quality gate (types + lint + test + build) +- `pnpm fix` - Lint fix + format +- `pnpm quality` - Full check + format check + knip ## Path Aliases Use path aliases for imports: diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 0098cae6..772fc481 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1216,6 +1216,7 @@ async function handleSendMessage( text: memberDeliveryText, summary: payload.summary, from: payload.from, + source: 'user_sent', }); // Best-effort live relay so active processes see the inbox row promptly. diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index c8b85363..171147f5 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -246,6 +246,28 @@ function createViewerMarkdownComponents( } return badge; } + if (href?.startsWith('team://')) { + const colorSet = getTeamColorSet('blue'); + const bg = getThemedBadge(colorSet, isLight); + return ( + + + {children} + + ); + } if (href?.startsWith('task://')) { const taskId = href.slice('task://'.length); return ( diff --git a/src/renderer/components/layout/PaneContainer.tsx b/src/renderer/components/layout/PaneContainer.tsx index 951c4686..b5065c13 100644 --- a/src/renderer/components/layout/PaneContainer.tsx +++ b/src/renderer/components/layout/PaneContainer.tsx @@ -1,152 +1,26 @@ /** * PaneContainer - Horizontal flex container that renders panes side by side. - * Wraps children with @dnd-kit DndContext provider for tab drag-and-drop. - * - * DnD interactions: - * - Drag within same TabBar → reorder tabs (reorderTabInPane) - * - Drag to another pane's TabBar → move tab to target pane (moveTabToPane) - * - Drag to pane edge zone → create new split pane (moveTabToNewPane) - * - Drag last tab out of pane → source pane auto-closes + * DndContext is owned by TabbedLayout (parent) for cross-component tab DnD. */ -import { Fragment, useCallback, useState } from 'react'; +import { Fragment } from 'react'; -import { - DndContext, - DragOverlay, - PointerSensor, - pointerWithin, - useSensor, - useSensors, -} from '@dnd-kit/core'; import { useStore } from '@renderer/store'; import { PaneResizeHandle } from './PaneResizeHandle'; import { PaneView } from './PaneView'; -import { DragOverlayTab } from './SortableTab'; - -import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; -import type { Tab } from '@renderer/types/tabs'; export const PaneContainer = (): React.JSX.Element => { const panes = useStore((s) => s.paneLayout.panes); - // Track the currently dragged tab for DragOverlay - const [activeTab, setActiveTab] = useState(null); - - // Configure pointer sensor with activation distance to avoid conflict with clicks - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, // 8px drag distance before activating - }, - }) - ); - - const handleDragStart = useCallback( - (event: DragStartEvent) => { - const { active } = event; - const data = active.data.current; - - if (data?.type === 'tab') { - const sourcePaneId = data.paneId as string; - const tabId = data.tabId as string; - - // Find the tab in the source pane - const pane = panes.find((p) => p.id === sourcePaneId); - const tab = pane?.tabs.find((t) => t.id === tabId); - if (tab) { - setActiveTab(tab); - } - } - }, - [panes] - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - - setActiveTab(null); - - if (!over || !active.data.current) return; - - const activeData = active.data.current; - const overData = over.data.current; - - if (activeData.type !== 'tab') return; - - const draggedTabId = activeData.tabId as string; - const sourcePaneId = activeData.paneId as string; - const state = useStore.getState(); - - // Case 1: Drop on a split-zone (edge of pane) → create new pane - if (overData?.type === 'split-zone') { - const targetPaneId = overData.paneId as string; - const side = overData.side as 'left' | 'right'; - state.moveTabToNewPane(draggedTabId, sourcePaneId, targetPaneId, side); - return; - } - - // Case 2: Drop on a tabbar (different pane) → move tab to that pane - if (overData?.type === 'tabbar') { - const targetPaneId = overData.paneId as string; - if (sourcePaneId !== targetPaneId) { - state.moveTabToPane(draggedTabId, sourcePaneId, targetPaneId); - } - return; - } - - // Case 3: Drop on another sortable tab - // This can mean either reorder within same pane or move to another pane's tab position - if (overData?.type === 'tab') { - const overTabId = overData.tabId as string; - const overPaneId = overData.paneId as string; - - if (sourcePaneId === overPaneId) { - // Reorder within the same pane - const pane = panes.find((p) => p.id === sourcePaneId); - if (!pane) return; - - const fromIndex = pane.tabs.findIndex((t) => t.id === draggedTabId); - const toIndex = pane.tabs.findIndex((t) => t.id === overTabId); - - if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { - state.reorderTabInPane(sourcePaneId, fromIndex, toIndex); - } - } else { - // Move to another pane, inserting at the over tab's position - const targetPane = panes.find((p) => p.id === overPaneId); - if (!targetPane) return; - - const insertIndex = targetPane.tabs.findIndex((t) => t.id === overTabId); - state.moveTabToPane(draggedTabId, sourcePaneId, overPaneId, insertIndex); - } - } - }, - [panes] - ); - return ( - -
- {panes.map((pane, i) => ( - - {i > 0 && } - - - ))} -
- - {/* Drag overlay - semi-transparent ghost of the dragged tab */} - - {activeTab ? : null} - -
+
+ {panes.map((pane, i) => ( + + {i > 0 && } + + + ))} +
); }; diff --git a/src/renderer/components/layout/PaneView.tsx b/src/renderer/components/layout/PaneView.tsx index e06bc3cb..bd400ce9 100644 --- a/src/renderer/components/layout/PaneView.tsx +++ b/src/renderer/components/layout/PaneView.tsx @@ -1,7 +1,7 @@ /** * PaneView - Single pane wrapper with focus management. - * Handles click-to-focus, visual focus indicator, width, - * and edge split drop zones for DnD. + * Handles click-to-focus, width, and edge split drop zones for DnD. + * TabBar is now rendered in TabBarRow (above sidebar + content area). */ import { useDndContext } from '@dnd-kit/core'; @@ -11,7 +11,6 @@ import { useShallow } from 'zustand/react/shallow'; import { PaneContent } from './PaneContent'; import { PaneSplitDropZone } from './PaneSplitDropZone'; -import { TabBar } from './TabBar'; interface PaneViewProps { paneId: string; @@ -47,21 +46,10 @@ export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => { className="relative flex min-w-0 flex-col" style={{ width: `${pane.widthFraction * 100}%`, + paddingTop: '36px', }} onMouseDown={handleMouseDown} > - {/* Focus indicator - accent border on top of focused pane's TabBar */} -
1 - ? '2px solid var(--color-accent, #6366f1)' - : '2px solid transparent', - }} - > - -
- {/* Edge split drop zones - visible only during active drag when under MAX_PANES */} diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx index a9ca48b8..c4c60163 100644 --- a/src/renderer/components/layout/Sidebar.tsx +++ b/src/renderer/components/layout/Sidebar.tsx @@ -1,9 +1,8 @@ /** - * Sidebar - Breadcrumb-style navigation with project/worktree hierarchy. + * Sidebar - Navigation with task list and session list. * * Structure: - * - Fixed Header: Project selector (Row 1) + Worktree selector (Row 2, conditional) - * - Tab bar: Tasks | Sessions + * - Tab bar: Collapse button + Tasks | Sessions * - Scrollable Body: Task list or date-grouped session list * - Resizable: Drag right edge to resize * - Collapsible: Cmd+B to toggle (Notion-style) @@ -12,14 +11,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; +import { formatShortcut } from '@renderer/utils/stringUtils'; +import { PanelLeft } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { DateGroupedSessions } from '../sidebar/DateGroupedSessions'; import { GlobalTaskList } from '../sidebar/GlobalTaskList'; import { defaultTaskFiltersState } from '../sidebar/taskFiltersState'; -import { SidebarHeader } from './SidebarHeader'; - import type { TaskFiltersState } from '../sidebar/taskFiltersState'; type SidebarTab = 'tasks' | 'sessions'; @@ -29,9 +28,10 @@ const MAX_WIDTH = 500; const DEFAULT_WIDTH = 280; export const Sidebar = (): React.JSX.Element => { - const { sidebarCollapsed } = useStore( + const { sidebarCollapsed, toggleSidebar } = useStore( useShallow((s) => ({ sidebarCollapsed: s.sidebarCollapsed, + toggleSidebar: s.toggleSidebar, })) ); const [width, setWidth] = useState(DEFAULT_WIDTH); @@ -39,6 +39,7 @@ export const Sidebar = (): React.JSX.Element => { const [sidebarTab, setSidebarTab] = useState('tasks'); const [taskFilters, setTaskFilters] = useState(defaultTaskFiltersState); const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false); + const [isCollapseHovered, setIsCollapseHovered] = useState(false); const sidebarRef = useRef(null); // Handle mouse move during resize @@ -101,14 +102,27 @@ export const Sidebar = (): React.JSX.Element => { minWidth: sidebarCollapsed ? 0 : width, }} > - - - {/* Tab bar: Tasks | Sessions — tab strip style, filters on the right */} + {/* Tab bar: Collapse button + Tasks | Sessions */}
-
+ {/* Collapse sidebar button */} + + +
-
-
- ); -}; diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 424162c8..71fda59f 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -12,13 +12,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; import { isElectronMode } from '@renderer/api'; -import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; import { useStore } from '@renderer/store'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Bell, Calendar, PanelLeft, Plus, Puzzle, RefreshCw, Settings, Users } from 'lucide-react'; +import { PanelLeft, RefreshCw } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { MoreMenu } from './MoreMenu'; import { SortableTab } from './SortableTab'; import { TabContextMenu } from './TabContextMenu'; @@ -38,15 +36,8 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { closeTabs, setSelectedTabIds, clearTabSelection, - openDashboard, fetchSessionDetail, fetchSessions, - unreadCount, - openNotificationsTab, - openTeamsTab, - openExtensionsTab, - openSchedulesTab, - openSettingsTab, sidebarCollapsed, toggleSidebar, splitPane, @@ -54,7 +45,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { pinnedSessionIds, toggleHideSession, hiddenSessionIds, - tabSessionData, } = useStore( useShallow((s) => ({ pane: s.paneLayout.panes.find((p) => p.id === paneId), @@ -67,15 +57,8 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { closeTabs: s.closeTabs, setSelectedTabIds: s.setSelectedTabIds, clearTabSelection: s.clearTabSelection, - openDashboard: s.openDashboard, fetchSessionDetail: s.fetchSessionDetail, fetchSessions: s.fetchSessions, - unreadCount: s.unreadCount, - openNotificationsTab: s.openNotificationsTab, - openTeamsTab: s.openTeamsTab, - openExtensionsTab: s.openExtensionsTab, - openSchedulesTab: s.openSchedulesTab, - openSettingsTab: s.openSettingsTab, sidebarCollapsed: s.sidebarCollapsed, toggleSidebar: s.toggleSidebar, splitPane: s.splitPane, @@ -83,7 +66,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { pinnedSessionIds: s.pinnedSessionIds, toggleHideSession: s.toggleHideSession, hiddenSessionIds: s.hiddenSessionIds, - tabSessionData: s.tabSessionData, })) ); @@ -97,21 +79,9 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { // Derive stable tab IDs array for SortableContext const tabIds = useMemo(() => openTabs.map((t) => t.id), [openTabs]); - // Derive session detail for the active tab (used by export dropdown) - const activeTabSessionDetail = activeTabId - ? (tabSessionData[activeTabId]?.sessionDetail ?? null) - : null; - // Hover states for buttons const [expandHover, setExpandHover] = useState(false); const [refreshHover, setRefreshHover] = useState(false); - const [newTabHover, setNewTabHover] = useState(false); - const [notificationsHover, setNotificationsHover] = useState(false); - const [teamsHover, setTeamsHover] = useState(false); - const [schedulesHover, setSchedulesHover] = useState(false); - const [extensionsHover, setExtensionsHover] = useState(false); - const [githubHover, setGithubHover] = useState(false); - const [settingsHover, setSettingsHover] = useState(false); // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>( @@ -121,7 +91,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { // Track last clicked tab for Shift range selection const lastClickedTabIdRef = useRef(null); - // Get the active tab + // Get the active tab for refresh button const activeTab = openTabs.find((tab) => tab.id === activeTabId); // Refs for auto-scrolling to active tab @@ -260,6 +230,10 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { ? hiddenSessionIds.includes(contextMenuTab.sessionId) : false; + // Detect macOS Electron for traffic lights padding + const isMacElectron = + isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac'); + // Show sidebar expand button only in the leftmost pane const isLeftmostPane = useStore( (s) => s.paneLayout.panes.length === 0 || s.paneLayout.panes[0]?.id === paneId @@ -267,17 +241,14 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { return (
{ onMouseEnter={() => setExpandHover(true)} onMouseLeave={() => setExpandHover(false)} className="mr-2 shrink-0 rounded-md p-1.5 transition-colors" - style={ - { - WebkitAppRegion: 'no-drag', - color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)', - backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent', - } as React.CSSProperties - } + style={{ + color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)', + backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent', + }} title="Expand sidebar" > @@ -310,15 +278,12 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { setDroppableRef(el); }} className="scrollbar-none flex min-w-0 flex-1 items-center gap-1" - style={ - { - WebkitAppRegion: 'no-drag', - outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', - outlineOffset: '-1px', - overflowX: 'auto', - overflowY: 'hidden', - } as React.CSSProperties - } + style={{ + outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', + outlineOffset: '-1px', + overflowX: 'auto', + overflowY: 'hidden', + }} > {openTabs.map((tab) => ( @@ -355,147 +320,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { )}
- {/* Drag spacer — fills empty space between tab list and action buttons. - Gives users a reliable window-drag target regardless of how many tabs are open. - Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */} -
- - {/* Right side actions */} -
- {/* New tab button */} - - - {/* Notifications bell icon */} - - - {/* Teams icon */} - - - {/* Schedules icon */} - - - {/* Extensions icon */} - - - {/* GitHub link */} - - - {/* Settings gear icon */} - - - {/* More menu (Search, Export, Analyze, Settings) */} - -
- {/* Context menu */} {contextMenu && contextMenuTabId && ( { + const { + unreadCount, + openNotificationsTab, + openTeamsTab, + openExtensionsTab, + openSchedulesTab, + openSettingsTab, + activeTabId, + openTabs, + tabSessionData, + } = useStore( + useShallow((s) => ({ + unreadCount: s.unreadCount, + openNotificationsTab: s.openNotificationsTab, + openTeamsTab: s.openTeamsTab, + openExtensionsTab: s.openExtensionsTab, + openSchedulesTab: s.openSchedulesTab, + openSettingsTab: s.openSettingsTab, + activeTabId: s.activeTabId, + openTabs: s.openTabs, + tabSessionData: s.tabSessionData, + })) + ); + + // Hover states for buttons + const [notificationsHover, setNotificationsHover] = useState(false); + const [teamsHover, setTeamsHover] = useState(false); + const [schedulesHover, setSchedulesHover] = useState(false); + const [extensionsHover, setExtensionsHover] = useState(false); + const [githubHover, setGithubHover] = useState(false); + const [settingsHover, setSettingsHover] = useState(false); + + // Derive active tab and session detail for MoreMenu + const activeTab = useMemo( + () => openTabs.find((t) => t.id === activeTabId), + [openTabs, activeTabId] + ); + const activeTabSessionDetail = activeTabId + ? (tabSessionData[activeTabId]?.sessionDetail ?? null) + : null; + + return ( +
+ {/* Notifications bell icon */} + + + {/* Teams icon */} + + + {/* Schedules icon */} + + + {/* Extensions icon */} + + + {/* GitHub link */} + + + {/* Settings gear icon */} + + + {/* More menu (Search, Export, Analyze, Settings) */} + +
+ ); +}; diff --git a/src/renderer/components/layout/TabBarRow.tsx b/src/renderer/components/layout/TabBarRow.tsx new file mode 100644 index 00000000..ffc06214 --- /dev/null +++ b/src/renderer/components/layout/TabBarRow.tsx @@ -0,0 +1,91 @@ +/** + * TabBarRow - Full-width tab bar row rendered above the sidebar + content area. + * Renders pane-specific TabBars proportionally + new tab button on the right. + * Handles window drag region and focus indicator for multi-pane layouts. + */ + +import { Fragment, useState } from 'react'; + +import { isElectronMode } from '@renderer/api'; +import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; +import { useStore } from '@renderer/store'; +import { Plus } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { TabBar } from './TabBar'; + +export const TabBarRow = (): React.JSX.Element => { + const { panes, focusedPaneId, openDashboard } = useStore( + useShallow((s) => ({ + panes: s.paneLayout.panes, + focusedPaneId: s.paneLayout.focusedPaneId, + openDashboard: s.openDashboard, + })) + ); + + const [newTabHover, setNewTabHover] = useState(false); + + const isMacElectron = + isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac'); + + return ( +
+ {/* Pane TabBars — proportional width, side by side */} +
+ {panes.map((pane, i) => ( + + {/* Separator between pane TabBars */} + {i > 0 && ( +
+ )} + + {/* Pane TabBar segment with focus indicator */} +
1 + ? '2px solid var(--color-accent, #6366f1)' + : '2px solid transparent', + }} + > + +
+ + ))} +
+ + {/* New tab button — right corner */} + +
+ ); +}; diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx index 1f171a5c..729c8c9a 100644 --- a/src/renderer/components/layout/TabbedLayout.tsx +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -1,16 +1,31 @@ /** - * TabbedLayout - Main layout with project-centric sidebar and multi-pane tabbed content. + * TabbedLayout - Main layout with full-width tab bar, sidebar, and multi-pane content. * * Layout structure: - * - Sidebar (280px): Project dropdown + date-grouped sessions - * - Main content: PaneContainer with one or more panes, each with TabBar + content + * - TabBarRow (full width): Pane TabBars + action buttons + * - Sidebar (280px): Task list / date-grouped sessions + * - Main content: PaneContainer with one or more panes + * + * Owns the DndContext for tab drag-and-drop across the entire layout + * (TabBarRow tabs + PaneContainer split zones). */ +import { useCallback, useState } from 'react'; + +import { + DndContext, + DragOverlay, + PointerSensor, + pointerWithin, + useSensor, + useSensors, +} from '@dnd-kit/core'; import { isElectronMode } from '@renderer/api'; import { getTrafficLightPaddingForZoom } from '@renderer/constants/layout'; import { useFullScreen } from '@renderer/hooks/useFullScreen'; import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts'; import { useZoomFactor } from '@renderer/hooks/useZoomFactor'; +import { useStore } from '@renderer/store'; import { UpdateBanner } from '../common/UpdateBanner'; import { UpdateDialog } from '../common/UpdateDialog'; @@ -21,6 +36,12 @@ import { GlobalTaskDetailDialog } from '../team/dialogs/GlobalTaskDetailDialog'; import { CustomTitleBar } from './CustomTitleBar'; import { PaneContainer } from './PaneContainer'; import { Sidebar } from './Sidebar'; +import { DragOverlayTab } from './SortableTab'; +import { TabBarActions } from './TabBarActions'; +import { TabBarRow } from './TabBarRow'; + +import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; +import type { Tab } from '@renderer/types/tabs'; export const TabbedLayout = (): React.JSX.Element => { useKeyboardShortcuts(); @@ -32,6 +53,98 @@ export const TabbedLayout = (): React.JSX.Element => { ? 8 : getTrafficLightPaddingForZoom(zoomFactor); + // --- DnD state (lifted from PaneContainer) --- + const panes = useStore((s) => s.paneLayout.panes); + const [activeTab, setActiveTab] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + const { active } = event; + const data = active.data.current; + + if (data?.type === 'tab') { + const sourcePaneId = data.paneId as string; + const tabId = data.tabId as string; + + const pane = panes.find((p) => p.id === sourcePaneId); + const tab = pane?.tabs.find((t) => t.id === tabId); + if (tab) { + setActiveTab(tab); + } + } + }, + [panes] + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + setActiveTab(null); + + if (!over || !active.data.current) return; + + const activeData = active.data.current; + const overData = over.data.current; + + if (activeData.type !== 'tab') return; + + const draggedTabId = activeData.tabId as string; + const sourcePaneId = activeData.paneId as string; + const state = useStore.getState(); + + // Case 1: Drop on a split-zone (edge of pane) → create new pane + if (overData?.type === 'split-zone') { + const targetPaneId = overData.paneId as string; + const side = overData.side as 'left' | 'right'; + state.moveTabToNewPane(draggedTabId, sourcePaneId, targetPaneId, side); + return; + } + + // Case 2: Drop on a tabbar (different pane) → move tab to that pane + if (overData?.type === 'tabbar') { + const targetPaneId = overData.paneId as string; + if (sourcePaneId !== targetPaneId) { + state.moveTabToPane(draggedTabId, sourcePaneId, targetPaneId); + } + return; + } + + // Case 3: Drop on another sortable tab + if (overData?.type === 'tab') { + const overTabId = overData.tabId as string; + const overPaneId = overData.paneId as string; + + if (sourcePaneId === overPaneId) { + const pane = panes.find((p) => p.id === sourcePaneId); + if (!pane) return; + + const fromIndex = pane.tabs.findIndex((t) => t.id === draggedTabId); + const toIndex = pane.tabs.findIndex((t) => t.id === overTabId); + + if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { + state.reorderTabInPane(sourcePaneId, fromIndex, toIndex); + } + } else { + const targetPane = panes.find((p) => p.id === overPaneId); + if (!targetPane) return; + + const insertIndex = targetPane.tabs.findIndex((t) => t.id === overTabId); + state.moveTabToPane(draggedTabId, sourcePaneId, overPaneId, insertIndex); + } + } + }, + [panes] + ); + return (
{ > -
- {/* Command Palette (Cmd+K) */} - + + +
+ {/* Command Palette (Cmd+K) */} + - {/* Sidebar - Project dropdown + Sessions (280px) */} - + {/* Sidebar - Task list / Sessions (280px) */} + - {/* Multi-pane content area */} - -
+ {/* Content column: floating actions bar + pane content */} +
+ {/* Content header with action buttons — floats over pane content */} +
+ +
+ + {/* Multi-pane content area — renders from top:0, behind the floating bar */} + +
+
+ + {/* Drag overlay - semi-transparent ghost of the dragged tab */} + + {activeTab ? : null} + + diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 5290ed30..edfa8348 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -249,6 +249,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele restoreTask, fetchDeletedTasks, deletedTasks, + launchParams, } = useStore( useShallow((s) => ({ data: s.selectedTeamData, @@ -295,6 +296,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele restoreTask: s.restoreTask, fetchDeletedTasks: s.fetchDeletedTasks, deletedTasks: s.deletedTasks, + launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, })) ); @@ -1124,6 +1126,26 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele Running )} + {data.isAlive && + launchParams?.model && + (() => { + const MODEL_LABELS: Record = { + opus: 'Opus 4.6', + sonnet: 'Sonnet 4.5', + haiku: 'Haiku 4.5', + }; + const modelLabel = MODEL_LABELS[launchParams.model] ?? launchParams.model; + const effortLabel = launchParams.effort + ? launchParams.effort.charAt(0).toUpperCase() + launchParams.effort.slice(1) + : ''; + const extLabel = launchParams.extendedContext ? '1M' : ''; + const parts = [modelLabel, effortLabel, extLabel].filter(Boolean).join(' '); + return ( + + {parts} + + ); + })()} {!data.isAlive && isTeamProvisioning && ( diff --git a/src/renderer/components/team/attachments/ImageLightbox.tsx b/src/renderer/components/team/attachments/ImageLightbox.tsx index 28232b0a..0cee2a9d 100644 --- a/src/renderer/components/team/attachments/ImageLightbox.tsx +++ b/src/renderer/components/team/attachments/ImageLightbox.tsx @@ -120,6 +120,10 @@ export const ImageLightbox = ({ scrollToZoom: true, }} styles={{ + // Radix Dialog's DismissableLayer sets body.style.pointerEvents = "none" + // when modal is open. The lightbox portal renders into body, inheriting + // pointer-events: none — making all buttons unclickable. Override here. + root: { pointerEvents: 'auto' }, container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' }, button: { padding: 16 }, }} diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 19732ef4..b9982cc5 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -391,15 +391,11 @@ export const MessageComposer = ({ )}
- {isProvisioning ? ( - - Launching... inbox delivery only - - ) : !isTeamAlive ? ( + {!isTeamAlive && !isProvisioning && ( Team offline - ) : null} + )} {/* Combined team + member selector */} {crossTeamTargets.length > 0 ? ( diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index d98d91b3..a755bf9a 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -408,6 +408,21 @@ export const createTabSlice: StateCreator = (set, ge const existing = getAllTabs(paneLayout).find((t) => t.type === 'dashboard'); if (existing) { + // Move existing dashboard tab to the rightmost position in its pane + const pane = findPaneByTabId(paneLayout, existing.id); + if (pane) { + const fromIndex = pane.tabs.findIndex((t) => t.id === existing.id); + const lastIndex = pane.tabs.length - 1; + if (fromIndex !== -1 && fromIndex !== lastIndex) { + const reordered = [...pane.tabs]; + const [moved] = reordered.splice(fromIndex, 1); + reordered.push(moved); + const updatedPane = { ...pane, tabs: reordered, activeTabId: existing.id }; + const newLayout = updatePane(paneLayout, updatedPane); + set(syncFromLayout(newLayout)); + return; + } + } state.setActiveTab(existing.id); return; } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 8287c623..691d5409 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -70,6 +70,7 @@ import type { CommentAttachmentPayload, CreateTaskRequest, CrossTeamSendRequest, + EffortLevel, GlobalTask, KanbanColumnId, LeadActivityState, @@ -247,6 +248,13 @@ export interface GlobalTaskDetailState { taskId: string; } +/** Per-team launch parameters shown in the header badge. */ +export interface TeamLaunchParams { + model?: string; // 'opus' | 'sonnet' | 'haiku' + effort?: EffortLevel; + extendedContext?: boolean; +} + export interface TeamSlice { teams: TeamSummary[]; /** O(1) lookup to avoid array scans in render-hot paths */ @@ -293,6 +301,8 @@ export interface TeamSlice { activeProvisioningRunId: string | null; provisioningError: string | null; clearProvisioningError: () => void; + /** Per-team launch parameters (model, effort, extended context) — persisted in localStorage. */ + launchParamsByTeam: Record; kanbanFilterQuery: string | null; provisioningProgressUnsubscribe: (() => void) | null; fetchBranches: (paths: string[]) => Promise; @@ -407,6 +417,56 @@ export interface TeamSlice { ) => Promise; } +// --- Per-team launch params persistence --- +const LAUNCH_PARAMS_PREFIX = 'team:launchParams:'; + +function loadAllLaunchParams(): Record { + const result: Record = {}; + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(LAUNCH_PARAMS_PREFIX)) { + const teamName = key.slice(LAUNCH_PARAMS_PREFIX.length); + const parsed = JSON.parse(localStorage.getItem(key)!) as TeamLaunchParams; + if (parsed && typeof parsed === 'object') { + result[teamName] = parsed; + } + } + } + } catch { + // ignore — best-effort restore + } + return result; +} + +function saveLaunchParams(teamName: string, params: TeamLaunchParams): void { + try { + localStorage.setItem(LAUNCH_PARAMS_PREFIX + teamName, JSON.stringify(params)); + } catch { + // ignore — best-effort persist + } +} + +function removeLaunchParams(teamName: string): void { + try { + localStorage.removeItem(LAUNCH_PARAMS_PREFIX + teamName); + } catch { + // ignore + } +} + +/** + * Parse raw model string from TeamLaunchRequest back into base model + extended context flag. + * E.g. 'opus[1m]' → { model: 'opus', extendedContext: true } + * 'sonnet' → { model: 'sonnet', extendedContext: false } + */ +function parseModelString(raw?: string): { model?: string; extendedContext: boolean } { + if (!raw) return { extendedContext: false }; + const match = raw.match(/^(\w+)\[1m\]$/); + if (match) return { model: match[1], extendedContext: true }; + return { model: raw, extendedContext: false }; +} + function loadToolApprovalSettings(): ToolApprovalSettings { try { const raw = localStorage.getItem('team:toolApprovalSettings'); @@ -469,6 +529,7 @@ export const createTeamSlice: StateCreator = (set, activeProvisioningRunId: null, provisioningError: null, clearProvisioningError: () => set({ provisioningError: null }), + launchParamsByTeam: loadAllLaunchParams(), fetchMemberSpawnStatuses: async (teamName: string) => { if (!api.teams?.getMemberSpawnStatuses) return; try { @@ -1167,6 +1228,24 @@ export const createTeamSlice: StateCreator = (set, ); } const response = await unwrapIpc('team:create', () => api.teams.createTeam(request)); + + // Persist per-team launch params (model, effort, extended context) + const { model: baseModel, extendedContext } = parseModelString(request.model); + if (baseModel) { + const params: TeamLaunchParams = { + model: baseModel, + effort: request.effort, + extendedContext, + }; + saveLaunchParams(request.teamName, params); + set((state) => ({ + launchParamsByTeam: { + ...state.launchParamsByTeam, + [request.teamName]: params, + }, + })); + } + set({ activeProvisioningRunId: response.runId, provisioningError: null, @@ -1233,6 +1312,32 @@ export const createTeamSlice: StateCreator = (set, })); try { const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request)); + + // Persist per-team launch params (model, effort, extended context) + const { model: baseModel, extendedContext } = parseModelString(request.model); + if (baseModel) { + const params: TeamLaunchParams = { + model: baseModel, + effort: request.effort, + extendedContext, + }; + saveLaunchParams(request.teamName, params); + set((state) => ({ + launchParamsByTeam: { + ...state.launchParamsByTeam, + [request.teamName]: params, + }, + })); + } else { + // No model selected — clear stored params + removeLaunchParams(request.teamName); + set((state) => { + const updated = { ...state.launchParamsByTeam }; + delete updated[request.teamName]; + return { launchParamsByTeam: updated }; + }); + } + set({ activeProvisioningRunId: response.runId, provisioningError: null, From 5da9e2372da47a2ae2f1dc2281632727197cb6c3 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 00:33:17 +0200 Subject: [PATCH 02/72] feat: enhance cross-team messaging and message storage - Introduced new parameters for cross-team messaging, including CROSS_TEAM_SENT_SOURCE for better tracking of sent messages. - Updated sendCrossTeamMessage function to append sent messages to the message store, ensuring a complete history of communications. - Enhanced tests to validate the new message storage functionality and ensure accurate retrieval of sent messages. - Improved handling of message timestamps and deduplication logic for cross-team communications. --- .../src/internal/crossTeam.js | 24 +- agent-teams-controller/test/crossTeam.test.js | 13 + docs/research/split-screen-multi-view.md | 210 +++++++++++++ .../catalog/OfficialMcpRegistryService.ts | 33 ++- .../services/team/TeamProvisioningService.ts | 279 ++++++++++++++++-- .../extensions/mcp/CustomMcpServerDialog.tsx | 3 +- .../extensions/mcp/McpServerCard.tsx | 22 +- .../extensions/mcp/McpServerDetailDialog.tsx | 136 ++++++--- src/renderer/components/layout/MoreMenu.tsx | 23 +- .../components/layout/TabBarActions.tsx | 2 +- src/renderer/utils/mentionLinkify.ts | 2 +- src/shared/types/extensions/index.ts | 1 + src/shared/types/extensions/mcp.ts | 13 + .../OfficialMcpRegistryService.test.ts | 6 + ...eamProvisioningServiceLiveMessages.test.ts | 84 ++++++ .../team/TeamProvisioningServiceRelay.test.ts | 50 ++-- 16 files changed, 785 insertions(+), 116 deletions(-) create mode 100644 docs/research/split-screen-multi-view.md diff --git a/agent-teams-controller/src/internal/crossTeam.js b/agent-teams-controller/src/internal/crossTeam.js index 34216c97..e2fb3a12 100644 --- a/agent-teams-controller/src/internal/crossTeam.js +++ b/agent-teams-controller/src/internal/crossTeam.js @@ -3,9 +3,14 @@ const path = require('path'); const crypto = require('crypto'); const { createControllerContext } = require('./context.js'); const { withFileLockSync } = require('./fileLock.js'); +const messageStore = require('./messageStore.js'); const cascadeGuard = require('./cascadeGuard.js'); const runtimeHelpers = require('./runtimeHelpers.js'); -const { formatCrossTeamText, CROSS_TEAM_SOURCE } = require('./crossTeamProtocol.js'); +const { + formatCrossTeamText, + CROSS_TEAM_SOURCE, + CROSS_TEAM_SENT_SOURCE, +} = require('./crossTeamProtocol.js'); const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const CROSS_TEAM_DEDUPE_WINDOW_MS = 5 * 60 * 1000; @@ -180,6 +185,7 @@ function sendCrossTeamMessage(context, flags) { replyToConversationId: replyToConversationId || undefined, }); const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; + const timestamp = new Date().toISOString(); const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary); const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`); @@ -206,7 +212,7 @@ function sendCrossTeamMessage(context, flags) { from, to: leadName, text: formattedText, - timestamp: new Date().toISOString(), + timestamp, read: false, summary: summary || `Cross-team message from ${fromTeam}`, messageId, @@ -224,6 +230,18 @@ function sendCrossTeamMessage(context, flags) { throw new Error('Cross-team inbox write verification failed'); } + messageStore.appendSentMessage(context.paths, { + from: fromMember, + to: `${toTeam}.${leadName}`, + text, + timestamp, + messageId, + summary: summary || `Cross-team message to ${toTeam}`, + source: CROSS_TEAM_SENT_SOURCE, + conversationId: resolvedConversationId, + replyToConversationId: replyToConversationId || undefined, + }); + outList.push({ messageId, fromTeam, @@ -234,7 +252,7 @@ function sendCrossTeamMessage(context, flags) { text, summary, chainDepth, - timestamp: new Date().toISOString(), + timestamp, }); writeJson(outboxPath, outList); }); diff --git a/agent-teams-controller/test/crossTeam.test.js b/agent-teams-controller/test/crossTeam.test.js index 729bfd55..74133b6c 100644 --- a/agent-teams-controller/test/crossTeam.test.js +++ b/agent-teams-controller/test/crossTeam.test.js @@ -87,6 +87,15 @@ describe('crossTeam module', () => { expect(outbox).toHaveLength(1); expect(outbox[0].toTeam).toBe('team-b'); expect(outbox[0].conversationId).toBeTruthy(); + + const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json'); + const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8')); + expect(sentMessages).toHaveLength(1); + expect(sentMessages[0].from).toBe('team-lead'); + expect(sentMessages[0].to).toBe('team-b.team-lead'); + expect(sentMessages[0].text).toBe('Hello'); + expect(sentMessages[0].source).toBe('cross_team_sent'); + expect(sentMessages[0].messageId).toBe(outbox[0].messageId); }); it('preserves reply conversation metadata for explicit replies', () => { @@ -152,6 +161,10 @@ describe('crossTeam module', () => { const outbox = controller.crossTeam.getCrossTeamOutbox(); expect(outbox).toHaveLength(1); + + const sentMessagesPath = path.join(claudeDir, 'teams', 'team-a', 'sentMessages.json'); + const sentMessages = JSON.parse(fs.readFileSync(sentMessagesPath, 'utf8')); + expect(sentMessages).toHaveLength(1); }); it('allows resending after dedupe window expires', () => { diff --git a/docs/research/split-screen-multi-view.md b/docs/research/split-screen-multi-view.md new file mode 100644 index 00000000..e76c59d4 --- /dev/null +++ b/docs/research/split-screen-multi-view.md @@ -0,0 +1,210 @@ +# Split Screen Multi-View Research + +> Исследование: поддержка одновременного просмотра нескольких сессий/команд в split pane. +> Дата: 2026-03-10 + +## Текущее состояние архитектуры + +### Split Pane System (уже реализовано) +- До **4 панелей** одновременно (`MAX_PANES = 4` в `src/renderer/types/panes.ts`) +- Drag-and-drop между панелями (dnd-kit, `TabbedLayout.tsx`) +- Resize handles между панелями (`PaneResizeHandle.tsx`) +- CSS `display: none` toggle — все вкладки mounted, только active видна (`PaneContent.tsx`) +- `TabUIContext` предоставляет `tabId` потомкам + +### Pane Layout Structure +```typescript +// src/renderer/types/panes.ts +interface Pane { + id: string; + tabs: Tab[]; + activeTabId: string; + selectedTabIds: string[]; + widthFraction: number; // 0-1, сумма всех = 1.0 +} + +interface PaneLayout { + panes: Pane[]; + focusedPaneId: string; // какая панель в фокусе +} +``` + +### Backward Compatibility Facade +Root-level `openTabs`, `activeTabId`, `selectedTabIds` синхронизируются из **focused pane only** через `syncFromLayout()` в `tabSlice.ts`. + +--- + +## Изоляция состояния: что per-tab vs глобальное + +### ✅ Per-Tab (уже изолировано) + +| Состояние | Хранение | Слайс | +|-----------|----------|-------| +| UI expansion state | `tabUIStates[tabId]` | `tabUISlice` | +| Scroll position | `tabUIStates[tabId].savedScrollTop` | `tabUISlice` | +| Context panel visibility | `tabUIStates[tabId].showContextPanel` | `tabUISlice` | +| Context phase selection | `tabUIStates[tabId].selectedContextPhase` | `tabUISlice` | +| Session data cache | `tabSessionData[tabId]` | `sessionDetailSlice` | +| Conversation cache | `tabSessionData[tabId].conversation` | `sessionDetailSlice` | + +**Паттерн чтения:** +```typescript +const stats = useStore((s) => { + const td = tabId ? s.tabSessionData[tabId] : null; + return td?.sessionClaudeMdStats ?? s.sessionClaudeMdStats; +}); +``` + +### ❌ Глобальное (проблемы для multi-view) + +| Состояние | Слайс | Проблема | +|-----------|-------|----------| +| `selectedTeamName` | `teamSlice` | Одна команда на всё приложение | +| `selectedTeamData` | `teamSlice` | Полные данные только одной команды | +| `searchQuery` | `conversationSlice` | Поиск общий для всех вкладок | +| `searchVisible` | `conversationSlice` | Показ поиска общий | +| `searchMatches` | `conversationSlice` | Результаты поиска общие | +| `currentSearchIndex` | `conversationSlice` | Навигация по результатам общая | +| `expandedAIGroupIds` | `conversationSlice` | Legacy дубль `tabUISlice` | +| `expandedDisplayItemIds` | `conversationSlice` | Legacy дубль `tabUISlice` | +| `expandedStepIds` | `conversationSlice` | Глобальное, логично per-tab | +| `activeDetailItem` | `conversationSlice` | Глобальное, логично per-tab | + +### ⚠️ Синхронизируемое (работает через swap) + +| Состояние | Механизм | +|-----------|----------| +| `selectedProjectId` | Swap при фокусе pane | +| `selectedSessionId` | Swap при фокусе pane | +| `sessionDetail` (global) | Swap из `tabSessionData[tabId]` | +| `conversation` (global) | Swap из `tabSessionData[tabId]` | + +--- + +## Варианты реализации + +### Вариант A: Полная поддержка split-screen для сессий +**Надёжность: 8/10 | Уверенность: 9/10** + +Основа уже заложена через `tabSessionData`. Нужно: + +1. **Search isolation** (~5 файлов): + - Перенести `searchQuery`, `searchVisible`, `searchMatches`, `currentSearchIndex` в `tabUISlice` + - Обновить `SearchBar`, `useSearchContextNavigation`, `searchHighlightUtils` + - Компоненты читают search state через `tabUIStates[tabId]` + +2. **Legacy cleanup** (~3 файла): + - Удалить `expandedAIGroupIds` и `expandedDisplayItemIds` из `conversationSlice` + - Убедиться все компоненты используют `tabUISlice` версии + - Удалить `expandedStepIds` из global scope + +3. **Верификация** (~3 файла): + - Проверить все компоненты в chat/ читают через `tabSessionData[tabId]` паттерн + - Проверить что `activeDetailItem` изолирован + +**Объём: ~8-12 файлов, средняя сложность.** + +### Вариант B: Полная поддержка split-screen для команд +**Надёжность: 7/10 | Уверенность: 7/10** + +Нужна новая инфраструктура: + +1. **Per-tab team data cache** (~5 файлов): + ```typescript + // В teamSlice или sessionDetailSlice + tabTeamData: Record + ``` + +2. **selectTeam() с tabId** (~3 файла): + - `selectTeam(teamName, tabId?)` — кэширует в `tabTeamData[tabId]` + - При переключении tab: swap из кэша или fetch + - При закрытии tab: cleanup кэша + +3. **Team компоненты** (~8 файлов): + - `TeamDetailView`, `TeamChatView`, `TeamKanbanView` и др. + - Читать через `tabTeamData[tabId]` паттерн + - File watcher: обновлять нужные tab кэши + +4. **Sidebar sync** (~2 файла): + - При фокусе pane с team tab: sync sidebar к этой команде + +**Объём: ~15-20 файлов, высокая сложность.** + +### Вариант C: A + B (полный split-screen) +**Надёжность: 6/10 | Уверенность: 7/10** + +**Объём: ~20-25 файлов.** + +--- + +## Риски + +### Высокие +1. **Race conditions при file watcher events** — обновление прилетает, нужно обновить правильный tab cache. Для сессий решено через `tabFetchGeneration` Map, для команд нужен аналог. +2. **Search isolation** — search завязан на глобальные `searchMatches` и навигацию по ним, самый трудоёмкий рефактор. + +### Средние +3. **Memory pressure** — каждый tab хранит полный кэш. Для сессий работает (cleanup при закрытии). Для команд нужен аналог. +4. **Sidebar sync** — сайдбар показывает контекст focused pane. При переключении нужен корректный swap project/worktree/team. +5. **Stale data** — два tab с одной сессией/командой: file watcher обновляет оба или только active? + +### Низкие +6. **DnD between panes** — перетаскивание team tab между panes должно триггерить cache transfer. +7. **Tab duplication** — `openTab()` проверяет дупликаты across ALL panes. Нужно ли разрешить одну и ту же команду в двух panes? + +--- + +## Ключевые файлы + +### Store Slices +| Файл | Роль | +|------|------| +| `src/renderer/store/slices/tabSlice.ts` | Tab lifecycle, session switching, backward compat | +| `src/renderer/store/slices/paneSlice.ts` | Multi-pane split/resize/focus | +| `src/renderer/store/slices/tabUISlice.ts` | Per-tab UI state (expansion, scroll) | +| `src/renderer/store/slices/sessionDetailSlice.ts` | Session data + per-tab caching | +| `src/renderer/store/slices/conversationSlice.ts` | Search, legacy expansion (нужен рефактор) | +| `src/renderer/store/slices/teamSlice.ts` | Team selection (глобальное, нужен рефактор) | + +### Layout Components +| Файл | Роль | +|------|------| +| `src/renderer/components/layout/TabbedLayout.tsx` | Main layout + DnD context | +| `src/renderer/components/layout/TabBarRow.tsx` | Full-width tab bar (pane-proportional) | +| `src/renderer/components/layout/TabBar.tsx` | Single pane tab bar | +| `src/renderer/components/layout/PaneContainer.tsx` | Split layout renderer | +| `src/renderer/components/layout/PaneView.tsx` | Single pane wrapper | +| `src/renderer/components/layout/PaneContent.tsx` | Tab content renderer (display-toggle) | +| `src/renderer/components/layout/SessionTabContent.tsx` | Session tab content | + +### Contexts +| Файл | Роль | +|------|------| +| `src/renderer/contexts/TabUIContext.tsx` | Per-tab ID provider | +| `src/renderer/contexts/useTabUIContext.ts` | Context hook | + +--- + +## Рекомендация + +**Начать с Варианта A** (сессии в split-screen): +- 80% инфраструктуры уже есть +- Нужно дочистить search isolation и legacy duplicates +- Низкий риск регрессий + +**Затем Вариант B** (команды): +- Когда паттерн per-tab caching отработан на сессиях +- Применить тот же подход к team data + +--- + +## Обнаруженные баги (побочный результат ресёрча) + +1. **Search state не изолирован** — поиск в одной вкладке влияет на другие +2. **Legacy дублирование** — `expandedAIGroupIds` существует и в `conversationSlice` и в `tabUISlice` +3. **Team tabs в split pane** — обе панели показывают одну команду (последнюю выбранную) diff --git a/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts b/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts index aed9f7a7..d6279afd 100644 --- a/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts +++ b/src/main/services/extensions/catalog/OfficialMcpRegistryService.ts @@ -10,7 +10,12 @@ import https from 'node:https'; import http from 'node:http'; import { createLogger } from '@shared/utils/logger'; -import type { McpCatalogItem, McpEnvVarDef, McpInstallSpec } from '@shared/types/extensions'; +import type { + McpAuthHeaderDef, + McpCatalogItem, + McpEnvVarDef, + McpInstallSpec, +} from '@shared/types/extensions'; const logger = createLogger('Extensions:OfficialMcpRegistry'); @@ -265,6 +270,7 @@ export class OfficialMcpRegistryService { const meta = entry._meta?.['io.modelcontextprotocol.registry/official']; const installSpec = this.deriveInstallSpec(server); const envVars = this.collectEnvVars(server); + const authHeaders = this.collectAuthHeaders(server); const requiresAuth = this.detectAuthRequired(server); return { @@ -285,6 +291,7 @@ export class OfficialMcpRegistryService { status: meta?.status, publishedAt: meta?.publishedAt, updatedAt: meta?.updatedAt, + authHeaders, }; } @@ -330,6 +337,30 @@ export class OfficialMcpRegistryService { return envVars; } + private collectAuthHeaders(server: RegistryServerEntry['server']): McpAuthHeaderDef[] { + const headers: McpAuthHeaderDef[] = []; + const seenKeys = new Set(); + + for (const remote of server.remotes ?? []) { + for (const header of remote.headers ?? []) { + const key = header.name.trim(); + if (!key || seenKeys.has(key)) { + continue; + } + seenKeys.add(key); + headers.push({ + key, + description: header.description, + isRequired: header.isRequired, + isSecret: header.isSecret, + valueTemplate: header.value, + }); + } + } + + return headers; + } + private detectAuthRequired(server: RegistryServerEntry['server']): boolean { for (const remote of server.remotes ?? []) { for (const header of remote.headers ?? []) { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 316629f0..16e99d2e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -201,6 +201,8 @@ interface ProvisioningRun { leadMsgSeq: number; /** Accumulated tool_use details between text messages. */ pendingToolCalls: ToolCallMeta[]; + /** True when a direct MCP cross_team_send happened and sentMessages history should refresh. */ + pendingDirectCrossTeamSendRefresh: boolean; /** Throttle timestamp for emitting inbox refresh events for lead text. */ lastLeadTextEmitMs: number; /** @@ -1090,6 +1092,7 @@ function isTransientProbeWarning(warning: string): boolean { export class TeamProvisioningService { private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; + private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000; private readonly runs = new Map(); private readonly activeByTeam = new Map(); @@ -1099,6 +1102,7 @@ export class TeamProvisioningService { private readonly memberInboxRelayInFlight = new Map>(); private readonly relayedMemberInboxMessageIds = new Map>(); private readonly pendingCrossTeamFirstReplies = new Map>(); + private readonly recentCrossTeamLeadDeliveryMessageIds = new Map>(); private readonly liveLeadProcessMessages = new Map(); private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; private helpOutputCache: string | null = null; @@ -1367,12 +1371,90 @@ export class TeamProvisioningService { } } + private getPendingCrossTeamReplyExpectationKeys(teamName: string): Set { + const teamMap = this.pendingCrossTeamFirstReplies.get(teamName.trim()); + if (!teamMap) return new Set(); + const cutoff = Date.now() - TeamProvisioningService.RECENT_CROSS_TEAM_DELIVERY_TTL_MS; + for (const [key, createdAt] of teamMap.entries()) { + if (createdAt < cutoff) { + teamMap.delete(key); + } + } + if (teamMap.size === 0) { + this.pendingCrossTeamFirstReplies.delete(teamName.trim()); + return new Set(); + } + return new Set(teamMap.keys()); + } + private getRunLeadName(run: ProvisioningRun): string { return ( run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead' ); } + private rememberRecentCrossTeamLeadDeliveryMessageIds( + teamName: string, + messageIds: string[] + ): void { + const normalizedIds = messageIds.map((id) => id.trim()).filter((id) => id.length > 0); + if (normalizedIds.length === 0) return; + const teamKey = teamName.trim(); + const current = + this.recentCrossTeamLeadDeliveryMessageIds.get(teamKey) ?? new Map(); + const now = Date.now(); + const cutoff = now - TeamProvisioningService.RECENT_CROSS_TEAM_DELIVERY_TTL_MS; + for (const [key, createdAt] of current.entries()) { + if (createdAt < cutoff) current.delete(key); + } + for (const messageId of normalizedIds) { + current.set(messageId, now); + } + if (current.size > 0) { + this.recentCrossTeamLeadDeliveryMessageIds.set(teamKey, current); + } + } + + private wasRecentlyDeliveredToLead(teamName: string, messageId: string): boolean { + const normalizedMessageId = messageId.trim(); + if (!normalizedMessageId) return false; + const teamKey = teamName.trim(); + const current = this.recentCrossTeamLeadDeliveryMessageIds.get(teamKey); + if (!current) return false; + const cutoff = Date.now() - TeamProvisioningService.RECENT_CROSS_TEAM_DELIVERY_TTL_MS; + for (const [key, createdAt] of current.entries()) { + if (createdAt < cutoff) current.delete(key); + } + if (current.size === 0) { + this.recentCrossTeamLeadDeliveryMessageIds.delete(teamKey); + return false; + } + return current.has(normalizedMessageId); + } + + private parseCrossTeamTargetTeam(value: string | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('cross-team:')) { + const teamName = trimmed.slice('cross-team:'.length).trim(); + return TEAM_NAME_PATTERN.test(teamName) ? teamName : null; + } + const dot = trimmed.indexOf('.'); + if (dot <= 0) return null; + const teamName = trimmed.slice(0, dot).trim(); + return TEAM_NAME_PATTERN.test(teamName) ? teamName : null; + } + + private getCrossTeamSourceTeam(value: string | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + const dot = trimmed.indexOf('.'); + if (dot <= 0) return null; + const teamName = trimmed.slice(0, dot).trim(); + return TEAM_NAME_PATTERN.test(teamName) ? teamName : null; + } + private extractStreamUserText(msg: Record): string | null { const topLevelContent = msg.content; if (typeof topLevelContent === 'string') { @@ -1415,29 +1497,48 @@ export class TeamProvisioningService { return text.length > 0 ? text : null; } - private async markDeliveredCrossTeamLeadMessagesRead( + private async matchCrossTeamLeadInboxMessages( teamName: string, leadName: string, deliveredBlocks: Array<{ teammateId: string; content: string; + toTeam: string; conversationId: string; }> - ): Promise { - if (deliveredBlocks.length === 0) return; + ): Promise< + Array<{ + teammateId: string; + content: string; + toTeam: string; + conversationId: string; + messageId: string; + wasRead: boolean; + }> + > { + if (deliveredBlocks.length === 0) return []; let leadInboxMessages: Awaited> = []; try { leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); } catch { - return; + return []; } - const toMark: (InboxMessage & { messageId: string })[] = []; + const usedMessageIds = new Set(); + const matches: Array<{ + teammateId: string; + content: string; + toTeam: string; + conversationId: string; + messageId: string; + wasRead: boolean; + }> = []; for (const block of deliveredBlocks) { const matchesBlock = (message: InboxMessage, requireExactText: boolean): boolean => { - if (message.read || message.source !== CROSS_TEAM_SOURCE) return false; + if (message.source !== CROSS_TEAM_SOURCE) return false; if (!this.hasStableMessageId(message)) return false; + if (usedMessageIds.has(message.messageId)) return false; if (message.from.trim() !== block.teammateId.trim()) return false; const messageConversationId = message.replyToConversationId?.trim() ?? @@ -1450,17 +1551,18 @@ export class TeamProvisioningService { leadInboxMessages.find((message) => matchesBlock(message, true)) ?? leadInboxMessages.find((message) => matchesBlock(message, false)); if (!matched || !this.hasStableMessageId(matched)) continue; - matched.read = true; - toMark.push(matched); + usedMessageIds.add(matched.messageId); + matches.push({ + teammateId: block.teammateId, + content: block.content, + toTeam: block.toTeam, + conversationId: block.conversationId, + messageId: matched.messageId, + wasRead: matched.read === true, + }); } - if (toMark.length === 0) return; - - try { - await this.markInboxMessagesRead(teamName, leadName, toMark); - } catch { - // best-effort - } + return matches; } private handleNativeTeammateUserMessage( @@ -1490,13 +1592,33 @@ export class TeamProvisioningService { }); if (crossTeamBlocks.length === 0) return; - run.activeCrossTeamReplyHints = crossTeamBlocks.map((block) => ({ - toTeam: block.toTeam, - conversationId: block.conversationId, - })); - const leadName = this.getRunLeadName(run); - void this.markDeliveredCrossTeamLeadMessagesRead(run.teamName, leadName, crossTeamBlocks); + void (async () => { + const matches = await this.matchCrossTeamLeadInboxMessages( + run.teamName, + leadName, + crossTeamBlocks + ); + const unreadMatches = matches.filter((match) => !match.wasRead); + if (unreadMatches.length > 0) { + try { + await this.markInboxMessagesRead(run.teamName, leadName, unreadMatches); + } catch { + // best-effort + } + } + const freshMatches = matches.filter( + (match) => !this.wasRecentlyDeliveredToLead(run.teamName, match.messageId) + ); + this.rememberRecentCrossTeamLeadDeliveryMessageIds( + run.teamName, + freshMatches.map((match) => match.messageId) + ); + run.activeCrossTeamReplyHints = freshMatches.map((match) => ({ + toTeam: match.toTeam, + conversationId: match.conversationId, + })); + })(); } private persistSentMessage(teamName: string, message: InboxMessage): void { @@ -2216,6 +2338,7 @@ export class TeamProvisioningService { activeCrossTeamReplyHints: [], leadMsgSeq: 0, pendingToolCalls: [], + pendingDirectCrossTeamSendRefresh: false, lastLeadTextEmitMs: 0, silentUserDmForward: null, silentUserDmForwardClearHandle: null, @@ -2540,6 +2663,7 @@ export class TeamProvisioningService { activeCrossTeamReplyHints: [], leadMsgSeq: 0, pendingToolCalls: [], + pendingDirectCrossTeamSendRefresh: false, lastLeadTextEmitMs: 0, silentUserDmForward: null, silentUserDmForwardClearHandle: null, @@ -3053,14 +3177,80 @@ export class TeamProvisioningService { if (unread.length === 0) return 0; + const latestOutboundByConversation = new Map(); + const latestReadInboundByConversation = new Map(); + for (const message of leadInboxMessages) { + const timestampMs = Date.parse(message.timestamp); + if (!Number.isFinite(timestampMs)) continue; + if (message.source === CROSS_TEAM_SENT_SOURCE) { + const conversationId = message.conversationId?.trim(); + const targetTeam = this.parseCrossTeamTargetTeam(message.to); + if (!conversationId || !targetTeam) continue; + const key = this.buildCrossTeamConversationKey(targetTeam, conversationId); + latestOutboundByConversation.set( + key, + Math.max(latestOutboundByConversation.get(key) ?? 0, timestampMs) + ); + continue; + } + if (message.source === CROSS_TEAM_SOURCE && message.read) { + const conversationId = + message.replyToConversationId?.trim() ?? + message.conversationId?.trim() ?? + parseCrossTeamPrefix(message.text)?.conversationId; + const sourceTeam = this.getCrossTeamSourceTeam(message.from); + if (!conversationId || !sourceTeam) continue; + const key = this.buildCrossTeamConversationKey(sourceTeam, conversationId); + latestReadInboundByConversation.set( + key, + Math.max(latestReadInboundByConversation.get(key) ?? 0, timestampMs) + ); + } + } + const pendingHistoricalReplies = new Set( + Array.from(latestOutboundByConversation.entries()) + .filter(([key, sentAtMs]) => sentAtMs > (latestReadInboundByConversation.get(key) ?? 0)) + .map(([key]) => key) + ); + const pendingTransientReplies = this.getPendingCrossTeamReplyExpectationKeys(teamName); + const matchedTransientReplyKeys = new Set(); + + const wasRecentlyDeliveredCrossTeam = (message: InboxMessage): boolean => { + if (message.source !== CROSS_TEAM_SOURCE) return false; + if (!this.hasStableMessageId(message)) return false; + return this.wasRecentlyDeliveredToLead(teamName, message.messageId); + }; + const isCrossTeamReplyToOwnOutbound = (message: InboxMessage): boolean => { + if (message.source !== CROSS_TEAM_SOURCE) return false; + const conversationId = + message.replyToConversationId?.trim() ?? + message.conversationId?.trim() ?? + parseCrossTeamPrefix(message.text)?.conversationId; + if (!conversationId) return false; + const sourceTeam = this.getCrossTeamSourceTeam(message.from); + if (!sourceTeam) return false; + const key = this.buildCrossTeamConversationKey(sourceTeam, conversationId); + if (pendingHistoricalReplies.has(key)) { + return true; + } + if (pendingTransientReplies.has(key)) { + matchedTransientReplyKeys.add(key); + return true; + } + return false; + }; + // Ignore (and auto-mark read) internal coordination noise like idle/shutdown messages. // Also ignore local sender-copy rows for cross-team traffic: those exist only so the UI // can show outbound activity and must not be re-injected into the live lead as new work. - // Incoming cross-team deliveries are handled through Claude's native - // path and are marked read when that raw user turn is observed, so we intentionally do not - // custom-relay them here. + // If the same cross-team delivery already arrived via a raw turn, + // suppress the duplicate relay here and simply mark the inbox row as read. const ignoredUnread = unread.filter( - (m) => isInboxNoiseMessage(m.text) || m.source === CROSS_TEAM_SENT_SOURCE + (m) => + isInboxNoiseMessage(m.text) || + m.source === CROSS_TEAM_SENT_SOURCE || + isCrossTeamReplyToOwnOutbound(m) || + wasRecentlyDeliveredCrossTeam(m) ); if (ignoredUnread.length > 0) { try { @@ -3068,13 +3258,20 @@ export class TeamProvisioningService { } catch { // best-effort } + for (const key of matchedTransientReplyKeys) { + const [otherTeam, conversationId] = key.split('\0'); + if (otherTeam && conversationId) { + this.clearPendingCrossTeamReplyExpectation(teamName, otherTeam, conversationId); + } + } } const actionableUnread = unread.filter( (m) => !isInboxNoiseMessage(m.text) && m.source !== CROSS_TEAM_SENT_SOURCE && - m.source !== CROSS_TEAM_SOURCE + !isCrossTeamReplyToOwnOutbound(m) && + !wasRecentlyDeliveredCrossTeam(m) ); if (actionableUnread.length === 0) return 0; @@ -3187,6 +3384,12 @@ export class TeamProvisioningService { relayedIds.add(m.messageId); } this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds)); + this.rememberRecentCrossTeamLeadDeliveryMessageIds( + teamName, + batch + .filter((message) => message.source === CROSS_TEAM_SOURCE) + .map((message) => message.messageId) + ); try { await this.markInboxMessagesRead(teamName, leadName, batch); @@ -3435,11 +3638,22 @@ export class TeamProvisioningService { if (part.type !== 'tool_use' || typeof part.name !== 'string') continue; const isNativeSendMessage = part.name === 'SendMessage'; const isTeamMessageSendTool = part.name === 'mcp__agent-teams__message_send'; - if (!isNativeSendMessage && !isTeamMessageSendTool) continue; + const isDirectCrossTeamSendTool = + part.name === 'mcp__agent-teams__cross_team_send' || part.name === 'cross_team_send'; + if (!isNativeSendMessage && !isTeamMessageSendTool && !isDirectCrossTeamSendTool) continue; const input = part.input; if (!input || typeof input !== 'object') continue; const inp = input as Record; + if (isDirectCrossTeamSendTool) { + const toTeam = typeof inp.toTeam === 'string' ? inp.toTeam.trim() : ''; + const text = typeof inp.text === 'string' ? stripAgentBlocks(inp.text).trim() : ''; + if (toTeam && text) { + run.pendingDirectCrossTeamSendRefresh = true; + } + continue; + } + const recipient = isNativeSendMessage ? typeof inp.recipient === 'string' ? inp.recipient @@ -3998,6 +4212,14 @@ export class TeamProvisioningService { this.setLeadActivity(run, 'idle'); } + if (run.pendingDirectCrossTeamSendRefresh) { + run.pendingDirectCrossTeamSendRefresh = false; + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: 'sentMessages.json', + }); + } if (run.leadRelayCapture) { const capture = run.leadRelayCapture; const combined = capture.textParts.join('\n').trim(); @@ -4033,6 +4255,7 @@ export class TeamProvisioningService { run.leadRelayCapture.rejectOnce(errorMsg); } // Clear silent relay flag after any errored turn. + run.pendingDirectCrossTeamSendRefresh = false; run.activeCrossTeamReplyHints = []; run.silentUserDmForward = null; if (run.silentUserDmForwardClearHandle) { @@ -4757,6 +4980,7 @@ export class TeamProvisioningService { */ private cleanupRun(run: ProvisioningRun): void { this.setLeadActivity(run, 'offline'); + run.pendingDirectCrossTeamSendRefresh = false; if (run.timeoutHandle) { clearTimeout(run.timeoutHandle); run.timeoutHandle = null; @@ -4776,6 +5000,7 @@ export class TeamProvisioningService { this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); this.pendingCrossTeamFirstReplies.delete(run.teamName); + this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName); run.activeCrossTeamReplyHints = []; for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { if (key.startsWith(`${run.teamName}:`)) { diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index d1586b21..53e1dc42 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -41,11 +41,10 @@ interface CustomMcpServerDialogProps { type TransportMode = 'stdio' | 'http'; type HttpTransport = 'streamable-http' | 'sse' | 'http'; -type Scope = 'local' | 'user' | 'project'; +type Scope = 'local' | 'user'; const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, { value: 'local', label: 'Local' }, ]; diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index dcab2697..d09e64b9 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -6,6 +6,7 @@ import { useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { api } from '@renderer/api'; @@ -45,6 +46,11 @@ export const McpServerCard = ({ server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined ); const canAutoInstall = !!server.installSpec; + const requiresConfiguration = + server.installSpec?.type === 'http' || + server.envVars.length > 0 || + server.requiresAuth || + (server.authHeaders?.length ?? 0) > 0; const [imgError, setImgError] = useState(false); const hasIcon = !!server.iconUrl && !imgError; @@ -197,7 +203,7 @@ export const McpServerCard = ({ )}
- {canAutoInstall && ( + {canAutoInstall && !requiresConfiguration && (
)} + {canAutoInstall && requiresConfiguration && ( +
+ +
+ )}
); diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index deef5be5..c7b72d29 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -40,11 +40,10 @@ interface McpServerDetailDialogProps { onClose: () => void; } -type Scope = 'local' | 'user' | 'project'; +type Scope = 'local' | 'user'; const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, { value: 'local', label: 'Local' }, ]; @@ -71,16 +70,29 @@ export const McpServerDetailDialog = ({ const [imgError, setImgError] = useState(false); const [autoFilledFields, setAutoFilledFields] = useState>(new Set()); - // Initialize form when server changes - const [lastServerId, setLastServerId] = useState(null); - if (server && server.id !== lastServerId) { - setLastServerId(server.id); + // Initialize form when dialog opens or server changes + useEffect(() => { + if (!server || !open) { + return; + } + setServerName(sanitizeMcpServerName(server.name)); setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, '']))); - setHeaders([]); + setHeaders( + (server.authHeaders ?? []).map((header) => ({ + key: header.key, + value: '', + secret: header.isSecret, + description: header.description, + isRequired: header.isRequired, + valueTemplate: header.valueTemplate, + locked: true, + })) + ); + setScope('user'); setImgError(false); setAutoFilledFields(new Set()); - } + }, [server?.id, open]); // Auto-fill env values from saved API keys useEffect(() => { @@ -142,6 +154,18 @@ export const McpServerDetailDialog = ({ const canAutoInstall = !!server.installSpec; const isHttp = server.installSpec?.type === 'http'; const hasIcon = !!server.iconUrl && !imgError; + const npmPackageUrl = + server.installSpec?.type === 'stdio' + ? `https://www.npmjs.com/package/${server.installSpec.npmPackage}` + : null; + const hasSuggestedHeaders = headers.some((header) => header.locked); + const missingRequiredEnvVars = server.envVars.some( + (env) => env.isRequired && !envValues[env.name]?.trim() + ); + const missingRequiredHeaders = headers.some( + (header) => header.isRequired && !header.value.trim() + ); + const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders; const handleInstall = () => { installMcpServer({ @@ -236,13 +260,21 @@ export const McpServerDetailDialog = ({ )}
Install Type -

- {server.installSpec - ? server.installSpec.type === 'stdio' - ? `npm: ${server.installSpec.npmPackage}` - : `HTTP: ${server.installSpec.transportType}` - : 'Manual setup required'} -

+ {server.installSpec?.type === 'stdio' ? ( + + ) : ( +

+ {server.installSpec + ? `HTTP: ${server.installSpec.transportType}` + : 'Manual setup required'} +

+ )}
{server.author && (
@@ -277,6 +309,12 @@ export const McpServerDetailDialog = ({ This server requires authentication
)} + {isHttp && !server.requiresAuth && (server.authHeaders?.length ?? 0) === 0 && ( +
+ Remote MCP servers may still require custom headers or API keys even when the registry + does not describe them. If connection fails after install, check the provider docs. +
+ )} {/* Install form */} {canAutoInstall && ( @@ -356,34 +394,54 @@ export const McpServerDetailDialog = ({ className="h-6 px-1.5 text-xs" > - Add + {hasSuggestedHeaders ? 'Add custom' : 'Add'}
{headers.length > 0 && (
{headers.map((header, index) => ( -
- updateHeader(index, 'key', e.target.value)} - className="h-7 w-32 text-xs" - placeholder="Header-Name" - /> - updateHeader(index, 'value', e.target.value)} - className="h-7 flex-1 text-xs" - placeholder="value" - /> - +
+
+ {header.locked ? ( + + {header.key} + + ) : ( + updateHeader(index, 'key', e.target.value)} + className="h-7 w-32 text-xs" + placeholder="Header-Name" + /> + )} + updateHeader(index, 'value', e.target.value)} + className="h-7 flex-1 text-xs" + placeholder={header.valueTemplate ?? header.description ?? 'value'} + /> + +
+ {(header.description || header.valueTemplate || header.isRequired) && ( +

+ {[ + header.isRequired ? 'Required' : null, + header.description, + header.valueTemplate, + ] + .filter(Boolean) + .join(' • ')} +

+ )}
))}
@@ -398,7 +456,7 @@ export const McpServerDetailDialog = ({ isInstalled={isInstalled} onInstall={handleInstall} onUninstall={handleUninstall} - disabled={!serverName.trim()} + disabled={installDisabled} size="default" errorMessage={installError} /> diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx index 4b30a736..13775035 100644 --- a/src/renderer/components/layout/MoreMenu.tsx +++ b/src/renderer/components/layout/MoreMenu.tsx @@ -1,7 +1,7 @@ /** * MoreMenu - Dropdown menu behind a "..." icon for less-frequent toolbar actions. * - * Groups: Search, Export (session-only), Analyze (session-only), Settings. + * Groups: Search, Export (session-only), Analyze (session-only). * Closes on outside click or Escape. */ @@ -10,7 +10,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { triggerDownload } from '@renderer/utils/sessionExporter'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Activity, Braces, FileText, MoreHorizontal, Search, Settings, Type } from 'lucide-react'; +import { Activity, Braces, FileText, MoreHorizontal, Search, Type } from 'lucide-react'; import type { SessionDetail } from '@renderer/types/data'; import type { Tab } from '@renderer/types/tabs'; @@ -41,7 +41,6 @@ export const MoreMenu = ({ const containerRef = useRef(null); const openCommandPalette = useStore((s) => s.openCommandPalette); - const openSettingsTab = useStore((s) => s.openSettingsTab); const openSessionReport = useStore((s) => s.openSessionReport); // Close on outside click @@ -133,19 +132,6 @@ export const MoreMenu = ({ ] : []; - const bottomItems: MenuItem[] = [ - { - id: 'settings', - label: 'Settings', - icon: Settings, - shortcut: formatShortcut(','), - onClick: () => { - openSettingsTab(); - setIsOpen(false); - }, - }, - ]; - const renderItem = (item: MenuItem): React.JSX.Element => (
)}
diff --git a/src/renderer/components/layout/TabBarActions.tsx b/src/renderer/components/layout/TabBarActions.tsx index 657c3731..676ba890 100644 --- a/src/renderer/components/layout/TabBarActions.tsx +++ b/src/renderer/components/layout/TabBarActions.tsx @@ -161,7 +161,7 @@ export const TabBarActions = (): React.JSX.Element => { - {/* More menu (Search, Export, Analyze, Settings) */} + {/* More menu (Search, Export, Analyze) */} { const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name; - return `${prefix}[@${canonical}](team://${encodeURIComponent(canonical)})`; + return `${prefix}[${canonical}](team://${encodeURIComponent(canonical)})`; }); } diff --git a/src/shared/types/extensions/index.ts b/src/shared/types/extensions/index.ts index 2d51a1d5..e91d3f21 100644 --- a/src/shared/types/extensions/index.ts +++ b/src/shared/types/extensions/index.ts @@ -17,6 +17,7 @@ export { inferCapabilities } from './plugin'; export type { InstalledMcpEntry, + McpAuthHeaderDef, McpCatalogItem, McpCustomInstallRequest, McpEnvVarDef, diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index 0131fc09..ef05d644 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -26,6 +26,7 @@ export interface McpCatalogItem { updatedAt?: string; author?: string; hostingType?: McpHostingType; + authHeaders?: McpAuthHeaderDef[]; } export interface McpToolDef { @@ -58,12 +59,24 @@ export interface McpEnvVarDef { isRequired?: boolean; // from registry, but treat all as optional in UI } +export interface McpAuthHeaderDef { + key: string; + description?: string; + isRequired?: boolean; + isSecret?: boolean; + valueTemplate?: string; +} + // ── HTTP headers (for auth/config of HTTP/SSE servers) ───────────────────── export interface McpHeaderDef { key: string; value: string; secret?: boolean; // true = mask in UI, don't log + description?: string; + isRequired?: boolean; + valueTemplate?: string; + locked?: boolean; } // ── Installed state (from ~/.claude.json / .mcp.json) ────────────────────── diff --git a/test/main/services/extensions/OfficialMcpRegistryService.test.ts b/test/main/services/extensions/OfficialMcpRegistryService.test.ts index 811e8929..9d7e08fc 100644 --- a/test/main/services/extensions/OfficialMcpRegistryService.test.ts +++ b/test/main/services/extensions/OfficialMcpRegistryService.test.ts @@ -143,6 +143,12 @@ describe('OfficialMcpRegistryService', () => { const adAdvisor = result.servers.find((s) => s.id === 'ai.adadvisor/mcp-server'); expect(adAdvisor?.requiresAuth).toBe(true); + expect(adAdvisor?.authHeaders).toHaveLength(1); + expect(adAdvisor?.authHeaders?.[0]).toMatchObject({ + key: 'Authorization', + isRequired: true, + isSecret: true, + }); }); it('collects environment variables', async () => { diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 591b7c05..ce9a5f97 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -128,6 +128,7 @@ interface RunLike { provisioningComplete: boolean; leadMsgSeq: number; pendingToolCalls: { name: string; preview: string }[]; + pendingDirectCrossTeamSendRefresh: boolean; lastLeadTextEmitMs: number; leadRelayCapture: null; silentUserDmForward: null; @@ -156,6 +157,7 @@ function attachRun( provisioningComplete: opts?.provisioningComplete ?? false, leadMsgSeq: 0, pendingToolCalls: [], + pendingDirectCrossTeamSendRefresh: false, lastLeadTextEmitMs: 0, leadRelayCapture: null, silentUserDmForward: null, @@ -588,6 +590,46 @@ describe('TeamProvisioningService pre-ready live messages', () => { expect(hoisted.sendInboxMessage).not.toHaveBeenCalled(); }); + it('refreshes sentMessages history after direct MCP cross_team_send succeeds', () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const emitter = vi.fn<(event: TeamChangeEvent) => void>(); + service.setTeamChangeEmitter(emitter); + const run = attachRun(service, 'my-team', { provisioningComplete: true }); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + content: [ + { + type: 'tool_use', + name: 'mcp__agent-teams__cross_team_send', + input: { + teamName: 'my-team', + toTeam: 'team-best', + text: 'Прямой вызов MCP.', + summary: 'Direct MCP send', + }, + }, + ], + }); + + expect(run.pendingDirectCrossTeamSendRefresh).toBe(true); + + callHandleStreamJsonMessage(service, run, { + type: 'result', + subtype: 'success', + }); + + expect(run.pendingDirectCrossTeamSendRefresh).toBe(false); + expect(emitter).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'inbox', + teamName: 'my-team', + detail: 'sentMessages.json', + }) + ); + }); + it('marks native cross-team teammate-message deliveries as read and restores reply hints', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); @@ -627,6 +669,48 @@ describe('TeamProvisioningService pre-ready live messages', () => { ]); }); + it('suppresses native duplicate cross-team teammate-message after recent relay delivery', async () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const content = + '\nПовторная доставка.'; + seedLeadInbox('my-team', [ + { + from: 'other-team.team-lead', + to: 'team-lead', + text: content, + timestamp: '2026-03-10T21:43:00.000Z', + read: false, + source: 'cross_team', + messageId: 'm-native-cross-team-dup', + conversationId: 'conv-native-dup', + replyToConversationId: 'conv-native-dup', + }, + ]); + const run = attachRun(service, 'my-team', { provisioningComplete: true }); + + (service as any).rememberRecentCrossTeamLeadDeliveryMessageIds('my-team', [ + 'm-native-cross-team-dup', + ]); + + callHandleStreamJsonMessage(service, run, { + type: 'user', + message: { + role: 'user', + content: `${content}`, + }, + }); + + await vi.waitFor(() => { + const updatedInbox = JSON.parse( + hoisted.files.get('/mock/teams/my-team/inboxes/team-lead.json') ?? '[]' + ) as Array<{ read?: boolean }>; + expect(updatedInbox[0]?.read).toBe(true); + }); + + expect(run.activeCrossTeamReplyHints).toEqual([]); + }); + it('rescues mistaken cross_team_send recipients into actual cross-team replies', async () => { const service = new TeamProvisioningService(); seedConfig('my-team'); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 5f60969b..f4d9ed15 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -355,7 +355,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(service.resolveCrossTeamReplyMetadata(teamName, 'other-team')).toBeNull(); }); - it('does not custom-relay incoming cross-team lead inbox messages', async () => { + it('includes explicit cross-team reply instructions in lead relay prompts', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; seedConfig(teamName); @@ -373,17 +373,24 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { ]); const { writeSpy } = attachAliveRun(service, teamName); - const relayed = await service.relayLeadInboxMessages(teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + expect(run?.leadRelayCapture).toBeTruthy(); - expect(relayed).toBe(0); - expect(writeSpy).toHaveBeenCalledTimes(0); + const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); + expect(payload).toContain('Source: cross_team'); + expect(payload).toContain('Cross-team conversationId: conv-explicit'); + expect(payload).toContain('Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"'); + expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"'); + expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"'); - const updatedInbox = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]' - ) as Array<{ messageId?: string; read?: boolean }>; - expect(updatedInbox).toHaveLength(1); - expect(updatedInbox[0]?.messageId).toBe('m-cross-team-explicit'); - expect(updatedInbox[0]?.read).toBe(false); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'Replying properly.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + await relayPromise; }); it('does not relay cross-team sender copies back into the live lead', async () => { @@ -482,7 +489,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(writeSpy).toHaveBeenCalledTimes(0); }); - it('leaves later cross-team follow-up messages for the native teammate-message path', async () => { + it('relays later follow-up messages after the first reply in a conversation was already received', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; seedConfig(teamName); @@ -522,17 +529,18 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { ]); const { writeSpy } = attachAliveRun(service, teamName); - const relayed = await service.relayLeadInboxMessages(teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + expect(run?.leadRelayCapture).toBeTruthy(); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'I will answer the follow-up.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); - expect(relayed).toBe(0); - expect(writeSpy).toHaveBeenCalledTimes(0); - - const updatedInbox = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]' - ) as Array<{ messageId?: string; read?: boolean }>; - expect(updatedInbox).toHaveLength(3); - expect(updatedInbox[2]?.messageId).toBe('m-cross-team-followup'); - expect(updatedInbox[2]?.read).toBe(false); + const relayed = await relayPromise; + expect(relayed).toBe(1); + expect(writeSpy).toHaveBeenCalledTimes(1); }); it('relays unread teammate inbox messages through the live team process', async () => { From 8216d25eace117f2439fb86558fadcbcac4c307d Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 00:55:13 +0200 Subject: [PATCH 03/72] feat: integrate MCP health diagnostics functionality - Added McpHealthDiagnosticsService to manage health checks for MCP servers. - Implemented IPC channels for diagnosing MCP server health, including new MCP_REGISTRY_DIAGNOSE channel. - Enhanced UI components to display diagnostic status and results for installed MCP servers. - Updated state management to track MCP diagnostics loading state and errors. - Improved overall user experience with real-time feedback on MCP server connectivity and health status. --- src/main/index.ts | 3 + src/main/ipc/extensions.ts | 21 ++- src/main/ipc/handlers.ts | 10 +- src/main/services/extensions/index.ts | 1 + .../state/McpHealthDiagnosticsService.ts | 90 ++++++++++++ src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 3 + .../extensions/mcp/McpServerCard.tsx | 32 ++++- .../extensions/mcp/McpServerDetailDialog.tsx | 48 ++++++- .../extensions/mcp/McpServersPanel.tsx | 130 +++++++++++++++++- src/renderer/store/slices/extensionsSlice.ts | 70 ++++++++-- src/shared/types/extensions/api.ts | 2 + src/shared/types/extensions/index.ts | 2 + src/shared/types/extensions/mcp.ts | 11 ++ .../McpHealthDiagnosticsService.test.ts | 43 ++++++ test/renderer/store/extensionsSlice.test.ts | 9 +- 16 files changed, 461 insertions(+), 17 deletions(-) create mode 100644 src/main/services/extensions/state/McpHealthDiagnosticsService.ts create mode 100644 test/main/services/extensions/McpHealthDiagnosticsService.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 79e64197..505ee563 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -77,6 +77,7 @@ import { ExtensionFacadeService, GlamaMcpEnrichmentService, McpCatalogAggregator, + McpHealthDiagnosticsService, McpInstallationStateService, McpInstallService, OfficialMcpRegistryService, @@ -711,6 +712,7 @@ function initializeServices(): void { const glamaMcpService = new GlamaMcpEnrichmentService(); const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService); const mcpStateService = new McpInstallationStateService(); + const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(null); const extensionFacadeService = new ExtensionFacadeService( pluginCatalogService, pluginStateService, @@ -783,6 +785,7 @@ function initializeServices(): void { pluginInstallService, mcpInstallService, apiKeyService, + mcpHealthDiagnosticsService, crossTeamService ); diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 53411a2b..d2948f79 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -17,6 +17,7 @@ import type { McpCatalogItem, McpCustomInstallRequest, McpInstallRequest, + McpServerDiagnostic, McpSearchResult, OperationResult, PluginInstallRequest, @@ -27,6 +28,7 @@ import type { ExtensionFacadeService } from '../services/extensions/ExtensionFac import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; import type { McpInstallService } from '../services/extensions/install/McpInstallService'; import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService'; +import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService'; import { API_KEYS_DELETE, @@ -36,6 +38,7 @@ import { API_KEYS_STORAGE_STATUS, MCP_GITHUB_STARS, MCP_REGISTRY_BROWSE, + MCP_REGISTRY_DIAGNOSE, MCP_REGISTRY_GET_BY_ID, MCP_REGISTRY_GET_INSTALLED, MCP_REGISTRY_INSTALL, @@ -63,6 +66,7 @@ let extensionFacade: ExtensionFacadeService | null = null; let pluginInstaller: PluginInstallService | null = null; let mcpInstaller: McpInstallService | null = null; let apiKeyService: ApiKeyService | null = null; +let mcpHealthDiagnostics: McpHealthDiagnosticsService | null = null; // ── Lifecycle ────────────────────────────────────────────────────────────── @@ -70,12 +74,14 @@ export function initializeExtensionHandlers( facade: ExtensionFacadeService, pluginInstall?: PluginInstallService, mcpInstall?: McpInstallService, - apiKeys?: ApiKeyService + apiKeys?: ApiKeyService, + mcpDiagnostics?: McpHealthDiagnosticsService ): void { extensionFacade = facade; pluginInstaller = pluginInstall ?? null; mcpInstaller = mcpInstall ?? null; apiKeyService = apiKeys ?? null; + mcpHealthDiagnostics = mcpDiagnostics ?? null; } export function registerExtensionHandlers(ipcMain: IpcMain): void { @@ -87,6 +93,7 @@ export function registerExtensionHandlers(ipcMain: IpcMain): void { ipcMain.handle(MCP_REGISTRY_BROWSE, handleMcpBrowse); ipcMain.handle(MCP_REGISTRY_GET_BY_ID, handleMcpGetById); ipcMain.handle(MCP_REGISTRY_GET_INSTALLED, handleMcpGetInstalled); + ipcMain.handle(MCP_REGISTRY_DIAGNOSE, handleMcpDiagnose); ipcMain.handle(MCP_REGISTRY_INSTALL, handleMcpInstall); ipcMain.handle(MCP_REGISTRY_INSTALL_CUSTOM, handleMcpInstallCustom); ipcMain.handle(MCP_REGISTRY_UNINSTALL, handleMcpUninstall); @@ -107,6 +114,7 @@ export function removeExtensionHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(MCP_REGISTRY_BROWSE); ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID); ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED); + ipcMain.removeHandler(MCP_REGISTRY_DIAGNOSE); ipcMain.removeHandler(MCP_REGISTRY_INSTALL); ipcMain.removeHandler(MCP_REGISTRY_INSTALL_CUSTOM); ipcMain.removeHandler(MCP_REGISTRY_UNINSTALL); @@ -222,6 +230,17 @@ async function handleMcpGetInstalled( ); } +function getMcpHealthDiagnostics(): McpHealthDiagnosticsService { + if (!mcpHealthDiagnostics) { + throw new Error('MCP health diagnostics not initialized'); + } + return mcpHealthDiagnostics; +} + +async function handleMcpDiagnose(): Promise> { + return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose()); +} + // ── Install/Uninstall Handlers ──────────────────────────────────────────── function getPluginInstaller(): PluginInstallService { diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 0a733e85..5572e348 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -108,6 +108,7 @@ import type { ExtensionFacadeService } from '../services/extensions/ExtensionFac import type { McpInstallService } from '../services/extensions/install/McpInstallService'; import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService'; +import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService'; import type { SchedulerService } from '../services/schedule/SchedulerService'; /** @@ -141,6 +142,7 @@ export function initializeIpcHandlers( pluginInstaller?: PluginInstallService, mcpInstaller?: McpInstallService, apiKeyService?: ApiKeyService, + mcpHealthDiagnosticsService?: McpHealthDiagnosticsService, crossTeamService?: CrossTeamService ): void { // Initialize domain handlers with registry @@ -177,7 +179,13 @@ export function initializeIpcHandlers( initializeScheduleHandlers(schedulerService); } if (extensionFacade) { - initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller, apiKeyService); + initializeExtensionHandlers( + extensionFacade, + pluginInstaller, + mcpInstaller, + apiKeyService, + mcpHealthDiagnosticsService + ); } if (crossTeamService) { initializeCrossTeamHandlers(crossTeamService); diff --git a/src/main/services/extensions/index.ts b/src/main/services/extensions/index.ts index 9e2517cb..d413f0aa 100644 --- a/src/main/services/extensions/index.ts +++ b/src/main/services/extensions/index.ts @@ -8,6 +8,7 @@ export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService'; export { McpCatalogAggregator } from './catalog/McpCatalogAggregator'; export { PluginInstallationStateService } from './state/PluginInstallationStateService'; export { McpInstallationStateService } from './state/McpInstallationStateService'; +export { McpHealthDiagnosticsService } from './state/McpHealthDiagnosticsService'; export { ExtensionFacadeService } from './ExtensionFacadeService'; export { PluginInstallService } from './install/PluginInstallService'; export { McpInstallService } from './install/McpInstallService'; diff --git a/src/main/services/extensions/state/McpHealthDiagnosticsService.ts b/src/main/services/extensions/state/McpHealthDiagnosticsService.ts new file mode 100644 index 00000000..2f6e0f47 --- /dev/null +++ b/src/main/services/extensions/state/McpHealthDiagnosticsService.ts @@ -0,0 +1,90 @@ +/** + * Runs `claude mcp list` and parses per-server health statuses. + */ + +import { execCli } from '@main/utils/childProcess'; +import { createLogger } from '@shared/utils/logger'; + +import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions'; + +const logger = createLogger('Extensions:McpHealthDiagnostics'); + +const TIMEOUT_MS = 30_000; + +export class McpHealthDiagnosticsService { + constructor(private readonly claudeBinary: string | null) {} + + async diagnose(): Promise { + const { stdout, stderr } = await execCli(this.claudeBinary, ['mcp', 'list'], { + timeout: TIMEOUT_MS, + }); + + const output = [stdout, stderr].filter(Boolean).join('\n'); + const diagnostics = parseMcpDiagnosticsOutput(output); + + logger.info(`Parsed ${diagnostics.length} MCP diagnostic entries`); + return diagnostics; + } +} + +export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] { + const checkedAt = Date.now(); + + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health')) + .map((line) => parseDiagnosticLine(line, checkedAt)) + .filter((entry): entry is McpServerDiagnostic => entry !== null); +} + +function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null { + const statusSeparatorIdx = line.lastIndexOf(' - '); + if (statusSeparatorIdx === -1) { + return null; + } + + const descriptor = line.slice(0, statusSeparatorIdx).trim(); + const statusChunk = line.slice(statusSeparatorIdx + 3).trim(); + + const nameSeparatorIdx = descriptor.indexOf(': '); + if (nameSeparatorIdx === -1) { + return null; + } + + const name = descriptor.slice(0, nameSeparatorIdx).trim(); + const target = descriptor.slice(nameSeparatorIdx + 2).trim(); + if (!name || !target) { + return null; + } + + const { status, statusLabel } = parseStatusChunk(statusChunk); + + return { + name, + target, + status, + statusLabel, + rawLine: line, + checkedAt, + }; +} + +function parseStatusChunk(statusChunk: string): { + status: McpServerHealthStatus; + statusLabel: string; +} { + const symbol = statusChunk[0]; + const label = statusChunk.slice(1).trim() || 'Unknown'; + + switch (symbol) { + case '✓': + return { status: 'connected', statusLabel: label }; + case '!': + return { status: 'needs-authentication', statusLabel: label }; + case '✗': + return { status: 'failed', statusLabel: label }; + default: + return { status: 'unknown', statusLabel: statusChunk }; + } +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 48e543ab..5cad249e 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -594,6 +594,9 @@ export const MCP_REGISTRY_GET_BY_ID = 'mcpRegistry:getById'; /** Get installed MCP servers */ export const MCP_REGISTRY_GET_INSTALLED = 'mcpRegistry:getInstalled'; +/** Run Claude CLI MCP health diagnostics */ +export const MCP_REGISTRY_DIAGNOSE = 'mcpRegistry:diagnose'; + /** Install a plugin */ export const PLUGIN_INSTALL = 'plugin:install'; diff --git a/src/preload/index.ts b/src/preload/index.ts index aabd845e..6cb6f82a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -151,6 +151,7 @@ import { PLUGIN_UNINSTALL, MCP_REGISTRY_SEARCH, MCP_REGISTRY_BROWSE, + MCP_REGISTRY_DIAGNOSE, MCP_REGISTRY_GET_BY_ID, MCP_REGISTRY_GET_INSTALLED, MCP_REGISTRY_INSTALL, @@ -275,6 +276,7 @@ import type { McpCatalogItem, McpCustomInstallRequest, McpInstallRequest, + McpServerDiagnostic, McpSearchResult, OperationResult, PluginInstallRequest, @@ -1395,6 +1397,7 @@ const electronAPI: ElectronAPI = { invokeIpcWithResult(MCP_REGISTRY_GET_BY_ID, registryId), getInstalled: (projectPath?: string) => invokeIpcWithResult(MCP_REGISTRY_GET_INSTALLED, projectPath), + diagnose: () => invokeIpcWithResult(MCP_REGISTRY_DIAGNOSE), install: (request: McpInstallRequest) => invokeIpcWithResult(MCP_REGISTRY_INSTALL, request), installCustom: (request: McpCustomInstallRequest) => diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index d09e64b9..cc3e2dfd 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -19,7 +19,7 @@ import { Github as GithubIcon } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; -import type { McpCatalogItem } from '@shared/types/extensions'; +import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions'; /** Ribbon colors by source */ const RIBBON_STYLES: Record = { @@ -30,12 +30,16 @@ const RIBBON_STYLES: Record = { interface McpServerCardProps { server: McpCatalogItem; isInstalled: boolean; + diagnostic?: McpServerDiagnostic | null; + diagnosticsLoading?: boolean; onClick: (serverId: string) => void; } export const McpServerCard = ({ server, isInstalled, + diagnostic, + diagnosticsLoading, onClick, }: McpServerCardProps): React.JSX.Element => { const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle'); @@ -53,6 +57,14 @@ export const McpServerCard = ({ (server.authHeaders?.length ?? 0) > 0; const [imgError, setImgError] = useState(false); const hasIcon = !!server.iconUrl && !imgError; + const diagnosticBadgeClass = + diagnostic?.status === 'connected' + ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' + : diagnostic?.status === 'needs-authentication' + ? 'border-amber-500/30 bg-amber-500/10 text-amber-400' + : diagnostic?.status === 'failed' + ? 'border-red-500/30 bg-red-500/10 text-red-400' + : 'border-border bg-surface-raised text-text-muted'; return (
)} + {isInstalled && diagnosticsLoading && !diagnostic && ( + + Checking... + + )} + {diagnostic && ( + + {diagnostic.statusLabel} + + )}
@@ -110,6 +135,11 @@ export const McpServerCard = ({ {/* Description */}

{server.description}

+ {diagnostic?.target && ( +

+ {diagnostic.target} +

+ )} {/* Footer indicators + install button */}
diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index c7b72d29..4af81ab8 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -31,11 +31,13 @@ import { InstallButton } from '../common/InstallButton'; import { SourceBadge } from '../common/SourceBadge'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; -import type { McpCatalogItem, McpHeaderDef } from '@shared/types/extensions'; +import type { McpCatalogItem, McpHeaderDef, McpServerDiagnostic } from '@shared/types/extensions'; interface McpServerDetailDialogProps { server: McpCatalogItem | null; isInstalled: boolean; + diagnostic?: McpServerDiagnostic | null; + diagnosticsLoading?: boolean; open: boolean; onClose: () => void; } @@ -50,6 +52,8 @@ const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ export const McpServerDetailDialog = ({ server, isInstalled, + diagnostic, + diagnosticsLoading, open, onClose, }: McpServerDetailDialogProps): React.JSX.Element => { @@ -166,6 +170,14 @@ export const McpServerDetailDialog = ({ (header) => header.isRequired && !header.value.trim() ); const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders; + const diagnosticBadgeClass = + diagnostic?.status === 'connected' + ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' + : diagnostic?.status === 'needs-authentication' + ? 'border-amber-500/30 bg-amber-500/10 text-amber-400' + : diagnostic?.status === 'failed' + ? 'border-red-500/30 bg-red-500/10 text-red-400' + : 'border-border bg-surface-raised text-text-muted'; const handleInstall = () => { installMcpServer({ @@ -315,6 +327,40 @@ export const McpServerDetailDialog = ({ does not describe them. If connection fails after install, check the provider docs.
)} + {(isInstalled || diagnosticsLoading) && ( +
+
+ Claude Status + {diagnosticsLoading && !diagnostic ? ( + + Checking... + + ) : diagnostic ? ( + + {diagnostic.statusLabel} + + ) : ( + + Not checked + + )} +
+ {diagnostic?.target && ( +
+

Launch Target

+ + {diagnostic.target} + +
+ )} +
+ )} {/* Install form */} {canAutoInstall && ( diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index e9e4ffb5..ef0bf85f 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; +import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Label } from '@renderer/components/ui/label'; @@ -15,14 +16,19 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { AlertTriangle, Search, Server } from 'lucide-react'; +import { formatRelativeTime } from '@renderer/utils/formatters'; +import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react'; import { SearchInput } from '../common/SearchInput'; import { McpServerCard } from './McpServerCard'; import { McpServerDetailDialog } from './McpServerDetailDialog'; -import type { McpCatalogItem } from '@shared/types/extensions'; +import type { + InstalledMcpEntry, + McpCatalogItem, + McpServerDiagnostic, +} from '@shared/types/extensions'; import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc'; @@ -74,6 +80,11 @@ export const McpServersPanel = ({ const mcpBrowse = useStore((s) => s.mcpBrowse); const installedServers = useStore((s) => s.mcpInstalledServers); const fetchMcpGitHubStars = useStore((s) => s.fetchMcpGitHubStars); + const mcpDiagnostics = useStore((s) => s.mcpDiagnostics); + const mcpDiagnosticsLoading = useStore((s) => s.mcpDiagnosticsLoading); + const mcpDiagnosticsError = useStore((s) => s.mcpDiagnosticsError); + const mcpDiagnosticsLastCheckedAt = useStore((s) => s.mcpDiagnosticsLastCheckedAt); + const runMcpDiagnostics = useStore((s) => s.runMcpDiagnostics); const [mcpSort, setMcpSort] = useState('name-asc'); const [mcpInstalledOnly, setMcpInstalledOnly] = useState(false); @@ -85,6 +96,10 @@ export const McpServersPanel = ({ } }, [browseCatalog.length, browseLoading, mcpBrowse]); + useEffect(() => { + void runMcpDiagnostics(); + }, [runMcpDiagnostics]); + // Fetch GitHub stars after catalog loads (fire-and-forget) useEffect(() => { const urls = browseCatalog.map((s) => s.repositoryUrl).filter((u): u is string => !!u); @@ -105,10 +120,41 @@ export const McpServersPanel = ({ [installedServers] ); + const installedEntriesByName = useMemo( + () => new Map(installedServers.map((entry) => [entry.name.toLowerCase(), entry] as const)), + [installedServers] + ); + /** Check if a catalog server is installed by comparing sanitized names */ const isServerInstalled = (server: McpCatalogItem): boolean => installedNames.has(sanitizeMcpServerName(server.name)); + const getInstalledEntry = (server: McpCatalogItem): InstalledMcpEntry | null => + installedEntriesByName.get(sanitizeMcpServerName(server.name)) ?? null; + + const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => { + const installedEntry = getInstalledEntry(server); + return installedEntry ? (mcpDiagnostics[installedEntry.name] ?? null) : null; + }; + + const allDiagnostics = useMemo( + () => Object.values(mcpDiagnostics).sort((a, b) => a.name.localeCompare(b.name)), + [mcpDiagnostics] + ); + + const getDiagnosticBadgeClass = (status: McpServerDiagnostic['status']): string => { + switch (status) { + case 'connected': + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'; + case 'needs-authentication': + return 'border-amber-500/30 bg-amber-500/10 text-amber-400'; + case 'failed': + return 'border-red-500/30 bg-red-500/10 text-red-400'; + default: + return 'border-border bg-surface-raised text-text-muted'; + } + }; + // Sort + filter const displayServers = useMemo(() => { let result = rawServers; @@ -131,6 +177,76 @@ export const McpServersPanel = ({ return (
+
+
+
+

MCP Health Status

+

+ {mcpDiagnosticsLoading ? ( + <> + Checking installed MCP servers via Claude CLI (claude mcp list) ... + + ) : mcpDiagnosticsLastCheckedAt ? ( + `Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}` + ) : ( + <> + Run diagnostics (claude mcp list) to verify installed MCP + connectivity. + + )} +

+
+ +
+ + {(mcpDiagnosticsLoading || allDiagnostics.length > 0) && ( +
+
+

Claude MCP List Results

+ {allDiagnostics.length > 0 && ( + {allDiagnostics.length} servers + )} +
+ {allDiagnostics.length > 0 ? ( +
+ {allDiagnostics.map((diagnostic) => ( +
+
+

{diagnostic.name}

+

+ {diagnostic.target} +

+
+ + {diagnostic.statusLabel} + +
+ ))} +
+ ) : ( +

Waiting for `claude mcp list` results...

+ )} +
+ )} +
+ {/* Search + Sort + Installed only row */}
@@ -217,6 +333,12 @@ export const McpServersPanel = ({
)} + {mcpDiagnosticsError && ( +
+ {mcpDiagnosticsError} +
+ )} + {/* Empty state */} {!isLoading && displayServers.length === 0 && (
@@ -251,6 +373,8 @@ export const McpServersPanel = ({ key={server.id} server={server} isInstalled={isServerInstalled(server)} + diagnostic={getDiagnostic(server)} + diagnosticsLoading={mcpDiagnosticsLoading} onClick={setSelectedMcpServerId} /> ))} @@ -275,6 +399,8 @@ export const McpServersPanel = ({ setSelectedMcpServerId(null)} /> diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index f4574549..58df5d40 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -17,6 +17,7 @@ import type { McpCatalogItem, McpCustomInstallRequest, McpInstallRequest, + McpServerDiagnostic, PluginInstallRequest, } from '@shared/types/extensions'; import type { StateCreator } from 'zustand'; @@ -41,6 +42,10 @@ export interface ExtensionsSlice { mcpBrowseError: string | null; mcpInstalledServers: InstalledMcpEntry[]; mcpInstalledProjectPath: string | null; + mcpDiagnostics: Record; + mcpDiagnosticsLoading: boolean; + mcpDiagnosticsError: string | null; + mcpDiagnosticsLastCheckedAt: number | null; // ── Install progress ── pluginInstallProgress: Record; @@ -62,6 +67,7 @@ export interface ExtensionsSlice { fetchPluginReadme: (pluginId: string) => void; mcpBrowse: (cursor?: string) => Promise; mcpFetchInstalled: (projectPath?: string) => Promise; + runMcpDiagnostics: () => Promise; // ── Mutation actions ── installPlugin: (request: PluginInstallRequest) => Promise; @@ -93,6 +99,7 @@ export interface ExtensionsSlice { // ============================================================================= let pluginFetchInFlight: Promise | null = null; +let mcpDiagnosticsInFlight: Promise | null = null; /** Duration to show "success" state before returning to idle */ const SUCCESS_DISPLAY_MS = 2_000; @@ -115,6 +122,10 @@ export const createExtensionsSlice: StateCreator { + const mcpRegistry = api.mcpRegistry; + if (!mcpRegistry) return; + + if (mcpDiagnosticsInFlight) { + await mcpDiagnosticsInFlight; + return; + } + + set({ mcpDiagnosticsLoading: true, mcpDiagnosticsError: null }); + + const promise = (async () => { + try { + const diagnostics = await mcpRegistry.diagnose(); + set({ + mcpDiagnostics: Object.fromEntries( + diagnostics.map((entry) => [entry.name, entry] as const) + ), + mcpDiagnosticsLoading: false, + mcpDiagnosticsLastCheckedAt: Date.now(), + }); + } catch (err) { + set({ + mcpDiagnosticsLoading: false, + mcpDiagnosticsError: + err instanceof Error ? err.message : 'Failed to check MCP server health', + }); + } finally { + mcpDiagnosticsInFlight = null; + } + })(); + + mcpDiagnosticsInFlight = promise; + await promise; + }, + // ── Plugin install ── installPlugin: async (request: PluginInstallRequest) => { if (!api.plugins) return; @@ -347,13 +394,15 @@ export const createExtensionsSlice: StateCreator ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' }, })); - // Refresh installed list - void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined); - setTimeout(() => { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' }, @@ -394,13 +443,15 @@ export const createExtensionsSlice: StateCreator ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' }, })); - // Refresh installed list - void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined); - setTimeout(() => { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' }, @@ -447,12 +498,15 @@ export const createExtensionsSlice: StateCreator ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' }, })); - void get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined); - setTimeout(() => { set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' }, diff --git a/src/shared/types/extensions/api.ts b/src/shared/types/extensions/api.ts index d4121bdf..76fde65f 100644 --- a/src/shared/types/extensions/api.ts +++ b/src/shared/types/extensions/api.ts @@ -16,6 +16,7 @@ import type { McpCatalogItem, McpCustomInstallRequest, McpInstallRequest, + McpServerDiagnostic, McpSearchResult, } from './mcp'; @@ -42,6 +43,7 @@ export interface McpCatalogAPI { ) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>; getById: (registryId: string) => Promise; getInstalled: (projectPath?: string) => Promise; + diagnose: () => Promise; install: (request: McpInstallRequest) => Promise; installCustom: (request: McpCustomInstallRequest) => Promise; uninstall: (name: string, scope?: string, projectPath?: string) => Promise; diff --git a/src/shared/types/extensions/index.ts b/src/shared/types/extensions/index.ts index e91d3f21..689423ee 100644 --- a/src/shared/types/extensions/index.ts +++ b/src/shared/types/extensions/index.ts @@ -20,6 +20,8 @@ export type { McpAuthHeaderDef, McpCatalogItem, McpCustomInstallRequest, + McpServerDiagnostic, + McpServerHealthStatus, McpEnvVarDef, McpHeaderDef, McpHostingType, diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index ef05d644..577bd32a 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -87,6 +87,17 @@ export interface InstalledMcpEntry { transport?: string; } +export type McpServerHealthStatus = 'connected' | 'needs-authentication' | 'failed' | 'unknown'; + +export interface McpServerDiagnostic { + name: string; + target: string; + status: McpServerHealthStatus; + statusLabel: string; + rawLine: string; + checkedAt: number; +} + // ── Install request (renderer → main, minimal trusted data) ──────────────── export interface McpInstallRequest { diff --git a/test/main/services/extensions/McpHealthDiagnosticsService.test.ts b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts new file mode 100644 index 00000000..bb239d68 --- /dev/null +++ b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { parseMcpDiagnosticsOutput } from '@main/services/extensions/state/McpHealthDiagnosticsService'; + +describe('parseMcpDiagnosticsOutput', () => { + it('parses mixed MCP health lines from claude mcp list', () => { + const diagnostics = parseMcpDiagnosticsOutput(`Checking MCP server health... + +plugin:context7:context7: npx -y @upstash/context7-mcp - ✓ Connected +plugin:figma:figma: https://mcp.figma.com/mcp (HTTP) - ✓ Connected +browsermcp: npx @browsermcp/mcp@latest - ✓ Connected +tavily-remote-mcp: npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test - ✗ Failed to connect +alpic: https://mcp.alpic.ai (HTTP) - ! Needs authentication`); + + expect(diagnostics).toHaveLength(5); + expect(diagnostics[0]).toMatchObject({ + name: 'plugin:context7:context7', + target: 'npx -y @upstash/context7-mcp', + status: 'connected', + statusLabel: 'Connected', + }); + expect(diagnostics[3]).toMatchObject({ + name: 'tavily-remote-mcp', + target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test', + status: 'failed', + statusLabel: 'Failed to connect', + }); + expect(diagnostics[4]).toMatchObject({ + name: 'alpic', + target: 'https://mcp.alpic.ai (HTTP)', + status: 'needs-authentication', + statusLabel: 'Needs authentication', + }); + }); + + it('ignores lines that do not look like MCP status rows', () => { + const diagnostics = parseMcpDiagnosticsOutput(`Checking MCP server health... +random log line +another log line`); + + expect(diagnostics).toEqual([]); + }); +}); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index a85cb0ca..80b175aa 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -20,6 +20,7 @@ vi.mock('../../../src/renderer/api', () => ({ browse: vi.fn(), getById: vi.fn(), getInstalled: vi.fn(), + diagnose: vi.fn(), install: vi.fn(), uninstall: vi.fn(), }, @@ -241,6 +242,7 @@ describe('extensionsSlice', () => { it('sets progress to pending then success', async () => { (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); + (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); const promise = store.getState().installMcpServer({ registryId: 'test-id', @@ -261,13 +263,14 @@ describe('extensionsSlice', () => { it('sets progress to pending then success', async () => { (api.mcpRegistry!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); + (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); - const promise = store.getState().uninstallMcpServer('test-server', 'user'); + const promise = store.getState().uninstallMcpServer('test-id', 'test-server', 'user'); - expect(store.getState().mcpInstallProgress['test-server']).toBe('pending'); + expect(store.getState().mcpInstallProgress['test-id']).toBe('pending'); await promise; - expect(store.getState().mcpInstallProgress['test-server']).toBe('success'); + expect(store.getState().mcpInstallProgress['test-id']).toBe('success'); }); }); }); From c5c41d2a0d53eac84e7701e489e5ca73b5b7f7f0 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 12:54:04 +0200 Subject: [PATCH 04/72] feat: enhance task creation and management features - Added new optional parameters 'createdBy' and 'from' to the task creation function for better tracking of task origins. - Updated task execution logic to include the new parameters, improving task metadata handling. - Enhanced tests to validate the new parameters and ensure correct task creation behavior. - Refactored related components to accommodate the changes in task management, ensuring a seamless user experience. --- mcp-server/src/tools/taskTools.ts | 32 +++- mcp-server/test/tools.test.ts | 11 ++ .../services/team/TeamProvisioningService.ts | 47 +++--- src/renderer/components/chat/ChatHistory.tsx | 52 +++---- .../chat/SessionContextPanel/index.tsx | 5 +- .../chat/SessionContextPanel/types.ts | 2 + src/renderer/components/layout/MoreMenu.tsx | 12 +- src/renderer/components/layout/PaneView.tsx | 1 - src/renderer/components/layout/Sidebar.tsx | 12 +- src/renderer/components/layout/TabBar.tsx | 24 +-- .../components/layout/TabBarActions.tsx | 44 +++--- src/renderer/components/layout/TabBarRow.tsx | 38 ++--- .../components/layout/TabbedLayout.tsx | 25 +--- .../components/sidebar/SidebarTaskItem.tsx | 85 ++++++----- .../components/team/TeamDetailView.tsx | 107 ++++++------- .../components/team/activity/ActivityItem.tsx | 11 +- .../team/dialogs/StatusHistoryTimeline.tsx | 17 ++- .../components/team/kanban/KanbanBoard.tsx | 73 +++++++-- .../team/kanban/KanbanSortPopover.tsx | 140 ++++++++++++++++++ 19 files changed, 488 insertions(+), 250 deletions(-) create mode 100644 src/renderer/components/team/kanban/KanbanSortPopover.tsx diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 94bdaf07..7d719197 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -20,23 +20,39 @@ export function registerTaskTools(server: Pick) { subject: z.string().min(1), description: z.string().optional(), owner: z.string().optional(), + createdBy: z.string().optional(), + from: z.string().optional(), blockedBy: z.array(z.string().min(1)).optional(), related: z.array(z.string().min(1)).optional(), prompt: z.string().optional(), startImmediately: z.boolean().optional(), }), - execute: async ({ teamName, claudeDir, subject, description, owner, blockedBy, related, prompt, startImmediately }) => { + execute: async ({ + teamName, + claudeDir, + subject, + description, + owner, + createdBy, + from, + blockedBy, + related, + prompt, + startImmediately, + }) => { const controller = getController(teamName, claudeDir); return await Promise.resolve( jsonTextContent( controller.tasks.createTask({ - subject, - ...(description ? { description } : {}), - ...(owner ? { owner } : {}), - ...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}), - ...(related?.length ? { related: related.join(',') } : {}), - ...(prompt ? { prompt } : {}), - ...(startImmediately !== undefined ? { startImmediately } : {}), + subject, + ...(description ? { description } : {}), + ...(owner ? { owner } : {}), + ...(createdBy ? { createdBy } : {}), + ...(!createdBy && from ? { from } : {}), + ...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}), + ...(related?.length ? { related: related.join(',') } : {}), + ...(prompt ? { prompt } : {}), + ...(startImmediately !== undefined ? { startImmediately } : {}), }) ) ); diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 72e79e79..bd0e76ba 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -109,9 +109,11 @@ describe('agent-teams-mcp tools', () => { teamName, subject: 'Review MCP adapter', owner: 'alice', + createdBy: 'ui-fixer', }) ); expect(createdTask.status).toBe('pending'); + expect(createdTask.historyEvents?.[0]?.actor).toBe('ui-fixer'); const listedTasks = parseJsonToolResult( await getTool('task_list').execute({ @@ -558,6 +560,15 @@ describe('agent-teams-mcp tools', () => { }).success ).toBe(false); + expect( + getTool('task_create').parameters?.safeParse({ + teamName: 'demo', + claudeDir: '/tmp/demo', + subject: 'Created by schema', + createdBy: 'ui-fixer', + }).success + ).toBe(true); + expect( getTool('process_register').parameters?.safeParse({ teamName: 'demo', diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 16e99d2e..65465ae1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -399,33 +399,38 @@ function buildTaskStatusProtocol(teamName: string): string { - If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls. - task_briefing may show short display labels like #abcd1234; MCP task tools also accept that short task ref. - Human-facing summaries should use the short display label like #abcd1234 for readability. -1. Use MCP tool task_start to mark task started: +1. If you are about to do implementation/fix work on a task yourself, make sure the owner reflects the actual implementer: + - If the task is unassigned or assigned to someone else, FIRST reassign it to yourself with MCP tool task_set_owner: + { teamName: "${teamName}", taskId: "", owner: "" } + - Do this only when you are genuinely taking over the work. + - Reviewing, approving, or leaving comments does NOT require changing ownership. +2. Use MCP tool task_start to mark task started: { teamName: "${teamName}", taskId: "" } - Start the task ONLY when you are actually beginning work on it. - Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work. -2. Use MCP tool task_complete BEFORE sending your final reply: +3. Use MCP tool task_complete BEFORE sending your final reply: { teamName: "${teamName}", taskId: "" } -3. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve: +4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve: { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true } -4. If review fails and changes are needed, use MCP tool review_request_changes: +5. If review fails and changes are needed, use MCP tool review_request_changes: { teamName: "${teamName}", taskId: "", comment: "" } -5. NEVER skip status updates. A task is NOT done until completed status is written. +6. NEVER skip status updates. A task is NOT done until completed status is written. - Never "bulk-complete" a batch of tasks at the end. Update status incrementally as you work. -6. To reply to a comment on a task, use MCP tool task_add_comment: +7. To reply to a comment on a task, use MCP tool task_add_comment: { teamName: "${teamName}", taskId: "", text: "", from: "" } -7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment: +8. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment: { teamName: "${teamName}", taskId: "", text: "", from: "" } Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task. -8. When sending a message about a specific task, include its short display label like # in your SendMessage summary field for traceability. -9. In ALL human-facing or teammate-facing message text, when you mention a task reference, ALWAYS write it with a leading # (for example: #abcd1234, not abcd1234 or "task abcd1234"). -10. Review workflow clarity (IMPORTANT): +9. When sending a message about a specific task, include its short display label like # in your SendMessage summary field for traceability. +10. In ALL human-facing or teammate-facing message text, when you mention a task reference, ALWAYS write it with a leading # (for example: #abcd1234, not abcd1234 or "task abcd1234"). +11. Review workflow clarity (IMPORTANT): - The work task (e.g. #1) is the thing that must end up APPROVED after review. - If you are reviewing work for task #X, run review_approve/review_request_changes on #X (the work task). - Do NOT approve a separate "review task" (e.g. #2 created just to ask for a review) — that will put the wrong task into APPROVED. - Typical flow: a) Owner finishes work on #X -> task_complete #X b) Reviewer accepts -> review_approve #X -11. CLARIFICATION PROTOCOL (CRITICAL — MANDATORY): +12. CLARIFICATION PROTOCOL (CRITICAL — MANDATORY): When you are blocked and need information to continue a task, you MUST do ALL steps below — skipping the board update or comment breaks traceability: a) STEP 1 — FIRST, set the clarification flag with MCP tool task_set_clarification: { teamName: "${teamName}", taskId: "", value: "lead" } @@ -437,15 +442,17 @@ function buildTaskStatusProtocol(teamName: string): string { If the lead replies via SendMessage instead, clear the flag yourself once you have the answer: { teamName: "${teamName}", taskId: "", value: "clear" } e) Do NOT set clarification to "user" yourself — only the team lead escalates to the user. -12. DEPENDENCY AWARENESS: +13. DEPENDENCY AWARENESS: When your task has blockedBy dependencies, check if they are completed before starting. When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed. -13. TASK QUEUE DISCIPLINE: +14. TASK QUEUE DISCIPLINE: - Use task_briefing as a compact queue view of your assigned tasks. - task_briefing may include full description/comments only for in_progress tasks; needsFix/pending/review/completed entries may be minimal on purpose. - Finish existing in_progress tasks first. - If you need more context for an in_progress task, you MAY call task_get, but it is not mandatory when task_briefing already gives enough detail. - - Before starting a needsFix or pending task, call task_get for that specific task first, then run task_start only when you truly begin. + - Before starting a needsFix or pending task, call task_get for that specific task first. + - If you are the one doing the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start. + - Then run task_start only when you truly begin. - If you complete fixes for a needsFix task, mark it completed and then send it back through review_request when ready for another review pass. Failure to follow this protocol means the task board will show incorrect status.`); } @@ -485,7 +492,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `IMPORTANT: The board MCP only supports these domains: task, kanban, review, message, process. There is NO "member" domain — team members are managed by spawning teammates via the Task tool, not via the board MCP.`, ``, `Task board operations — use MCP tools directly:`, - `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "", blockedBy?: ["1","2"], related?: ["3"] }`, + `- Create task: task_create { teamName: "${teamName}", subject: "...", description?: "...", owner?: "", createdBy?: "", blockedBy?: ["1","2"], related?: ["3"] }`, `- Assign/reassign owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: "" }`, `- Clear owner: task_set_owner { teamName: "${teamName}", taskId: "", owner: null }`, `- Start task (preferred over set-status): task_start { teamName: "${teamName}", taskId: "" }`, @@ -496,7 +503,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Attach file to a specific comment:`, ` 1) Find commentId: task_get { teamName: "${teamName}", taskId: "" }`, ` 2) Attach: task_attach_comment_file { teamName: "${teamName}", taskId: "", commentId: "", filePath: "", mode?: "copy|link", filename?: "", mimeType?: "" }`, - `- Create with deps (blocked work MUST be pending): task_create { teamName: "${teamName}", subject: "...", owner: "", blockedBy: ["1","2"], related?: ["3"], startImmediately: false }`, + `- Create with deps (blocked work MUST be pending): task_create { teamName: "${teamName}", subject: "...", owner: "", createdBy: "", blockedBy: ["1","2"], related?: ["3"], startImmediately: false }`, `- Link dependency: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, `- Link related: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "related" }`, `- Unlink: task_unlink { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, @@ -517,6 +524,8 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `Notification policy:`, `- Task assignment notifications are handled by the board runtime, so do NOT send a separate SendMessage for the same assignment unless you have extra context that is not already on the task.`, `- Review requests are also handled by the board runtime: review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request unless you are adding materially new context that is not already on the task.`, + `- Ownership must reflect the person actually doing the implementation/fix work. If someone takes over execution, update the owner immediately before they start. Do NOT leave the lead/planner as owner when another member is doing the work.`, + `- Set createdBy when creating tasks so workflow history shows who created the task.`, ``, `Clarification handling (CRITICAL — MANDATORY for correct task board state):`, `- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag and wakes the owner) or SendMessage.`, @@ -851,7 +860,7 @@ function buildLaunchPrompt( When you receive that follow-up message: - Execute tasks sequentially and keep the board + user updated: - Identify the next READY task (pending, not blocked by incomplete dependencies). - - If the task is unassigned, set yourself ("${leadName}") as owner. + - If the task is unassigned or assigned to someone else but you are the one about to do the work, set yourself ("${leadName}") as owner. - If the work you are about to do is not represented on the board yet, create/update the task first before continuing. - BEFORE doing any work on a task: mark it started (in_progress). - Immediately SendMessage "user" that you started task # (what you're doing + next step). @@ -893,7 +902,9 @@ ${actionModeProtocol} Then: - If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you. - After that, prioritize tasks marked Needs fixes after review, then normal pending tasks. - - Before you start any needsFix or pending task, call task_get for that specific task, and only then run task_start when you truly begin. + - Before you start any needsFix or pending task, call task_get for that specific task. + - If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start. + - Only then run task_start when you truly begin. - If you have no tasks, wait for new assignments.`; }) .join('\n\n'); diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index d695f837..df86fd30 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -13,8 +13,6 @@ import { SessionContextPanel } from './SessionContextPanel/index'; /** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */ const SCROLL_THRESHOLD = 300; -/** Must match the `w-80` (320px) context panel width used in the layout below. */ -const CONTEXT_PANEL_WIDTH_PX = 320; import { computeRemainingContext, @@ -833,6 +831,28 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { style={{ backgroundColor: 'var(--color-surface)' }} >
+ {/* Context panel sidebar (left) */} + {isContextPanelVisible && allContextInjections.length > 0 && ( +
+ setContextPanelVisible(false)} + projectRoot={sessionDetail?.session?.projectPath} + onNavigateToTurn={handleNavigateToTurn} + onNavigateToTool={handleNavigateToTool} + onNavigateToUserGroup={handleNavigateToUserGroup} + totalSessionTokens={lastAiGroupTotalTokens} + sessionMetrics={sessionDetail?.metrics} + subagentCostUsd={subagentCostUsd} + onViewReport={effectiveTabId ? () => openSessionReport(effectiveTabId) : undefined} + phaseInfo={sessionPhaseInfo ?? undefined} + selectedPhase={selectedContextPhase} + onPhaseChange={setSelectedContextPhase} + side="left" + /> +
+ )} + {/* Chat content */}
{ > {/* Sticky Context button */} {allContextInjections.length > 0 && ( -
+
)} - - {/* Context panel sidebar */} - {isContextPanelVisible && allContextInjections.length > 0 && ( -
- setContextPanelVisible(false)} - projectRoot={sessionDetail?.session?.projectPath} - onNavigateToTurn={handleNavigateToTurn} - onNavigateToTool={handleNavigateToTool} - onNavigateToUserGroup={handleNavigateToUserGroup} - totalSessionTokens={lastAiGroupTotalTokens} - sessionMetrics={sessionDetail?.metrics} - subagentCostUsd={subagentCostUsd} - onViewReport={effectiveTabId ? () => openSessionReport(effectiveTabId) : undefined} - phaseInfo={sessionPhaseInfo ?? undefined} - selectedPhase={selectedContextPhase} - onPhaseChange={setSelectedContextPhase} - /> -
- )}
); diff --git a/src/renderer/components/chat/SessionContextPanel/index.tsx b/src/renderer/components/chat/SessionContextPanel/index.tsx index 2c956ef5..60754d7e 100644 --- a/src/renderer/components/chat/SessionContextPanel/index.tsx +++ b/src/renderer/components/chat/SessionContextPanel/index.tsx @@ -55,6 +55,7 @@ export const SessionContextPanel = ({ phaseInfo, selectedPhase, onPhaseChange, + side = 'left', }: Readonly): React.ReactElement => { // View mode: category sections or ranked list const [viewMode, setViewMode] = useState('category'); @@ -184,7 +185,9 @@ export const SessionContextPanel = ({ className="flex h-full flex-col" style={{ backgroundColor: COLOR_SURFACE, - borderLeft: `1px solid ${COLOR_BORDER}`, + ...(side === 'left' + ? { borderRight: `1px solid ${COLOR_BORDER}` } + : { borderLeft: `1px solid ${COLOR_BORDER}` }), }} > void; + /** Which side of the content the panel is on: left → borderRight, right → borderLeft */ + side?: 'left' | 'right'; } // ============================================================================= diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx index 13775035..ccc70c57 100644 --- a/src/renderer/components/layout/MoreMenu.tsx +++ b/src/renderer/components/layout/MoreMenu.tsx @@ -10,7 +10,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { triggerDownload } from '@renderer/utils/sessionExporter'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Activity, Braces, FileText, MoreHorizontal, Search, Type } from 'lucide-react'; +import { Activity, Braces, Calendar, FileText, MoreHorizontal, Search, Type } from 'lucide-react'; import type { SessionDetail } from '@renderer/types/data'; import type { Tab } from '@renderer/types/tabs'; @@ -42,6 +42,7 @@ export const MoreMenu = ({ const openCommandPalette = useStore((s) => s.openCommandPalette); const openSessionReport = useStore((s) => s.openSessionReport); + const openSchedulesTab = useStore((s) => s.openSchedulesTab); // Close on outside click useEffect(() => { @@ -95,6 +96,15 @@ export const MoreMenu = ({ setIsOpen(false); }, }, + { + id: 'schedules', + label: 'Schedules', + icon: Calendar, + onClick: () => { + openSchedulesTab(); + setIsOpen(false); + }, + }, ]; const sessionItems: MenuItem[] = isSessionWithData diff --git a/src/renderer/components/layout/PaneView.tsx b/src/renderer/components/layout/PaneView.tsx index bd400ce9..29db5e24 100644 --- a/src/renderer/components/layout/PaneView.tsx +++ b/src/renderer/components/layout/PaneView.tsx @@ -46,7 +46,6 @@ export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => { className="relative flex min-w-0 flex-col" style={{ width: `${pane.widthFraction * 100}%`, - paddingTop: '36px', }} onMouseDown={handleMouseDown} > diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx index c4c60163..224f4b9b 100644 --- a/src/renderer/components/layout/Sidebar.tsx +++ b/src/renderer/components/layout/Sidebar.tsx @@ -42,12 +42,12 @@ export const Sidebar = (): React.JSX.Element => { const [isCollapseHovered, setIsCollapseHovered] = useState(false); const sidebarRef = useRef(null); - // Handle mouse move during resize + // Handle mouse move during resize (right sidebar: width = viewport - clientX) const handleMouseMove = useCallback( (e: MouseEvent) => { if (!isResizing) return; - const newWidth = e.clientX; + const newWidth = window.innerWidth - e.clientX; if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) { setWidth(newWidth); } @@ -85,18 +85,18 @@ export const Sidebar = (): React.JSX.Element => { return (
{ - )} - {/* Tab list with horizontal scroll, sortable DnD, and droppable area. Capped at 75% so the drag spacer always has room to the right. */}
{ openNotificationsTab, openTeamsTab, openExtensionsTab, - openSchedulesTab, openSettingsTab, activeTabId, openTabs, tabSessionData, + sidebarCollapsed, + toggleSidebar, } = useStore( useShallow((s) => ({ unreadCount: s.unreadCount, openNotificationsTab: s.openNotificationsTab, openTeamsTab: s.openTeamsTab, openExtensionsTab: s.openExtensionsTab, - openSchedulesTab: s.openSchedulesTab, openSettingsTab: s.openSettingsTab, activeTabId: s.activeTabId, openTabs: s.openTabs, tabSessionData: s.tabSessionData, + sidebarCollapsed: s.sidebarCollapsed, + toggleSidebar: s.toggleSidebar, })) ); // Hover states for buttons const [notificationsHover, setNotificationsHover] = useState(false); const [teamsHover, setTeamsHover] = useState(false); - const [schedulesHover, setSchedulesHover] = useState(false); const [extensionsHover, setExtensionsHover] = useState(false); const [githubHover, setGithubHover] = useState(false); const [settingsHover, setSettingsHover] = useState(false); + const [expandHover, setExpandHover] = useState(false); // Derive active tab and session detail for MoreMenu const activeTab = useMemo( @@ -95,21 +97,6 @@ export const TabBarActions = (): React.JSX.Element => { - {/* Schedules icon */} - - {/* Extensions icon */} - {/* More menu (Search, Export, Analyze) */} + {/* More menu (Search, Export, Analyze, Schedules) */} + + {/* Expand sidebar — rightmost, only when collapsed */} + {sidebarCollapsed && ( + + )}
); }; diff --git a/src/renderer/components/layout/TabBarRow.tsx b/src/renderer/components/layout/TabBarRow.tsx index ffc06214..e10f0238 100644 --- a/src/renderer/components/layout/TabBarRow.tsx +++ b/src/renderer/components/layout/TabBarRow.tsx @@ -13,6 +13,7 @@ import { Plus } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { TabBar } from './TabBar'; +import { TabBarActions } from './TabBarActions'; export const TabBarRow = (): React.JSX.Element => { const { panes, focusedPaneId, openDashboard } = useStore( @@ -67,25 +68,28 @@ export const TabBarRow = (): React.JSX.Element => {
))} + + {/* New tab button — right after last tab */} +
- {/* New tab button — right corner */} - + {/* Action buttons — right side */} +
); }; diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx index 729c8c9a..b384c429 100644 --- a/src/renderer/components/layout/TabbedLayout.tsx +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -37,7 +37,6 @@ import { CustomTitleBar } from './CustomTitleBar'; import { PaneContainer } from './PaneContainer'; import { Sidebar } from './Sidebar'; import { DragOverlayTab } from './SortableTab'; -import { TabBarActions } from './TabBarActions'; import { TabBarRow } from './TabBarRow'; import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; @@ -165,32 +164,16 @@ export const TabbedLayout = (): React.JSX.Element => { {/* Command Palette (Cmd+K) */} - {/* Sidebar - Task list / Sessions (280px) */} - - - {/* Content column: floating actions bar + pane content */} + {/* Content area */}
- {/* Content header with action buttons — floats over pane content */} -
- -
- - {/* Multi-pane content area — renders from top:0, behind the floating bar */}
+ + {/* Sidebar - Task list / Sessions (right side) */} +
{/* Drag overlay - semi-transparent ghost of the dragged tab */} diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index dc017ec9..42725baa 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -147,7 +147,7 @@ export const SidebarTaskItem = ({ return ( +
+
+

+ {leadSessionLoading + ? 'Loading context…' + : 'Open the team lead session to view context.'} +

+
+
+ )} +
+ )} +
- {/* Context button pinned to bottom-right of viewport */} + {/* Context button pinned to bottom-left of viewport */} {leadSessionId && (
-
-
-

- {leadSessionLoading - ? 'Loading context…' - : 'Open the team lead session to view context.'} -

-
-
- )} - - )} {editorOpen && data.config.projectPath && ( diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index e384e621..8b6a3617 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -398,8 +398,15 @@ export const ActivityItem = ({ return result; }, [strippedText, memberColorMap, teamNames, systemLabel]); - const rawSummary = - message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; + const rawSummary = useMemo(() => { + const s = message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; + if (s) return s; + // Fallback: use the beginning of message text as preview for plain-text messages + const plain = stripAgentBlocks(message.text).trim(); + if (!plain) return ''; + const oneLine = plain.replace(/\n+/g, ' '); + return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; + }, [message.summary, structured, message.text]); const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); // Noise messages: minimal inline row diff --git a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx index ea95f9e7..1962d905 100644 --- a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx +++ b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx @@ -47,7 +47,7 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro {time} - {event.actor ? ( + {shouldShowTrailingActor(event) && event.actor ? ( Created as + {event.actor ? ( + <> + by + + + ) : null} ); case 'status_changed': @@ -174,6 +185,10 @@ function dotColor(event: TaskHistoryEvent): string { } } +function shouldShowTrailingActor(event: TaskHistoryEvent): boolean { + return event.type !== 'task_created'; +} + function dotColorForStatus(status: TeamTaskStatus): string { switch (status) { case 'pending': diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index eaa1e830..fed458ca 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -23,9 +23,11 @@ import { import { KanbanColumn } from './KanbanColumn'; import { KanbanFilterPopover } from './KanbanFilterPopover'; +import { KanbanSortPopover } from './KanbanSortPopover'; import { KanbanTaskCard } from './KanbanTaskCard'; import type { KanbanFilterState } from './KanbanFilterPopover'; +import type { KanbanSortField, KanbanSortState } from './KanbanSortPopover'; import type { DragEndEvent } from '@dnd-kit/core'; import type { Session } from '@renderer/types/data'; import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types'; @@ -66,10 +68,12 @@ interface KanbanBoardProps { teamName: string; kanbanState: KanbanState; filter: KanbanFilterState; + sort: KanbanSortState; sessions: Session[]; leadSessionId?: string; members: ResolvedTeamMember[]; onFilterChange: (filter: KanbanFilterState) => void; + onSortChange: (sort: KanbanSortState) => void; onRequestReview: (taskId: string) => void; onApprove: (taskId: string) => void; onRequestChanges: (taskId: string) => void; @@ -149,6 +153,47 @@ function sortColumnTasksByOrder(columnTasks: TeamTask[], order?: string[]): Team return ordered; } +/** Сортирует задачи по выбранному полю. */ +function sortColumnTasksByField( + columnTasks: TeamTask[], + field: KanbanSortField, + order?: string[] +): TeamTask[] { + if (field === 'manual') { + return sortColumnTasksByOrder(columnTasks, order); + } + + return [...columnTasks].sort((a, b) => { + if (field === 'updatedAt') { + const tsA = a.updatedAt + ? new Date(a.updatedAt).getTime() + : a.createdAt + ? new Date(a.createdAt).getTime() + : 0; + const tsB = b.updatedAt + ? new Date(b.updatedAt).getTime() + : b.createdAt + ? new Date(b.createdAt).getTime() + : 0; + return tsB - tsA; // desc — свежие вверху + } + if (field === 'createdAt') { + const tsA = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const tsB = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return tsB - tsA; // desc — новые вверху + } + if (field === 'owner') { + const ownerA = (a.owner ?? '').toLowerCase(); + const ownerB = (b.owner ?? '').toLowerCase(); + if (!ownerA && !ownerB) return 0; + if (!ownerA) return 1; // unassigned — в конец + if (!ownerB) return -1; + return ownerA.localeCompare(ownerB); + } + return 0; + }); +} + interface SortableKanbanTaskCardProps { task: TeamTask; columnId: KanbanColumnId; @@ -234,10 +279,12 @@ export const KanbanBoard = ({ teamName, kanbanState, filter, + sort, sessions, leadSessionId, members, onFilterChange, + onSortChange, onRequestReview, onApprove, onRequestChanges, @@ -277,10 +324,10 @@ export const KanbanBoard = ({ for (const column of COLUMNS) { const columnTasks = grouped.get(column.id) ?? []; const order = kanbanState.columnOrder?.[column.id]; - result.set(column.id, sortColumnTasksByOrder(columnTasks, order)); + result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); } return result; - }, [grouped, kanbanState.columnOrder]); + }, [grouped, kanbanState.columnOrder, sort.field]); const sensors = useSensors( useSensor(PointerSensor, { @@ -343,7 +390,7 @@ export const KanbanBoard = ({ ) ); } - if (onColumnOrderChange) { + if (onColumnOrderChange && sort.field === 'manual') { const itemIds = columnTasks.map((t) => t.id); return ( <> @@ -423,13 +470,17 @@ export const KanbanBoard = ({
{toolbarLeft != null &&
{toolbarLeft}
}
- +
+ +
+ +
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( @@ -543,7 +594,7 @@ export const KanbanBoard = ({ ); - if (onColumnOrderChange) { + if (onColumnOrderChange && sort.field === 'manual') { return ( {boardContent} diff --git a/src/renderer/components/team/kanban/KanbanSortPopover.tsx b/src/renderer/components/team/kanban/KanbanSortPopover.tsx new file mode 100644 index 00000000..f05c2491 --- /dev/null +++ b/src/renderer/components/team/kanban/KanbanSortPopover.tsx @@ -0,0 +1,140 @@ +import { Button } from '@renderer/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { cn } from '@renderer/lib/utils'; +import { ArrowDownUp, ArrowUpDown, Calendar, Clock, GripVertical, User } from 'lucide-react'; + +export type KanbanSortField = 'updatedAt' | 'createdAt' | 'owner' | 'manual'; + +export interface KanbanSortState { + field: KanbanSortField; +} + +const SORT_OPTIONS: { + field: KanbanSortField; + label: string; + description: string; + icon: React.ReactNode; +}[] = [ + { + field: 'updatedAt', + label: 'Last updated', + description: 'Recently updated first', + icon: , + }, + { + field: 'createdAt', + label: 'Created', + description: 'Newest first', + icon: , + }, + { + field: 'owner', + label: 'Owner', + description: 'Alphabetically by assignee', + icon: , + }, + { + field: 'manual', + label: 'Manual', + description: 'Drag-and-drop order', + icon: , + }, +]; + +interface KanbanSortPopoverProps { + sort: KanbanSortState; + onSortChange: (sort: KanbanSortState) => void; +} + +export const KanbanSortPopover = ({ + sort, + onSortChange, +}: KanbanSortPopoverProps): React.JSX.Element => { + const isNonDefault = sort.field !== 'updatedAt'; + + return ( + + + + + + + + Sort tasks + + +
+

+ Sort by +

+
+ {SORT_OPTIONS.map((option) => { + const isSelected = sort.field === option.field; + return ( + + ); + })} +
+
+ {isNonDefault && ( +
+ +
+ )} +
+
+ ); +}; From b6ec4084511b772c3af5b5984ebb095dc8d30db3 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 13:28:44 +0200 Subject: [PATCH 05/72] feat: enhance error handling and reporting in ErrorBoundary component - Added functionality to copy error details to clipboard and create GitHub issue reports directly from the error boundary. - Introduced a new state variable to manage the copy confirmation status. - Enhanced UI with buttons for copying error details and reporting bugs, improving user experience during error handling. - Updated the rendering logic to display additional context about the error and the copied status. - Refactored the component to ensure proper cleanup of timeouts on unmount. --- .../components/common/ErrorBoundary.tsx | 106 +++- src/renderer/components/team/TaskTooltip.tsx | 46 +- .../components/team/TeamDetailView.tsx | 371 +++---------- .../components/team/activity/ActivityItem.tsx | 25 +- .../team/activity/LeadThoughtsGroup.tsx | 3 +- .../team/dialogs/CreateTaskDialog.tsx | 4 + .../team/dialogs/CreateTeamDialog.tsx | 273 ++++++---- .../team/dialogs/LaunchTeamDialog.tsx | 292 ++++++---- .../team/dialogs/OptionalSettingsSection.tsx | 91 ++++ .../team/dialogs/ProjectPathSelector.tsx | 218 ++++---- .../components/team/dialogs/ReviewDialog.tsx | 3 + .../team/dialogs/SendMessageDialog.tsx | 3 + .../team/dialogs/TaskCommentInput.tsx | 3 + .../team/dialogs/TaskCommentsSection.tsx | 9 +- .../components/team/kanban/KanbanBoard.tsx | 5 +- .../team/messages/MessageComposer.tsx | 3 + .../team/messages/MessagesPanel.tsx | 506 ++++++++++++++++++ .../components/ui/MentionSuggestionList.tsx | 48 +- .../components/ui/MentionableTextarea.tsx | 241 +++++---- .../ui/TaskReferenceInteractionLayer.tsx | 89 +++ src/renderer/hooks/useMentionDetection.ts | 111 ++-- src/renderer/hooks/useResizablePanel.ts | 122 +++++ src/renderer/hooks/useTaskSuggestions.ts | 123 +++++ src/renderer/store/slices/teamSlice.ts | 12 + src/renderer/types/mention.ts | 22 +- src/renderer/utils/bugReportUtils.ts | 157 ++++++ src/renderer/utils/chipUtils.ts | 109 ++-- src/renderer/utils/mentionSuggestions.ts | 27 + src/renderer/utils/taskReferenceUtils.ts | 62 +++ 29 files changed, 2255 insertions(+), 829 deletions(-) create mode 100644 src/renderer/components/team/dialogs/OptionalSettingsSection.tsx create mode 100644 src/renderer/components/team/messages/MessagesPanel.tsx create mode 100644 src/renderer/components/ui/TaskReferenceInteractionLayer.tsx create mode 100644 src/renderer/hooks/useResizablePanel.ts create mode 100644 src/renderer/hooks/useTaskSuggestions.ts create mode 100644 src/renderer/utils/bugReportUtils.ts create mode 100644 src/renderer/utils/mentionSuggestions.ts create mode 100644 src/renderer/utils/taskReferenceUtils.ts diff --git a/src/renderer/components/common/ErrorBoundary.tsx b/src/renderer/components/common/ErrorBoundary.tsx index bf885222..a8904129 100644 --- a/src/renderer/components/common/ErrorBoundary.tsx +++ b/src/renderer/components/common/ErrorBoundary.tsx @@ -1,7 +1,14 @@ import React, { Component, type ErrorInfo, type ReactNode } from 'react'; +import { useStore } from '@renderer/store'; import { createLogger } from '@shared/utils/logger'; -import { AlertTriangle, RefreshCw } from 'lucide-react'; +import { AlertTriangle, Bug, Check, Copy, RefreshCw } from 'lucide-react'; + +import { + buildBugReportText, + buildGitHubBugReportUrl, + type BugReportContext, +} from '@renderer/utils/bugReportUtils'; const logger = createLogger('Component:ErrorBoundary'); @@ -12,15 +19,19 @@ interface Props { interface State { hasError: boolean; + copiedReport: boolean; error: Error | null; errorInfo: ErrorInfo | null; } export class ErrorBoundary extends Component { + private copyResetTimeout: ReturnType | null = null; + constructor(props: Props) { super(props); this.state = { hasError: false, + copiedReport: false, error: null, errorInfo: null, }; @@ -40,16 +51,83 @@ export class ErrorBoundary extends Component { }; handleReset = (): void => { + if (this.copyResetTimeout) { + clearTimeout(this.copyResetTimeout); + this.copyResetTimeout = null; + } + this.setState({ hasError: false, + copiedReport: false, error: null, errorInfo: null, }); }; + componentWillUnmount(): void { + if (this.copyResetTimeout) { + clearTimeout(this.copyResetTimeout); + this.copyResetTimeout = null; + } + } + + getBugReportContext = (): BugReportContext => { + const state = useStore.getState(); + const activeTab = state.getActiveTab(); + + return { + activeTabType: activeTab?.type ?? null, + activeTabLabel: activeTab?.label ?? null, + activeTeamName: activeTab?.teamName ?? null, + selectedTeamName: state.selectedTeamName, + taskId: state.globalTaskDetail?.taskId ?? state.pendingReviewRequest?.taskId ?? null, + sessionId: activeTab?.sessionId ?? null, + projectId: activeTab?.projectId ?? state.activeProjectId, + }; + }; + + handleCreateGitHubIssue = (): void => { + const issueUrl = buildGitHubBugReportUrl({ + error: this.state.error, + componentStack: this.state.errorInfo?.componentStack ?? null, + context: this.getBugReportContext(), + }); + + if (window.electronAPI?.openExternal) { + void window.electronAPI.openExternal(issueUrl); + return; + } + + window.open(issueUrl, '_blank', 'noopener,noreferrer'); + }; + + handleCopyErrorDetails = async (): Promise => { + try { + await navigator.clipboard.writeText( + buildBugReportText({ + error: this.state.error, + componentStack: this.state.errorInfo?.componentStack ?? null, + context: this.getBugReportContext(), + }) + ); + + if (this.copyResetTimeout) { + clearTimeout(this.copyResetTimeout); + } + + this.setState({ copiedReport: true }); + this.copyResetTimeout = setTimeout(() => { + this.setState({ copiedReport: false }); + this.copyResetTimeout = null; + }, 2000); + } catch (error) { + logger.warn('Failed to copy error details:', error); + } + }; + // eslint-disable-next-line sonarjs/function-return-type -- Error boundaries inherently return different content based on error state render(): ReactNode { - const { hasError, error, errorInfo } = this.state; + const { hasError, copiedReport, error, errorInfo } = this.state; const { children, fallback } = this.props; if (hasError) { @@ -85,13 +163,31 @@ export class ErrorBoundary extends Component {
)} -
+
+ +
+

+ GitHub bug reports and copied diagnostics include the error message, stack traces, app + version, active tab, selected team, task context, and environment details. +

); } diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index b8dd6466..b761ad76 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -50,6 +50,8 @@ function getStatusLabel(column: string): string { interface TaskTooltipProps { /** Canonical task id or short display id reference. */ taskId: string; + /** Optional owning team for cross-team task references. */ + teamName?: string; /** Rendered trigger element. */ children: React.ReactElement; /** Tooltip placement. */ @@ -62,11 +64,34 @@ interface TaskTooltipProps { */ export const TaskTooltip = ({ taskId, + teamName, children, side = 'top', }: TaskTooltipProps): React.JSX.Element => { - const tasks = useStore((s) => s.selectedTeamData?.tasks); - const members = useStore((s) => s.selectedTeamData?.members); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const selectedTeamData = useStore((s) => s.selectedTeamData); + const globalTasks = useStore((s) => s.globalTasks); + const teamByName = useStore((s) => s.teamByName); + + const tasks = useMemo(() => { + if (teamName && selectedTeamName === teamName) { + return selectedTeamData?.tasks ?? []; + } + if (teamName) { + return globalTasks.filter((task) => task.teamName === teamName); + } + const currentTasks = selectedTeamData?.tasks ?? []; + const currentMatch = currentTasks.find((task) => taskMatchesRef(task, taskId)); + if (currentMatch) return currentTasks; + return globalTasks; + }, [globalTasks, selectedTeamData, selectedTeamName, teamName, taskId]); + + const members = useMemo(() => { + if (teamName && selectedTeamName === teamName) { + return selectedTeamData?.members ?? []; + } + return []; + }, [selectedTeamData, selectedTeamName, teamName]); const task = useMemo(() => tasks?.find((t) => taskMatchesRef(t, taskId)), [tasks, taskId]); @@ -81,11 +106,24 @@ export const TaskTooltip = ({ const column = getEffectiveColumn(task); const statusColor = STATUS_COLORS[column] ?? STATUS_COLORS.pending; const label = getStatusLabel(column); + const taskTeamName = + typeof (task as unknown as { teamName?: unknown }).teamName === 'string' + ? (task as unknown as { teamName: string }).teamName + : undefined; + const resolvedTeamName = teamName ?? taskTeamName; + const resolvedTeamDisplayName = resolvedTeamName + ? teamByName[resolvedTeamName]?.displayName + : null; return ( {children} + {resolvedTeamName ? ( +
+ {resolvedTeamDisplayName || resolvedTeamName} +
+ ) : null} {/* Subject */}
{formatTaskDisplayLabel(task)}{' '} @@ -109,8 +147,10 @@ export const TaskTooltip = ({ ) : null} {/* Owner */} - {task.owner ? ( + {task.owner && members.length > 0 ? ( + ) : task.owner ? ( + {task.owner} ) : ( Unassigned )} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index b2561a3b..58104332 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -15,37 +15,27 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; +import { useResizablePanel } from '@renderer/hooks/useResizablePanel'; import { useTabUI } from '@renderer/hooks/useTabUI'; -import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; -import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; -import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; -import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; -import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, - Bell, - CheckCheck, - ChevronsDownUp, - ChevronsUpDown, - ChevronRight, Clock, Code, Columns3, FolderOpen, GitBranch, History, - MessageSquare, Pencil, Play, Plus, @@ -59,9 +49,6 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { ActiveTasksBlock } from './activity/ActiveTasksBlock'; -import { ActivityTimeline } from './activity/ActivityTimeline'; -import { PendingRepliesBlock } from './activity/PendingRepliesBlock'; import { AddMemberDialog } from './dialogs/AddMemberDialog'; import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; @@ -78,8 +65,7 @@ const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) ); import { MemberList } from './members/MemberList'; -import { MessageComposer } from './messages/MessageComposer'; -import { MessagesFilterPopover } from './messages/MessagesFilterPopover'; +import { MessagesPanel } from './messages/MessagesPanel'; import { ChangeReviewDialog } from './review/ChangeReviewDialog'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; @@ -90,16 +76,10 @@ import { TeamSessionsSection } from './TeamSessionsSection'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanSortState } from './kanban/KanbanSortPopover'; -import type { MessagesFilterState } from './messages/MessagesFilterPopover'; import type { ContextInjection } from '@renderer/types/contextInjection'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; -import type { - InboxMessage, - MemberSpawnStatusEntry, - ResolvedTeamMember, - TeamTaskWithKanban, -} from '@shared/types'; +import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; interface TeamDetailViewProps { @@ -223,7 +203,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele updateTaskStatus, updateTaskOwner, sendTeamMessage, - sendCrossTeamMessage, requestReview, createTeamTask, startTask, @@ -252,6 +231,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele fetchDeletedTasks, deletedTasks, launchParams, + messagesPanelMode, + messagesPanelWidth, + setMessagesPanelMode, + setMessagesPanelWidth, } = useStore( useShallow((s) => ({ data: s.selectedTeamData, @@ -268,7 +251,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele updateTaskStatus: s.updateTaskStatus, updateTaskOwner: s.updateTaskOwner, sendTeamMessage: s.sendTeamMessage, - sendCrossTeamMessage: s.sendCrossTeamMessage, requestReview: s.requestReview, createTeamTask: s.createTeamTask, startTask: s.startTask, @@ -299,6 +281,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele fetchDeletedTasks: s.fetchDeletedTasks, deletedTasks: s.deletedTasks, launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + setMessagesPanelMode: s.setMessagesPanelMode, + setMessagesPanelWidth: s.setMessagesPanelWidth, })) ); @@ -312,6 +298,20 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele } = useTabUI(); const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); + // Messages panel resize + const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = + useResizablePanel({ + width: messagesPanelWidth, + onWidthChange: setMessagesPanelWidth, + minWidth: 280, + maxWidth: 600, + side: 'left', + }); + + const toggleMessagesPanelMode = useCallback(() => { + setMessagesPanelMode(messagesPanelMode === 'sidebar' ? 'inline' : 'sidebar'); + }, [messagesPanelMode, setMessagesPanelMode]); + useEffect(() => { if (tabId) { initTabUIState(tabId); @@ -344,15 +344,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }, [memberSpawnStatuses]); const [kanbanSearch, setKanbanSearch] = useState(''); - const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); - const [messagesFilter, setMessagesFilter] = useState({ - from: new Set(), - to: new Set(), - showNoise: false, - }); - const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); - const [messagesCollapsed, setMessagesCollapsed] = useState(true); - const [statusBlockCollapsed, setStatusBlockCollapsed] = useState(false); // Open editor overlay when a file reveal is requested (e.g. from chip click) const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); @@ -633,32 +624,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele [data?.members] ); - const filteredMessages = useMemo(() => { - if (!data) return []; - return filterTeamMessages(data.messages, { - timeWindow, - filter: messagesFilter, - searchQuery: messagesSearchQuery, - }); - }, [data, timeWindow, messagesFilter, messagesSearchQuery]); - - const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? ''); - const { expandedSet, toggle: toggleExpandOverride } = useTeamMessagesExpanded(teamName ?? ''); - const messagesUnreadCount = useMemo( - () => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length, - [filteredMessages, readSet] - ); - const handleMessageVisible = useCallback( - (message: InboxMessage) => markRead(toMessageKey(message)), - [markRead] - ); - const handleMarkAllRead = useCallback(() => { - const keys = filteredMessages - .filter((m) => !m.read && !readSet.has(toMessageKey(m))) - .map((m) => toMessageKey(m)); - markAllRead(keys); - }, [filteredMessages, readSet, markAllRead]); - const kanbanDisplayTasks = useMemo(() => { const query = kanbanSearch.trim(); if (!query) return filteredTasks; @@ -673,50 +638,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); - const pendingCrossTeamReplies = useMemo( - () => computePendingCrossTeamReplies(data?.messages ?? []), - [data?.messages] - ); - - /** Whether the Status block has any visible items (pending replies or active tasks). */ - const hasStatusItems = useMemo(() => { - const members = data?.members ?? []; - const tasks = data?.tasks ?? []; - - // Check pending replies (mirrors PendingRepliesBlock logic) - const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) => - members.some((m) => m.name === name) - ); - if (hasPendingReplies) return true; - if (pendingCrossTeamReplies.length > 0) return true; - - // Check active tasks (mirrors ActiveTasksBlock logic) - const tMap = new Map(tasks.map((t) => [t.id, t])); - return members.some((m) => { - if (!m.currentTaskId) return false; - const task = tMap.get(m.currentTaskId); - if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false; - return true; - }); - }, [data?.members, data?.tasks, pendingRepliesByMember, pendingCrossTeamReplies.length]); - - useEffect(() => { - if (!data || Object.keys(pendingRepliesByMember).length === 0) return; - const next = { ...pendingRepliesByMember }; - let changed = false; - for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) { - const hasReply = data.messages.some((m) => { - if (m.from !== memberName) return false; - const ts = Date.parse(m.timestamp); - return Number.isFinite(ts) && ts > sentAtMs; - }); - if (hasReply) { - delete next[memberName]; - changed = true; - } - } - if (changed) setPendingRepliesByMember(next); - }, [data, pendingRepliesByMember]); const openCreateTaskDialog = ( subject = '', @@ -1007,6 +928,53 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
)} + {/* Messages sidebar (left, after context panel) */} + {messagesPanelMode === 'sidebar' && ( +
+ { + openCreateTaskDialog(subject, description); + }} + onReplyToMessage={(message) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }} + onRestartTeam={() => setLaunchDialogOpen(true)} + onTaskIdClick={(taskId) => { + const task = + taskMap.get(taskId) ?? + data.tasks.find((candidate) => candidate.displayId === taskId); + if (task) setSelectedTask(task); + }} + /> + {/* Resize handle */} +
+
+ )} +
- } - badge={filteredMessages.length} - secondaryBadge={ - filteredMessages.length > 0 && messagesUnreadCount > 0 - ? messagesUnreadCount - : undefined - } - afterBadge={ - messagesUnreadCount > 0 ? ( - - - - - Mark all as read - - ) : undefined - } - headerExtra={ - - - - - Desktop notifications plugin - - } - 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" - /> - {messagesSearchQuery && ( - - )} -
- - - - - - - {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} - - -
- } - > - { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - void sendTeamMessage(teamName, { - member, - text, - summary, - attachments, - actionMode, - }).catch(() => { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - }); - }} - onCrossTeamSend={(toTeam, text, summary, actionMode) => { - void sendCrossTeamMessage({ - fromTeam: teamName, - fromMember: 'user', - toTeam, - text, - actionMode, - summary, - }); - }} - /> - {/* Status block: button floats right (absolute, no layout impact); - expanded content renders full-width in normal flow. */} - {hasStatusItems && ( - <> -
- -
- {!statusBlockCollapsed && ( -
- - -
- )} - - )} - { openCreateTaskDialog(subject, description); }} @@ -1755,7 +1551,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); setSendDialogOpen(true); }} - onMessageVisible={handleMessageVisible} onRestartTeam={() => setLaunchDialogOpen(true)} onTaskIdClick={(taskId) => { const task = @@ -1764,7 +1559,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele if (task) setSelectedTask(task); }} /> -
+ )} ` in plain text to markdown links with task:// protocol. */ -export function linkifyTaskIdsInMarkdown(text: string): string { - return text.replace(/#([A-Za-z0-9-]+)\b/g, '[#$1](task://$1)'); -} - /** Render `#` in plain text as clickable inline elements with TaskTooltip. */ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { return text.split(/(#[A-Za-z0-9-]+\b)/g).map((part, i) => { @@ -304,7 +300,9 @@ export const ActivityItem = ({ }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const { isLight } = useTheme(); - const formattedRole = formatAgentRole(memberRole); + // Hide role when it matches the sender name (avoids "lead" badge + "Team Lead" text duplication) + const formattedRole = + memberRole && memberRole !== message.from ? formatAgentRole(memberRole) : null; const teams = useStore((s) => s.teams); const teamNames = useMemo( @@ -312,9 +310,18 @@ export const ActivityItem = ({ [teams] ); - const timestamp = Number.isNaN(Date.parse(message.timestamp)) - ? message.timestamp - : new Date(message.timestamp).toLocaleString(); + const timestamp = useMemo(() => { + if (Number.isNaN(Date.parse(message.timestamp))) return message.timestamp; + const date = new Date(message.timestamp); + const now = new Date(); + const isToday = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + return isToday + ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : date.toLocaleString(); + }, [message.timestamp]); const structured = parseStructuredAgentMessage(message.text); // Only flag agent messages as rate-limited, not user's own quotes diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index a35827fa..10db9c8f 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -15,12 +15,11 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; +import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react'; - -import { linkifyTaskIdsInMarkdown } from './ActivityItem'; import { AnimatedHeightReveal, ENTRY_REVEAL_ANIMATION_MS, diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 02f3b59d..6e6b7b83 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -24,6 +24,7 @@ import { import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useStore } from '@renderer/store'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; @@ -78,6 +79,7 @@ export const CreateTaskDialog = ({ }: CreateTaskDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const [subject, setSubject] = useState(defaultSubject); const descriptionDraft = useDraftPersistence({ key: `createTask:${teamName}:description`, @@ -291,6 +293,7 @@ export const CreateTaskDialog = ({ value={descriptionDraft.value} onValueChange={descriptionDraft.setValue} suggestions={mentionSuggestions} + taskSuggestions={taskSuggestions} chips={descChipDraft.chips} onChipRemove={handleDescChipRemove} projectPath={projectPath} @@ -315,6 +318,7 @@ export const CreateTaskDialog = ({ value={promptDraft.value} onValueChange={promptDraft.setValue} suggestions={mentionSuggestions} + taskSuggestions={taskSuggestions} projectPath={projectPath} minRows={3} maxRows={12} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 5bd3cae5..af2e5448 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -34,6 +34,7 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; import { ExtendedContextCheckbox } from './ExtendedContextCheckbox'; +import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; @@ -561,6 +562,34 @@ export const CreateTeamDialog = ({ return args; }, [skipPermissions, effectiveModel, selectedEffort]); + const launchOptionalSummary = useMemo(() => { + const summary: string[] = []; + if (prompt.trim()) summary.push('Lead prompt'); + if (selectedModel) summary.push(`Model: ${selectedModel}`); + if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); + if (extendedContext) summary.push('Extended context'); + if (skipPermissions) summary.push('Auto-approve tools'); + if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); + if (customArgs.trim()) summary.push('Custom CLI args'); + return summary; + }, [ + prompt, + selectedModel, + selectedEffort, + extendedContext, + skipPermissions, + worktreeEnabled, + worktreeName, + customArgs, + ]); + + const teamDetailsSummary = useMemo(() => { + const summary: string[] = []; + if (description.trim()) summary.push('Description'); + if (teamColor) summary.push(`Color: ${teamColor}`); + return summary; + }, [description, teamColor]); + const activeError = localError ?? provisioningError; const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; @@ -810,16 +839,21 @@ export const CreateTeamDialog = ({ />
-
-
+
+
setLaunchTeam(checked === true)} /> - +
+ +

+ Start the team immediately via local Claude CLI. +

+
{launchTeam ? ( @@ -837,119 +871,136 @@ export const CreateTeamDialog = ({ fieldError={fieldErrors.cwd} /> -
- - - Draft saved - - ) : null - } - /> -
+ +
+
+ + + Draft saved + + ) : null + } + /> +
-
- - - - {launchTeam && ( - + + + + +
+ + - )} -
- +
+
) : null}
-
- - descriptionDraft.setValue(event.target.value)} - placeholder="Brief description of the team purpose" - /> - {descriptionDraft.isSaved ? ( - Draft saved - ) : null} -
+
+ +
+
+ + descriptionDraft.setValue(event.target.value)} + placeholder="Brief description of the team purpose" + /> + {descriptionDraft.isSaved ? ( + Draft saved + ) : null} +
-
- -
- {TEAM_COLOR_NAMES.map((colorName) => { - const colorSet = getTeamColorSet(colorName); - const isSelected = teamColor === colorName; - return ( - - ); - })} -
+
+ +
+ {TEAM_COLOR_NAMES.map((colorName) => { + const colorSet = getTeamColorSet(colorName); + const isSelected = teamColor === colorName; + return ( + + ); + })} +
+
+
+
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 47f8dace..c89f32cb 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -39,6 +39,7 @@ import { import { AdvancedCliSection } from './AdvancedCliSection'; import { EffortLevelSelector } from './EffortLevelSelector'; +import { OptionalSettingsSection } from './OptionalSettingsSection'; import { ProjectPathSelector } from './ProjectPathSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import { CronScheduleInput } from '../schedule/CronScheduleInput'; @@ -506,6 +507,32 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return args; }, [isLaunch, skipPermissions, selectedModel, extendedContext, selectedEffort, clearContext]); + const launchOptionalSummary = useMemo(() => { + if (!isLaunch) return []; + + const summary: string[] = []; + if (promptDraft.value.trim()) summary.push('Lead prompt'); + if (selectedModel) summary.push(`Model: ${selectedModel}`); + if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); + if (extendedContext) summary.push('Extended context'); + if (skipPermissions) summary.push('Auto-approve tools'); + if (clearContext) summary.push('Fresh session'); + if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); + if (customArgs.trim()) summary.push('Custom CLI args'); + return summary; + }, [ + isLaunch, + promptDraft.value, + selectedModel, + selectedEffort, + extendedContext, + skipPermissions, + clearContext, + worktreeEnabled, + worktreeName, + customArgs, + ]); + // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- @@ -794,7 +821,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen Schedule-only: Schedule configuration section ═══════════════════════════════════════════════════════════════════ */} {isSchedule ? ( -
+
+ + {isOpen ? ( +
+ {children} +
+ ) : null} +
+ ); +}; diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index 60a49eb9..b4abadcd 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -73,116 +73,122 @@ export const ProjectPathSelector = ({
-
- - -
+
+
+ + +
- {cwdMode === 'project' ? ( -
-
- - ({ - value: project.path, - label: project.name, - description: project.path, - }))} - value={selectedProjectPath} - onValueChange={onSelectedProjectPathChange} - placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} - searchPlaceholder="Search project by name or path" - emptyMessage="Nothing found" - disabled={projectsLoading || projects.length === 0} - renderOption={(option, isSelected, query) => ( - <> - + {cwdMode === 'project' ? ( +
+
+ +
+ ({ + value: project.path, + label: project.name, + description: project.path, + }))} + value={selectedProjectPath} + onValueChange={onSelectedProjectPathChange} + placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} + searchPlaceholder="Search project by name or path" + emptyMessage="Nothing found" + disabled={projectsLoading || projects.length === 0} + renderOption={(option, isSelected, query) => ( + <> + +
+

+ {renderHighlightedText(option.label, query)} +

+

+ {renderHighlightedText(option.description ?? '', query)} +

+
+ )} /> -
-

- {renderHighlightedText(option.label, query)} -

-

- {renderHighlightedText(option.description ?? '', query)} -

-
- - )} - /> -
- {!selectedProjectPath ? ( -

- Select a project from the list -

- ) : null} - {projectsError ?

{projectsError}

: null} - {!projectsLoading && projects.length === 0 ? ( -

- No projects found, switch to custom path. -

- ) : null} +
+
+ {!selectedProjectPath ? ( +

+ Select a project from the list +

+ ) : null} + {projectsError ?

{projectsError}

: null} + {!projectsLoading && projects.length === 0 ? ( +

+ No projects found, switch to custom path. +

+ ) : null} +
+ ) : ( +
+
+ + onCustomCwdChange(event.target.value)} + placeholder="/absolute/path/to/project" + /> + +
+

+ If the directory does not exist, it will be created automatically. +

+
+ )}
- ) : ( -
-
- - onCustomCwdChange(event.target.value)} - placeholder="/absolute/path/to/project" - /> - -
-

- If the directory does not exist, it will be created automatically. -

-
- )} +
{fieldError ?

{fieldError}

: null}
diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx index 6f0c2f17..6e1c67bb 100644 --- a/src/renderer/components/team/dialogs/ReviewDialog.tsx +++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx @@ -9,6 +9,7 @@ import { } from '@renderer/components/ui/dialog'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -37,6 +38,7 @@ export const ReviewDialog = ({ onSubmit, }: ReviewDialogProps): React.JSX.Element => { const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const draft = useDraftPersistence({ key: `requestChanges:${teamName}:${taskId ?? ''}`, enabled: Boolean(teamName && taskId), @@ -85,6 +87,7 @@ export const ReviewDialog = ({ onValueChange={draft.setValue} placeholder="Describe what needs to change... (Enter to submit)" suggestions={mentionSuggestions} + taskSuggestions={taskSuggestions} projectPath={projectPath} onModEnter={handleSubmit} minRows={4} diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 9d9bd376..c2820f9a 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { useAttachments } from '@renderer/hooks/useAttachments'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useStore } from '@renderer/store'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; @@ -209,6 +210,7 @@ export const SendMessageDialog = ({ ); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; @@ -465,6 +467,7 @@ export const SendMessageDialog = ({ onValueChange={textDraft.setValue} suggestions={mentionSuggestions} teamSuggestions={teamMentionSuggestions} + taskSuggestions={taskSuggestions} chips={chipDraft.chips} onChipRemove={handleChipRemove} projectPath={projectPath} diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index a232c76f..e24b99f3 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useStore } from '@renderer/store'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; @@ -55,6 +56,7 @@ export const TaskCommentInput = ({ const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const [pendingAttachments, setPendingAttachments] = useState([]); const [attachError, setAttachError] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null); @@ -279,6 +281,7 @@ export const TaskCommentInput = ({ onValueChange={draft.setValue} suggestions={mentionSuggestions} teamSuggestions={teamMentionSuggestions} + taskSuggestions={taskSuggestions} projectPath={projectPath} chips={chipDraft.chips} onFileChipInsert={chipDraft.addChip} diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 3789a2d2..02c89396 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -13,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; @@ -21,6 +22,7 @@ import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; +import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { formatDistanceToNow } from 'date-fns'; @@ -61,11 +63,6 @@ interface TaskCommentsSectionProps { unreadCommentIds?: Set; } -/** Convert `#` in plain text to markdown links with task:// protocol. */ -function linkifyTaskIdsInMarkdown(text: string): string { - return text.replace(/#([A-Za-z0-9-]+)\b/g, '[#$1](task://$1)'); -} - export const TaskCommentsSection = ({ teamName, taskId, @@ -103,6 +100,7 @@ export const TaskCommentsSection = ({ const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const teamNamesForLinkify = useMemo( () => teamMentionSuggestions.map((t) => t.name), [teamMentionSuggestions] @@ -394,6 +392,7 @@ export const TaskCommentsSection = ({ onValueChange={draft.setValue} suggestions={mentionSuggestions} teamSuggestions={teamMentionSuggestions} + taskSuggestions={taskSuggestions} projectPath={projectPath} chips={chipDraft.chips} onFileChipInsert={chipDraft.addChip} diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index fed458ca..f6b747ef 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -541,7 +541,10 @@ export const KanbanBoard = ({
{viewMode === 'grid' ? ( -
+
{visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index b9982cc5..8d3814c4 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -8,6 +8,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useComposerDraft } from '@renderer/hooks/useComposerDraft'; +import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; @@ -133,6 +134,7 @@ export const MessageComposer = ({ ); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); + const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const trimmed = draft.text.trim(); @@ -757,6 +759,7 @@ export const MessageComposer = ({ onValueChange={draft.setText} suggestions={mentionSuggestions} teamSuggestions={teamMentionSuggestions} + taskSuggestions={taskSuggestions} chips={draft.chips} onChipRemove={draft.removeChip} projectPath={projectPath} diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx new file mode 100644 index 00000000..255f8ecc --- /dev/null +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -0,0 +1,506 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; +import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; +import { useStore } from '@renderer/store'; +import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies'; +import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { + Bell, + CheckCheck, + ChevronsDownUp, + ChevronsUpDown, + ChevronRight, + MessageSquare, + PanelLeftClose, + PanelLeft, + Search, + X, +} from 'lucide-react'; + +import { ActiveTasksBlock } from '../activity/ActiveTasksBlock'; +import { ActivityTimeline } from '../activity/ActivityTimeline'; +import { PendingRepliesBlock } from '../activity/PendingRepliesBlock'; +import { CollapsibleTeamSection } from '../CollapsibleTeamSection'; +import { MessageComposer } from './MessageComposer'; +import { MessagesFilterPopover } from './MessagesFilterPopover'; + +import type { MessagesFilterState } from './MessagesFilterPopover'; +import type { ActionMode } from './ActionModeSelector'; +import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; + +interface TimeWindow { + start: number; + end: number; +} + +interface MessagesPanelProps { + teamName: string; + position: 'sidebar' | 'inline'; + onTogglePosition: () => void; + /** Active (non-removed) members. */ + members: ResolvedTeamMember[]; + /** All team tasks. */ + tasks: TeamTaskWithKanban[]; + /** All raw messages from team data. */ + messages: InboxMessage[]; + /** Whether the team is alive. */ + isTeamAlive?: boolean; + /** Time window for filtering. */ + timeWindow: TimeWindow | null; + /** Team session IDs for timeline. */ + teamSessionIds: Set; + /** Current lead session ID. */ + currentLeadSessionId?: string; + /** Pending replies tracker (shared with parent for MemberList). */ + pendingRepliesByMember: Record; + /** Update pending replies tracker. */ + onPendingReplyChange: (updater: (prev: Record) => Record) => void; + /** Callback when a member is clicked in the timeline. */ + onMemberClick?: (member: ResolvedTeamMember) => void; + /** Callback when a task is clicked from timeline or status block. */ + onTaskClick?: (task: TeamTaskWithKanban) => void; + /** Callback to open create task dialog from a message. */ + onCreateTaskFromMessage?: (subject: string, description: string) => void; + /** Callback to open reply dialog for a message. */ + onReplyToMessage?: (message: InboxMessage) => void; + /** Callback when "Restart team" is clicked. */ + onRestartTeam?: () => void; + /** Callback when a task ID link is clicked. */ + onTaskIdClick?: (taskId: string) => void; +} + +export const MessagesPanel = ({ + teamName, + position, + onTogglePosition, + members, + tasks, + messages, + isTeamAlive, + timeWindow, + teamSessionIds, + currentLeadSessionId, + pendingRepliesByMember, + onPendingReplyChange, + onMemberClick, + onTaskClick, + onCreateTaskFromMessage, + onReplyToMessage, + onRestartTeam, + onTaskIdClick, +}: MessagesPanelProps): React.JSX.Element => { + const sendTeamMessage = useStore((s) => s.sendTeamMessage); + const sendCrossTeamMessage = useStore((s) => s.sendCrossTeamMessage); + const sendingMessage = useStore((s) => s.sendingMessage); + const sendMessageError = useStore((s) => s.sendMessageError); + const lastSendMessageResult = useStore((s) => s.lastSendMessageResult); + + const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); + const [messagesFilter, setMessagesFilter] = useState({ + from: new Set(), + to: new Set(), + showNoise: false, + }); + const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); + const [messagesCollapsed, setMessagesCollapsed] = useState(true); + const [statusBlockCollapsed, setStatusBlockCollapsed] = useState(false); + + const filteredMessages = useMemo(() => { + return filterTeamMessages(messages, { + timeWindow, + filter: messagesFilter, + searchQuery: messagesSearchQuery, + }); + }, [messages, timeWindow, messagesFilter, messagesSearchQuery]); + + const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName); + const { expandedSet, toggle: toggleExpandOverride } = useTeamMessagesExpanded(teamName); + + const messagesUnreadCount = useMemo( + () => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length, + [filteredMessages, readSet] + ); + + const handleMessageVisible = useCallback( + (message: InboxMessage) => markRead(toMessageKey(message)), + [markRead] + ); + + const handleMarkAllRead = useCallback(() => { + const keys = filteredMessages + .filter((m) => !m.read && !readSet.has(toMessageKey(m))) + .map((m) => toMessageKey(m)); + markAllRead(keys); + }, [filteredMessages, readSet, markAllRead]); + + const pendingCrossTeamReplies = useMemo( + () => computePendingCrossTeamReplies(messages), + [messages] + ); + + /** Whether the Status block has any visible items (pending replies or active tasks). */ + const hasStatusItems = useMemo(() => { + const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) => + members.some((m) => m.name === name) + ); + if (hasPendingReplies) return true; + if (pendingCrossTeamReplies.length > 0) return true; + + const tMap = new Map(tasks.map((t) => [t.id, t])); + return members.some((m) => { + if (!m.currentTaskId) return false; + const task = tMap.get(m.currentTaskId); + if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false; + return true; + }); + }, [members, tasks, pendingRepliesByMember, pendingCrossTeamReplies.length]); + + // Auto-clear pending replies when a member actually responds + useEffect(() => { + if (Object.keys(pendingRepliesByMember).length === 0) return; + const next = { ...pendingRepliesByMember }; + let changed = false; + for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) { + const hasReply = messages.some((m) => { + if (m.from !== memberName) return false; + const ts = Date.parse(m.timestamp); + return Number.isFinite(ts) && ts > sentAtMs; + }); + if (hasReply) { + delete next[memberName]; + changed = true; + } + } + if (changed) onPendingReplyChange(() => next); + }, [messages, pendingRepliesByMember, onPendingReplyChange]); + + const handleSend = useCallback( + ( + member: string, + text: string, + summary?: string, + attachments?: Parameters[1] extends { attachments?: infer A } + ? A + : never, + actionMode?: ActionMode + ) => { + const sentAtMs = Date.now(); + onPendingReplyChange((prev) => ({ ...prev, [member]: sentAtMs })); + void sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + }).catch(() => { + onPendingReplyChange((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + }); + }, + [teamName, sendTeamMessage, onPendingReplyChange] + ); + + const handleCrossTeamSend = useCallback( + (toTeam: string, text: string, summary?: string, actionMode?: ActionMode) => { + void sendCrossTeamMessage({ + fromTeam: teamName, + fromMember: 'user', + toTeam, + text, + actionMode, + summary, + }); + }, + [teamName, sendCrossTeamMessage] + ); + + // ---- Shared content (used in both modes) ---- + const searchAndFilterBar = ( +
+
+ + 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" + /> + {messagesSearchQuery && ( + + )} +
+ + + + + + + {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} + + +
+ ); + + const messagesContent = ( + <> + + {/* Status block: button floats right (absolute, no layout impact); + expanded content renders full-width in normal flow. */} + {hasStatusItems && ( + <> +
+ +
+ {!statusBlockCollapsed && ( +
+ + +
+ )} + + )} + + + ); + + // ---- Sidebar mode ---- + if (position === 'sidebar') { + return ( +
+ {/* Header */} +
+ + Messages + {filteredMessages.length > 0 && ( + + {filteredMessages.length} + + )} + {messagesUnreadCount > 0 && ( + + + + {messagesUnreadCount} new + + + {messagesUnreadCount} unread + + )} + {messagesUnreadCount > 0 && ( + + + + + Mark all as read + + )} + + + + + Desktop notifications plugin + +
+ + + + + Move to inline + +
+
+ {/* Search & filter bar */} +
+ {searchAndFilterBar} +
+ {/* Scrollable content */} +
{messagesContent}
+
+ ); + } + + // ---- Inline mode (wrapped in CollapsibleTeamSection) ---- + return ( + } + badge={filteredMessages.length} + secondaryBadge={ + filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined + } + afterBadge={ + messagesUnreadCount > 0 ? ( + + + + + Mark all as read + + ) : undefined + } + headerExtra={ + <> + + + + + Desktop notifications plugin + + + + + + Move to sidebar + + + } + defaultOpen + action={
{searchAndFilterBar}
} + > + {messagesContent} +
+ ); +}; diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index 49190bc8..64ef3bbd 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { nameColorSet } from '@renderer/utils/projectColor'; -import { Folder, Loader2, UsersRound } from 'lucide-react'; +import { Folder, Hash, Loader2, UsersRound } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -67,17 +67,23 @@ export const MentionSuggestionList = ({ }, [selectedIndex]); if (suggestions.length === 0) { + const emptyStateText = filesLoading + ? 'Searching...' + : hasFileSearch + ? 'No matching suggestions' + : 'No matching suggestions'; return (
- {hasFileSearch ? 'No matching members, teams, or files' : 'No matching members'} + {emptyStateText}
); } // Categorize suggestions (folders are grouped with files) - type Section = 'member' | 'team' | 'file'; + type Section = 'member' | 'team' | 'task' | 'file'; const getSuggestionSection = (s: MentionSuggestion): Section => { if (s.type === 'file' || s.type === 'folder') return 'file'; + if (s.type === 'task') return 'task'; if (s.type === 'team') return 'team'; return 'member'; }; @@ -85,6 +91,7 @@ export const MentionSuggestionList = ({ const sectionLabel: Record = { member: 'Members', team: 'Teams', + task: 'Tasks', file: 'Files', }; @@ -103,6 +110,7 @@ export const MentionSuggestionList = ({ const isFolder = s.type === 'folder'; const isFileOrFolder = isFile || isFolder; const isTeam = section === 'team'; + const isTask = section === 'task'; // Insert section header on transition if (showSections && section !== currentSection) { @@ -141,6 +149,8 @@ export const MentionSuggestionList = ({ ) : isFile ? ( + ) : isTask ? ( + ) : isTeam ? ( )} - - - +
+
+ + + + {isTask && !s.isCurrentTeamTask && s.teamDisplayName ? ( + + {s.teamDisplayName} + + ) : null} +
+ {isTask && s.subtitle ? ( +
{s.subtitle}
+ ) : null} +
{isTeam && s.isOnline !== undefined ? ( ) : null} - {s.subtitle ? ( + {s.subtitle && !isTask ? ( {s.subtitle} ) : null} diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index a25b1456..c0cdb145 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -1,12 +1,18 @@ import * as React from 'react'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { PROSE_LINK } from '@renderer/constants/cssVariables'; import { useFileSuggestions } from '@renderer/hooks/useFileSuggestions'; import { useMentionDetection } from '@renderer/hooks/useMentionDetection'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { chipToken } from '@renderer/types/inlineChip'; +import { + doesSuggestionMatchQuery, + getSuggestionInsertionText, +} from '@renderer/utils/mentionSuggestions'; import { nameColorSet } from '@renderer/utils/projectColor'; +import { findTaskReferenceMatches } from '@renderer/utils/taskReferenceUtils'; import { createChipFromSelection, findChipBoundary, @@ -18,6 +24,7 @@ import { AutoResizeTextarea } from './auto-resize-textarea'; import { ChipInteractionLayer } from './ChipInteractionLayer'; import { CodeChipBadge } from './CodeChipBadge'; import { MentionSuggestionList } from './MentionSuggestionList'; +import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer'; import type { AutoResizeTextareaProps } from './auto-resize-textarea'; import type { InlineChip } from '@renderer/types/inlineChip'; @@ -38,13 +45,19 @@ interface MentionSegment { suggestion: MentionSuggestion; } +interface TaskSegment { + type: 'task'; + value: string; + suggestion: MentionSuggestion; +} + interface ChipSegment { type: 'chip'; value: string; chip: InlineChip; } -type Segment = TextSegment | MentionSegment | ChipSegment; +type Segment = TextSegment | MentionSegment | TaskSegment | ChipSegment; // --------------------------------------------------------------------------- // Mention segment parsing (splits text into plain text + @mention segments) @@ -63,7 +76,9 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S if (!text || suggestions.length === 0) return [{ type: 'text', value: text }]; // Sort by name length descending for greedy matching - const sorted = [...suggestions].sort((a, b) => b.name.length - a.name.length); + const sorted = [...suggestions] + .filter((suggestion) => suggestion.type !== 'task') + .sort((a, b) => b.name.length - a.name.length); const segments: Segment[] = []; let i = 0; @@ -86,9 +101,10 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S let matched = false; for (const suggestion of sorted) { - const end = i + 1 + suggestion.name.length; + const insertionText = getSuggestionInsertionText(suggestion); + const end = i + 1 + insertionText.length; if (end > text.length) continue; - if (text.slice(i + 1, end).toLowerCase() !== suggestion.name.toLowerCase()) continue; + if (text.slice(i + 1, end).toLowerCase() !== insertionText.toLowerCase()) continue; // Character after name must be boundary if (end < text.length) { @@ -119,6 +135,40 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S return segments; } +function parseSuggestionSegments( + text: string, + mentionSuggestions: MentionSuggestion[], + taskSuggestions: MentionSuggestion[] +): Segment[] { + if (!text) return [{ type: 'text', value: text }]; + + const taskMatches = findTaskReferenceMatches(text, taskSuggestions); + if (taskMatches.length === 0) { + return parseMentionSegments(text, mentionSuggestions); + } + + const segments: Segment[] = []; + let lastEnd = 0; + + for (const match of taskMatches) { + if (match.start > lastEnd) { + segments.push(...parseMentionSegments(text.slice(lastEnd, match.start), mentionSuggestions)); + } + segments.push({ + type: 'task', + value: match.raw, + suggestion: match.suggestion, + }); + lastEnd = match.end; + } + + if (lastEnd < text.length) { + segments.push(...parseMentionSegments(text.slice(lastEnd), mentionSuggestions)); + } + + return segments; +} + // --------------------------------------------------------------------------- // Extended segment parser: chips + mentions // --------------------------------------------------------------------------- @@ -129,11 +179,12 @@ function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): S */ function parseSegments( text: string, - suggestions: MentionSuggestion[], + mentionSuggestions: MentionSuggestion[], + taskSuggestions: MentionSuggestion[], chips: InlineChip[] ): Segment[] { if (!text) return [{ type: 'text', value: text }]; - if (chips.length === 0) return parseMentionSegments(text, suggestions); + if (chips.length === 0) return parseSuggestionSegments(text, mentionSuggestions, taskSuggestions); // Build a map of chip tokens for fast lookup const chipTokenMap = new Map(); @@ -154,7 +205,9 @@ function parseSegments( } chipPositions.sort((a, b) => a.start - b.start); - if (chipPositions.length === 0) return parseMentionSegments(text, suggestions); + if (chipPositions.length === 0) { + return parseSuggestionSegments(text, mentionSuggestions, taskSuggestions); + } const segments: Segment[] = []; let lastEnd = 0; @@ -163,7 +216,7 @@ function parseSegments( // Text before this chip → parse for mentions if (pos.start > lastEnd) { const fragment = text.slice(lastEnd, pos.start); - segments.push(...parseMentionSegments(fragment, suggestions)); + segments.push(...parseSuggestionSegments(fragment, mentionSuggestions, taskSuggestions)); } segments.push({ type: 'chip', value: pos.token, chip: pos.chip }); lastEnd = pos.end; @@ -171,7 +224,9 @@ function parseSegments( // Remaining text after last chip → parse for mentions if (lastEnd < text.length) { - segments.push(...parseMentionSegments(text.slice(lastEnd), suggestions)); + segments.push( + ...parseSuggestionSegments(text.slice(lastEnd), mentionSuggestions, taskSuggestions) + ); } return segments; @@ -210,6 +265,8 @@ interface MentionableTextareaProps extends Omit< onFileChipInsert?: (chip: InlineChip) => void; /** Team suggestions for cross-team @mentions */ teamSuggestions?: MentionSuggestion[]; + /** Task suggestions for #task references */ + taskSuggestions?: MentionSuggestion[]; /** Called when Enter (without Shift) is pressed. */ onModEnter?: () => void; } @@ -230,6 +287,7 @@ export const MentionableTextarea = React.forwardRef 0; const setRefs = React.useCallback( (node: HTMLTextAreaElement | null) => { @@ -260,9 +319,10 @@ export const MentionableTextarea = React.forwardRef 0, + triggerChars: enableTaskSearch ? ['@', '#'] : ['@'], + isTriggerEnabled: (triggerChar) => { + if (triggerChar === '#') return enableTaskSearch; + return suggestions.length > 0 || enableFiles || teamSuggestions.length > 0; + }, }); // --- File suggestions --- const { suggestions: fileSuggestions, loading: filesLoading } = useFileSuggestions( enableFiles ? projectPath : null, - query, - isOpen && enableFiles + activeTriggerChar === '@' ? query : '', + isOpen && enableFiles && activeTriggerChar === '@' ); + const isAtTrigger = activeTriggerChar !== '#'; + + const memberSuggestions = React.useMemo(() => { + if (!isOpen || !isAtTrigger) return []; + if (!query) return suggestions; + return suggestions.filter((member) => doesSuggestionMatchQuery(member, query)); + }, [isAtTrigger, isOpen, query, suggestions]); + // --- Team suggestions filtered by query --- const filteredTeamSuggestions = React.useMemo(() => { - if (teamSuggestions.length === 0 || !isOpen) return []; + if (teamSuggestions.length === 0 || !isOpen || !isAtTrigger) return []; if (!query) return teamSuggestions; - const lower = query.toLowerCase(); - return teamSuggestions.filter((t) => t.name.toLowerCase().includes(lower)); - }, [teamSuggestions, isOpen, query]); + return teamSuggestions.filter((team) => doesSuggestionMatchQuery(team, query)); + }, [teamSuggestions, isAtTrigger, isOpen, query]); + + const filteredTaskSuggestions = React.useMemo(() => { + if (taskSuggestions.length === 0 || !isOpen || activeTriggerChar !== '#') return []; + if (!query) return taskSuggestions; + return taskSuggestions.filter((task) => doesSuggestionMatchQuery(task, query)); + }, [taskSuggestions, activeTriggerChar, isOpen, query]); // Merged suggestion list: members → online teams → offline teams → files - const allSuggestions = React.useMemo(() => { + const atSuggestions = React.useMemo(() => { const onlineTeams = filteredTeamSuggestions.filter((t) => t.isOnline); const offlineTeams = filteredTeamSuggestions.filter((t) => !t.isOnline); const merged = [...memberSuggestions, ...onlineTeams, ...offlineTeams]; @@ -302,21 +378,19 @@ export const MentionableTextarea = React.forwardRef { - setMergedIndex(0); - }, [query, allSuggestions.length]); - - // Use merged index when we have extra suggestion types (teams or files) - const hasMergedSuggestions = enableFiles || teamSuggestions.length > 0; - - // Effective index: use merged when extra types present, hook's index otherwise - const effectiveIndex = hasMergedSuggestions ? mergedIndex : selectedIndex; - const effectiveSuggestions = hasMergedSuggestions ? allSuggestions : memberSuggestions; + if (!isOpen) return; + if (effectiveSuggestions.length === 0) { + setSelectedIndex(0); + return; + } + if (selectedIndex >= effectiveSuggestions.length) { + setSelectedIndex(0); + } + }, [effectiveSuggestions.length, isOpen, selectedIndex, setSelectedIndex]); // --- File selection handler --- const handleFileSelect = React.useCallback( @@ -436,8 +510,8 @@ export const MentionableTextarea = React.forwardRef { if (s.type === 'file') { handleFileSelect(s); @@ -465,17 +539,22 @@ export const MentionableTextarea = React.forwardRef 0 || teamSuggestions.length > 0 || chips.length > 0; + const hasOverlay = + suggestions.length > 0 || + teamSuggestions.length > 0 || + taskSuggestions.length > 0 || + chips.length > 0; // Combine member + team suggestions for overlay parsing - const allOverlaySuggestions = React.useMemo( + const mentionOverlaySuggestions = React.useMemo( () => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions), [suggestions, teamSuggestions] ); const segments = React.useMemo( - () => (hasOverlay ? parseSegments(value, allOverlaySuggestions, chips) : []), - [hasOverlay, value, allOverlaySuggestions, chips] + () => + hasOverlay ? parseSegments(value, mentionOverlaySuggestions, taskSuggestions, chips) : [], + [hasOverlay, value, mentionOverlaySuggestions, taskSuggestions, chips] ); // Sync backdrop scroll with textarea scroll + track scrollTop for interaction layer @@ -561,47 +640,15 @@ export const MentionableTextarea = React.forwardRef) => { - if (!isOpen || allSuggestions.length === 0) return; - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setMergedIndex((prev) => (prev + 1) % allSuggestions.length); - break; - case 'ArrowUp': - e.preventDefault(); - setMergedIndex((prev) => (prev - 1 + allSuggestions.length) % allSuggestions.length); - break; - case 'Enter': - if (!e.shiftKey) { - e.preventDefault(); - if (allSuggestions[mergedIndex]) { - handleMergedSelect(allSuggestions[mergedIndex]); - } - } - break; - case 'Escape': - e.preventDefault(); - dismiss(); - break; - } - }, - [isOpen, allSuggestions, mergedIndex, handleMergedSelect, dismiss] - ); - - // Composed key handler: mention logic first (when open) → Mod+Enter submit → chip logic → mention fallback + // Composed key handler: suggestion logic first (when open) → Mod+Enter submit → chip logic const composedHandleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { - // When mention dropdown is open, let mention handler consume Enter/Arrow keys first + // When the suggestion dropdown is open, let it consume Enter/Arrow keys first if (isOpen && effectiveSuggestions.length > 0) { - if (hasMergedSuggestions) { - fileMentionHandleKeyDown(e); - } else { - mentionHandleKeyDown(e); - } + mentionHandleKeyDown(e, effectiveSuggestions.length, (index) => { + const next = effectiveSuggestions[index]; + if (next) handleActiveSelect(next); + }); if (e.defaultPrevented) return; } // Enter (without Shift) → submit; Shift+Enter → newline @@ -611,22 +658,15 @@ export const MentionableTextarea = React.forwardRef [ - 'Tip: Use @ to mention team members or search files', + 'Tip: Use @ for members/files and # for tasks', 'Tip: Mention "create a task" to add it to the kanban', "Tip: Don't overload the team lead with tasks — ask them to delegate to teammates", ], @@ -731,7 +771,8 @@ export const MentionableTextarea = React.forwardRef 0 || enableFiles || teamSuggestions.length > 0); + showHint && + (suggestions.length > 0 || enableFiles || teamSuggestions.length > 0 || enableTaskSearch); const showFooter = showHintRow || footerRight; return ( @@ -759,6 +800,17 @@ export const MentionableTextarea = React.forwardRef; } + if (seg.type === 'task') { + return ( + + {seg.value} + + ); + } // mention (member or team) const isTeamMention = seg.suggestion.type === 'team'; const colorSet = seg.suggestion.color @@ -785,6 +837,15 @@ export const MentionableTextarea = React.forwardRef ) : null} + {taskSuggestions.length > 0 ? ( + + ) : null} +
) : null} diff --git a/src/renderer/components/ui/TaskReferenceInteractionLayer.tsx b/src/renderer/components/ui/TaskReferenceInteractionLayer.tsx new file mode 100644 index 00000000..9e2a969f --- /dev/null +++ b/src/renderer/components/ui/TaskReferenceInteractionLayer.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; + +import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; +import { useStore } from '@renderer/store'; +import { calculateInlineMatchPositions } from '@renderer/utils/chipUtils'; +import { findTaskReferenceMatches } from '@renderer/utils/taskReferenceUtils'; + +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { InlineMatchPosition } from '@renderer/utils/chipUtils'; + +interface TaskReferenceInteractionLayerProps { + taskSuggestions: MentionSuggestion[]; + value: string; + textareaRef: React.RefObject; + scrollTop: number; +} + +type PositionedTaskReference = InlineMatchPosition; + +export const TaskReferenceInteractionLayer = ({ + taskSuggestions, + value, + textareaRef, + scrollTop, +}: TaskReferenceInteractionLayerProps): React.JSX.Element | null => { + const [positions, setPositions] = React.useState([]); + const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); + + React.useLayoutEffect(() => { + if (taskSuggestions.length === 0 || !value.includes('#')) { + setPositions([]); + return; + } + + const textarea = textareaRef.current; + if (!textarea) return; + + const matches = findTaskReferenceMatches(value, taskSuggestions).map((match) => ({ + item: match.suggestion, + start: match.start, + end: match.end, + token: match.raw, + })); + + setPositions(calculateInlineMatchPositions(textarea, value, matches)); + }, [taskSuggestions, textareaRef, value]); + + if (positions.length === 0) return null; + + return ( +
+
+ {positions.map((position, index) => { + const suggestion = position.item; + const taskId = suggestion.taskId; + const teamName = suggestion.teamName; + if (!taskId) return null; + + return ( + +
+
+ ); +}; diff --git a/src/renderer/hooks/useMentionDetection.ts b/src/renderer/hooks/useMentionDetection.ts index a283aa26..461bf6c0 100644 --- a/src/renderer/hooks/useMentionDetection.ts +++ b/src/renderer/hooks/useMentionDetection.ts @@ -1,14 +1,17 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from 'react'; + +import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions'; import type { MentionSuggestion } from '@renderer/types/mention'; interface UseMentionDetectionOptions { - suggestions: MentionSuggestion[]; value: string; onValueChange: (v: string) => void; textareaRef: React.RefObject; - /** When true, detect @-trigger even if suggestions list is empty (e.g. for file-only search) */ - enableTriggerAlways?: boolean; + /** Supported trigger characters, e.g. ['@', '#'] */ + triggerChars?: string[]; + /** Enable or disable individual triggers dynamically. */ + isTriggerEnabled?: (triggerChar: string) => boolean; } export interface DropdownPosition { @@ -18,13 +21,18 @@ export interface DropdownPosition { interface UseMentionDetectionResult { isOpen: boolean; + activeTriggerChar: string | null; query: string; - filteredSuggestions: MentionSuggestion[]; selectedIndex: number; + setSelectedIndex: Dispatch>; dropdownPosition: DropdownPosition | null; selectSuggestion: (s: MentionSuggestion) => void; dismiss: () => void; - handleKeyDown: (e: React.KeyboardEvent) => void; + handleKeyDown: ( + e: React.KeyboardEvent, + suggestionCount: number, + onSelectSuggestion: (index: number) => void + ) => void; handleChange: (e: React.ChangeEvent) => void; handleSelect: (e: React.SyntheticEvent) => void; /** Getter for trigger index — use at call time to avoid stale closure (returns -1 if no active trigger) */ @@ -33,6 +41,7 @@ interface UseMentionDetectionResult { interface MentionTrigger { triggerIndex: number; + triggerChar: string; query: string; } @@ -117,27 +126,32 @@ export function getCaretCoordinates( } /** - * Scans backwards from cursor position to find an @ trigger. + * Scans backwards from cursor position to find an active trigger. * Returns null if no valid trigger found. * * Rules: - * - @ must be at start of text or preceded by whitespace - * - Text between @ and cursor must not contain spaces + * - trigger must be at start of text or preceded by whitespace + * - Text between trigger and cursor must not contain spaces */ -export function findMentionTrigger(text: string, cursorPos: number): MentionTrigger | null { +export function findMentionTrigger( + text: string, + cursorPos: number, + triggerChars: string[] = ['@'] +): MentionTrigger | null { if (cursorPos <= 0) return null; const beforeCursor = text.slice(0, cursorPos); + const allowedTriggerChars = new Set(triggerChars); // Scan backwards to find @ for (let i = beforeCursor.length - 1; i >= 0; i--) { const char = beforeCursor[i]; - // If we hit whitespace or newline before finding @, no valid trigger + // If we hit whitespace or newline before finding a trigger, no valid trigger if (char === ' ' || char === '\t' || char === '\n' || char === '\r') return null; - if (char === '@') { - // @ must be at start or after whitespace/newline + if (allowedTriggerChars.has(char)) { + // trigger must be at start or after whitespace/newline if (i > 0) { const preceding = beforeCursor[i - 1]; if (preceding !== ' ' && preceding !== '\t' && preceding !== '\n' && preceding !== '\r') { @@ -146,7 +160,7 @@ export function findMentionTrigger(text: string, cursorPos: number): MentionTrig } const query = beforeCursor.slice(i + 1); - return { triggerIndex: i, query }; + return { triggerIndex: i, triggerChar: char, query }; } } @@ -154,34 +168,31 @@ export function findMentionTrigger(text: string, cursorPos: number): MentionTrig } export function useMentionDetection({ - suggestions, value, onValueChange, textareaRef, - enableTriggerAlways, + triggerChars = ['@'], + isTriggerEnabled, }: UseMentionDetectionOptions): UseMentionDetectionResult { const [isOpen, setIsOpen] = useState(false); + const [activeTriggerChar, setActiveTriggerChar] = useState(null); const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const [dropdownPosition, setDropdownPosition] = useState(null); const triggerIndexRef = useRef(-1); + const activeTriggerCharRef = useRef(null); // Track current query in a ref so detectTrigger can avoid resetting selectedIndex // on redundant selectionchange events (e.g. after ArrowDown/Up keyboard navigation) const queryRef = useRef(''); - const filteredSuggestions = useMemo(() => { - if (!isOpen) return []; - if (!query) return suggestions; - const lower = query.toLowerCase(); - return suggestions.filter((s) => s.name.toLowerCase().includes(lower)); - }, [isOpen, query, suggestions]); - const dismiss = useCallback(() => { setIsOpen(false); + setActiveTriggerChar(null); setQuery(''); setSelectedIndex(0); setDropdownPosition(null); triggerIndexRef.current = -1; + activeTriggerCharRef.current = null; queryRef.current = ''; }, []); @@ -201,11 +212,12 @@ export function useMentionDetection({ const selectSuggestion = useCallback( (s: MentionSuggestion) => { const textarea = textareaRef.current; - if (!textarea || triggerIndexRef.current < 0) return; + const triggerChar = activeTriggerCharRef.current; + if (!textarea || triggerIndexRef.current < 0 || !triggerChar) return; const before = value.slice(0, triggerIndexRef.current); - const after = value.slice(triggerIndexRef.current + 1 + query.length); - const insertion = `@${s.name} `; + const after = value.slice(triggerIndexRef.current + 1 + queryRef.current.length); + const insertion = `${triggerChar}${getSuggestionInsertionText(s)} `; const newValue = before + insertion + after; const newCursorPos = before.length + insertion.length; @@ -218,11 +230,11 @@ export function useMentionDetection({ textarea.selectionEnd = newCursorPos; }); }, - [value, query, onValueChange, textareaRef, dismiss] + [value, onValueChange, textareaRef, dismiss] ); /** - * Detects whether cursor is inside an @-trigger region and opens/dismisses the dropdown. + * Detects whether cursor is inside a trigger region and opens/dismisses the dropdown. * * Called from handleSelect (selectionchange) — must NOT reset selectedIndex when * the trigger is already active with the same query, otherwise ArrowDown/Up navigation @@ -230,12 +242,17 @@ export function useMentionDetection({ */ const detectTrigger = useCallback( (cursorPos: number) => { - const trigger = findMentionTrigger(value, cursorPos); - if (trigger && (suggestions.length > 0 || enableTriggerAlways)) { + const trigger = findMentionTrigger(value, cursorPos, triggerChars); + const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false; + if (trigger && isEnabled) { const sameQuery = - triggerIndexRef.current === trigger.triggerIndex && queryRef.current === trigger.query; + triggerIndexRef.current === trigger.triggerIndex && + activeTriggerCharRef.current === trigger.triggerChar && + queryRef.current === trigger.query; triggerIndexRef.current = trigger.triggerIndex; + activeTriggerCharRef.current = trigger.triggerChar; queryRef.current = trigger.query; + setActiveTriggerChar(trigger.triggerChar); setQuery(trigger.query); setIsOpen(true); // Only reset selection when trigger/query actually changed — @@ -248,7 +265,7 @@ export function useMentionDetection({ dismiss(); } }, - [value, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition] + [value, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition] ); const handleChange = useCallback( @@ -258,10 +275,13 @@ export function useMentionDetection({ // Detect trigger based on cursor position after the change const cursorPos = e.target.selectionStart; - const trigger = findMentionTrigger(newValue, cursorPos); - if (trigger && (suggestions.length > 0 || enableTriggerAlways)) { + const trigger = findMentionTrigger(newValue, cursorPos, triggerChars); + const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false; + if (trigger && isEnabled) { triggerIndexRef.current = trigger.triggerIndex; + activeTriggerCharRef.current = trigger.triggerChar; queryRef.current = trigger.query; + setActiveTriggerChar(trigger.triggerChar); setQuery(trigger.query); setIsOpen(true); // Text changed — always reset selection to first item @@ -271,7 +291,7 @@ export function useMentionDetection({ dismiss(); } }, - [onValueChange, suggestions.length, enableTriggerAlways, dismiss, computeDropdownPosition] + [onValueChange, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition] ); const handleSelect = useCallback( @@ -283,24 +303,26 @@ export function useMentionDetection({ ); const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (!isOpen || filteredSuggestions.length === 0) return; + ( + e: React.KeyboardEvent, + suggestionCount: number, + onSelectSuggestion: (index: number) => void + ) => { + if (!isOpen || suggestionCount === 0) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); - setSelectedIndex((prev) => (prev + 1) % filteredSuggestions.length); + setSelectedIndex((prev) => (prev + 1) % suggestionCount); break; case 'ArrowUp': e.preventDefault(); - setSelectedIndex( - (prev) => (prev - 1 + filteredSuggestions.length) % filteredSuggestions.length - ); + setSelectedIndex((prev) => (prev - 1 + suggestionCount) % suggestionCount); break; case 'Enter': if (!e.shiftKey) { e.preventDefault(); - selectSuggestion(filteredSuggestions[selectedIndex]); + onSelectSuggestion(selectedIndex); } break; case 'Escape': @@ -309,16 +331,17 @@ export function useMentionDetection({ break; } }, - [isOpen, filteredSuggestions, selectedIndex, selectSuggestion, dismiss] + [isOpen, selectedIndex, dismiss] ); const getTriggerIndex = useCallback(() => triggerIndexRef.current, []); return { isOpen, + activeTriggerChar, query, - filteredSuggestions, selectedIndex, + setSelectedIndex, dropdownPosition, selectSuggestion, dismiss, diff --git a/src/renderer/hooks/useResizablePanel.ts b/src/renderer/hooks/useResizablePanel.ts new file mode 100644 index 00000000..9400ba9c --- /dev/null +++ b/src/renderer/hooks/useResizablePanel.ts @@ -0,0 +1,122 @@ +/** + * useResizablePanel - Reusable hook for mouse-based panel resizing. + * + * Extracted from the resize pattern in Sidebar.tsx. + * Handles mousedown/mousemove/mouseup on document, cursor and userSelect overrides. + * + * @param options.width Current panel width (controlled) + * @param options.onWidthChange Callback when width changes during drag + * @param options.minWidth Minimum allowed width (default 280) + * @param options.maxWidth Maximum allowed width (default 500) + * @param options.side Which side the panel is on: + * 'left' → panel is on the left, resize handle on right edge + * 'right' → panel is on the right, resize handle on left edge + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +const DEFAULT_MIN_WIDTH = 280; +const DEFAULT_MAX_WIDTH = 500; + +interface UseResizablePanelOptions { + width: number; + onWidthChange: (width: number) => void; + minWidth?: number; + maxWidth?: number; + side: 'left' | 'right'; +} + +interface ResizeHandleProps { + onMouseDown: (e: React.MouseEvent) => void; +} + +interface UseResizablePanelReturn { + isResizing: boolean; + handleProps: ResizeHandleProps; +} + +export function useResizablePanel({ + width, + onWidthChange, + minWidth = DEFAULT_MIN_WIDTH, + maxWidth = DEFAULT_MAX_WIDTH, + side, +}: UseResizablePanelOptions): UseResizablePanelReturn { + const [isResizing, setIsResizing] = useState(false); + + // Store the panel's left offset for 'left' side panels. + // Updated on resize start so the formula stays correct if layout shifts. + const panelLeftRef = useRef(0); + + // Keep callbacks in refs to avoid stale closures in mousemove listener + const onWidthChangeRef = useRef(onWidthChange); + onWidthChangeRef.current = onWidthChange; + + const minWidthRef = useRef(minWidth); + minWidthRef.current = minWidth; + + const maxWidthRef = useRef(maxWidth); + maxWidthRef.current = maxWidth; + + const sideRef = useRef(side); + sideRef.current = side; + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isResizing) return; + + let newWidth: number; + if (sideRef.current === 'left') { + // Panel on the left: width = cursor position - panel left edge + newWidth = e.clientX - panelLeftRef.current; + } else { + // Panel on the right: width = viewport width - cursor position + newWidth = window.innerWidth - e.clientX; + } + + if (newWidth >= minWidthRef.current && newWidth <= maxWidthRef.current) { + onWidthChangeRef.current(newWidth); + } + }, + [isResizing] + ); + + const handleMouseUp = useCallback(() => { + setIsResizing(false); + }, []); + + useEffect(() => { + if (isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isResizing, handleMouseMove, handleMouseUp]); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + + if (side === 'left') { + // Calculate the left edge of the panel from cursor position minus current width + panelLeftRef.current = e.clientX - width; + } + + setIsResizing(true); + }, + [side, width] + ); + + return { + isResizing, + handleProps: { onMouseDown }, + }; +} diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts new file mode 100644 index 00000000..a5b8d277 --- /dev/null +++ b/src/renderer/hooks/useTaskSuggestions.ts @@ -0,0 +1,123 @@ +import { useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { getTaskDisplayId } from '@shared/utils/taskIdentity'; + +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { GlobalTask, TeamTaskWithKanban } from '@shared/types'; + +export interface UseTaskSuggestionsResult { + suggestions: MentionSuggestion[]; +} + +interface TaskWithTeamContext { + task: TeamTaskWithKanban | GlobalTask; + teamName: string; + teamDisplayName: string; + teamColor?: string; + isCurrentTeamTask: boolean; + ownerColor?: string; +} + +function getTaskTimestamp(task: TeamTaskWithKanban | GlobalTask): number { + const value = task.updatedAt ?? task.createdAt; + return value ? Date.parse(value) || 0 : 0; +} + +function buildTaskSuggestion({ + task, + teamName, + teamDisplayName, + teamColor, + isCurrentTeamTask, + ownerColor, +}: TaskWithTeamContext): MentionSuggestion { + const displayId = getTaskDisplayId(task); + return { + id: `task:${teamName}:${task.id}`, + name: displayId, + insertText: displayId, + subtitle: task.subject, + color: teamColor, + type: 'task', + taskId: task.id, + teamName, + teamDisplayName, + isCurrentTeamTask, + ownerName: task.owner, + ownerColor, + searchText: [task.subject, teamDisplayName, teamName, task.owner].filter(Boolean).join(' '), + }; +} + +function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean { + return task.status !== 'deleted' && !task.deletedAt; +} + +export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult { + const globalTasks = useStore((s) => s.globalTasks); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const selectedTeamData = useStore((s) => s.selectedTeamData); + const teamByName = useStore((s) => s.teamByName); + + const suggestions = useMemo(() => { + const tasks: TaskWithTeamContext[] = []; + const seenTaskIds = new Set(); + + if (currentTeamName) { + const currentTeamSummary = teamByName[currentTeamName]; + const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName; + const currentTeamMembers = + selectedTeamName === currentTeamName && selectedTeamData + ? selectedTeamData.members + : (currentTeamSummary?.members ?? []); + const currentTeamTasks = + selectedTeamName === currentTeamName && selectedTeamData + ? selectedTeamData.tasks + : globalTasks.filter((task) => task.teamName === currentTeamName); + + for (const task of currentTeamTasks) { + if (!isVisibleTask(task)) continue; + seenTaskIds.add(task.id); + tasks.push({ + task, + teamName: currentTeamName, + teamDisplayName: currentTeamDisplayName, + teamColor: currentTeamSummary?.color, + isCurrentTeamTask: true, + ownerColor: currentTeamMembers.find((member) => member.name === task.owner)?.color, + }); + } + } + + for (const task of globalTasks) { + if (!isVisibleTask(task)) continue; + if (seenTaskIds.has(task.id)) continue; + const teamSummary = teamByName[task.teamName]; + tasks.push({ + task, + teamName: task.teamName, + teamDisplayName: task.teamDisplayName, + teamColor: teamSummary?.color, + isCurrentTeamTask: task.teamName === currentTeamName, + ownerColor: teamSummary?.members?.find((member) => member.name === task.owner)?.color, + }); + } + + tasks.sort((a, b) => { + if (a.isCurrentTeamTask !== b.isCurrentTeamTask) { + return a.isCurrentTeamTask ? -1 : 1; + } + + const timeDelta = getTaskTimestamp(b.task) - getTaskTimestamp(a.task); + if (timeDelta !== 0) return timeDelta; + + if (a.teamName !== b.teamName) return a.teamName.localeCompare(b.teamName); + return getTaskDisplayId(a.task).localeCompare(getTaskDisplayId(b.task)); + }); + + return tasks.map(buildTaskSuggestion); + }, [currentTeamName, globalTasks, selectedTeamData, selectedTeamName, teamByName]); + + return { suggestions }; +} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 691d5409..9593923f 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -415,6 +415,12 @@ export interface TeamSlice { allow: boolean, message?: string ) => Promise; + + // Messages panel UI state + messagesPanelMode: 'sidebar' | 'inline'; + messagesPanelWidth: number; + setMessagesPanelMode: (mode: 'sidebar' | 'inline') => void; + setMessagesPanelWidth: (width: number) => void; } // --- Per-team launch params persistence --- @@ -563,6 +569,12 @@ export const createTeamSlice: StateCreator = (set, pendingApprovals: [], toolApprovalSettings: loadToolApprovalSettings(), + // Messages panel UI state + messagesPanelMode: 'sidebar' as const, + messagesPanelWidth: 340, + setMessagesPanelMode: (mode: 'sidebar' | 'inline') => set({ messagesPanelMode: mode }), + setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }), + fetchBranches: async (paths: string[]) => { const results: Record = {}; for (const p of paths) { diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts index 0f402767..fd4bdca0 100644 --- a/src/renderer/types/mention.ts +++ b/src/renderer/types/mention.ts @@ -1,18 +1,34 @@ export interface MentionSuggestion { /** Unique key (name or draft.id) */ id: string; - /** Name to insert: @name */ + /** Human-readable primary label (for tasks: short display id without `#`) */ name: string; /** Role displayed in suggestion list */ subtitle?: string; /** Color name from TeamColorSet palette */ color?: string; - /** Suggestion type — 'member' (default), 'team', 'file', or 'folder' */ - type?: 'member' | 'team' | 'file' | 'folder'; + /** Suggestion type — 'member' (default), 'team', 'file', 'folder', or 'task' */ + type?: 'member' | 'team' | 'file' | 'folder' | 'task'; /** Whether the team is currently online (team suggestions only) */ isOnline?: boolean; /** Absolute file/folder path (file/folder suggestions only) */ filePath?: string; /** Relative display path (file/folder suggestions only) */ relativePath?: string; + /** Optional exact text inserted after the trigger (defaults to `name`) */ + insertText?: string; + /** Optional extra searchable text (subject, team name, path, etc.) */ + searchText?: string; + /** Canonical task id (task suggestions only) */ + taskId?: string; + /** Owning team name (task suggestions only) */ + teamName?: string; + /** Owning team display name (task suggestions only) */ + teamDisplayName?: string; + /** Whether the task belongs to the currently active team */ + isCurrentTeamTask?: boolean; + /** Owning task owner name (task suggestions only) */ + ownerName?: string; + /** Owning task owner color (task suggestions only) */ + ownerColor?: string; } diff --git a/src/renderer/utils/bugReportUtils.ts b/src/renderer/utils/bugReportUtils.ts new file mode 100644 index 00000000..ee66f3a3 --- /dev/null +++ b/src/renderer/utils/bugReportUtils.ts @@ -0,0 +1,157 @@ +import packageJson from '../../../package.json'; + +const GITHUB_BUG_REPORT_URL = 'https://github.com/777genius/claude_agent_teams_ui/issues/new'; +const MAX_TITLE_LENGTH = 120; +const URL_MAX_STACK_LENGTH = 1800; +const URL_MAX_COMPONENT_STACK_LENGTH = 1200; +const COPY_MAX_STACK_LENGTH = 12000; +const COPY_MAX_COMPONENT_STACK_LENGTH = 8000; + +export interface BugReportContext { + activeTabType?: string | null; + activeTabLabel?: string | null; + activeTeamName?: string | null; + selectedTeamName?: string | null; + taskId?: string | null; + sessionId?: string | null; + projectId?: string | null; +} + +export interface BugReportOptions { + error: Error | null; + componentStack?: string | null; + context?: BugReportContext; +} + +const truncate = (value: string, maxLength: number): string => { + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, maxLength)}\n...[truncated]`; +}; + +const buildIssueTitle = (error: Error | null): string => { + const baseTitle = error ? `[BUG] ${error.name}: ${error.message}` : '[BUG] Application crash'; + return truncate(baseTitle, MAX_TITLE_LENGTH); +}; + +const getRuntimeLabel = (): string => (window.electronAPI ? 'Electron renderer' : 'Web browser'); + +const formatOptional = (value: string | null | undefined): string => { + if (!value) { + return 'Not available'; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : 'Not available'; +}; + +const getOperatingSystemLabel = (): string => { + const { userAgent } = window.navigator; + + if (userAgent.includes('Mac OS X')) return 'macOS'; + if (userAgent.includes('Windows')) return 'Windows'; + if (userAgent.includes('Linux')) return 'Linux'; + + return 'Unknown'; +}; + +const formatActiveTab = (context?: BugReportContext): string => { + if (!context?.activeTabType) { + return 'Not available'; + } + + if (!context.activeTabLabel) { + return context.activeTabType; + } + + return `${context.activeTabType} (${context.activeTabLabel})`; +}; + +const buildBugReportMarkdown = ( + { error, componentStack, context }: BugReportOptions, + stackLimits: { js: number; react: number } +): string => { + const message = error?.message ?? 'Unknown application crash'; + const jsStack = error?.stack ? truncate(error.stack, stackLimits.js) : 'Not available'; + const reactComponentStack = componentStack + ? truncate(componentStack, stackLimits.react) + : 'Not available'; + + return [ + '**Describe the bug**', + 'The app crashed and showed the global error screen.', + '', + '**What happened**', + `- Error: \`${message}\``, + `- Error type: \`${error?.name ?? 'UnknownError'}\``, + `- Active tab: ${formatActiveTab(context)}`, + `- Active team tab: ${formatOptional(context?.activeTeamName)}`, + `- Selected team: ${formatOptional(context?.selectedTeamName)}`, + `- Current task: ${formatOptional(context?.taskId)}`, + `- Session ID: ${formatOptional(context?.sessionId)}`, + `- Project ID: ${formatOptional(context?.projectId)}`, + '', + '**Steps to reproduce**', + '1. Open the app and navigate to the screen where the crash happened.', + '2. Repeat the action that triggered the error.', + '3. Observe the global error screen.', + '', + '**Expected behavior**', + 'The app should continue working instead of crashing.', + '', + '**Screenshots**', + 'Attach a screenshot if you have one.', + '', + '**Environment**', + `- OS: ${getOperatingSystemLabel()}`, + `- Runtime: ${getRuntimeLabel()}`, + `- App version: ${packageJson.version}`, + '', + '**Diagnostics**', + '```text', + `Timestamp: ${new Date().toISOString()}`, + `Current URL: ${window.location.href}`, + `User Agent: ${window.navigator.userAgent}`, + `Error name: ${error?.name ?? 'UnknownError'}`, + `Error message: ${message}`, + `Active tab: ${formatActiveTab(context)}`, + `Active team tab: ${formatOptional(context?.activeTeamName)}`, + `Selected team: ${formatOptional(context?.selectedTeamName)}`, + `Current task: ${formatOptional(context?.taskId)}`, + `Session ID: ${formatOptional(context?.sessionId)}`, + `Project ID: ${formatOptional(context?.projectId)}`, + '```', + '', + '**JavaScript stack trace**', + '```text', + jsStack, + '```', + '', + '**React component stack**', + '```text', + reactComponentStack, + '```', + ].join('\n'); +}; + +export const buildBugReportText = (options: BugReportOptions): string => + buildBugReportMarkdown(options, { + js: COPY_MAX_STACK_LENGTH, + react: COPY_MAX_COMPONENT_STACK_LENGTH, + }); + +export const buildGitHubBugReportUrl = (options: BugReportOptions): string => { + const params = new URLSearchParams({ + template: 'bug_report.md', + labels: 'bug', + title: buildIssueTitle(options.error), + body: buildBugReportMarkdown(options, { + js: URL_MAX_STACK_LENGTH, + react: URL_MAX_COMPONENT_STACK_LENGTH, + }), + }); + + return `${GITHUB_BUG_REPORT_URL}?${params.toString()}`; +}; diff --git a/src/renderer/utils/chipUtils.ts b/src/renderer/utils/chipUtils.ts index d68049d8..ffc11395 100644 --- a/src/renderer/utils/chipUtils.ts +++ b/src/renderer/utils/chipUtils.ts @@ -168,16 +168,26 @@ export interface ChipPosition { height: number; } -/** - * Calculates screen positions of chip tokens in textarea using the mirror div technique. - * Creates a temporary mirror div that replicates textarea layout and measures chip spans. - */ -export function calculateChipPositions( +export interface InlineMatch { + item: T; + start: number; + end: number; + token: string; +} + +export interface InlineMatchPosition extends InlineMatch { + top: number; + left: number; + width: number; + height: number; +} + +export function calculateInlineMatchPositions( textarea: HTMLTextAreaElement, text: string, - chips: InlineChip[] -): ChipPosition[] { - if (chips.length === 0) return []; + matches: InlineMatch[] +): InlineMatchPosition[] { + if (matches.length === 0) return []; const cs = window.getComputedStyle(textarea); const mirror = document.createElement('div'); @@ -210,60 +220,77 @@ export function calculateChipPositions( mirror.style.overflow = 'hidden'; mirror.style.height = 'auto'; - // Build content with chip tokens wrapped in spans - const chipSpans = new Map(); - const tokenPositions: { chip: InlineChip; token: string; index: number }[] = []; + const sortedMatches = [...matches].sort((a, b) => a.start - b.start); + const tokenSpans = new Map(); - // Find all chip token positions in text - for (const chip of chips) { - const token = chipToken(chip); - const idx = text.indexOf(token); - if (idx !== -1) { - tokenPositions.push({ chip, token, index: idx }); - } - } - - // Sort by position in text - tokenPositions.sort((a, b) => a.index - b.index); - - // Build mirror content let lastEnd = 0; - for (const { chip, token, index } of tokenPositions) { - // Text before this chip - if (index > lastEnd) { - const textNode = document.createTextNode(text.slice(lastEnd, index)); - mirror.appendChild(textNode); + sortedMatches.forEach((match, index) => { + if (match.start > lastEnd) { + mirror.appendChild(document.createTextNode(text.slice(lastEnd, match.start))); } - // Chip span const span = document.createElement('span'); - span.textContent = token; + span.textContent = text.slice(match.start, match.end); mirror.appendChild(span); - chipSpans.set(chip.id, span); + tokenSpans.set(index, span); - lastEnd = index + token.length; - } + lastEnd = match.end; + }); - // Text after last chip if (lastEnd < text.length) { mirror.appendChild(document.createTextNode(text.slice(lastEnd))); } document.body.appendChild(mirror); - const positions: ChipPosition[] = []; - for (const { chip } of tokenPositions) { - const span = chipSpans.get(chip.id); - if (!span) continue; + const positions: InlineMatchPosition[] = []; + sortedMatches.forEach((match, index) => { + const span = tokenSpans.get(index); + if (!span) return; positions.push({ - chip, + ...match, top: span.offsetTop, left: span.offsetLeft, width: span.offsetWidth, height: span.offsetHeight, }); - } + }); document.body.removeChild(mirror); return positions; } + +/** + * Calculates screen positions of chip tokens in textarea using the mirror div technique. + * Creates a temporary mirror div that replicates textarea layout and measures chip spans. + */ +export function calculateChipPositions( + textarea: HTMLTextAreaElement, + text: string, + chips: InlineChip[] +): ChipPosition[] { + if (chips.length === 0) return []; + const tokenMatches: InlineMatch[] = []; + for (const chip of chips) { + const token = chipToken(chip); + let searchFrom = 0; + while (searchFrom < text.length) { + const idx = text.indexOf(token, searchFrom); + if (idx === -1) break; + tokenMatches.push({ + item: chip, + start: idx, + end: idx + token.length, + token, + }); + searchFrom = idx + token.length; + } + } + return calculateInlineMatchPositions(textarea, text, tokenMatches).map((position) => ({ + chip: position.item, + top: position.top, + left: position.left, + width: position.width, + height: position.height, + })); +} diff --git a/src/renderer/utils/mentionSuggestions.ts b/src/renderer/utils/mentionSuggestions.ts new file mode 100644 index 00000000..c8c37d4f --- /dev/null +++ b/src/renderer/utils/mentionSuggestions.ts @@ -0,0 +1,27 @@ +import type { MentionSuggestion } from '@renderer/types/mention'; + +export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' { + return suggestion.type === 'task' ? '#' : '@'; +} + +export function getSuggestionInsertionText(suggestion: MentionSuggestion): string { + return suggestion.insertText ?? suggestion.name; +} + +export function doesSuggestionMatchQuery(suggestion: MentionSuggestion, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return true; + + const haystacks = [ + suggestion.name, + suggestion.subtitle, + suggestion.relativePath, + suggestion.searchText, + suggestion.teamDisplayName, + suggestion.teamName, + ] + .filter(Boolean) + .map((value) => value!.toLowerCase()); + + return haystacks.some((value) => value.includes(normalizedQuery)); +} diff --git a/src/renderer/utils/taskReferenceUtils.ts b/src/renderer/utils/taskReferenceUtils.ts new file mode 100644 index 00000000..db1d1d99 --- /dev/null +++ b/src/renderer/utils/taskReferenceUtils.ts @@ -0,0 +1,62 @@ +import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions'; + +import type { MentionSuggestion } from '@renderer/types/mention'; + +const TASK_REF_REGEX = /#([A-Za-z0-9-]+)\b/g; + +export interface TaskReferenceMatch { + start: number; + end: number; + raw: string; + ref: string; + suggestion: MentionSuggestion; +} + +export function linkifyTaskIdsInMarkdown(text: string): string { + return text.replace(TASK_REF_REGEX, '[#$1](task://$1)'); +} + +export function findTaskReferenceMatches( + text: string, + taskSuggestions: MentionSuggestion[] +): TaskReferenceMatch[] { + if (!text || taskSuggestions.length === 0) return []; + + const suggestionByRef = new Map(); + for (const suggestion of taskSuggestions) { + if (suggestion.type !== 'task') continue; + const ref = getSuggestionInsertionText(suggestion).trim().toLowerCase(); + if (!ref || suggestionByRef.has(ref)) continue; + suggestionByRef.set(ref, suggestion); + } + + if (suggestionByRef.size === 0) return []; + + const matches: TaskReferenceMatch[] = []; + for (const match of text.matchAll(TASK_REF_REGEX)) { + const raw = match[0]; + const ref = match[1]; + const start = match.index ?? -1; + if (start < 0) continue; + + if (start > 0) { + const preceding = text[start - 1]; + if (preceding !== ' ' && preceding !== '\t' && preceding !== '\n' && preceding !== '\r') { + continue; + } + } + + const suggestion = suggestionByRef.get(ref.toLowerCase()); + if (!suggestion) continue; + + matches.push({ + start, + end: start + raw.length, + raw, + ref, + suggestion, + }); + } + + return matches; +} From 057591060a1fc59e0961ae0e88451c5e32cfbe48 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 13:41:38 +0200 Subject: [PATCH 06/72] feat: enhance UI components with dynamic theming and improved task handling - Updated MemberBadge component to support an additional 'xs' size variant for better responsiveness. - Refactored TaskTooltip to improve task retrieval logic, ensuring accurate task matching based on team context. - Enhanced CreateTeamDialog and LaunchTeamDialog with dynamic background color adjustments based on theme. - Improved OptionalSettingsSection to utilize theme-aware styles for better visual consistency. - Updated MemberDraftRow to apply theme-based background and shadow effects for improved aesthetics. - Enhanced MentionSuggestionList to include dynamic theming for task owner display and team colors. - Refactored task reference utilities to improve task suggestion resolution and boundary handling. --- src/renderer/components/team/MemberBadge.tsx | 11 ++-- src/renderer/components/team/TaskTooltip.tsx | 28 ++++++--- .../team/dialogs/CreateTeamDialog.tsx | 21 ++++++- .../team/dialogs/LaunchTeamDialog.tsx | 11 +++- .../team/dialogs/OptionalSettingsSection.tsx | 49 ++++++++++++--- .../team/members/MemberDraftRow.tsx | 15 ++++- .../components/ui/ChipInteractionLayer.tsx | 2 +- .../components/ui/MentionSuggestionList.tsx | 23 ++++++- .../components/ui/MentionableTextarea.tsx | 12 ++++ .../ui/TaskReferenceInteractionLayer.tsx | 12 ++++ src/renderer/utils/taskReferenceUtils.ts | 62 ++++++++++++++----- 11 files changed, 198 insertions(+), 48 deletions(-) diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx index c4f40187..580763dd 100644 --- a/src/renderer/components/team/MemberBadge.tsx +++ b/src/renderer/components/team/MemberBadge.tsx @@ -13,7 +13,7 @@ interface MemberBadgeProps { name: string; color?: string; /** Avatar + badge size variant */ - size?: 'sm' | 'md'; + size?: 'xs' | 'sm' | 'md'; /** Hide the avatar icon, show only the name badge */ hideAvatar?: boolean; onClick?: (name: string) => void; @@ -37,9 +37,10 @@ export const MemberBadge = ({ }: MemberBadgeProps): React.JSX.Element => { const colors = getTeamColorSet(color ?? ''); const { isLight } = useTheme(); - const avatarSize = size === 'md' ? 32 : 24; - const avatarClass = size === 'md' ? 'size-6' : 'size-5'; - const textClass = size === 'md' ? 'text-xs' : 'text-[10px]'; + const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18; + const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4'; + const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]'; + const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5'; const badgeStyle = { backgroundColor: getThemedBadge(colors, isLight), @@ -58,7 +59,7 @@ export const MemberBadge = ({ const badge = ( {name === 'team-lead' ? 'lead' : name} diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index b761ad76..e5f863b3 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -73,27 +73,39 @@ export const TaskTooltip = ({ const globalTasks = useStore((s) => s.globalTasks); const teamByName = useStore((s) => s.teamByName); - const tasks = useMemo(() => { + const task = useMemo(() => { if (teamName && selectedTeamName === teamName) { - return selectedTeamData?.tasks ?? []; + return ( + (selectedTeamData?.tasks ?? []).find((candidate) => taskMatchesRef(candidate, taskId)) ?? + null + ); } + if (teamName) { - return globalTasks.filter((task) => task.teamName === teamName); + return ( + globalTasks.find( + (candidate) => candidate.teamName === teamName && taskMatchesRef(candidate, taskId) + ) ?? null + ); } + const currentTasks = selectedTeamData?.tasks ?? []; const currentMatch = currentTasks.find((task) => taskMatchesRef(task, taskId)); - if (currentMatch) return currentTasks; - return globalTasks; + if (currentMatch) return currentMatch; + + const globalMatches = globalTasks.filter((candidate) => taskMatchesRef(candidate, taskId)); + return globalMatches.length === 1 ? globalMatches[0] : null; }, [globalTasks, selectedTeamData, selectedTeamName, teamName, taskId]); const members = useMemo(() => { if (teamName && selectedTeamName === teamName) { return selectedTeamData?.members ?? []; } + if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) { + return selectedTeamData?.members ?? []; + } return []; - }, [selectedTeamData, selectedTeamName, teamName]); - - const task = useMemo(() => tasks?.find((t) => taskMatchesRef(t, taskId)), [tasks, taskId]); + }, [selectedTeamData, selectedTeamName, teamName, task]); const colorMap = useMemo( () => (members ? buildMemberColorMap(members) : new Map()), diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index af2e5448..6bd633bd 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -839,18 +839,33 @@ export const CreateTeamDialog = ({ />
-
+
setLaunchTeam(checked === true)} />
-
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index c89f32cb..7317a386 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -20,6 +20,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; +import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; @@ -108,6 +109,7 @@ function getLocalTimezone(): string { export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => { const { open, onClose } = props; + const { isLight } = useTheme(); const isLaunch = props.mode === 'launch'; const isSchedule = props.mode === 'schedule'; const schedule = isSchedule ? ((props as LaunchDialogScheduleMode).schedule ?? null) : null; @@ -821,7 +823,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen Schedule-only: Schedule configuration section ═══════════════════════════════════════════════════════════════════ */} {isSchedule ? ( -
+
@@ -80,7 +113,7 @@ export const OptionalSettingsSection = ({
{children} diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index e8eddd8a..621719f5 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -8,6 +8,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; +import { useTheme } from '@renderer/hooks/useTheme'; import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { ChevronDown, ChevronRight, Info } from 'lucide-react'; @@ -47,6 +48,7 @@ export const MemberDraftRow = ({ projectPath, mentionSuggestions = [], }: MemberDraftRowProps): React.JSX.Element => { + const { isLight } = useTheme(); const memberColorSet = getTeamColorSet( getMemberColorByName(member.name.trim() || `member-${index}`) ); @@ -117,12 +119,19 @@ export const MemberDraftRow = ({ return (
+ ); diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx index 6e1c67bb..9781ad4e 100644 --- a/src/renderer/components/team/dialogs/ReviewDialog.tsx +++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx @@ -13,6 +13,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import { Send } from 'lucide-react'; @@ -56,11 +57,11 @@ export const ReviewDialog = ({ [members, colorMap] ); - const trimmed = draft.value.trim(); + const trimmed = stripEncodedTaskReferenceMetadata(draft.value).trim(); const remaining = MAX_TEXT_LENGTH - trimmed.length; const handleSubmit = (): void => { - const comment = trimmed || undefined; + const comment = stripEncodedTaskReferenceMetadata(trimmed) || undefined; draft.clearDraft(); onSubmit(comment); }; diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index c2820f9a..2ee40a73 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -26,6 +26,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; @@ -214,7 +215,7 @@ export const SendMessageDialog = ({ const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; - const trimmedText = textDraft.value.trim(); + const trimmedText = stripEncodedTaskReferenceMetadata(textDraft.value).trim(); const serialized = serializeChipsWithText(trimmedText, chipDraft.chips); const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized; const remaining = MAX_TEXT_LENGTH - finalText.length; diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index e24b99f3..613ebda0 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -12,6 +12,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; +import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; @@ -73,7 +74,7 @@ export const TaskCommentInput = ({ [members, colorMap] ); - const trimmed = draft.value.trim(); + const trimmed = stripEncodedTaskReferenceMetadata(draft.value).trim(); const remaining = MAX_TEXT_LENGTH - trimmed.length; const canSubmit = (trimmed.length > 0 || pendingAttachments.length > 0) && diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 02c89396..91106df6 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -22,7 +22,10 @@ import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; -import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; +import { + linkifyTaskIdsInMarkdown, + stripEncodedTaskReferenceMetadata, +} from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { formatDistanceToNow } from 'date-fns'; @@ -145,7 +148,7 @@ export const TaskCommentsSection = ({ [members, colorMap] ); - const trimmed = draft.value.trim(); + const trimmed = stripEncodedTaskReferenceMetadata(draft.value).trim(); const remaining = MAX_TEXT_LENGTH - trimmed.length; const canSubmit = (trimmed.length > 0 || chipDraft.chips.length > 0) && diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 621719f5..7281dbb9 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -11,7 +11,7 @@ import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useTheme } from '@renderer/hooks/useTheme'; import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { getMemberColorByName } from '@shared/constants/memberColors'; -import { ChevronDown, ChevronRight, Info } from 'lucide-react'; +import { ChevronDown, ChevronRight, Info, Trash2 } from 'lucide-react'; import type { MemberDraft } from './membersEditorTypes'; import type { InlineChip } from '@renderer/types/inlineChip'; @@ -191,10 +191,12 @@ export const MemberDraftRow = ({
{showWorkflow && onWorkflowChange && workflowExpanded ? ( diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 7a435ad2..da8f9800 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button'; import { Label } from '@renderer/components/ui/label'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { getMemberColorByName } from '@shared/constants/memberColors'; +import { Plus } from 'lucide-react'; import { MembersJsonEditor } from '../dialogs/MembersJsonEditor'; @@ -174,12 +175,13 @@ export const MembersEditorSection = ({ {!hideContent && (
- - {showJsonEditor ? ( + {showJsonEditor && !jsonEditorOpen ? ( ) : null}
@@ -208,7 +210,12 @@ export const MembersEditorSection = ({ /> ))} {jsonEditorOpen && showJsonEditor ? ( - + ) : null}
{hasDuplicates ? ( diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 8d3814c4..b1975755 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -17,6 +17,7 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { nameColorSet } from '@renderer/utils/projectColor'; +import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; @@ -136,7 +137,7 @@ export const MessageComposer = ({ const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); - const trimmed = draft.text.trim(); + const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 255f8ecc..783d4e92 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -425,7 +425,9 @@ export const MessagesPanel = ({ {searchAndFilterBar}
{/* Scrollable content */} -
{messagesContent}
+
+ {messagesContent} +
); } diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index ea4e8073..d5525093 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -19,6 +19,7 @@ import { reconcileChips, removeChipTokenFromText, } from '@renderer/utils/chipUtils'; +import { Link2 } from 'lucide-react'; import { AutoResizeTextarea } from './auto-resize-textarea'; import { ChipInteractionLayer } from './ChipInteractionLayer'; @@ -49,6 +50,12 @@ interface TaskSegment { type: 'task'; value: string; suggestion: MentionSuggestion; + encoded: boolean; +} + +interface UrlSegment { + type: 'url'; + value: string; } interface ChipSegment { @@ -57,7 +64,41 @@ interface ChipSegment { chip: InlineChip; } -type Segment = TextSegment | MentionSegment | TaskSegment | ChipSegment; +type Segment = TextSegment | MentionSegment | TaskSegment | UrlSegment | ChipSegment; + +interface TextMatch { + start: number; + end: number; + value: string; +} + +const URL_REGEX = /https?:\/\/[^\s]+/g; + +function trimUrlMatch(rawUrl: string): string { + return rawUrl.replace(/[),.!?;:]+$/g, ''); +} + +function findUrlMatches(text: string): TextMatch[] { + if (!text) return []; + + const matches: TextMatch[] = []; + for (const match of text.matchAll(URL_REGEX)) { + const rawValue = match[0]; + const start = match.index ?? -1; + if (start < 0) continue; + + const trimmedValue = trimUrlMatch(rawValue); + if (!trimmedValue) continue; + + matches.push({ + start, + end: start + trimmedValue.length, + value: trimmedValue, + }); + } + + return matches; +} // --------------------------------------------------------------------------- // Mention segment parsing (splits text into plain text + @mention segments) @@ -142,6 +183,37 @@ function parseSuggestionSegments( ): Segment[] { if (!text) return [{ type: 'text', value: text }]; + const urlMatches = findUrlMatches(text); + if (urlMatches.length > 0) { + const segments: Segment[] = []; + let lastEnd = 0; + + for (const match of urlMatches) { + if (match.start > lastEnd) { + segments.push( + ...parseSuggestionSegments( + text.slice(lastEnd, match.start), + mentionSuggestions, + taskSuggestions + ) + ); + } + segments.push({ + type: 'url', + value: match.value, + }); + lastEnd = match.end; + } + + if (lastEnd < text.length) { + segments.push( + ...parseSuggestionSegments(text.slice(lastEnd), mentionSuggestions, taskSuggestions) + ); + } + + return segments; + } + const taskMatches = findTaskReferenceMatches(text, taskSuggestions); if (taskMatches.length === 0) { return parseMentionSegments(text, mentionSuggestions); @@ -158,6 +230,7 @@ function parseSuggestionSegments( type: 'task', value: match.raw, suggestion: match.suggestion, + encoded: match.encoded, }); lastEnd = match.end; } @@ -235,6 +308,9 @@ function parseSegments( // Default fallback color for mentions without a team color const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)'; const DEFAULT_MENTION_TEXT = '#60a5fa'; +const URL_BADGE_BG = 'rgba(30, 58, 138, 0.32)'; +const URL_BADGE_BORDER = 'rgba(96, 165, 250, 0.28)'; +const URL_BADGE_TEXT = '#f8fafc'; // --------------------------------------------------------------------------- // Component @@ -540,6 +616,8 @@ export const MentionableTextarea = React.forwardRef 0 || teamSuggestions.length > 0 || taskSuggestions.length > 0 || @@ -570,10 +648,18 @@ export const MentionableTextarea = React.forwardRef { + const boundary = findTaskReferenceMatches(value, taskSuggestions).find( + (match) => match.encoded && cursorPos >= match.start && cursorPos <= match.end + ); + return boundary ? { start: boundary.start, end: boundary.end } : null; + }, + [taskSuggestions, value] + ); + const handleChipKeyDown = React.useCallback( (e: React.KeyboardEvent) => { - if (chips.length === 0 || !onChipRemove) return; - const textarea = internalRef.current; if (!textarea) return; @@ -584,6 +670,17 @@ export const MentionableTextarea = React.forwardRef { + textarea.setSelectionRange(taskBoundary.start, taskBoundary.start); + }); + return; + } + if (chips.length === 0 || !onChipRemove) return; // If cursor is at chip end → delete entire chip const boundary = findChipBoundary(value, chips, cursorPos); if (cursorPos === boundary?.end) { @@ -597,6 +694,17 @@ export const MentionableTextarea = React.forwardRef { + textarea.setSelectionRange(taskBoundary.start, taskBoundary.start); + }); + return; + } + if (chips.length === 0 || !onChipRemove) return; // If cursor is at chip start → delete entire chip const boundary = findChipBoundary(value, chips, cursorPos); if (cursorPos === boundary?.start) { @@ -609,6 +717,13 @@ export const MentionableTextarea = React.forwardRef taskBoundary.start && + selectionStart < taskBoundary.end + ) { + const distToStart = selectionStart - taskBoundary.start; + const distToEnd = taskBoundary.end - selectionStart; + const snapTo = distToStart <= distToEnd ? taskBoundary.start : taskBoundary.end; + requestAnimationFrame(() => { + textarea.setSelectionRange(snapTo, snapTo); + }); + } }, - [mentionHandleSelect, chips, value] + [mentionHandleSelect, chips, value, findEncodedTaskBoundary] ); // --- Chip remove handler (from X button in interaction layer) --- @@ -804,13 +958,41 @@ export const MentionableTextarea = React.forwardRef {seg.value} ); } + if (seg.type === 'url') { + return ( + + + {seg.value} + + ); + } // mention (member or team) const isTeamMention = seg.suggestion.type === 'team'; const colorSet = seg.suggestion.color diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts index a5b8d277..77a53e40 100644 --- a/src/renderer/hooks/useTaskSuggestions.ts +++ b/src/renderer/hooks/useTaskSuggestions.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; +import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -36,7 +37,7 @@ function buildTaskSuggestion({ return { id: `task:${teamName}:${task.id}`, name: displayId, - insertText: displayId, + insertText: createEncodedTaskReference(displayId, task.id, teamName), subtitle: task.subject, color: teamColor, type: 'task', diff --git a/src/renderer/types/inlineChip.ts b/src/renderer/types/inlineChip.ts index fd65b5ae..b35caa64 100644 --- a/src/renderer/types/inlineChip.ts +++ b/src/renderer/types/inlineChip.ts @@ -6,6 +6,7 @@ */ import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction'; +import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; // ============================================================================= // Types @@ -97,9 +98,10 @@ export function chipToMarkdown(chip: InlineChip): string { * Replaces each chip token in the text with its markdown representation. */ export function serializeChipsWithText(text: string, chips: InlineChip[]): string { - if (chips.length === 0) return text; + const strippedText = stripEncodedTaskReferenceMetadata(text); + if (chips.length === 0) return strippedText; - let result = text; + let result = strippedText; for (const chip of chips) { const token = chipToken(chip); result = result.split(token).join(chipToMarkdown(chip)); diff --git a/src/renderer/utils/taskReferenceUtils.ts b/src/renderer/utils/taskReferenceUtils.ts index 06f3a663..ad81a311 100644 --- a/src/renderer/utils/taskReferenceUtils.ts +++ b/src/renderer/utils/taskReferenceUtils.ts @@ -3,6 +3,12 @@ import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions'; import type { MentionSuggestion } from '@renderer/types/mention'; const TASK_REF_REGEX = /#([A-Za-z0-9-]+)\b/g; +const TASK_META_START = '\u2063'; +const TASK_META_END = '\u2064'; +const ZERO_WIDTH_ALPHABET = ['\u200B', '\u200C', '\u200D', '\u2060'] as const; +const ZERO_WIDTH_TO_BITS = new Map( + ZERO_WIDTH_ALPHABET.map((char, index) => [char, index] as const) +); function isAllowedTaskRefBoundary(char: string | undefined): boolean { if (!char) return true; @@ -47,6 +53,113 @@ export interface TaskReferenceMatch { raw: string; ref: string; suggestion: MentionSuggestion; + encoded: boolean; +} + +interface EncodedTaskMetadata { + taskId: string; + teamName: string; + displayId: string; +} + +interface EncodedTaskMetadataMatch { + metadata: EncodedTaskMetadata; + end: number; +} + +function encodeZeroWidthPayload(value: string): string { + const bytes = new TextEncoder().encode(value); + let encoded = ''; + + for (const byte of bytes) { + encoded += ZERO_WIDTH_ALPHABET[(byte >> 6) & 0b11]; + encoded += ZERO_WIDTH_ALPHABET[(byte >> 4) & 0b11]; + encoded += ZERO_WIDTH_ALPHABET[(byte >> 2) & 0b11]; + encoded += ZERO_WIDTH_ALPHABET[byte & 0b11]; + } + + return encoded; +} + +function decodeZeroWidthPayload(value: string): string | null { + if (value.length % 4 !== 0) return null; + + const bytes = new Uint8Array(value.length / 4); + for (let i = 0; i < value.length; i += 4) { + const a = ZERO_WIDTH_TO_BITS.get(value.charAt(i) as (typeof ZERO_WIDTH_ALPHABET)[number]); + const b = ZERO_WIDTH_TO_BITS.get(value.charAt(i + 1) as (typeof ZERO_WIDTH_ALPHABET)[number]); + const c = ZERO_WIDTH_TO_BITS.get(value.charAt(i + 2) as (typeof ZERO_WIDTH_ALPHABET)[number]); + const d = ZERO_WIDTH_TO_BITS.get(value.charAt(i + 3) as (typeof ZERO_WIDTH_ALPHABET)[number]); + if (a == null || b == null || c == null || d == null) return null; + bytes[i / 4] = (a << 6) | (b << 4) | (c << 2) | d; + } + + try { + return new TextDecoder().decode(bytes); + } catch { + return null; + } +} + +function extractEncodedTaskMetadata( + text: string, + position: number +): EncodedTaskMetadataMatch | null { + if (text[position] !== TASK_META_START) return null; + + const end = text.indexOf(TASK_META_END, position + 1); + if (end === -1) return null; + + const encodedPayload = text.slice(position + 1, end); + const decodedPayload = decodeZeroWidthPayload(encodedPayload); + if (!decodedPayload) return null; + + try { + const parsed = JSON.parse(decodedPayload) as EncodedTaskMetadata; + if (!parsed.taskId || !parsed.teamName || !parsed.displayId) return null; + return { + metadata: parsed, + end: end + 1, + }; + } catch { + return null; + } +} + +function buildTaskSuggestionFromMetadata( + metadata: EncodedTaskMetadata, + taskSuggestions: MentionSuggestion[] +): MentionSuggestion { + return ( + taskSuggestions.find( + (suggestion) => + suggestion.type === 'task' && + suggestion.taskId === metadata.taskId && + suggestion.teamName === metadata.teamName + ) ?? { + id: `task:${metadata.teamName}:${metadata.taskId}`, + name: metadata.displayId, + type: 'task', + taskId: metadata.taskId, + teamName: metadata.teamName, + teamDisplayName: metadata.teamName, + } + ); +} + +export function createEncodedTaskReference( + displayId: string, + taskId: string, + teamName: string +): string { + const encodedPayload = encodeZeroWidthPayload( + JSON.stringify({ + displayId, + taskId, + teamName, + } satisfies EncodedTaskMetadata) + ); + return `#${displayId}${TASK_META_START}${encodedPayload}${TASK_META_END}`; } export function linkifyTaskIdsInMarkdown(text: string): string { @@ -56,6 +169,26 @@ export function linkifyTaskIdsInMarkdown(text: string): string { }); } +export function stripEncodedTaskReferenceMetadata(text: string): string { + if (!text.includes(TASK_META_START)) return text; + + let result = ''; + let cursor = 0; + while (cursor < text.length) { + const start = text.indexOf(TASK_META_START, cursor); + if (start === -1) { + result += text.slice(cursor); + break; + } + + result += text.slice(cursor, start); + const match = extractEncodedTaskMetadata(text, start); + cursor = match ? match.end : start + 1; + } + + return result; +} + export function findTaskReferenceMatches( text: string, taskSuggestions: MentionSuggestion[] @@ -76,15 +209,19 @@ export function findTaskReferenceMatches( const preceding = start > 0 ? text[start - 1] : undefined; if (!isAllowedTaskRefBoundary(preceding)) continue; - const suggestion = resolveTaskSuggestion(suggestionsByRef.get(ref.toLowerCase()) ?? []); + const metadataMatch = extractEncodedTaskMetadata(text, start + raw.length); + const suggestion = metadataMatch + ? buildTaskSuggestionFromMetadata(metadataMatch.metadata, taskSuggestions) + : resolveTaskSuggestion(suggestionsByRef.get(ref.toLowerCase()) ?? []); if (!suggestion) continue; matches.push({ start, - end: start + raw.length, + end: metadataMatch?.end ?? start + raw.length, raw, ref, suggestion, + encoded: metadataMatch != null, }); } From 6bcb81d337c62e3291bcf9f1b20c138d38cf8a41 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 15:14:19 +0200 Subject: [PATCH 08/72] feat: implement structured task references and enhance task handling - Introduced a new structured task reference format `{ taskId, displayId, teamName }` for consistent task mention persistence across UI and storage. - Enhanced message handling in various components to support the new task reference structure, including normalization and validation. - Updated task-related functions to accommodate optional task reference fields, improving task management and messaging capabilities. - Improved rendering and navigation of task references in the UI, ensuring stable links across messages and comments. - Refactored task reference utilities for better integration and usability within the application. --- CLAUDE.md | 10 +- .../src/internal/messageStore.js | 19 + agent-teams-controller/src/internal/review.js | 2 + .../src/internal/taskStore.js | 22 + agent-teams-controller/src/internal/tasks.js | 7 + src/main/ipc/crossTeam.ts | 44 +- src/main/ipc/teams.ts | 85 ++- src/main/services/team/CrossTeamService.ts | 5 +- src/main/services/team/TeamDataService.ts | 27 +- src/main/services/team/TeamInboxReader.ts | 1 + src/main/services/team/TeamInboxWriter.ts | 1 + .../services/team/TeamProvisioningService.ts | 2 + .../services/team/TeamSentMessagesStore.ts | 1 + src/main/services/team/TeamTaskReader.ts | 20 + src/main/workers/team-fs-worker.ts | 12 + src/preload/index.ts | 17 +- .../chat/viewers/MarkdownViewer.tsx | 9 +- .../components/layout/SortableTab.tsx | 15 +- src/renderer/components/layout/TabBar.tsx | 2 +- src/renderer/components/team/TaskTooltip.tsx | 7 +- .../components/team/TeamDetailView.tsx | 19 +- .../components/team/activity/ActivityItem.tsx | 136 ++-- .../team/activity/ActivityTimeline.tsx | 34 +- .../team/activity/LeadThoughtsGroup.tsx | 44 +- .../team/activity/ReplyQuoteBlock.tsx | 18 +- .../team/dialogs/AddMemberDialog.tsx | 2 +- .../team/dialogs/CreateTaskDialog.tsx | 32 +- .../team/dialogs/CreateTeamDialog.tsx | 4 +- .../team/dialogs/LaunchTeamDialog.tsx | 8 +- .../components/team/dialogs/ReviewDialog.tsx | 14 +- .../team/dialogs/SendMessageDialog.tsx | 23 +- .../team/dialogs/TaskCommentInput.tsx | 15 +- .../team/dialogs/TaskCommentsSection.tsx | 27 +- .../team/members/MemberDraftRow.tsx | 2 +- .../team/messages/MessageComposer.tsx | 597 +++++++++--------- .../team/messages/MessagesPanel.tsx | 15 +- .../components/ui/MentionableTextarea.tsx | 131 ++-- .../components/ui/UrlInteractionLayer.tsx | 102 +++ src/renderer/store/slices/teamSlice.ts | 9 +- src/renderer/utils/taskReferenceUtils.ts | 118 +++- src/renderer/utils/urlMatchUtils.ts | 44 ++ src/shared/types/api.ts | 4 +- src/shared/types/team.ts | 24 +- 43 files changed, 1212 insertions(+), 518 deletions(-) create mode 100644 src/renderer/components/ui/UrlInteractionLayer.tsx create mode 100644 src/renderer/utils/urlMatchUtils.ts diff --git a/CLAUDE.md b/CLAUDE.md index 61d3d26c..7be051f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,14 @@ Claude Code's "Orchestrate Teams" feature: multiple sessions coordinate as a tea - **Display summary** counts distinct teammates (by name) separately from regular subagents - **Team tools**: TeamCreate, TaskCreate, TaskUpdate, TaskList, TaskGet, SendMessage, TeamDelete — have readable summaries in `toolSummaryHelpers.ts` +### Structured Task References +- **TaskRef**: `{ taskId, displayId, teamName }` — shared typed reference used to persist task mentions across UI and storage +- **Persisted optional fields**: `InboxMessage.taskRefs`, `TaskComment.taskRefs`, `TeamTask.descriptionTaskRefs`, `TeamTask.promptTaskRefs` +- **Request surfaces**: `SendMessageRequest.taskRefs`, `AddTaskCommentRequest.taskRefs`, `CreateTaskRequest.descriptionTaskRefs`, `CreateTaskRequest.promptTaskRefs`, `UpdateKanbanPatch` `request_changes.taskRefs` +- **Renderer flow**: task-aware inputs use `useTaskSuggestions()` with `taskReferenceUtils.ts` to extract refs from text; encoded zero-width metadata preserves exact task identity while keeping visible text readable +- **Main/IPC flow**: `src/main/ipc/teams.ts` and `src/main/ipc/crossTeam.ts` validate structured refs before `TeamDataService`, inbox stores, task stores, and readers persist/rehydrate them +- **Rendering/navigation**: `linkifyTaskIdsInMarkdown()` and `parseTaskLinkHref()` turn persisted refs into stable `task://` links across messages, comments, task descriptions, and activity items + ### Visible Context Tracking Tracks what consumes tokens in Claude's context window across 6 categories (discriminated union on `category` field): @@ -139,7 +147,7 @@ Check for changes in message parsing or chunk building logic. | Services/Components | PascalCase | `ProjectScanner.ts` | | Utilities | camelCase | `pathDecoder.ts` | | Constants | UPPER_SNAKE_CASE | `PARALLEL_WINDOW_MS` | -| Type Guards | isXxx | `isRealUserMessage()` | +| Type Guards | isXxx | `isParsedRealUserMessage()` | | Builders | buildXxx | `buildChunks()` | | Getters | getXxx | `getResponses()` | diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index 5bc59bfb..5e549131 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -51,6 +51,23 @@ function normalizeAttachments(attachments) { return normalized.length > 0 ? normalized : undefined; } +function normalizeTaskRefs(taskRefs) { + if (!Array.isArray(taskRefs) || taskRefs.length === 0) { + return undefined; + } + + const normalized = taskRefs + .filter((item) => item && typeof item === 'object') + .map((item) => ({ + taskId: String(item.taskId || '').trim(), + displayId: String(item.displayId || '').trim(), + teamName: String(item.teamName || '').trim(), + })) + .filter((item) => item.taskId && item.displayId && item.teamName); + + return normalized.length > 0 ? normalized : undefined; +} + function buildMessage(flags, defaults) { const timestamp = typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso(); @@ -59,6 +76,7 @@ function buildMessage(flags, defaults) { ? flags.messageId.trim() : crypto.randomUUID(); const attachments = normalizeAttachments(flags.attachments); + const taskRefs = normalizeTaskRefs(flags.taskRefs); return { from: @@ -69,6 +87,7 @@ function buildMessage(flags, defaults) { text: String(flags.text || ''), timestamp, read: defaults.read, + ...(taskRefs ? { taskRefs } : {}), ...(typeof flags.summary === 'string' && flags.summary.trim() ? { summary: flags.summary.trim() } : {}), diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index a665f183..b2d86afe 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -185,6 +185,7 @@ function requestChanges(context, taskId, flags = {}) { text: comment, from, type: 'review_request', + ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), notifyOwner: false, }); messages.sendMessage(context, { @@ -193,6 +194,7 @@ function requestChanges(context, taskId, flags = {}) { text: `Task #${task.displayId || task.id} needs fixes.\n\n${comment}\n\n` + 'The task has been moved back to pending. When you are ready to resume, review the task context, start it explicitly, implement the fixes, mark it completed, and request review again.', + ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), summary: `Fix request for #${task.displayId || task.id}`, source: 'system_notification', ...(leadSessionId ? { leadSessionId } : {}), diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index f02216e9..c8c87abc 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -166,6 +166,23 @@ function parseRelationshipList(paths, value) { return rawValues.map((entry) => resolveTaskRef(paths, entry)); } +function normalizeTaskRefs(taskRefs) { + if (!Array.isArray(taskRefs) || taskRefs.length === 0) { + return undefined; + } + + const normalized = taskRefs + .filter((item) => item && typeof item === 'object') + .map((item) => ({ + taskId: String(item.taskId || '').trim(), + displayId: String(item.displayId || '').trim(), + teamName: String(item.teamName || '').trim(), + })) + .filter((item) => item.taskId && item.displayId && item.teamName); + + return normalized.length > 0 ? normalized : undefined; +} + function computeInitialStatus(paths, input, owner, blockedByIds) { const explicit = normalizeStatus(input.status); if (explicit) return explicit; @@ -270,6 +287,7 @@ function createTask(paths, input = {}) { typeof input.description === 'string' && input.description.length > 0 ? input.description : String(input.subject || '').trim(), + descriptionTaskRefs: normalizeTaskRefs(input.descriptionTaskRefs), activeForm: typeof input.activeForm === 'string' ? input.activeForm @@ -301,6 +319,9 @@ function createTask(paths, input = {}) { ? input.projectPath.trim() : undefined, comments: Array.isArray(input.comments) ? input.comments : undefined, + prompt: + typeof input.prompt === 'string' && input.prompt.trim() ? input.prompt.trim() : undefined, + promptTaskRefs: normalizeTaskRefs(input.promptTaskRefs), needsClarification: input.needsClarification === 'lead' || input.needsClarification === 'user' ? input.needsClarification @@ -434,6 +455,7 @@ function addTaskComment(paths, taskRef, text, options = {}) { ? options.createdAt.trim() : nowIso(), type: options.type || 'regular', + ...(normalizeTaskRefs(options.taskRefs) ? { taskRefs: normalizeTaskRefs(options.taskRefs) } : {}), ...(Array.isArray(options.attachments) && options.attachments.length > 0 ? { attachments: options.attachments } : {}), diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 67f61eab..47445b80 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -91,6 +91,7 @@ function maybeNotifyAssignedOwner(context, task, options = {}) { member: owner, from: sender, text: buildAssignmentMessage(context, task, options), + taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined, summary, source: 'system_notification', ...(leadSessionId ? { leadSessionId } : {}), @@ -123,6 +124,7 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) { member: owner, from: normalizeActorName(comment.author) || leadName, text: buildCommentNotificationMessage(context, task, comment), + taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined, summary: `Comment on #${task.displayId || task.id}`, source: 'system_notification', ...(leadSessionId ? { leadSessionId } : {}), @@ -135,6 +137,10 @@ function createTask(context, input) { maybeNotifyAssignedOwner(context, task, { description: input.description, prompt: input.prompt, + taskRefs: [ + ...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []), + ...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []), + ], from: input.from, }); } @@ -221,6 +227,7 @@ function addTaskComment(context, taskId, flags) { ...(flags.id ? { id: flags.id } : {}), ...(flags.createdAt ? { createdAt: flags.createdAt } : {}), ...(flags.type ? { type: flags.type } : {}), + ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), ...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}), }); diff --git a/src/main/ipc/crossTeam.ts b/src/main/ipc/crossTeam.ts index 03d57a25..11b0fff8 100644 --- a/src/main/ipc/crossTeam.ts +++ b/src/main/ipc/crossTeam.ts @@ -7,9 +7,10 @@ import { import { createLogger } from '@shared/utils/logger'; import { isAgentActionMode } from '../services/team/actionModeInstructions'; +import { validateTaskId, validateTeamName } from './guards'; import type { CrossTeamService } from '../services/team/CrossTeamService'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; -import type { IpcResult } from '@shared/types'; +import type { IpcResult, TaskRef } from '@shared/types'; const logger = createLogger('IPC:crossTeam'); @@ -19,6 +20,42 @@ export function initializeCrossTeamHandlers(service: CrossTeamService): void { crossTeamService = service; } +function validateTaskRefs( + value: unknown +): { valid: true; value: TaskRef[] | undefined } | { valid: false; error: string } { + if (value === undefined) { + return { valid: true, value: undefined }; + } + if (!Array.isArray(value)) { + return { valid: false, error: 'taskRefs must be an array' }; + } + + const taskRefs: TaskRef[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + return { valid: false, error: 'taskRefs entries must be objects' }; + } + const row = entry as Partial; + const taskId = typeof row.taskId === 'string' ? row.taskId.trim() : ''; + const displayId = typeof row.displayId === 'string' ? row.displayId.trim() : ''; + const teamName = typeof row.teamName === 'string' ? row.teamName.trim() : ''; + if (!taskId || !displayId || !teamName) { + return { valid: false, error: 'Each taskRef must include taskId, displayId, and teamName' }; + } + const vTaskId = validateTaskId(taskId); + if (!vTaskId.valid) { + return { valid: false, error: vTaskId.error ?? 'Invalid taskRef taskId' }; + } + const vTeamName = validateTeamName(teamName); + if (!vTeamName.valid) { + return { valid: false, error: vTeamName.error ?? 'Invalid taskRef teamName' }; + } + taskRefs.push({ taskId: vTaskId.value!, displayId, teamName: vTeamName.value! }); + } + + return { valid: true, value: taskRefs }; +} + function getService(): CrossTeamService { if (!crossTeamService) { throw new Error('CrossTeamService not initialized'); @@ -52,6 +89,10 @@ async function handleSend( if (req.actionMode !== undefined && !isAgentActionMode(req.actionMode)) { throw new Error('actionMode must be one of: do, ask, delegate'); } + const taskRefs = validateTaskRefs(req.taskRefs); + if (!taskRefs.valid) { + throw new Error(taskRefs.error); + } return getService().send({ fromTeam: String(req.fromTeam ?? ''), fromMember: String(req.fromMember ?? ''), @@ -60,6 +101,7 @@ async function handleSend( replyToConversationId: typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined, text: String(req.text ?? ''), + taskRefs: taskRefs.value, actionMode: isAgentActionMode(req.actionMode) ? req.actionMode : undefined, summary: typeof req.summary === 'string' ? req.summary : undefined, chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 772fc481..44566ab7 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -98,6 +98,7 @@ import type { TeamProvisioningService, } from '../services'; import type { + AddTaskCommentRequest, AgentActionMode, AttachmentFileData, AttachmentMeta, @@ -115,6 +116,7 @@ import type { SendMessageResult, TaskAttachmentMeta, TaskComment, + TaskRef, TeamClaudeLogsQuery, TeamClaudeLogsResponse, TeamConfig, @@ -927,12 +929,55 @@ function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch { } if (patch.op === 'request_changes') { - return patch.comment === undefined || typeof patch.comment === 'string'; + return ( + (patch.comment === undefined || typeof patch.comment === 'string') && + validateTaskRefs((patch as { taskRefs?: unknown }).taskRefs).valid + ); } return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved'); } +function validateTaskRefs( + value: unknown +): { valid: true; value: TaskRef[] | undefined } | { valid: false; error: string } { + if (value === undefined) { + return { valid: true, value: undefined }; + } + if (!Array.isArray(value)) { + return { valid: false, error: 'taskRefs must be an array' }; + } + + const taskRefs: TaskRef[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + return { valid: false, error: 'taskRefs entries must be objects' }; + } + const row = entry as Partial; + const taskId = typeof row.taskId === 'string' ? row.taskId.trim() : ''; + const displayId = typeof row.displayId === 'string' ? row.displayId.trim() : ''; + const teamName = typeof row.teamName === 'string' ? row.teamName.trim() : ''; + if (!taskId || !displayId || !teamName) { + return { valid: false, error: 'Each taskRef must include taskId, displayId, and teamName' }; + } + const validatedTaskId = validateTaskId(taskId); + if (!validatedTaskId.valid) { + return { valid: false, error: validatedTaskId.error ?? 'Invalid taskRef taskId' }; + } + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { valid: false, error: validatedTeamName.error ?? 'Invalid taskRef teamName' }; + } + taskRefs.push({ + taskId: validatedTaskId.value!, + displayId, + teamName: validatedTeamName.value!, + }); + } + + return { valid: true, value: taskRefs }; +} + async function handleGetAttachments( _event: IpcMainInvokeEvent, teamName: unknown, @@ -1068,6 +1113,10 @@ async function handleSendMessage( if (payload.actionMode !== undefined && !isAgentActionMode(payload.actionMode)) { return { success: false, error: 'actionMode must be one of: do, ask, delegate' }; } + const validatedTaskRefs = validateTaskRefs(payload.taskRefs); + if (!validatedTaskRefs.valid) { + return { success: false, error: validatedTaskRefs.error }; + } let validatedAttachments: AttachmentPayload[] | undefined; if ( @@ -1175,7 +1224,8 @@ async function handleSendMessage( resolvedLeadName, payload.text!, payload.summary, - attachmentMeta + attachmentMeta, + validatedTaskRefs.value ); } catch (persistError) { logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`); @@ -1199,6 +1249,7 @@ async function handleSendMessage( messageId: result.messageId, source: 'user_sent', attachments: attachmentMeta, + taskRefs: validatedTaskRefs.value, }); return result; @@ -1217,6 +1268,7 @@ async function handleSendMessage( summary: payload.summary, from: payload.from, source: 'user_sent', + taskRefs: validatedTaskRefs.value, }); // Best-effort live relay so active processes see the inbox row promptly. @@ -1265,6 +1317,10 @@ async function handleCreateTask( if (payload.description !== undefined && typeof payload.description !== 'string') { return { success: false, error: 'description must be string' }; } + const validatedDescriptionTaskRefs = validateTaskRefs(payload.descriptionTaskRefs); + if (!validatedDescriptionTaskRefs.valid) { + return { success: false, error: validatedDescriptionTaskRefs.error }; + } if (payload.owner !== undefined) { const validatedOwner = validateMemberName(payload.owner); if (!validatedOwner.valid) { @@ -1298,6 +1354,10 @@ async function handleCreateTask( return { success: false, error: 'prompt exceeds max length (5000)' }; } } + const validatedPromptTaskRefs = validateTaskRefs(payload.promptTaskRefs); + if (!validatedPromptTaskRefs.valid) { + return { success: false, error: validatedPromptTaskRefs.error }; + } if (payload.startImmediately !== undefined && typeof payload.startImmediately !== 'boolean') { return { success: false, error: 'startImmediately must be a boolean' }; } @@ -1309,7 +1369,9 @@ async function handleCreateTask( owner: payload.owner?.trim() || undefined, blockedBy: payload.blockedBy, related: payload.related, + descriptionTaskRefs: validatedDescriptionTaskRefs.value, prompt: payload.prompt?.trim() || undefined, + promptTaskRefs: validatedPromptTaskRefs.value, startImmediately: payload.startImmediately, }) ); @@ -2222,19 +2284,27 @@ async function handleAddTaskComment( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, - text: unknown, - attachments?: unknown + request: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; const vTask = validateTaskId(taskId); if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; + if (!request || typeof request !== 'object') { + return { success: false, error: 'Invalid add task comment request' }; + } + const payload = request as Partial; + const text = payload.text; if (typeof text !== 'string' || text.trim().length === 0) return { success: false, error: 'Comment text must be non-empty' }; if (text.trim().length > MAX_TEXT_LENGTH) return { success: false, error: `Comment exceeds ${MAX_TEXT_LENGTH} characters` }; + const validatedTaskRefs = validateTaskRefs(payload.taskRefs); + if (!validatedTaskRefs.valid) { + return { success: false, error: validatedTaskRefs.error }; + } - const rawAttachments = Array.isArray(attachments) ? attachments : []; + const rawAttachments = Array.isArray(payload.attachments) ? payload.attachments : []; if (rawAttachments.length > MAX_ATTACHMENTS) { return { success: false, error: `Maximum ${MAX_ATTACHMENTS} attachments per comment` }; } @@ -2248,7 +2318,7 @@ async function handleAddTaskComment( if (!att || typeof att !== 'object') { throw new Error('Invalid attachment data'); } - const a = att as Record; + const a = att as unknown as Record; if ( typeof a.id !== 'string' || typeof a.filename !== 'string' || @@ -2279,7 +2349,8 @@ async function handleAddTaskComment( vTeam.value!, vTask.value!, text.trim(), - savedAttachments + savedAttachments, + validatedTaskRefs.value ); }); } diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index cdc0e48b..2561ba93 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -46,7 +46,7 @@ export class CrossTeamService { ) {} async send(request: CrossTeamSendRequest): Promise { - const { fromTeam, fromMember, toTeam, text, summary, actionMode } = request; + const { fromTeam, fromMember, toTeam, text, taskRefs, summary, actionMode } = request; const chainDepth = request.chainDepth ?? 0; const messageId = request.messageId?.trim() || randomUUID(); const timestamp = request.timestamp ?? new Date().toISOString(); @@ -105,6 +105,7 @@ export class CrossTeamService { conversationId, replyToConversationId, text, + taskRefs, summary, chainDepth, timestamp, @@ -127,6 +128,7 @@ export class CrossTeamService { source: CROSS_TEAM_SOURCE, conversationId, replyToConversationId, + taskRefs, }); }); @@ -144,6 +146,7 @@ export class CrossTeamService { from: fromMember, to: `${toTeam}.${leadName}`, text, + taskRefs, timestamp, messageId, summary: summary ?? `Cross-team message to ${toTeam}`, diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index b25bd97d..7ffd317a 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -50,6 +50,7 @@ import type { SendMessageResult, TaskAttachmentMeta, TaskComment, + TaskRef, TeamConfig, TeamCreateConfigRequest, TeamData, @@ -803,12 +804,16 @@ export class TeamDataService { const task = controller.tasks.createTask({ subject: request.subject, ...(request.description?.trim() ? { description: request.description.trim() } : {}), + ...(request.descriptionTaskRefs?.length + ? { descriptionTaskRefs: request.descriptionTaskRefs } + : {}), ...(request.owner ? { owner: request.owner } : {}), ...(blockedBy.length > 0 ? { blockedBy } : {}), ...(related.length > 0 ? { related } : {}), ...(projectPath ? { projectPath } : {}), createdBy: 'user', ...(request.prompt?.trim() ? { prompt: request.prompt.trim() } : {}), + ...(request.promptTaskRefs?.length ? { promptTaskRefs: request.promptTaskRefs } : {}), ...(shouldStart ? { startImmediately: true } : {}), }) as TeamTask; @@ -847,6 +852,7 @@ export class TeamDataService { member: task.owner, from: leadName, text: parts.join('\n'), + taskRefs: task.descriptionTaskRefs, summary: `Task ${this.getTaskLabel(task)} started`, source: 'system_notification', }); @@ -992,13 +998,15 @@ export class TeamDataService { teamName: string, taskId: string, text: string, - attachments?: TaskAttachmentMeta[] + attachments?: TaskAttachmentMeta[], + taskRefs?: TaskRef[] ): Promise { const controller = this.getController(teamName); const addResult = controller.tasks.addTaskComment(taskId, { from: 'user', text, attachments, + taskRefs, }) as { task?: TeamTask; comment?: TaskComment }; const comment = addResult.comment ?? @@ -1008,6 +1016,7 @@ export class TeamDataService { text, createdAt: new Date().toISOString(), type: 'regular', + ...(taskRefs && taskRefs.length > 0 ? { taskRefs } : {}), ...(attachments && attachments.length > 0 ? { attachments } : {}), } as TaskComment); @@ -1031,6 +1040,15 @@ export class TeamDataService { member: enrichedRequest.member, from: enrichedRequest.from, text: enrichedRequest.text, + timestamp: enrichedRequest.timestamp, + messageId: enrichedRequest.messageId, + to: enrichedRequest.to, + color: enrichedRequest.color, + conversationId: enrichedRequest.conversationId, + replyToConversationId: enrichedRequest.replyToConversationId, + toolSummary: enrichedRequest.toolSummary, + toolCalls: enrichedRequest.toolCalls, + taskRefs: enrichedRequest.taskRefs, summary: enrichedRequest.summary, source: enrichedRequest.source, leadSessionId: enrichedRequest.leadSessionId, @@ -1078,7 +1096,8 @@ export class TeamDataService { leadName: string, text: string, summary?: string, - attachments?: AttachmentMeta[] + attachments?: AttachmentMeta[], + taskRefs?: TaskRef[] ): Promise { let leadSessionId: string | undefined; try { @@ -1092,6 +1111,7 @@ export class TeamDataService { from: 'user', to: leadName, text, + taskRefs, summary, source: 'user_sent', attachments: attachments?.length ? attachments : undefined, @@ -1462,6 +1482,9 @@ export class TeamDataService { controller.review.requestChanges(taskId, { from: 'user', comment: patch.comment?.trim() || 'Reviewer requested changes.', + ...(patch.op === 'request_changes' && patch.taskRefs?.length + ? { taskRefs: patch.taskRefs } + : {}), ...(leadSessionId ? { leadSessionId } : {}), }); } diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index b28216e5..c2c2497f 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -98,6 +98,7 @@ export class TeamInboxReader { text: row.text, timestamp: row.timestamp, read: typeof row.read === 'boolean' ? row.read : false, + taskRefs: Array.isArray(row.taskRefs) ? row.taskRefs : undefined, summary: typeof row.summary === 'string' ? row.summary : undefined, color: typeof row.color === 'string' ? row.color : undefined, messageId: row.messageId, diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index a82f2152..fa6368b1 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -27,6 +27,7 @@ export class TeamInboxWriter { text: request.text, timestamp: request.timestamp ?? new Date().toISOString(), read: false, + taskRefs: request.taskRefs?.length ? request.taskRefs : undefined, summary: request.summary, messageId, attachments: attachmentMeta?.length ? attachmentMeta : undefined, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 65465ae1..2211ed1c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1648,6 +1648,7 @@ export class TeamProvisioningService { leadSessionId: message.leadSessionId, conversationId: message.conversationId, replyToConversationId: message.replyToConversationId, + taskRefs: message.taskRefs, attachments: message.attachments, color: message.color, toolSummary: message.toolSummary, @@ -1674,6 +1675,7 @@ export class TeamProvisioningService { leadSessionId: message.leadSessionId, conversationId: message.conversationId, replyToConversationId: message.replyToConversationId, + taskRefs: message.taskRefs, attachments: message.attachments, color: message.color, toolSummary: message.toolSummary, diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index 10dece31..9716914d 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -72,6 +72,7 @@ export class TeamSentMessagesStore { text: row.text, timestamp: row.timestamp, read: typeof row.read === 'boolean' ? row.read : true, + taskRefs: Array.isArray(row.taskRefs) ? row.taskRefs : undefined, summary: typeof row.summary === 'string' ? row.summary : undefined, messageId: row.messageId, color: typeof row.color === 'string' ? row.color : undefined, diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 8ff5eaf2..e3e9e10e 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -13,6 +13,7 @@ import type { TaskAttachmentMeta, TaskComment, TaskHistoryEvent, + TaskRef, TaskWorkInterval, TeamTask, TeamTaskStatus, @@ -34,6 +35,21 @@ function isValidMimeTypeString(value: unknown): value is string { return true; } +function normalizeTaskRefs(value: unknown): TaskRef[] | undefined { + if (!Array.isArray(value)) return undefined; + const taskRefs = (value as unknown[]) + .filter( + (entry): entry is Record => Boolean(entry) && typeof entry === 'object' + ) + .map((entry) => ({ + taskId: typeof entry.taskId === 'string' ? entry.taskId : '', + displayId: typeof entry.displayId === 'string' ? entry.displayId : '', + teamName: typeof entry.teamName === 'string' ? entry.teamName : '', + })) + .filter((entry) => entry.taskId && entry.displayId && entry.teamName); + return taskRefs.length > 0 ? taskRefs : undefined; +} + export class TeamTaskReader { /** * Returns the next available numeric task ID by scanning ALL task files @@ -155,7 +171,10 @@ export class TeamTaskReader { ), subject, description: typeof parsed.description === 'string' ? parsed.description : undefined, + descriptionTaskRefs: normalizeTaskRefs(parsed.descriptionTaskRefs), activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined, + prompt: typeof parsed.prompt === 'string' ? parsed.prompt : undefined, + promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs), owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes( @@ -193,6 +212,7 @@ export class TeamTaskReader { type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type) ? c.type : ('regular' as const), + taskRefs: normalizeTaskRefs((c as unknown as Record).taskRefs), attachments: Array.isArray(c.attachments) ? (() => { const filtered = (c.attachments as unknown[]) diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index b67bfbd1..61324be3 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -106,7 +106,10 @@ interface ParsedTask { subject?: unknown; title?: unknown; description?: unknown; + descriptionTaskRefs?: unknown; activeForm?: unknown; + prompt?: unknown; + promptTaskRefs?: unknown; owner?: unknown; createdBy?: unknown; status?: unknown; @@ -143,6 +146,7 @@ interface RawComment { text?: unknown; createdAt?: unknown; type?: unknown; + taskRefs?: unknown; } // --------------------------------------------------------------------------- @@ -526,6 +530,7 @@ function normalizeComments(parsed: ParsedTask): unknown[] | undefined { author: c.author as string, text: c.text as string, createdAt: c.createdAt as string, + taskRefs: Array.isArray(c.taskRefs) ? c.taskRefs : undefined, type: c.type === 'regular' || c.type === 'review_request' || c.type === 'review_approved' ? (c.type as string) @@ -626,7 +631,14 @@ async function readTasksDirForTeam( ), subject, description: typeof parsed.description === 'string' ? parsed.description : undefined, + descriptionTaskRefs: Array.isArray(parsed.descriptionTaskRefs) + ? (parsed.descriptionTaskRefs as unknown[]) + : undefined, activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined, + prompt: typeof parsed.prompt === 'string' ? parsed.prompt : undefined, + promptTaskRefs: Array.isArray(parsed.promptTaskRefs) + ? (parsed.promptTaskRefs as unknown[]) + : undefined, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, status: diff --git a/src/preload/index.ts b/src/preload/index.ts index 6cb6f82a..2b49b8ad 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -195,6 +195,7 @@ import { import type { AddMemberRequest, + AddTaskCommentRequest, AgentChangeSet, AppConfig, ApplyReviewRequest, @@ -205,7 +206,6 @@ import type { ClaudeRootInfo, CliInstallationStatus, CliInstallerProgress, - CommentAttachmentPayload, ConflictCheckResult, ContextInfo, CreateScheduleInput, @@ -878,19 +878,8 @@ const electronAPI: ElectronAPI = { updateConfig: async (teamName: string, updates: TeamUpdateConfigRequest) => { return invokeIpcWithResult(TEAM_UPDATE_CONFIG, teamName, updates); }, - addTaskComment: async ( - teamName: string, - taskId: string, - text: string, - attachments?: CommentAttachmentPayload[] - ) => { - return invokeIpcWithResult( - TEAM_ADD_TASK_COMMENT, - teamName, - taskId, - text, - attachments - ); + addTaskComment: async (teamName: string, taskId: string, request: AddTaskCommentRequest) => { + return invokeIpcWithResult(TEAM_ADD_TASK_COMMENT, teamName, taskId, request); }, addMember: async (teamName: string, request: AddMemberRequest) => { return invokeIpcWithResult(TEAM_ADD_MEMBER, teamName, request); diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 171147f5..07f42f62 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -28,6 +28,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors' import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; +import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { FileText, UsersRound } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; @@ -269,9 +270,13 @@ function createViewerMarkdownComponents( ); } if (href?.startsWith('task://')) { - const taskId = href.slice('task://'.length); + const parsedTaskLink = parseTaskLinkHref(href); + const taskId = parsedTaskLink?.taskId; + if (!taskId) { + return <>{children}; + } return ( - + onTabClick(tab.id, e)} onMouseDown={(e) => onMouseDown(tab.id, e)} diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 25650a74..c1b99164 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -236,7 +236,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { return (
- +
) : null} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 58104332..50e4876f 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -79,7 +79,12 @@ import type { KanbanSortState } from './kanban/KanbanSortPopover'; import type { ContextInjection } from '@renderer/types/contextInjection'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; -import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { + MemberSpawnStatusEntry, + ResolvedTeamMember, + TaskRef, + TeamTaskWithKanban, +} from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; interface TeamDetailViewProps { @@ -796,7 +801,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele blockedBy?: string[], related?: string[], prompt?: string, - startImmediately?: boolean + startImmediately?: boolean, + descriptionTaskRefs?: TaskRef[], + promptTaskRefs?: TaskRef[] ): void => { setCreatingTask(true); void (async () => { @@ -808,6 +815,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele blockedBy, related, prompt, + descriptionTaskRefs, + promptTaskRefs, startImmediately, }); @@ -1567,7 +1576,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele taskId={requestChangesTaskId} members={data?.members ?? []} onCancel={() => setRequestChangesTaskId(null)} - onSubmit={(comment) => { + onSubmit={(comment, taskRefs) => { if (!requestChangesTaskId) { return; } @@ -1576,6 +1585,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele await updateKanban(teamName, requestChangesTaskId, { op: 'request_changes', comment, + taskRefs, }); setRequestChangesTaskId(null); } catch { @@ -1777,7 +1787,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele sending={sendingMessage} sendError={sendMessageError} lastResult={lastSendMessageResult} - onSend={(member, text, summary, attachments, actionMode) => { + onSend={(member, text, summary, attachments, actionMode, taskRefs) => { void (async () => { const sentAtMs = Date.now(); setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); @@ -1788,6 +1798,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele summary, attachments, actionMode, + taskRefs, }); } catch { setPendingRepliesByMember((prev) => { diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 34984506..85dd7186 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -1,6 +1,7 @@ import { Fragment, useMemo } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { CopyButton } from '@renderer/components/common/CopyButton'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; @@ -24,7 +25,7 @@ import { } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; -import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; +import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { CROSS_TEAM_SENT_SOURCE, @@ -129,6 +130,8 @@ interface ActivityItemProps { zebraShade?: boolean; /** Explicit collapse state for timeline-controlled collapsed mode. */ collapseState?: ActivityCollapseState; + /** Compact header mode for narrow message lists. */ + compactHeader?: boolean; } function getStringField(obj: StructuredMessage, key: string): string | null { @@ -297,6 +300,7 @@ export const ActivityItem = ({ onRestartTeam, zebraShade, collapseState, + compactHeader = false, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const { isLight } = useTheme(); @@ -399,7 +403,7 @@ export const ActivityItem = ({ const displayText = useMemo(() => { if (!strippedText) return null; let result = highlightSystemLabels(strippedText, !!systemLabel); - result = linkifyTaskIdsInMarkdown(result); + result = linkifyTaskIdsInMarkdown(result, message.taskRefs); if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames); return result; @@ -435,7 +439,7 @@ export const ActivityItem = ({ }; const isHeaderClickable = isManaged ? collapseState.canToggle : false; - const showChevron = isHeaderClickable; + const showChevron = isHeaderClickable && !compactHeader; const isUserSent = message.source === 'user_sent' || isCrossTeamSent; const isSystemMessage = message.from === 'system'; const onManagedToggle = isManaged ? collapseState.onToggle : undefined; @@ -518,13 +522,13 @@ export const ActivityItem = ({ {/* Role */} - {formattedRole ? ( + {!compactHeader && formattedRole ? ( {formattedRole} @@ -580,8 +584,9 @@ export const ActivityItem = ({ name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to} color={crossTeamTarget ? undefined : recipientColor} hideAvatar={ + compactHeader || (crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to) === - 'user' + 'user' } onClick={onMemberNameClick} disableHoverCard={crossTeamTarget != null} @@ -595,44 +600,8 @@ export const ActivityItem = ({ {onTaskIdClick ? linkifyTaskIds(summaryText, onTaskIdClick) : summaryText} - {/* Timestamp + reply + create task */} + {/* Timestamp */}
- {onReply && ( - - - - - Reply to message - - )} - {onCreateTask && ( - - - - - Create task from message - - )} {timestamp} @@ -660,29 +629,72 @@ export const ActivityItem = ({ ) : displayText ? ( - - { - const link = (e.target as HTMLElement).closest( - 'a[href^="task://"]' - ); - if (link) { - e.preventDefault(); +
+
+ {onReply ? ( + + + + + Reply to message + + ) : null} + {onCreateTask ? ( + + + + + Create task from message + + ) : null} + +
+ + { + const link = (e.target as HTMLElement).closest( + 'a[href^="task://"]' + ); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const href = link.getAttribute('href'); + const parsedTaskLink = href ? parseTaskLinkHref(href) : null; + if (parsedTaskLink?.taskId) onTaskIdClick(parsedTaskLink.taskId); + } } - } - : undefined - } - > - - - + : undefined + } + > + + + +
) : summaryText ? (

{summaryText} diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index a40c1b33..689707d9 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -56,6 +56,7 @@ interface ActivityTimelineProps { const VIEWPORT_THRESHOLD = 0.15; const MESSAGES_PAGE_SIZE = 30; +const COMPACT_MESSAGES_WIDTH_PX = 400; /** Inline compaction boundary divider — styled like session separators but with amber accent. */ const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => ( @@ -98,6 +99,7 @@ const MessageRowWithObserver = ({ onTaskIdClick, onRestartTeam, collapseState, + compactHeader, }: { message: InboxMessage; teamName: string; @@ -116,6 +118,7 @@ const MessageRowWithObserver = ({ onTaskIdClick?: (taskId: string) => void; onRestartTeam?: () => void; collapseState?: ActivityCollapseState; + compactHeader?: boolean; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -165,6 +168,7 @@ const MessageRowWithObserver = ({ onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} collapseState={collapseState} + compactHeader={compactHeader} /> ); @@ -188,6 +192,31 @@ export const ActivityTimeline = ({ currentLeadSessionId, }: ActivityTimelineProps): React.JSX.Element => { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); + const rootRef = useRef(null); + const [compactHeader, setCompactHeader] = useState(false); + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + + const updateCompactMode = (width: number): void => { + setCompactHeader((prev) => { + const next = width < COMPACT_MESSAGES_WIDTH_PX; + return prev === next ? prev : next; + }); + }; + + updateCompactMode(el.getBoundingClientRect().width); + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + updateCompactMode(entry.contentRect.width); + }); + + observer.observe(el); + return () => observer.disconnect(); + }, []); const colorMap = members ? buildMemberColorMap(members) : new Map(); const localMemberNames = new Set((members ?? []).map((member) => member.name.trim())); @@ -357,7 +386,7 @@ export const ActivityTimeline = ({ } return ( -

+
{/* Pinned (newest) thought group — always at top */} {pinnedThoughtGroup && (() => { @@ -380,6 +409,7 @@ export const ActivityTimeline = ({ onTaskIdClick={onTaskIdClick} memberColorMap={colorMap} onReply={onReplyToMessage} + compactHeader={compactHeader} /> ); })()} @@ -440,6 +470,7 @@ export const ActivityTimeline = ({ onTaskIdClick={onTaskIdClick} memberColorMap={colorMap} onReply={onReplyToMessage} + compactHeader={compactHeader} /> ); @@ -489,6 +520,7 @@ export const ActivityTimeline = ({ onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} collapseState={collapseState} + compactHeader={compactHeader} /> ); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 10db9c8f..ac8853ca 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -15,7 +15,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; -import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; +import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; @@ -126,6 +126,8 @@ interface LeadThoughtsGroupRowProps { memberColorMap?: Map; /** Called when user clicks the reply button on a thought. */ onReply?: (message: InboxMessage) => void; + /** Compact header mode for narrow message lists. */ + compactHeader?: boolean; } function formatTime(timestamp: string): string { @@ -237,7 +239,7 @@ const LeadThoughtItem = ({ const displayContent = useMemo(() => { let text = thought.text.replace(/\n/g, ' \n'); - text = linkifyTaskIdsInMarkdown(text); + text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { text = linkifyAllMentionsInMarkdown(text, memberColorMap ?? new Map(), teamNames); } @@ -393,8 +395,9 @@ const LeadThoughtItem = ({ if (link) { e.preventDefault(); e.stopPropagation(); - const taskId = link.getAttribute('href')?.replace('task://', ''); - if (taskId) onTaskIdClick(taskId); + const href = link.getAttribute('href'); + const parsedTaskLink = href ? parseTaskLinkHref(href) : null; + if (parsedTaskLink?.taskId) onTaskIdClick(parsedTaskLink.taskId); } } : undefined @@ -462,6 +465,7 @@ export const LeadThoughtsGroupRow = ({ onTaskIdClick, memberColorMap, onReply, + compactHeader = false, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); @@ -725,7 +729,7 @@ export const LeadThoughtsGroupRow = ({ } > {/* Chevron for collapse mode */} - {canToggleBodyVisibility ? ( + {canToggleBodyVisibility && !compactHeader ? ( ) : null} {/* Lead avatar with optional live indicator */} -
- - {isLive ? ( - - - - - ) : null} -
+ {!compactHeader ? ( +
+ + {isLive ? ( + + + + + ) : null} +
+ ) : null} {thoughts.length} thoughts diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx index 64162b29..208291a2 100644 --- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx +++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx @@ -2,8 +2,10 @@ import { useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting'; +import type { TaskRef } from '@shared/types'; interface ReplyQuoteBlockProps { reply: ParsedMessageReply; @@ -11,6 +13,8 @@ interface ReplyQuoteBlockProps { memberColor?: string; /** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */ bodyMaxHeight?: string; + /** Structured task refs for the reply body, when available. */ + replyTaskRefs?: TaskRef[]; } /** Threshold (characters) above which the "more/less" toggle is shown. */ @@ -20,6 +24,7 @@ export const ReplyQuoteBlock = ({ reply, memberColor, bodyMaxHeight = 'max-h-56', + replyTaskRefs, }: ReplyQuoteBlockProps): React.JSX.Element => { const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD; const [expanded, setExpanded] = useState(false); @@ -43,7 +48,11 @@ export const ReplyQuoteBlock = ({ {/* Quote text */}
- +
{/* More/less toggle */} @@ -59,7 +68,12 @@ export const ReplyQuoteBlock = ({
{/* Reply text */} - +
); }; diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index a53d9a9f..28bd4753 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -180,7 +180,7 @@ export const AddMemberDialog = ({ placeholder="How this agent should behave, what tasks it handles..." footerRight={ workflowDraft.isSaved ? ( - Draft saved + Saved ) : null } /> diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 95958717..4b222685 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -30,14 +30,17 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; +import { + extractTaskRefsFromText, + stripEncodedTaskReferenceMetadata, +} from '@renderer/utils/taskReferenceUtils'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, Search } from 'lucide-react'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types'; interface CreateTaskDialogProps { open: boolean; @@ -58,7 +61,9 @@ interface CreateTaskDialogProps { blockedBy?: string[], related?: string[], prompt?: string, - startImmediately?: boolean + startImmediately?: boolean, + descriptionTaskRefs?: TaskRef[], + promptTaskRefs?: TaskRef[] ) => void; submitting?: boolean; } @@ -175,18 +180,23 @@ export const CreateTaskDialog = ({ const handleSubmit = (): void => { if (!canSubmit) return; - const serializedDesc = serializeChipsWithText( - descriptionDraft.value.trim(), - descChipDraft.chips - ); + const trimmedDescription = stripEncodedTaskReferenceMetadata(descriptionDraft.value.trim()); + const trimmedPrompt = stripEncodedTaskReferenceMetadata(promptDraft.value.trim()); + const serializedDesc = serializeChipsWithText(trimmedDescription, descChipDraft.chips); + const descriptionTaskRefs = extractTaskRefsFromText(descriptionDraft.value, taskSuggestions); + const promptTaskRefs = trimmedPrompt + ? extractTaskRefsFromText(promptDraft.value, taskSuggestions) + : []; onSubmit( subject.trim(), serializedDesc, owner || undefined, blockedBy.length > 0 ? blockedBy : undefined, related.length > 0 ? related : undefined, - stripEncodedTaskReferenceMetadata(promptDraft.value.trim()) || undefined, - startImmediately + trimmedPrompt || undefined, + startImmediately, + descriptionTaskRefs, + promptTaskRefs ); descriptionDraft.clearDraft(); descChipDraft.clearChipDraft(); @@ -303,7 +313,7 @@ export const CreateTaskDialog = ({ maxRows={12} footerRight={ descriptionDraft.isSaved ? ( - Draft saved + Saved ) : null } /> @@ -325,7 +335,7 @@ export const CreateTaskDialog = ({ maxRows={12} footerRight={ promptDraft.isSaved ? ( - Draft saved + Saved ) : null } /> diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 6bd633bd..ea078c71 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -912,7 +912,7 @@ export const CreateTeamDialog = ({ footerRight={ promptDraft.isSaved ? ( - Draft saved + Saved ) : null } @@ -980,7 +980,7 @@ export const CreateTeamDialog = ({ placeholder="Brief description of the team purpose" /> {descriptionDraft.isSaved ? ( - Draft saved + Saved ) : null}
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 7317a386..d632279f 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -926,9 +926,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen placeholder="Instructions for team lead..." footerRight={ promptDraft.isSaved ? ( - - Draft saved - + Saved ) : null } /> @@ -1025,9 +1023,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen placeholder="Instructions for Claude to execute on schedule..." footerRight={ promptDraft.isSaved ? ( - - Draft saved - + Saved ) : null } /> diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx index 9781ad4e..ad9c6d4e 100644 --- a/src/renderer/components/team/dialogs/ReviewDialog.tsx +++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx @@ -13,13 +13,16 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; +import { + extractTaskRefsFromText, + stripEncodedTaskReferenceMetadata, +} from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import { Send } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { ResolvedTeamMember } from '@shared/types'; +import type { ResolvedTeamMember, TaskRef } from '@shared/types'; interface ReviewDialogProps { open: boolean; @@ -27,7 +30,7 @@ interface ReviewDialogProps { taskId: string | null; members: ResolvedTeamMember[]; onCancel: () => void; - onSubmit: (comment?: string) => void; + onSubmit: (comment?: string, taskRefs?: TaskRef[]) => void; } export const ReviewDialog = ({ @@ -62,8 +65,9 @@ export const ReviewDialog = ({ const handleSubmit = (): void => { const comment = stripEncodedTaskReferenceMetadata(trimmed) || undefined; + const taskRefs = trimmed ? extractTaskRefsFromText(draft.value, taskSuggestions) : []; draft.clearDraft(); - onSubmit(comment); + onSubmit(comment, taskRefs); }; return ( @@ -114,7 +118,7 @@ export const ReviewDialog = ({ ) : null} {draft.isSaved ? ( - Draft saved + Saved ) : null}
} diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 2ee40a73..6730b1ff 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -26,7 +26,10 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; +import { + extractTaskRefsFromText, + stripEncodedTaskReferenceMetadata, +} from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; @@ -35,7 +38,12 @@ import { MemberBadge } from '../MemberBadge'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types'; +import type { + AttachmentPayload, + ResolvedTeamMember, + SendMessageResult, + TaskRef, +} from '@shared/types'; interface QuotedMessage { from: string; @@ -61,7 +69,8 @@ interface SendMessageDialogProps { text: string, summary?: string, attachments?: AttachmentPayload[], - actionMode?: ActionMode + actionMode?: ActionMode, + taskRefs?: TaskRef[] ) => void; onClose: () => void; } @@ -237,12 +246,14 @@ export const SendMessageDialog = ({ const handleSubmit = (): void => { if (!canSend) return; + const taskRefs = extractTaskRefsFromText(textDraft.value, taskSuggestions); onSend( member.trim(), finalText, trimmedText, attachments.length > 0 ? attachments : undefined, - actionMode + actionMode, + taskRefs ); textDraft.clearDraft(); chipDraft.clearChipDraft(); @@ -512,9 +523,7 @@ export const SendMessageDialog = ({ ) : null} {textDraft.isSaved ? ( - - Draft saved - + Saved ) : null}
} diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index 613ebda0..024731c7 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -12,7 +12,10 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; -import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; +import { + extractTaskRefsFromText, + stripEncodedTaskReferenceMetadata, +} from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; @@ -132,6 +135,7 @@ export const TaskCommentInput = ({ const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, serialized || '(image)') : serialized || '(image)'; + const taskRefs = extractTaskRefsFromText(draft.value, taskSuggestions); const attachments: CommentAttachmentPayload[] | undefined = pendingAttachments.length > 0 ? pendingAttachments.map((a) => ({ @@ -141,7 +145,11 @@ export const TaskCommentInput = ({ base64Data: a.base64Data, })) : undefined; - await addTaskComment(teamName, taskId, text, attachments); + await addTaskComment(teamName, taskId, { + text, + attachments, + taskRefs, + }); draft.clearDraft(); chipDraft.clearChipDraft(); setPendingAttachments([]); @@ -161,6 +169,7 @@ export const TaskCommentInput = ({ replyTo, onClearReply, pendingAttachments, + taskSuggestions, ]); // Handle paste from MentionableTextarea area @@ -340,7 +349,7 @@ export const TaskCommentInput = ({ ) : null} {draft.isSaved ? ( - Draft saved + Saved ) : null}
} diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 91106df6..949f2e12 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -23,7 +23,9 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { + extractTaskRefsFromText, linkifyTaskIdsInMarkdown, + parseTaskLinkHref, stripEncodedTaskReferenceMetadata, } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; @@ -160,14 +162,25 @@ export const TaskCommentsSection = ({ try { const serialized = serializeChipsWithText(trimmed, chipDraft.chips); const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, serialized) : serialized; - await addTaskComment(teamName, taskId, text); + const taskRefs = extractTaskRefsFromText(draft.value, taskSuggestions); + await addTaskComment(teamName, taskId, { text, taskRefs }); draft.clearDraft(); chipDraft.clearChipDraft(); setReplyTo(null); } catch { // Error is stored in addCommentError via store } - }, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, chipDraft, replyTo]); + }, [ + canSubmit, + addTaskComment, + teamName, + taskId, + trimmed, + draft, + chipDraft, + replyTo, + taskSuggestions, + ]); return (
@@ -281,6 +294,7 @@ export const TaskCommentsSection = ({ replyText: stripAgentBlocks(reply.replyText), }} memberColor={colorMap.get(reply.agentName)} + replyTaskRefs={comment.taskRefs} bodyMaxHeight="max-h-none" /> ) : ( @@ -294,8 +308,9 @@ export const TaskCommentsSection = ({ if (link) { e.preventDefault(); e.stopPropagation(); - const id = link.getAttribute('href')?.replace('task://', ''); - if (id) onTaskIdClick(id); + const href = link.getAttribute('href'); + const parsed = href ? parseTaskLinkHref(href) : null; + if (parsed?.taskId) onTaskIdClick(parsed.taskId); } } : undefined @@ -303,7 +318,7 @@ export const TaskCommentsSection = ({ > { - let t = linkifyTaskIdsInMarkdown(displayText); + let t = linkifyTaskIdsInMarkdown(displayText, comment.taskRefs); if (colorMap.size > 0 || teamNamesForLinkify.length > 0) t = linkifyAllMentionsInMarkdown( t, @@ -426,7 +441,7 @@ export const TaskCommentsSection = ({ ) : null} {draft.isSaved ? ( - Draft saved + Saved ) : null}
} diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 7281dbb9..c0203880 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -222,7 +222,7 @@ export const MemberDraftRow = ({ placeholder="How this agent should behave, interact with others..." footerRight={ workflowDraft.isSaved ? ( - Draft saved + Saved ) : null } /> diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index b1975755..662e6c8d 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -17,13 +17,21 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { nameColorSet } from '@renderer/utils/projectColor'; -import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; +import { + extractTaskRefsFromText, + stripEncodedTaskReferenceMetadata, +} from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; -import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types'; +import type { + AttachmentPayload, + ResolvedTeamMember, + SendMessageResult, + TaskRef, +} from '@shared/types'; interface MessageComposerProps { teamName: string; @@ -37,13 +45,15 @@ interface MessageComposerProps { text: string, summary?: string, attachments?: AttachmentPayload[], - actionMode?: ActionMode + actionMode?: ActionMode, + taskRefs?: TaskRef[] ) => void; onCrossTeamSend?: ( toTeam: string, text: string, summary?: string, - actionMode?: ActionMode + actionMode?: ActionMode, + taskRefs?: TaskRef[] ) => void; } @@ -202,9 +212,10 @@ export const MessageComposer = ({ const handleSend = useCallback(() => { if (!canSend) return; pendingSendRef.current = true; + const taskRefs = extractTaskRefsFromText(draft.text, taskSuggestions); const serialized = serializeChipsWithText(trimmed, draft.chips); if (isCrossTeam && selectedTeam && onCrossTeamSend) { - onCrossTeamSend(selectedTeam, serialized, trimmed, actionMode); + onCrossTeamSend(selectedTeam, serialized, trimmed, actionMode, taskRefs); } else { // Summary should stay compact (no expanded chip markdown) onSend( @@ -212,7 +223,8 @@ export const MessageComposer = ({ serialized, trimmed, draft.attachments.length > 0 ? draft.attachments : undefined, - actionMode + actionMode, + taskRefs ); } }, [ @@ -226,6 +238,7 @@ export const MessageComposer = ({ selectedTeam, draft.attachments, draft.chips, + taskSuggestions, ]); // Clear draft only after send completes successfully (sending: true → false, no error) @@ -323,10 +336,12 @@ export const MessageComposer = ({ ); const remaining = MAX_TEXT_LENGTH - trimmed.length; + const hasAttachmentPreviewContent = + draft.attachments.length > 0 || Boolean(draft.attachmentError ?? imageRestrictionError); return (
-
- {isLeadRecipient ? ( - <> - - - - - - - {!isTeamAlive - ? 'Team must be online to attach images' - : !draft.canAddMore - ? 'Maximum attachments reached' - : 'Attach images (paste or drag & drop)'} - - -
- +
+ {isLeadRecipient ? ( + <> + -
- - ) : ( - - )} - -
- {!isTeamAlive && !isProvisioning && ( - - Team offline - - )} - - {/* Combined team + member selector */} - {crossTeamTargets.length > 0 ? ( -
- - + + + + + {!isTeamAlive + ? 'Team must be online to attach images' + : !draft.canAddMore + ? 'Maximum attachments reached' + : 'Attach images (paste or drag & drop)'} + + + + ) : null} + +
+ {!isTeamAlive && !isProvisioning && ( + + Team offline + + )} + + {/* Combined team + member selector */} + {crossTeamTargets.length > 0 ? ( +
+ + + + + +
+ {/* Current team option */} + - - -
- {/* Current team option */} + This team + + current + + {!isCrossTeam ? ( + + ) : null} + + + {/* Separator */} +
+ + {/* Other teams */} + {crossTeamTargets.map((target) => { + const isSelected = selectedTeam === target.teamName; + return ( + + ); + })} +
+ + + + + - - {/* Separator */} -
- - {/* Other teams */} - {crossTeamTargets.map((target) => { - const isSelected = selectedTeam === target.teamName; - return ( - - ); - })} -
- -
- - + ); + } + const sorted = [...filtered].sort((a, b) => { + const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0; + const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0; + return bIsLead - aIsLead; + }); + return sorted.map((m) => { + const resolvedColor = colorMap.get(m.name); + const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); + const isSelected = m.name === recipient; + return ( + + ); + }); + })()} +
+
+ +
+ ) : ( +
- ) : ( - - - - - { - e.preventDefault(); - setRecipientSearch(''); - setTimeout(() => recipientSearchRef.current?.focus(), 0); - }} - > - {members.length > 5 && ( -
- - setRecipientSearch(e.target.value)} - /> -
- )} -
- {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */} - {(() => { - const query = recipientSearch.toLowerCase().trim(); - const filtered = query - ? members.filter((m) => m.name.toLowerCase().includes(query)) - : members; - if (filtered.length === 0) { - return ( -
- No results -
- ); - } - const sorted = [...filtered].sort((a, b) => { - const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0; - const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0; - return bIsLead - aIsLead; - }); - return sorted.map((m) => { - const resolvedColor = colorMap.get(m.name); - const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); - const isSelected = m.name === recipient; - return ( - - ); - }); - })()} -
-
-
- )} + )} +
+ + {hasAttachmentPreviewContent ? ( + + ) : null}
) : null} {draft.isSaved ? ( - Draft saved + Saved ) : null}
} diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 783d4e92..9117f27c 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -32,7 +32,7 @@ import { MessagesFilterPopover } from './MessagesFilterPopover'; import type { MessagesFilterState } from './MessagesFilterPopover'; import type { ActionMode } from './ActionModeSelector'; -import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { InboxMessage, ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types'; interface TimeWindow { start: number; @@ -188,7 +188,8 @@ export const MessagesPanel = ({ attachments?: Parameters[1] extends { attachments?: infer A } ? A : never, - actionMode?: ActionMode + actionMode?: ActionMode, + taskRefs?: TaskRef[] ) => { const sentAtMs = Date.now(); onPendingReplyChange((prev) => ({ ...prev, [member]: sentAtMs })); @@ -198,6 +199,7 @@ export const MessagesPanel = ({ summary, attachments, actionMode, + taskRefs, }).catch(() => { onPendingReplyChange((prev) => { if (prev[member] !== sentAtMs) return prev; @@ -211,12 +213,19 @@ export const MessagesPanel = ({ ); const handleCrossTeamSend = useCallback( - (toTeam: string, text: string, summary?: string, actionMode?: ActionMode) => { + ( + toTeam: string, + text: string, + summary?: string, + actionMode?: ActionMode, + taskRefs?: TaskRef[] + ) => { void sendCrossTeamMessage({ fromTeam: teamName, fromMember: 'user', toTeam, text, + taskRefs, actionMode, summary, }); diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index d5525093..98ba46db 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -19,13 +19,18 @@ import { reconcileChips, removeChipTokenFromText, } from '@renderer/utils/chipUtils'; -import { Link2 } from 'lucide-react'; +import { + findUrlBoundary, + findUrlMatches, + removeUrlMatchFromText, +} from '@renderer/utils/urlMatchUtils'; import { AutoResizeTextarea } from './auto-resize-textarea'; import { ChipInteractionLayer } from './ChipInteractionLayer'; import { CodeChipBadge } from './CodeChipBadge'; import { MentionSuggestionList } from './MentionSuggestionList'; import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer'; +import { UrlInteractionLayer } from './UrlInteractionLayer'; import type { AutoResizeTextareaProps } from './auto-resize-textarea'; import type { InlineChip } from '@renderer/types/inlineChip'; @@ -66,40 +71,6 @@ interface ChipSegment { type Segment = TextSegment | MentionSegment | TaskSegment | UrlSegment | ChipSegment; -interface TextMatch { - start: number; - end: number; - value: string; -} - -const URL_REGEX = /https?:\/\/[^\s]+/g; - -function trimUrlMatch(rawUrl: string): string { - return rawUrl.replace(/[),.!?;:]+$/g, ''); -} - -function findUrlMatches(text: string): TextMatch[] { - if (!text) return []; - - const matches: TextMatch[] = []; - for (const match of text.matchAll(URL_REGEX)) { - const rawValue = match[0]; - const start = match.index ?? -1; - if (start < 0) continue; - - const trimmedValue = trimUrlMatch(rawValue); - if (!trimmedValue) continue; - - matches.push({ - start, - end: start + trimmedValue.length, - value: trimmedValue, - }); - } - - return matches; -} - // --------------------------------------------------------------------------- // Mention segment parsing (splits text into plain text + @mention segments) // --------------------------------------------------------------------------- @@ -308,9 +279,9 @@ function parseSegments( // Default fallback color for mentions without a team color const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)'; const DEFAULT_MENTION_TEXT = '#60a5fa'; -const URL_BADGE_BG = 'rgba(30, 58, 138, 0.32)'; -const URL_BADGE_BORDER = 'rgba(96, 165, 250, 0.28)'; -const URL_BADGE_TEXT = '#f8fafc'; +const URL_BADGE_BG = 'rgba(37, 99, 235, 0.12)'; +const URL_BADGE_BORDER = 'rgba(96, 165, 250, 0.22)'; +const URL_BADGE_TEXT = '#bfdbfe'; // --------------------------------------------------------------------------- // Component @@ -325,7 +296,7 @@ interface MentionableTextareaProps extends Omit< suggestions: MentionSuggestion[]; hintText?: string; showHint?: boolean; - /** Content rendered at the right side of the footer row (e.g. "Draft saved") */ + /** Content rendered at the right side of the footer row (e.g. "Saved") */ footerRight?: React.ReactNode; /** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */ cornerAction?: React.ReactNode; @@ -658,6 +629,11 @@ export const MentionableTextarea = React.forwardRef findUrlBoundary(value, cursorPos), + [value] + ); + const handleChipKeyDown = React.useCallback( (e: React.KeyboardEvent) => { const textarea = internalRef.current; @@ -670,6 +646,16 @@ export const MentionableTextarea = React.forwardRef { + textarea.setSelectionRange(urlBoundary.start, urlBoundary.start); + }); + return; + } const taskBoundary = findEncodedTaskBoundary(cursorPos); if (taskBoundary && cursorPos === taskBoundary.end) { e.preventDefault(); @@ -694,6 +680,16 @@ export const MentionableTextarea = React.forwardRef { + textarea.setSelectionRange(urlBoundary.start, urlBoundary.start); + }); + return; + } const taskBoundary = findEncodedTaskBoundary(cursorPos); if (taskBoundary && cursorPos === taskBoundary.start) { e.preventDefault(); @@ -717,6 +713,12 @@ export const MentionableTextarea = React.forwardRef { textarea.setSelectionRange(snapTo, snapTo); }); + return; + } + + const urlBoundary = findUrlTokenBoundary(selectionStart); + if (urlBoundary && selectionStart > urlBoundary.start && selectionStart < urlBoundary.end) { + const distToStart = selectionStart - urlBoundary.start; + const distToEnd = urlBoundary.end - selectionStart; + const snapTo = distToStart <= distToEnd ? urlBoundary.start : urlBoundary.end; + requestAnimationFrame(() => { + textarea.setSelectionRange(snapTo, snapTo); + }); } }, - [mentionHandleSelect, chips, value, findEncodedTaskBoundary] + [mentionHandleSelect, chips, value, findEncodedTaskBoundary, findUrlTokenBoundary] ); // --- Chip remove handler (from X button in interaction layer) --- @@ -981,14 +1012,13 @@ export const MentionableTextarea = React.forwardRef - {seg.value} ); @@ -1028,6 +1058,21 @@ export const MentionableTextarea = React.forwardRef ) : null} + {value.includes('http://') || value.includes('https://') ? ( + { + const newText = removeUrlMatchFromText(value, match); + onValueChange(newText); + requestAnimationFrame(() => { + internalRef.current?.setSelectionRange(match.start, match.start); + }); + }} + /> + ) : null} + ; + scrollTop: number; + onRemove: (match: TextMatch) => void; +} + +type PositionedUrlReference = InlineMatchPosition; + +export const UrlInteractionLayer = ({ + value, + textareaRef, + scrollTop, + onRemove, +}: UrlInteractionLayerProps): React.JSX.Element | null => { + const [positions, setPositions] = React.useState([]); + + React.useLayoutEffect(() => { + if (!value.includes('http://') && !value.includes('https://')) { + setPositions([]); + return; + } + + const textarea = textareaRef.current; + if (!textarea) return; + + const matches = findUrlMatches(value).map((match) => ({ + item: match, + start: match.start, + end: match.end, + token: match.value, + })); + + setPositions(calculateInlineMatchPositions(textarea, value, matches)); + }, [textareaRef, value]); + + if (positions.length === 0) return null; + + return ( +
+
+ {positions.map((position, index) => ( +
+ +
+ ))} +
+
+ ); +}; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 9593923f..cb828c79 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -67,7 +67,7 @@ import type { AppState } from '../types'; import type { AppConfig } from '@renderer/types/data'; import type { AddMemberRequest, - CommentAttachmentPayload, + AddTaskCommentRequest, CreateTaskRequest, CrossTeamSendRequest, EffortLevel, @@ -346,8 +346,7 @@ export interface TeamSlice { addTaskComment: ( teamName: string, taskId: string, - text: string, - attachments?: CommentAttachmentPayload[] + request: AddTaskCommentRequest ) => Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; removeMember: (teamName: string, memberName: string) => Promise; @@ -1113,11 +1112,11 @@ export const createTeamSlice: StateCreator = (set, ); }, - addTaskComment: async (teamName, taskId, text, attachments) => { + addTaskComment: async (teamName, taskId, request) => { set({ addingComment: true, addCommentError: null }); try { const comment = await unwrapIpc('team:addTaskComment', () => - api.teams.addTaskComment(teamName, taskId, text, attachments) + api.teams.addTaskComment(teamName, taskId, request) ); set({ addingComment: false }); await get().refreshTeamData(teamName); diff --git a/src/renderer/utils/taskReferenceUtils.ts b/src/renderer/utils/taskReferenceUtils.ts index ad81a311..d1ceef72 100644 --- a/src/renderer/utils/taskReferenceUtils.ts +++ b/src/renderer/utils/taskReferenceUtils.ts @@ -1,6 +1,7 @@ import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions'; import type { MentionSuggestion } from '@renderer/types/mention'; +import type { TaskRef } from '@shared/types'; const TASK_REF_REGEX = /#([A-Za-z0-9-]+)\b/g; const TASK_META_START = '\u2063'; @@ -67,6 +68,12 @@ interface EncodedTaskMetadataMatch { end: number; } +interface ParsedTaskLinkHref { + taskId: string; + teamName?: string; + displayId?: string; +} + function encodeZeroWidthPayload(value: string): string { const bytes = new TextEncoder().encode(value); let encoded = ''; @@ -147,6 +154,20 @@ function buildTaskSuggestionFromMetadata( ); } +function buildTaskRefFromSuggestion( + suggestion: MentionSuggestion, + displayId: string +): TaskRef | null { + if (!suggestion.taskId || !suggestion.teamName) { + return null; + } + return { + taskId: suggestion.taskId, + displayId, + teamName: suggestion.teamName, + }; +} + export function createEncodedTaskReference( displayId: string, taskId: string, @@ -162,11 +183,71 @@ export function createEncodedTaskReference( return `#${displayId}${TASK_META_START}${encodedPayload}${TASK_META_END}`; } -export function linkifyTaskIdsInMarkdown(text: string): string { - return text.replace(TASK_REF_REGEX, (raw, ref: string, offset: number) => { - const preceding = offset > 0 ? text[offset - 1] : undefined; - return isAllowedTaskRefBoundary(preceding) ? `[${raw}](task://${ref})` : raw; - }); +export function buildTaskLinkHref(taskRef: TaskRef): string { + return `task://${encodeURIComponent(taskRef.taskId)}?team=${encodeURIComponent(taskRef.teamName)}&display=${encodeURIComponent(taskRef.displayId)}`; +} + +export function parseTaskLinkHref(href: string): ParsedTaskLinkHref | null { + if (!href.startsWith('task://')) return null; + try { + const raw = href.slice('task://'.length); + if (!raw) return null; + + const queryIndex = raw.indexOf('?'); + if (queryIndex === -1) { + return { + taskId: decodeURIComponent(raw), + }; + } + + const taskIdPart = raw.slice(0, queryIndex); + const search = new URLSearchParams(raw.slice(queryIndex + 1)); + const teamName = search.get('team'); + const displayId = search.get('display'); + return { + taskId: decodeURIComponent(taskIdPart), + teamName: teamName ? decodeURIComponent(teamName) : undefined, + displayId: displayId ? decodeURIComponent(displayId) : undefined, + }; + } catch { + return null; + } +} + +export function linkifyTaskIdsInMarkdown(text: string, taskRefs?: TaskRef[]): string { + if (!text) return text; + + const orderedTaskRefs = taskRefs ?? []; + let taskRefIndex = 0; + let result = ''; + let cursor = 0; + + for (const match of text.matchAll(TASK_REF_REGEX)) { + const raw = match[0]; + const ref = match[1]; + const start = match.index ?? -1; + if (start < 0) continue; + + result += text.slice(cursor, start); + const preceding = start > 0 ? text[start - 1] : undefined; + if (!isAllowedTaskRefBoundary(preceding)) { + result += raw; + cursor = start + raw.length; + continue; + } + + const structuredTaskRef = + taskRefIndex < orderedTaskRefs.length && + orderedTaskRefs[taskRefIndex]?.displayId.toLowerCase() === ref.toLowerCase() + ? orderedTaskRefs[taskRefIndex++] + : undefined; + const href = structuredTaskRef ? buildTaskLinkHref(structuredTaskRef) : `task://${ref}`; + result += `[${raw}](${href})`; + cursor = start + raw.length; + } + + result += text.slice(cursor); + return result; } export function stripEncodedTaskReferenceMetadata(text: string): string { @@ -193,12 +274,10 @@ export function findTaskReferenceMatches( text: string, taskSuggestions: MentionSuggestion[] ): TaskReferenceMatch[] { - if (!text || taskSuggestions.length === 0) return []; + if (!text) return []; const suggestionsByRef = buildSuggestionsByRef(taskSuggestions); - if (suggestionsByRef.size === 0) return []; - const matches: TaskReferenceMatch[] = []; for (const match of text.matchAll(TASK_REF_REGEX)) { const raw = match[0]; @@ -227,3 +306,26 @@ export function findTaskReferenceMatches( return matches; } + +export function extractTaskRefsFromText( + text: string, + taskSuggestions: MentionSuggestion[] +): TaskRef[] { + if (!text) return []; + + return findTaskReferenceMatches(text, taskSuggestions) + .map((match) => { + if (match.encoded) { + const metadataMatch = extractEncodedTaskMetadata(text, match.start + match.raw.length); + if (!metadataMatch) return null; + return { + taskId: metadataMatch.metadata.taskId, + displayId: metadataMatch.metadata.displayId, + teamName: metadataMatch.metadata.teamName, + } satisfies TaskRef; + } + + return buildTaskRefFromSuggestion(match.suggestion, match.ref); + }) + .filter((taskRef): taskRef is TaskRef => taskRef !== null); +} diff --git a/src/renderer/utils/urlMatchUtils.ts b/src/renderer/utils/urlMatchUtils.ts new file mode 100644 index 00000000..878164a9 --- /dev/null +++ b/src/renderer/utils/urlMatchUtils.ts @@ -0,0 +1,44 @@ +export interface TextMatch { + start: number; + end: number; + value: string; +} + +const URL_REGEX = /https?:\/\/[^\s]+/g; + +function trimUrlMatch(rawUrl: string): string { + return rawUrl.replace(/[),.!?;:]+$/g, ''); +} + +export function findUrlMatches(text: string): TextMatch[] { + if (!text) return []; + + const matches: TextMatch[] = []; + for (const match of text.matchAll(URL_REGEX)) { + const rawValue = match[0]; + const start = match.index ?? -1; + if (start < 0) continue; + + const trimmedValue = trimUrlMatch(rawValue); + if (!trimmedValue) continue; + + matches.push({ + start, + end: start + trimmedValue.length, + value: trimmedValue, + }); + } + + return matches; +} + +export function findUrlBoundary(text: string, cursorPos: number): TextMatch | null { + return ( + findUrlMatches(text).find((match) => cursorPos >= match.start && cursorPos <= match.end) ?? null + ); +} + +export function removeUrlMatchFromText(text: string, match: TextMatch): string { + const removeEnd = match.end < text.length && text[match.end] === '\n' ? match.end + 1 : match.end; + return text.slice(0, match.start) + text.slice(removeEnd); +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 978a8edf..f987861f 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -38,6 +38,7 @@ import type { } from './review'; import type { AddMemberRequest, + AddTaskCommentRequest, AttachmentFileData, CommentAttachmentPayload, CreateTaskRequest, @@ -472,8 +473,7 @@ export interface TeamsAPI { addTaskComment: ( teamName: string, taskId: string, - text: string, - attachments?: CommentAttachmentPayload[] + request: AddTaskCommentRequest ) => Promise; setTaskClarification: ( teamName: string, diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 6d485982..c9895305 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -117,12 +117,19 @@ export type TaskHistoryEvent = export type TaskCommentType = 'regular' | 'review_request' | 'review_approved'; +export interface TaskRef { + taskId: string; + displayId: string; + teamName: string; +} + export interface TaskComment { id: string; author: string; text: string; createdAt: string; type: TaskCommentType; + taskRefs?: TaskRef[]; /** Attachments on this comment. Metadata only — files stored on disk. */ attachments?: TaskAttachmentMeta[]; } @@ -135,7 +142,10 @@ export interface TeamTask { displayId?: string; subject: string; description?: string; + descriptionTaskRefs?: TaskRef[]; activeForm?: string; + prompt?: string; + promptTaskRefs?: TaskRef[]; owner?: string; createdBy?: string; status: TeamTaskStatus; @@ -244,6 +254,7 @@ export interface InboxMessage { text: string; timestamp: string; read: boolean; + taskRefs?: TaskRef[]; summary?: string; color?: string; messageId?: string; @@ -273,6 +284,7 @@ export type AgentActionMode = 'do' | 'ask' | 'delegate'; export interface SendMessageRequest { member: string; text: string; + taskRefs?: TaskRef[]; actionMode?: AgentActionMode; summary?: string; from?: string; @@ -298,6 +310,12 @@ export interface SendMessageResult { deduplicated?: boolean; } +export interface AddTaskCommentRequest { + text: string; + attachments?: CommentAttachmentPayload[]; + taskRefs?: TaskRef[]; +} + export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; /** @@ -329,7 +347,7 @@ export interface KanbanState { export type UpdateKanbanPatch = | { op: 'set_column'; column: Extract } | { op: 'remove' } - | { op: 'request_changes'; comment?: string }; + | { op: 'request_changes'; comment?: string; taskRefs?: TaskRef[] }; export interface ResolvedTeamMember { name: string; @@ -398,10 +416,12 @@ export interface TeamLaunchResponse { export interface CreateTaskRequest { subject: string; description?: string; + descriptionTaskRefs?: TaskRef[]; owner?: string; blockedBy?: string[]; related?: string[]; prompt?: string; + promptTaskRefs?: TaskRef[]; startImmediately?: boolean; } @@ -656,6 +676,7 @@ export interface CrossTeamMessage { conversationId?: string; replyToConversationId?: string; text: string; + taskRefs?: TaskRef[]; summary?: string; chainDepth: number; timestamp: string; @@ -670,6 +691,7 @@ export interface CrossTeamSendRequest { conversationId?: string; replyToConversationId?: string; text: string; + taskRefs?: TaskRef[]; actionMode?: AgentActionMode; summary?: string; chainDepth?: number; From 2317c948ff79a8b227d4709fbf9b1bbc23ff11f6 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 17:18:24 +0200 Subject: [PATCH 09/72] feat: enhance Kanban board with grid layout and persistence improvements - Introduced a new KanbanGridLayout component for improved task organization and layout management. - Updated KanbanBoard to utilize the new grid layout, enhancing the visual structure of tasks. - Added CSS styles for grid layout and resizing handles to improve user interaction. - Refactored KanbanColumn to support additional customization options for headers and body styles. - Enhanced persistence flows to rely on repository abstractions, promoting better separation of concerns in storage management. - Updated package dependencies to include react-grid-layout and react-resizable for enhanced layout capabilities. --- CLAUDE.md | 6 + package.json | 2 + pnpm-lock.yaml | 59 ++++++ .../components/team/kanban/KanbanBoard.tsx | 46 ++--- .../components/team/kanban/KanbanColumn.tsx | 27 ++- .../team/kanban/KanbanGridLayout.tsx | 185 ++++++++++++++++++ src/renderer/hooks/useMentionDetection.ts | 17 +- src/renderer/hooks/usePersistedGridLayout.ts | 107 ++++++++++ src/renderer/index.css | 112 +++++++++++ src/renderer/main.tsx | 2 + .../BrowserGridLayoutRepository.ts | 96 +++++++++ .../layout-system/GridLayoutRepository.ts | 7 + .../layout-system/gridLayoutSchema.ts | 160 +++++++++++++++ .../services/layout-system/gridLayoutTypes.ts | 17 ++ 14 files changed, 812 insertions(+), 31 deletions(-) create mode 100644 src/renderer/components/team/kanban/KanbanGridLayout.tsx create mode 100644 src/renderer/hooks/usePersistedGridLayout.ts create mode 100644 src/renderer/services/layout-system/BrowserGridLayoutRepository.ts create mode 100644 src/renderer/services/layout-system/GridLayoutRepository.ts create mode 100644 src/renderer/services/layout-system/gridLayoutSchema.ts create mode 100644 src/renderer/services/layout-system/gridLayoutTypes.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7be051f9..99f14a97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,3 +187,9 @@ Note: renderer utils/hooks/types do NOT have barrel exports — import directly 1. External packages 2. Path aliases (@main, @renderer, @shared) 3. Relative imports + +### Storage And Persistence +- New persistence flows should depend on small repository/storage abstractions, not directly on `localStorage`, `IndexedDB`, Electron APIs, or JSON files from UI components/hooks. +- Keep persistence concerns split by responsibility: schema/normalization, repository interface, concrete storage implementation, and UI adapter logic should live in separate modules. +- Prefer designs where the high-level feature code can swap local browser/Electron storage for a server-backed implementation without rewriting the rendering layer. +- Reuse generic persistence/layout infrastructure when adding new draggable/resizable surfaces instead of copying feature-specific storage code. diff --git a/package.json b/package.json index d29ac95a..d1601c0e 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,9 @@ "node-pty": "^1.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-grid-layout": "^2.2.2", "react-markdown": "^10.1.0", + "react-resizable": "^3.1.3", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bd4c861..3dd843b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,9 +212,15 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-grid-layout: + specifier: ^2.2.2 + version: 2.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.27)(react@18.3.1) + react-resizable: + specifier: ^3.1.3 + version: 3.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rehype-highlight: specifier: ^7.0.2 version: 7.0.2 @@ -3770,6 +3776,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@4.0.3: + resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -5560,6 +5569,18 @@ packages: peerDependencies: react: ^18.3.1 + react-draggable@4.5.0: + resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + react-grid-layout@2.2.2: + resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5593,6 +5614,12 @@ packages: '@types/react': optional: true + react-resizable@3.1.3: + resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==} + peerDependencies: + react: '>= 16.3' + react-dom: '>= 16.3' + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -5690,6 +5717,9 @@ packages: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -10785,6 +10815,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@4.0.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -12938,6 +12970,24 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-draggable@4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react-grid-layout@2.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + fast-equals: 4.0.3 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-draggable: 4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-resizable: 3.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + resize-observer-polyfill: 1.5.1 + react-is@16.13.1: {} react-markdown@10.1.0(@types/react@18.3.27)(react@18.3.1): @@ -12979,6 +13029,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.27 + react-resizable@3.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-draggable: 4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -13125,6 +13182,8 @@ snapshots: dependencies: pe-library: 0.4.1 + resize-observer-polyfill@1.5.1: {} + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index f6b747ef..811d1f20 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -8,7 +8,6 @@ import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useResizableColumns } from '@renderer/hooks/useResizableColumns'; import { cn } from '@renderer/lib/utils'; - import { CheckCircle2, ClipboardList, @@ -23,6 +22,7 @@ import { import { KanbanColumn } from './KanbanColumn'; import { KanbanFilterPopover } from './KanbanFilterPopover'; +import { KanbanGridLayout } from './KanbanGridLayout'; import { KanbanSortPopover } from './KanbanSortPopover'; import { KanbanTaskCard } from './KanbanTaskCard'; @@ -303,6 +303,8 @@ export const KanbanBoard = ({ onOpenTrash, }: KanbanBoardProps): React.JSX.Element => { const [viewMode, setViewMode] = useState('grid'); + const enableTaskSorting = + viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]); const grouped = useMemo(() => { @@ -390,7 +392,7 @@ export const KanbanBoard = ({ ) ); } - if (onColumnOrderChange && sort.field === 'manual') { + if (enableTaskSorting) { const itemIds = columnTasks.map((t) => t.id); return ( <> @@ -541,33 +543,31 @@ export const KanbanBoard = ({
{viewMode === 'grid' ? ( -
- {visibleColumns.map((column) => { + column.id)} + columns={visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; - return ( - - {renderCards(column.id, columnTasks)} - - ); + + return { + id: column.id, + title: column.title, + count: columnTasks.length, + icon: accent.icon, + headerBg: accent.headerBg, + bodyBg: accent.bodyBg, + content: renderCards(column.id, columnTasks), + }; })} -
+ /> ) : (
{visibleColumns.map((column, index) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; const width = columnWidths.get(column.id) ?? 256; + const handleProps = getHandleProps(column.id); return (
@@ -584,7 +584,9 @@ export const KanbanBoard = ({ {index < visibleColumns.length - 1 ? (
@@ -597,7 +599,7 @@ export const KanbanBoard = ({ ); - if (onColumnOrderChange && sort.field === 'manual') { + if (enableTaskSorting) { return ( {boardContent} diff --git a/src/renderer/components/team/kanban/KanbanColumn.tsx b/src/renderer/components/team/kanban/KanbanColumn.tsx index 99627d58..a45f1e88 100644 --- a/src/renderer/components/team/kanban/KanbanColumn.tsx +++ b/src/renderer/components/team/kanban/KanbanColumn.tsx @@ -7,6 +7,10 @@ interface KanbanColumnProps { icon?: React.ReactNode; headerBg?: string; bodyBg?: string; + className?: string; + headerClassName?: string; + bodyClassName?: string; + headerAccessory?: React.ReactNode; children: React.ReactNode; } @@ -16,29 +20,42 @@ export const KanbanColumn = ({ icon, headerBg, bodyBg, + className, + headerClassName, + bodyClassName, + headerAccessory, children, }: KanbanColumnProps): React.JSX.Element => { return (

{icon} {title}

- - {count} - +
+ {headerAccessory} + + {count} + +
-
{children}
+
+ {children} +
); }; diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx new file mode 100644 index 00000000..b8e54ca8 --- /dev/null +++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx @@ -0,0 +1,185 @@ +/* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy'; + +import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout'; +import { browserGridLayoutRepository } from '@renderer/services/layout-system/BrowserGridLayoutRepository'; +import { GripVertical } from 'lucide-react'; + +import { KanbanColumn } from './KanbanColumn'; + +import type { PersistedGridLayoutItem } from '@renderer/services/layout-system/gridLayoutTypes'; +import type { KanbanColumnId } from '@shared/types'; +import type { ReactElement, Ref } from 'react'; +import type { Layout, LayoutItem, ResizeHandleAxis } from 'react-grid-layout/legacy'; + +const GRID_COLS = 12; +const GRID_ROW_HEIGHT = 18; +const GRID_MARGIN: [number, number] = [12, 12]; +const DEFAULT_ITEM_WIDTH = 4; +const DEFAULT_ITEM_HEIGHT_PX = 400; +const DEFAULT_ITEM_HEIGHT = Math.max( + 1, + Math.round((DEFAULT_ITEM_HEIGHT_PX + GRID_MARGIN[1]) / (GRID_ROW_HEIGHT + GRID_MARGIN[1])) +); +const DEFAULT_MIN_HEIGHT = 10; +const DEFAULT_MIN_WIDTH = 3; +const GRID_SCOPE_PREFIX = 'kanban-grid-layout:v2'; +const RESIZE_HANDLES: ResizeHandleAxis[] = ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne']; +const WidthAwareGridLayout = WidthProvider(ReactGridLayout); + +export interface KanbanGridColumn { + id: KanbanColumnId; + title: string; + count: number; + icon?: React.ReactNode; + headerBg?: string; + bodyBg?: string; + content: React.ReactNode; +} + +interface KanbanGridLayoutProps { + teamName: string; + columns: KanbanGridColumn[]; + allColumnIds: KanbanColumnId[]; +} + +function buildDefaultItems(itemIds: string[]): PersistedGridLayoutItem[] { + return itemIds.map((id, index) => ({ + id, + x: (index % 3) * DEFAULT_ITEM_WIDTH, + y: Math.floor(index / 3) * DEFAULT_ITEM_HEIGHT, + w: DEFAULT_ITEM_WIDTH, + h: DEFAULT_ITEM_HEIGHT, + minW: DEFAULT_MIN_WIDTH, + minH: DEFAULT_MIN_HEIGHT, + })); +} + +function toReactGridLayoutItem(item: PersistedGridLayoutItem): LayoutItem { + return { + i: item.id, + x: item.x, + y: item.y, + w: item.w, + h: item.h, + minW: item.minW, + minH: item.minH, + maxW: item.maxW, + maxH: item.maxH, + }; +} + +function fromReactGridLayout(layout: Layout): PersistedGridLayoutItem[] { + return layout.map((item) => ({ + id: item.i, + x: item.x, + y: item.y, + w: item.w, + h: item.h, + minW: item.minW, + minH: item.minH, + maxW: item.maxW, + maxH: item.maxH, + })); +} + +function renderResizeHandle(axis: ResizeHandleAxis, ref: Ref): ReactElement { + return ( +