{
onMouseEnter={() => setExpandHover(true)}
onMouseLeave={() => setExpandHover(false)}
className="mr-2 shrink-0 rounded-md p-1.5 transition-colors"
- style={
- {
- WebkitAppRegion: 'no-drag',
- color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
- backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
- } as React.CSSProperties
- }
+ style={{
+ color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
+ backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
+ }}
title="Expand sidebar"
>
@@ -310,15 +278,12 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
setDroppableRef(el);
}}
className="scrollbar-none flex min-w-0 flex-1 items-center gap-1"
- style={
- {
- WebkitAppRegion: 'no-drag',
- outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
- outlineOffset: '-1px',
- overflowX: 'auto',
- overflowY: 'hidden',
- } as React.CSSProperties
- }
+ style={{
+ outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
+ outlineOffset: '-1px',
+ overflowX: 'auto',
+ overflowY: 'hidden',
+ }}
>
{openTabs.map((tab) => (
@@ -355,147 +320,6 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
)}
- {/* Drag spacer — fills empty space between tab list and action buttons.
- Gives users a reliable window-drag target regardless of how many tabs are open.
- Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
-
- {/* New tab button */}
-
-
- {/* Notifications bell icon */}
-
-
- {/* Teams icon */}
-
-
- {/* Schedules icon */}
-
-
- {/* Extensions icon */}
-
-
- {/* GitHub link */}
-
-
- {/* Settings gear icon */}
-
-
- {/* More menu (Search, Export, Analyze, Settings) */}
-
-
-
{/* Context menu */}
{contextMenu && contextMenuTabId && (
{
+ const {
+ unreadCount,
+ openNotificationsTab,
+ openTeamsTab,
+ openExtensionsTab,
+ openSchedulesTab,
+ openSettingsTab,
+ activeTabId,
+ openTabs,
+ tabSessionData,
+ } = useStore(
+ useShallow((s) => ({
+ unreadCount: s.unreadCount,
+ openNotificationsTab: s.openNotificationsTab,
+ openTeamsTab: s.openTeamsTab,
+ openExtensionsTab: s.openExtensionsTab,
+ openSchedulesTab: s.openSchedulesTab,
+ openSettingsTab: s.openSettingsTab,
+ activeTabId: s.activeTabId,
+ openTabs: s.openTabs,
+ tabSessionData: s.tabSessionData,
+ }))
+ );
+
+ // Hover states for buttons
+ const [notificationsHover, setNotificationsHover] = useState(false);
+ const [teamsHover, setTeamsHover] = useState(false);
+ const [schedulesHover, setSchedulesHover] = useState(false);
+ const [extensionsHover, setExtensionsHover] = useState(false);
+ const [githubHover, setGithubHover] = useState(false);
+ const [settingsHover, setSettingsHover] = useState(false);
+
+ // Derive active tab and session detail for MoreMenu
+ const activeTab = useMemo(
+ () => openTabs.find((t) => t.id === activeTabId),
+ [openTabs, activeTabId]
+ );
+ const activeTabSessionDetail = activeTabId
+ ? (tabSessionData[activeTabId]?.sessionDetail ?? null)
+ : null;
+
+ return (
+
+ {/* Notifications bell icon */}
+
+
+ {/* Teams icon */}
+
+
+ {/* Schedules icon */}
+
+
+ {/* Extensions icon */}
+
+
+ {/* GitHub link */}
+
+
+ {/* Settings gear icon */}
+
+
+ {/* More menu (Search, Export, Analyze, Settings) */}
+
+
+ );
+};
diff --git a/src/renderer/components/layout/TabBarRow.tsx b/src/renderer/components/layout/TabBarRow.tsx
new file mode 100644
index 00000000..ffc06214
--- /dev/null
+++ b/src/renderer/components/layout/TabBarRow.tsx
@@ -0,0 +1,91 @@
+/**
+ * TabBarRow - Full-width tab bar row rendered above the sidebar + content area.
+ * Renders pane-specific TabBars proportionally + new tab button on the right.
+ * Handles window drag region and focus indicator for multi-pane layouts.
+ */
+
+import { Fragment, useState } from 'react';
+
+import { isElectronMode } from '@renderer/api';
+import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
+import { useStore } from '@renderer/store';
+import { Plus } from 'lucide-react';
+import { useShallow } from 'zustand/react/shallow';
+
+import { TabBar } from './TabBar';
+
+export const TabBarRow = (): React.JSX.Element => {
+ const { panes, focusedPaneId, openDashboard } = useStore(
+ useShallow((s) => ({
+ panes: s.paneLayout.panes,
+ focusedPaneId: s.paneLayout.focusedPaneId,
+ openDashboard: s.openDashboard,
+ }))
+ );
+
+ const [newTabHover, setNewTabHover] = useState(false);
+
+ const isMacElectron =
+ isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac');
+
+ return (
+
+ {/* Pane TabBars — proportional width, side by side */}
+
+ {panes.map((pane, i) => (
+
+ {/* Separator between pane TabBars */}
+ {i > 0 && (
+
+ )}
+
+ {/* Pane TabBar segment with focus indicator */}
+ 1
+ ? '2px solid var(--color-accent, #6366f1)'
+ : '2px solid transparent',
+ }}
+ >
+
+
+
+ ))}
+
+
+ {/* New tab button — right corner */}
+
+
+ );
+};
diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx
index 1f171a5c..729c8c9a 100644
--- a/src/renderer/components/layout/TabbedLayout.tsx
+++ b/src/renderer/components/layout/TabbedLayout.tsx
@@ -1,16 +1,31 @@
/**
- * TabbedLayout - Main layout with project-centric sidebar and multi-pane tabbed content.
+ * TabbedLayout - Main layout with full-width tab bar, sidebar, and multi-pane content.
*
* Layout structure:
- * - Sidebar (280px): Project dropdown + date-grouped sessions
- * - Main content: PaneContainer with one or more panes, each with TabBar + content
+ * - TabBarRow (full width): Pane TabBars + action buttons
+ * - Sidebar (280px): Task list / date-grouped sessions
+ * - Main content: PaneContainer with one or more panes
+ *
+ * Owns the DndContext for tab drag-and-drop across the entire layout
+ * (TabBarRow tabs + PaneContainer split zones).
*/
+import { useCallback, useState } from 'react';
+
+import {
+ DndContext,
+ DragOverlay,
+ PointerSensor,
+ pointerWithin,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
import { isElectronMode } from '@renderer/api';
import { getTrafficLightPaddingForZoom } from '@renderer/constants/layout';
import { useFullScreen } from '@renderer/hooks/useFullScreen';
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
import { useZoomFactor } from '@renderer/hooks/useZoomFactor';
+import { useStore } from '@renderer/store';
import { UpdateBanner } from '../common/UpdateBanner';
import { UpdateDialog } from '../common/UpdateDialog';
@@ -21,6 +36,12 @@ import { GlobalTaskDetailDialog } from '../team/dialogs/GlobalTaskDetailDialog';
import { CustomTitleBar } from './CustomTitleBar';
import { PaneContainer } from './PaneContainer';
import { Sidebar } from './Sidebar';
+import { DragOverlayTab } from './SortableTab';
+import { TabBarActions } from './TabBarActions';
+import { TabBarRow } from './TabBarRow';
+
+import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
+import type { Tab } from '@renderer/types/tabs';
export const TabbedLayout = (): React.JSX.Element => {
useKeyboardShortcuts();
@@ -32,6 +53,98 @@ export const TabbedLayout = (): React.JSX.Element => {
? 8
: getTrafficLightPaddingForZoom(zoomFactor);
+ // --- DnD state (lifted from PaneContainer) ---
+ const panes = useStore((s) => s.paneLayout.panes);
+ const [activeTab, setActiveTab] = useState(null);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ })
+ );
+
+ const handleDragStart = useCallback(
+ (event: DragStartEvent) => {
+ const { active } = event;
+ const data = active.data.current;
+
+ if (data?.type === 'tab') {
+ const sourcePaneId = data.paneId as string;
+ const tabId = data.tabId as string;
+
+ const pane = panes.find((p) => p.id === sourcePaneId);
+ const tab = pane?.tabs.find((t) => t.id === tabId);
+ if (tab) {
+ setActiveTab(tab);
+ }
+ }
+ },
+ [panes]
+ );
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ setActiveTab(null);
+
+ if (!over || !active.data.current) return;
+
+ const activeData = active.data.current;
+ const overData = over.data.current;
+
+ if (activeData.type !== 'tab') return;
+
+ const draggedTabId = activeData.tabId as string;
+ const sourcePaneId = activeData.paneId as string;
+ const state = useStore.getState();
+
+ // Case 1: Drop on a split-zone (edge of pane) → create new pane
+ if (overData?.type === 'split-zone') {
+ const targetPaneId = overData.paneId as string;
+ const side = overData.side as 'left' | 'right';
+ state.moveTabToNewPane(draggedTabId, sourcePaneId, targetPaneId, side);
+ return;
+ }
+
+ // Case 2: Drop on a tabbar (different pane) → move tab to that pane
+ if (overData?.type === 'tabbar') {
+ const targetPaneId = overData.paneId as string;
+ if (sourcePaneId !== targetPaneId) {
+ state.moveTabToPane(draggedTabId, sourcePaneId, targetPaneId);
+ }
+ return;
+ }
+
+ // Case 3: Drop on another sortable tab
+ if (overData?.type === 'tab') {
+ const overTabId = overData.tabId as string;
+ const overPaneId = overData.paneId as string;
+
+ if (sourcePaneId === overPaneId) {
+ const pane = panes.find((p) => p.id === sourcePaneId);
+ if (!pane) return;
+
+ const fromIndex = pane.tabs.findIndex((t) => t.id === draggedTabId);
+ const toIndex = pane.tabs.findIndex((t) => t.id === overTabId);
+
+ if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
+ state.reorderTabInPane(sourcePaneId, fromIndex, toIndex);
+ }
+ } else {
+ const targetPane = panes.find((p) => p.id === overPaneId);
+ if (!targetPane) return;
+
+ const insertIndex = targetPane.tabs.findIndex((t) => t.id === overTabId);
+ state.moveTabToPane(draggedTabId, sourcePaneId, overPaneId, insertIndex);
+ }
+ }
+ },
+ [panes]
+ );
+
return (
{
>
-
- {/* Command Palette (Cmd+K) */}
-
+
+
+
+ {/* Command Palette (Cmd+K) */}
+
- {/* Sidebar - Project dropdown + Sessions (280px) */}
-
+ {/* Sidebar - Task list / Sessions (280px) */}
+
- {/* Multi-pane content area */}
-
-
+ {/* Content column: floating actions bar + pane content */}
+
+ {/* Content header with action buttons — floats over pane content */}
+
+
+
+
+ {/* Multi-pane content area — renders from top:0, behind the floating bar */}
+
+
+
+
+ {/* Drag overlay - semi-transparent ghost of the dragged tab */}
+
+ {activeTab ? : null}
+
+
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index 5290ed30..edfa8348 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -249,6 +249,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
restoreTask,
fetchDeletedTasks,
deletedTasks,
+ launchParams,
} = useStore(
useShallow((s) => ({
data: s.selectedTeamData,
@@ -295,6 +296,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
restoreTask: s.restoreTask,
fetchDeletedTasks: s.fetchDeletedTasks,
deletedTasks: s.deletedTasks,
+ launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
}))
);
@@ -1124,6 +1126,26 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
Running
)}
+ {data.isAlive &&
+ launchParams?.model &&
+ (() => {
+ const MODEL_LABELS: Record
= {
+ opus: 'Opus 4.6',
+ sonnet: 'Sonnet 4.5',
+ haiku: 'Haiku 4.5',
+ };
+ const modelLabel = MODEL_LABELS[launchParams.model] ?? launchParams.model;
+ const effortLabel = launchParams.effort
+ ? launchParams.effort.charAt(0).toUpperCase() + launchParams.effort.slice(1)
+ : '';
+ const extLabel = launchParams.extendedContext ? '1M' : '';
+ const parts = [modelLabel, effortLabel, extLabel].filter(Boolean).join(' ');
+ return (
+
+ {parts}
+
+ );
+ })()}
{!data.isAlive && isTeamProvisioning && (
diff --git a/src/renderer/components/team/attachments/ImageLightbox.tsx b/src/renderer/components/team/attachments/ImageLightbox.tsx
index 28232b0a..0cee2a9d 100644
--- a/src/renderer/components/team/attachments/ImageLightbox.tsx
+++ b/src/renderer/components/team/attachments/ImageLightbox.tsx
@@ -120,6 +120,10 @@ export const ImageLightbox = ({
scrollToZoom: true,
}}
styles={{
+ // Radix Dialog's DismissableLayer sets body.style.pointerEvents = "none"
+ // when modal is open. The lightbox portal renders into body, inheriting
+ // pointer-events: none — making all buttons unclickable. Override here.
+ root: { pointerEvents: 'auto' },
container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
button: { padding: 16 },
}}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
index 19732ef4..b9982cc5 100644
--- a/src/renderer/components/team/messages/MessageComposer.tsx
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -391,15 +391,11 @@ export const MessageComposer = ({
)}
- {isProvisioning ? (
-
- Launching... inbox delivery only
-
- ) : !isTeamAlive ? (
+ {!isTeamAlive && !isProvisioning && (
Team offline
- ) : null}
+ )}
{/* Combined team + member selector */}
{crossTeamTargets.length > 0 ? (
diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts
index d98d91b3..a755bf9a 100644
--- a/src/renderer/store/slices/tabSlice.ts
+++ b/src/renderer/store/slices/tabSlice.ts
@@ -408,6 +408,21 @@ export const createTabSlice: StateCreator
= (set, ge
const existing = getAllTabs(paneLayout).find((t) => t.type === 'dashboard');
if (existing) {
+ // Move existing dashboard tab to the rightmost position in its pane
+ const pane = findPaneByTabId(paneLayout, existing.id);
+ if (pane) {
+ const fromIndex = pane.tabs.findIndex((t) => t.id === existing.id);
+ const lastIndex = pane.tabs.length - 1;
+ if (fromIndex !== -1 && fromIndex !== lastIndex) {
+ const reordered = [...pane.tabs];
+ const [moved] = reordered.splice(fromIndex, 1);
+ reordered.push(moved);
+ const updatedPane = { ...pane, tabs: reordered, activeTabId: existing.id };
+ const newLayout = updatePane(paneLayout, updatedPane);
+ set(syncFromLayout(newLayout));
+ return;
+ }
+ }
state.setActiveTab(existing.id);
return;
}
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts
index 8287c623..691d5409 100644
--- a/src/renderer/store/slices/teamSlice.ts
+++ b/src/renderer/store/slices/teamSlice.ts
@@ -70,6 +70,7 @@ import type {
CommentAttachmentPayload,
CreateTaskRequest,
CrossTeamSendRequest,
+ EffortLevel,
GlobalTask,
KanbanColumnId,
LeadActivityState,
@@ -247,6 +248,13 @@ export interface GlobalTaskDetailState {
taskId: string;
}
+/** Per-team launch parameters shown in the header badge. */
+export interface TeamLaunchParams {
+ model?: string; // 'opus' | 'sonnet' | 'haiku'
+ effort?: EffortLevel;
+ extendedContext?: boolean;
+}
+
export interface TeamSlice {
teams: TeamSummary[];
/** O(1) lookup to avoid array scans in render-hot paths */
@@ -293,6 +301,8 @@ export interface TeamSlice {
activeProvisioningRunId: string | null;
provisioningError: string | null;
clearProvisioningError: () => void;
+ /** Per-team launch parameters (model, effort, extended context) — persisted in localStorage. */
+ launchParamsByTeam: Record;
kanbanFilterQuery: string | null;
provisioningProgressUnsubscribe: (() => void) | null;
fetchBranches: (paths: string[]) => Promise;
@@ -407,6 +417,56 @@ export interface TeamSlice {
) => Promise;
}
+// --- Per-team launch params persistence ---
+const LAUNCH_PARAMS_PREFIX = 'team:launchParams:';
+
+function loadAllLaunchParams(): Record {
+ const result: Record = {};
+ try {
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key?.startsWith(LAUNCH_PARAMS_PREFIX)) {
+ const teamName = key.slice(LAUNCH_PARAMS_PREFIX.length);
+ const parsed = JSON.parse(localStorage.getItem(key)!) as TeamLaunchParams;
+ if (parsed && typeof parsed === 'object') {
+ result[teamName] = parsed;
+ }
+ }
+ }
+ } catch {
+ // ignore — best-effort restore
+ }
+ return result;
+}
+
+function saveLaunchParams(teamName: string, params: TeamLaunchParams): void {
+ try {
+ localStorage.setItem(LAUNCH_PARAMS_PREFIX + teamName, JSON.stringify(params));
+ } catch {
+ // ignore — best-effort persist
+ }
+}
+
+function removeLaunchParams(teamName: string): void {
+ try {
+ localStorage.removeItem(LAUNCH_PARAMS_PREFIX + teamName);
+ } catch {
+ // ignore
+ }
+}
+
+/**
+ * Parse raw model string from TeamLaunchRequest back into base model + extended context flag.
+ * E.g. 'opus[1m]' → { model: 'opus', extendedContext: true }
+ * 'sonnet' → { model: 'sonnet', extendedContext: false }
+ */
+function parseModelString(raw?: string): { model?: string; extendedContext: boolean } {
+ if (!raw) return { extendedContext: false };
+ const match = raw.match(/^(\w+)\[1m\]$/);
+ if (match) return { model: match[1], extendedContext: true };
+ return { model: raw, extendedContext: false };
+}
+
function loadToolApprovalSettings(): ToolApprovalSettings {
try {
const raw = localStorage.getItem('team:toolApprovalSettings');
@@ -469,6 +529,7 @@ export const createTeamSlice: StateCreator = (set,
activeProvisioningRunId: null,
provisioningError: null,
clearProvisioningError: () => set({ provisioningError: null }),
+ launchParamsByTeam: loadAllLaunchParams(),
fetchMemberSpawnStatuses: async (teamName: string) => {
if (!api.teams?.getMemberSpawnStatuses) return;
try {
@@ -1167,6 +1228,24 @@ export const createTeamSlice: StateCreator = (set,
);
}
const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
+
+ // Persist per-team launch params (model, effort, extended context)
+ const { model: baseModel, extendedContext } = parseModelString(request.model);
+ if (baseModel) {
+ const params: TeamLaunchParams = {
+ model: baseModel,
+ effort: request.effort,
+ extendedContext,
+ };
+ saveLaunchParams(request.teamName, params);
+ set((state) => ({
+ launchParamsByTeam: {
+ ...state.launchParamsByTeam,
+ [request.teamName]: params,
+ },
+ }));
+ }
+
set({
activeProvisioningRunId: response.runId,
provisioningError: null,
@@ -1233,6 +1312,32 @@ export const createTeamSlice: StateCreator = (set,
}));
try {
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
+
+ // Persist per-team launch params (model, effort, extended context)
+ const { model: baseModel, extendedContext } = parseModelString(request.model);
+ if (baseModel) {
+ const params: TeamLaunchParams = {
+ model: baseModel,
+ effort: request.effort,
+ extendedContext,
+ };
+ saveLaunchParams(request.teamName, params);
+ set((state) => ({
+ launchParamsByTeam: {
+ ...state.launchParamsByTeam,
+ [request.teamName]: params,
+ },
+ }));
+ } else {
+ // No model selected — clear stored params
+ removeLaunchParams(request.teamName);
+ set((state) => {
+ const updated = { ...state.launchParamsByTeam };
+ delete updated[request.teamName];
+ return { launchParamsByTeam: updated };
+ });
+ }
+
set({
activeProvisioningRunId: response.runId,
provisioningError: null,