From 9d2062c8c09c662a84b90250b232e8d3b4aa6d75 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 10 Mar 2026 23:34:58 +0200 Subject: [PATCH] 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,