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:
iliya 2026-03-10 23:34:58 +02:00
parent b1a00d67ed
commit 9d2062c8c0
16 changed files with 670 additions and 446 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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 (

View file

@ -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>
);
};

View file

@ -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 */}

View file

@ -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"

View file

@ -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>
);
};

View file

@ -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

View 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>
);
};

View 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>
);
};

View file

@ -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 />

View file

@ -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" />

View file

@ -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 },
}}

View file

@ -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 ? (

View file

@ -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;
}

View file

@ -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,