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.
This commit is contained in:
parent
b1a00d67ed
commit
9d2062c8c0
16 changed files with 670 additions and 446 deletions
23
CLAUDE.md
23
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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -246,6 +246,28 @@ function createViewerMarkdownComponents(
|
|||
}
|
||||
return badge;
|
||||
}
|
||||
if (href?.startsWith('team://')) {
|
||||
const colorSet = getTeamColorSet('blue');
|
||||
const bg = getThemedBadge(colorSet, isLight);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: bg,
|
||||
color: colorSet.text,
|
||||
borderRadius: '3px',
|
||||
boxShadow: `0 0 0 1.5px ${bg}`,
|
||||
fontSize: 'inherit',
|
||||
cursor: 'default',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
}}
|
||||
>
|
||||
<UsersRound size={11} style={{ flexShrink: 0 }} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (href?.startsWith('task://')) {
|
||||
const taskId = href.slice('task://'.length);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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<Tab | null>(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 (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div id="pane-container" className="flex flex-1 overflow-hidden">
|
||||
{panes.map((pane, i) => (
|
||||
<Fragment key={pane.id}>
|
||||
{i > 0 && <PaneResizeHandle leftPaneId={panes[i - 1].id} rightPaneId={pane.id} />}
|
||||
<PaneView paneId={pane.id} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Drag overlay - semi-transparent ghost of the dragged tab */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeTab ? <DragOverlayTab tab={activeTab} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<div id="pane-container" className="flex flex-1 overflow-hidden">
|
||||
{panes.map((pane, i) => (
|
||||
<Fragment key={pane.id}>
|
||||
{i > 0 && <PaneResizeHandle leftPaneId={panes[i - 1].id} rightPaneId={pane.id} />}
|
||||
<PaneView paneId={pane.id} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div
|
||||
style={{
|
||||
borderTop:
|
||||
isFocused && paneCount > 1
|
||||
? '2px solid var(--color-accent, #6366f1)'
|
||||
: '2px solid transparent',
|
||||
}}
|
||||
>
|
||||
<TabBar paneId={paneId} />
|
||||
</div>
|
||||
|
||||
<PaneContent pane={pane} />
|
||||
|
||||
{/* Edge split drop zones - visible only during active drag when under MAX_PANES */}
|
||||
|
|
|
|||
|
|
@ -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<SidebarTab>('tasks');
|
||||
const [taskFilters, setTaskFilters] = useState<TaskFiltersState>(defaultTaskFiltersState);
|
||||
const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false);
|
||||
const [isCollapseHovered, setIsCollapseHovered] = useState(false);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle mouse move during resize
|
||||
|
|
@ -101,14 +102,27 @@ export const Sidebar = (): React.JSX.Element => {
|
|||
minWidth: sidebarCollapsed ? 0 : width,
|
||||
}}
|
||||
>
|
||||
<SidebarHeader />
|
||||
|
||||
{/* Tab bar: Tasks | Sessions — tab strip style, filters on the right */}
|
||||
{/* Tab bar: Collapse button + Tasks | Sessions */}
|
||||
<div
|
||||
className="flex shrink-0 items-end gap-2 border-b px-3 pt-1"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div className="flex flex-1" />
|
||||
{/* Collapse sidebar button */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
onMouseEnter={() => setIsCollapseHovered(true)}
|
||||
onMouseLeave={() => setIsCollapseHovered(false)}
|
||||
className="mb-1 shrink-0 rounded-md p-1 transition-colors"
|
||||
style={{
|
||||
color: isCollapseHovered ? 'var(--color-text-secondary)' : 'var(--color-text-muted)',
|
||||
backgroundColor: isCollapseHovered ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title={`Collapse sidebar (${formatShortcut('B')})`}
|
||||
>
|
||||
<PanelLeft className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
<div className="flex" role="tablist" aria-label="Sidebar view">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
/**
|
||||
* SidebarHeader - Minimal header with logo and collapse button.
|
||||
*
|
||||
* Layout:
|
||||
* - Row 1: Logo (left, after macOS traffic lights) + Collapse button (right)
|
||||
* - Row 1 is the drag region for window movement
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
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 { PanelLeft } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { AppLogo } from '../common/AppLogo';
|
||||
|
||||
export const SidebarHeader = (): React.JSX.Element => {
|
||||
const isMacElectron =
|
||||
isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac');
|
||||
|
||||
const { toggleSidebar } = useStore(
|
||||
useShallow((s) => ({
|
||||
toggleSidebar: s.toggleSidebar,
|
||||
}))
|
||||
);
|
||||
|
||||
const [isCollapseHovered, setIsCollapseHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full flex-col"
|
||||
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
||||
>
|
||||
<div
|
||||
className="flex select-none items-center gap-1.5 pr-1"
|
||||
style={
|
||||
{
|
||||
height: `${HEADER_ROW1_HEIGHT}px`,
|
||||
paddingLeft: isMacElectron ? 'var(--macos-traffic-light-padding-left, 72px)' : 0,
|
||||
WebkitAppRegion: isMacElectron ? 'drag' : undefined,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{isMacElectron && (
|
||||
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<AppLogo size={22} className="shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
onMouseEnter={() => setIsCollapseHovered(true)}
|
||||
onMouseLeave={() => setIsCollapseHovered(false)}
|
||||
className="shrink-0 rounded-md p-1.5 transition-colors"
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: 'no-drag',
|
||||
color: isCollapseHovered ? 'var(--color-text-secondary)' : 'var(--color-text-muted)',
|
||||
backgroundColor: isCollapseHovered ? 'var(--color-surface-raised)' : 'transparent',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
title={`Collapse sidebar (${formatShortcut('B')})`}
|
||||
>
|
||||
<PanelLeft className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<string | null>(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 (
|
||||
<div
|
||||
className="flex items-center justify-between pr-2"
|
||||
className="flex h-full items-center pr-2"
|
||||
style={
|
||||
{
|
||||
height: `${HEADER_ROW1_HEIGHT}px`,
|
||||
paddingLeft:
|
||||
sidebarCollapsed && isLeftmostPane
|
||||
isMacElectron && isLeftmostPane
|
||||
? 'var(--macos-traffic-light-padding-left, 72px)'
|
||||
: '8px',
|
||||
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
opacity: isFocused || paneCount === 1 ? 1 : 0.7,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
|
|
@ -289,13 +260,10 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
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"
|
||||
>
|
||||
<PanelLeft className="size-4" />
|
||||
|
|
@ -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',
|
||||
}}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{openTabs.map((tab) => (
|
||||
|
|
@ -355,147 +320,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
className="min-w-[48px] shrink-0 self-stretch"
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div
|
||||
className="ml-2 flex shrink-0 items-center gap-1"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{/* New tab button */}
|
||||
<button
|
||||
onClick={openDashboard}
|
||||
onMouseEnter={() => setNewTabHover(true)}
|
||||
onMouseLeave={() => setNewTabHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="New tab (Dashboard)"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* Notifications bell icon */}
|
||||
<button
|
||||
onClick={openNotificationsTab}
|
||||
onMouseEnter={() => setNotificationsHover(true)}
|
||||
onMouseLeave={() => setNotificationsHover(false)}
|
||||
className="relative rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-red-500 px-1 text-xs font-medium text-white">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Teams icon */}
|
||||
<button
|
||||
onClick={openTeamsTab}
|
||||
onMouseEnter={() => setTeamsHover(true)}
|
||||
onMouseLeave={() => setTeamsHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: teamsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: teamsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Teams"
|
||||
>
|
||||
<Users className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* Schedules icon */}
|
||||
<button
|
||||
onClick={openSchedulesTab}
|
||||
onMouseEnter={() => setSchedulesHover(true)}
|
||||
onMouseLeave={() => setSchedulesHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: schedulesHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: schedulesHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Schedules"
|
||||
>
|
||||
<Calendar className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* Extensions icon */}
|
||||
<button
|
||||
onClick={openExtensionsTab}
|
||||
onMouseEnter={() => setExtensionsHover(true)}
|
||||
onMouseLeave={() => setExtensionsHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: extensionsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: extensionsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Extensions"
|
||||
>
|
||||
<Puzzle className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* GitHub link */}
|
||||
<button
|
||||
onClick={() =>
|
||||
void window.electronAPI.openExternal(
|
||||
'https://github.com/777genius/claude_agent_teams_ui'
|
||||
)
|
||||
}
|
||||
onMouseEnter={() => setGithubHover(true)}
|
||||
onMouseLeave={() => setGithubHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="GitHub"
|
||||
>
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Settings gear icon */}
|
||||
<button
|
||||
onClick={() => openSettingsTab()}
|
||||
onMouseEnter={() => setSettingsHover(true)}
|
||||
onMouseLeave={() => setSettingsHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* More menu (Search, Export, Analyze, Settings) */}
|
||||
<MoreMenu
|
||||
activeTab={activeTab}
|
||||
activeTabSessionDetail={activeTabSessionDetail}
|
||||
activeTabId={activeTabId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && contextMenuTabId && (
|
||||
<TabContextMenu
|
||||
|
|
|
|||
172
src/renderer/components/layout/TabBarActions.tsx
Normal file
172
src/renderer/components/layout/TabBarActions.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* TabBarActions - Right-side action buttons for the tab bar row.
|
||||
* Extracted from TabBar to render once (not per-pane).
|
||||
* Reads focused pane data from root store selectors (auto-synced via syncRootState).
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Bell, Calendar, Puzzle, Settings, Users } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { MoreMenu } from './MoreMenu';
|
||||
|
||||
export const TabBarActions = (): React.JSX.Element => {
|
||||
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 (
|
||||
<div
|
||||
className="ml-2 flex shrink-0 items-center gap-1"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{/* Notifications bell icon */}
|
||||
<button
|
||||
onClick={openNotificationsTab}
|
||||
onMouseEnter={() => setNotificationsHover(true)}
|
||||
onMouseLeave={() => setNotificationsHover(false)}
|
||||
className="relative rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-red-500 px-1 text-xs font-medium text-white">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Teams icon */}
|
||||
<button
|
||||
onClick={openTeamsTab}
|
||||
onMouseEnter={() => setTeamsHover(true)}
|
||||
onMouseLeave={() => setTeamsHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: teamsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: teamsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Teams"
|
||||
>
|
||||
<Users className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* Schedules icon */}
|
||||
<button
|
||||
onClick={openSchedulesTab}
|
||||
onMouseEnter={() => setSchedulesHover(true)}
|
||||
onMouseLeave={() => setSchedulesHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: schedulesHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: schedulesHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Schedules"
|
||||
>
|
||||
<Calendar className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* Extensions icon */}
|
||||
<button
|
||||
onClick={openExtensionsTab}
|
||||
onMouseEnter={() => setExtensionsHover(true)}
|
||||
onMouseLeave={() => setExtensionsHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: extensionsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: extensionsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Extensions"
|
||||
>
|
||||
<Puzzle className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* GitHub link */}
|
||||
<button
|
||||
onClick={() =>
|
||||
void (isElectronMode()
|
||||
? window.electronAPI.openExternal('https://github.com/777genius/claude_agent_teams_ui')
|
||||
: window.open('https://github.com/777genius/claude_agent_teams_ui', '_blank'))
|
||||
}
|
||||
onMouseEnter={() => setGithubHover(true)}
|
||||
onMouseLeave={() => setGithubHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="GitHub"
|
||||
>
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Settings gear icon */}
|
||||
<button
|
||||
onClick={() => openSettingsTab()}
|
||||
onMouseEnter={() => setSettingsHover(true)}
|
||||
onMouseLeave={() => setSettingsHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* More menu (Search, Export, Analyze, Settings) */}
|
||||
<MoreMenu
|
||||
activeTab={activeTab}
|
||||
activeTabSessionDetail={activeTabSessionDetail}
|
||||
activeTabId={activeTabId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
91
src/renderer/components/layout/TabBarRow.tsx
Normal file
91
src/renderer/components/layout/TabBarRow.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className="flex shrink-0 items-center"
|
||||
style={
|
||||
{
|
||||
height: `${HEADER_ROW1_HEIGHT}px`,
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
WebkitAppRegion: isMacElectron ? 'drag' : undefined,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Pane TabBars — proportional width, side by side */}
|
||||
<div className="flex min-w-0 flex-1 self-stretch">
|
||||
{panes.map((pane, i) => (
|
||||
<Fragment key={pane.id}>
|
||||
{/* Separator between pane TabBars */}
|
||||
{i > 0 && (
|
||||
<div
|
||||
className="w-px shrink-0 self-stretch"
|
||||
style={{ backgroundColor: 'var(--color-border-emphasis)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pane TabBar segment with focus indicator */}
|
||||
<div
|
||||
className="min-w-0"
|
||||
style={{
|
||||
width: `${pane.widthFraction * 100}%`,
|
||||
borderTop:
|
||||
focusedPaneId === pane.id && panes.length > 1
|
||||
? '2px solid var(--color-accent, #6366f1)'
|
||||
: '2px solid transparent',
|
||||
}}
|
||||
>
|
||||
<TabBar paneId={pane.id} />
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* New tab button — right corner */}
|
||||
<button
|
||||
onClick={openDashboard}
|
||||
onMouseEnter={() => setNewTabHover(true)}
|
||||
onMouseLeave={() => setNewTabHover(false)}
|
||||
className="mr-2 shrink-0 rounded-md p-2 transition-colors"
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: 'no-drag',
|
||||
color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
title="New tab (Dashboard)"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<Tab | null>(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 (
|
||||
<div
|
||||
className="flex h-screen flex-col bg-claude-dark-bg text-claude-dark-text"
|
||||
|
|
@ -41,16 +154,50 @@ export const TabbedLayout = (): React.JSX.Element => {
|
|||
>
|
||||
<CustomTitleBar />
|
||||
<UpdateBanner />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Command Palette (Cmd+K) */}
|
||||
<CommandPalette />
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<TabBarRow />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Command Palette (Cmd+K) */}
|
||||
<CommandPalette />
|
||||
|
||||
{/* Sidebar - Project dropdown + Sessions (280px) */}
|
||||
<Sidebar />
|
||||
{/* Sidebar - Task list / Sessions (280px) */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Multi-pane content area */}
|
||||
<PaneContainer />
|
||||
</div>
|
||||
{/* Content column: floating actions bar + pane content */}
|
||||
<div
|
||||
className="relative flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
{/* Content header with action buttons — floats over pane content */}
|
||||
<div
|
||||
className="absolute right-0 top-0 z-10 flex items-center justify-end pr-2"
|
||||
style={{
|
||||
height: '36px',
|
||||
left: 0,
|
||||
backgroundColor: 'rgba(20, 20, 22, 0.45)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<TabBarActions />
|
||||
</div>
|
||||
|
||||
{/* Multi-pane content area — renders from top:0, behind the floating bar */}
|
||||
<PaneContainer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag overlay - semi-transparent ghost of the dragged tab */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeTab ? <DragOverlayTab tab={activeTab} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<GlobalTaskDetailDialog />
|
||||
<UpdateDialog />
|
||||
<WorkspaceIndicator />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</span>
|
||||
)}
|
||||
{data.isAlive &&
|
||||
launchParams?.model &&
|
||||
(() => {
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
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 (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-secondary)]">
|
||||
{parts}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{!data.isAlive && isTeamProvisioning && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-yellow-400" />
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -391,15 +391,11 @@ export const MessageComposer = ({
|
|||
)}
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{isProvisioning ? (
|
||||
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||
Launching... inbox delivery only
|
||||
</span>
|
||||
) : !isTeamAlive ? (
|
||||
{!isTeamAlive && !isProvisioning && (
|
||||
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||
Team offline
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Combined team + member selector */}
|
||||
{crossTeamTargets.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -408,6 +408,21 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, TeamLaunchParams>;
|
||||
kanbanFilterQuery: string | null;
|
||||
provisioningProgressUnsubscribe: (() => void) | null;
|
||||
fetchBranches: (paths: string[]) => Promise<void>;
|
||||
|
|
@ -407,6 +417,56 @@ export interface TeamSlice {
|
|||
) => Promise<void>;
|
||||
}
|
||||
|
||||
// --- Per-team launch params persistence ---
|
||||
const LAUNCH_PARAMS_PREFIX = 'team:launchParams:';
|
||||
|
||||
function loadAllLaunchParams(): Record<string, TeamLaunchParams> {
|
||||
const result: Record<string, TeamLaunchParams> = {};
|
||||
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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue