- Added a new Contributor License Agreement (CLA) document to clarify contribution terms. - Updated the license from MIT to GNU Affero General Public License v3.0 (AGPL-3.0). - Introduced task comments feature, allowing users to add comments to tasks with validation for input length and content. - Enhanced the TeamDataService to handle adding comments and sending notifications to task owners. - Updated UI components to display comments and allow users to submit comments via a text area. - Integrated task comments into the task detail dialog for better user interaction. These changes aim to improve task management and enhance collaboration within teams.
771 lines
25 KiB
TypeScript
771 lines
25 KiB
TypeScript
/**
|
|
* Tab slice - manages tab state and actions.
|
|
*
|
|
* Facade pattern: All tab mutations operate on the paneLayout and sync
|
|
* root-level openTabs/activeTabId/selectedTabIds from the focused pane
|
|
* for backward compatibility.
|
|
*/
|
|
|
|
import {
|
|
createSearchNavigationRequest,
|
|
findTabBySession,
|
|
findTabBySessionAndProject,
|
|
truncateLabel,
|
|
} from '@renderer/types/tabs';
|
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
|
|
|
import {
|
|
findPane,
|
|
findPaneByTabId,
|
|
getAllTabs,
|
|
removePane as removePaneHelper,
|
|
syncFocusedPaneState,
|
|
updatePane,
|
|
} from '../utils/paneHelpers';
|
|
import { getFullResetState } from '../utils/stateResetHelpers';
|
|
|
|
import type { AppState, SearchNavigationContext } from '../types';
|
|
import type { PaneLayout } from '@renderer/types/panes';
|
|
import type { OpenTabOptions, Tab, TabInput, TabNavigationRequest } from '@renderer/types/tabs';
|
|
import type { StateCreator } from 'zustand';
|
|
|
|
// =============================================================================
|
|
// Slice Interface
|
|
// =============================================================================
|
|
|
|
export interface TabSlice {
|
|
// State (synced from focused pane for backward compat)
|
|
openTabs: Tab[];
|
|
activeTabId: string | null;
|
|
selectedTabIds: string[];
|
|
|
|
// Project context state
|
|
activeProjectId: string | null;
|
|
|
|
// Actions
|
|
openTab: (tab: TabInput, options?: OpenTabOptions) => void;
|
|
closeTab: (tabId: string) => void;
|
|
setActiveTab: (tabId: string) => void;
|
|
openDashboard: () => void;
|
|
getActiveTab: () => Tab | null;
|
|
isSessionOpen: (sessionId: string) => boolean;
|
|
enqueueTabNavigation: (tabId: string, request: TabNavigationRequest) => void;
|
|
consumeTabNavigation: (tabId: string, requestId: string) => void;
|
|
saveTabScrollPosition: (tabId: string, scrollTop: number) => void;
|
|
|
|
// Project context actions
|
|
setActiveProject: (projectId: string) => void;
|
|
|
|
// Per-tab UI state actions
|
|
setTabContextPanelVisible: (tabId: string, visible: boolean) => void;
|
|
updateTabLabel: (tabId: string, label: string) => void;
|
|
|
|
// Multi-select actions
|
|
setSelectedTabIds: (ids: string[]) => void;
|
|
clearTabSelection: () => void;
|
|
|
|
// Bulk close actions
|
|
closeOtherTabs: (tabId: string) => void;
|
|
closeTabsToRight: (tabId: string) => void;
|
|
closeAllTabs: () => void;
|
|
closeTabs: (tabIds: string[]) => void;
|
|
|
|
// Navigation actions
|
|
navigateToSession: (
|
|
projectId: string,
|
|
sessionId: string,
|
|
fromSearch?: boolean,
|
|
searchContext?: SearchNavigationContext
|
|
) => void;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Sync root-level state from the focused pane.
|
|
*/
|
|
function syncFromLayout(layout: PaneLayout): Record<string, unknown> {
|
|
const synced = syncFocusedPaneState(layout);
|
|
return {
|
|
paneLayout: layout,
|
|
openTabs: synced.openTabs,
|
|
activeTabId: synced.activeTabId,
|
|
selectedTabIds: synced.selectedTabIds,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update a tab in whichever pane contains it, returning the new layout.
|
|
*/
|
|
function updateTabInLayout(
|
|
layout: PaneLayout,
|
|
tabId: string,
|
|
updater: (tab: Tab) => Tab
|
|
): PaneLayout {
|
|
const pane = findPaneByTabId(layout, tabId);
|
|
if (!pane) return layout;
|
|
return updatePane(layout, {
|
|
...pane,
|
|
tabs: pane.tabs.map((t) => (t.id === tabId ? updater(t) : t)),
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Slice Creator
|
|
// =============================================================================
|
|
|
|
export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, get) => ({
|
|
// Initial state (synced from focused pane)
|
|
openTabs: [],
|
|
activeTabId: null,
|
|
selectedTabIds: [],
|
|
|
|
// Project context state
|
|
activeProjectId: null,
|
|
|
|
// Open a tab in the focused pane, or focus existing if sessionId matches (within focused pane)
|
|
openTab: (tab: TabInput, options?: OpenTabOptions) => {
|
|
const state = get();
|
|
const { paneLayout } = state;
|
|
const focusedPane = findPane(paneLayout, paneLayout.focusedPaneId);
|
|
if (!focusedPane) return;
|
|
|
|
// If opening a session tab, check for duplicates first (unless forceNewTab)
|
|
if (tab.type === 'session' && tab.sessionId && !options?.forceNewTab) {
|
|
// Check across ALL panes for dedup
|
|
const allTabs = getAllTabs(paneLayout);
|
|
const existing = findTabBySession(allTabs, tab.sessionId);
|
|
if (existing) {
|
|
// Focus existing tab (which will also focus its pane)
|
|
state.setActiveTab(existing.id);
|
|
return;
|
|
}
|
|
|
|
// Replace active tab if replaceActiveTab option is set or active tab is a dashboard
|
|
const activeTab = focusedPane.tabs.find((t) => t.id === focusedPane.activeTabId);
|
|
if (activeTab && (options?.replaceActiveTab || activeTab.type === 'dashboard')) {
|
|
// Cleanup old tab's state if it was a session tab
|
|
if (activeTab.type === 'session') {
|
|
state.cleanupTabUIState(activeTab.id);
|
|
state.cleanupTabSessionData(activeTab.id);
|
|
}
|
|
|
|
const replacementTab: Tab = {
|
|
...tab,
|
|
id: activeTab.id,
|
|
label: truncateLabel(tab.label),
|
|
createdAt: Date.now(),
|
|
};
|
|
|
|
const updatedPane = {
|
|
...focusedPane,
|
|
tabs: focusedPane.tabs.map((t) => (t.id === activeTab.id ? replacementTab : t)),
|
|
activeTabId: replacementTab.id,
|
|
};
|
|
const newLayout = updatePane(paneLayout, updatedPane);
|
|
set(syncFromLayout(newLayout));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Create new tab with generated id and timestamp
|
|
const newTab: Tab = {
|
|
...tab,
|
|
id: crypto.randomUUID(),
|
|
label: truncateLabel(tab.label),
|
|
createdAt: Date.now(),
|
|
};
|
|
|
|
const updatedPane = {
|
|
...focusedPane,
|
|
tabs: [...focusedPane.tabs, newTab],
|
|
activeTabId: newTab.id,
|
|
};
|
|
const newLayout = updatePane(paneLayout, updatedPane);
|
|
set(syncFromLayout(newLayout));
|
|
},
|
|
|
|
// Close a tab by ID in whichever pane contains it
|
|
closeTab: (tabId: string) => {
|
|
const state = get();
|
|
const { paneLayout } = state;
|
|
const pane = findPaneByTabId(paneLayout, tabId);
|
|
if (!pane) return;
|
|
|
|
const index = pane.tabs.findIndex((t) => t.id === tabId);
|
|
if (index === -1) return;
|
|
|
|
// Cleanup per-tab UI state and session data
|
|
state.cleanupTabUIState(tabId);
|
|
state.cleanupTabSessionData(tabId);
|
|
|
|
const newTabs = pane.tabs.filter((t) => t.id !== tabId);
|
|
|
|
// Determine new active tab within this pane
|
|
let newActiveId = pane.activeTabId;
|
|
if (pane.activeTabId === tabId) {
|
|
newActiveId = newTabs[index]?.id ?? newTabs[index - 1]?.id ?? null;
|
|
}
|
|
|
|
// If pane becomes empty and it's not the only pane, close the pane
|
|
if (newTabs.length === 0 && paneLayout.panes.length > 1) {
|
|
state.closePane(pane.id);
|
|
return;
|
|
}
|
|
|
|
// If all tabs across all panes are gone, reset to initial state
|
|
const allOtherTabs = paneLayout.panes.filter((p) => p.id !== pane.id).flatMap((p) => p.tabs);
|
|
if (newTabs.length === 0 && allOtherTabs.length === 0) {
|
|
const updatedPane = { ...pane, tabs: [], activeTabId: null, selectedTabIds: [] };
|
|
const newLayout = updatePane(paneLayout, updatedPane);
|
|
set({
|
|
...syncFromLayout(newLayout),
|
|
...getFullResetState(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const updatedPane = {
|
|
...pane,
|
|
tabs: newTabs,
|
|
activeTabId: newActiveId,
|
|
selectedTabIds: pane.selectedTabIds.filter((id) => id !== tabId),
|
|
};
|
|
const newLayout = updatePane(paneLayout, updatedPane);
|
|
set(syncFromLayout(newLayout));
|
|
|
|
// Sync sidebar state for the newly active tab (project, repository, sessions)
|
|
if (newActiveId) {
|
|
get().setActiveTab(newActiveId);
|
|
}
|
|
},
|
|
|
|
// Switch focus to an existing tab
|
|
// Also syncs sidebar state for session tabs to match the tab's project/session
|
|
setActiveTab: (tabId: string) => {
|
|
const state = get();
|
|
const { paneLayout } = state;
|
|
|
|
// Find which pane contains this tab
|
|
const pane = findPaneByTabId(paneLayout, tabId);
|
|
if (!pane) return;
|
|
|
|
const tab = pane.tabs.find((t) => t.id === tabId);
|
|
if (!tab) return;
|
|
|
|
// Update pane's activeTabId and focus the pane
|
|
const updatedPane = { ...pane, activeTabId: tabId };
|
|
let newLayout = updatePane(paneLayout, updatedPane);
|
|
newLayout = { ...newLayout, focusedPaneId: pane.id };
|
|
set(syncFromLayout(newLayout));
|
|
|
|
// For session tabs, sync sidebar state to match
|
|
if (tab.type === 'session' && tab.sessionId && tab.projectId) {
|
|
const sessionId = tab.sessionId;
|
|
const projectId = tab.projectId;
|
|
const sessionChanged = state.selectedSessionId !== sessionId;
|
|
|
|
// Check if per-tab data is already cached
|
|
const cachedTabData = state.tabSessionData[tabId];
|
|
const hasCachedData = cachedTabData?.conversation != null;
|
|
|
|
// Find the repository and worktree containing this session
|
|
let foundRepo: string | null = null;
|
|
let foundWorktree: string | null = null;
|
|
|
|
for (const repo of state.repositoryGroups) {
|
|
for (const wt of repo.worktrees) {
|
|
if (wt.sessions.includes(sessionId)) {
|
|
foundRepo = repo.id;
|
|
foundWorktree = wt.id;
|
|
break;
|
|
}
|
|
}
|
|
if (foundRepo) break;
|
|
}
|
|
|
|
if (foundRepo && foundWorktree) {
|
|
const worktreeChanged = state.selectedWorktreeId !== foundWorktree;
|
|
set({
|
|
selectedRepositoryId: foundRepo,
|
|
selectedWorktreeId: foundWorktree,
|
|
selectedSessionId: sessionId,
|
|
activeProjectId: foundWorktree,
|
|
selectedProjectId: foundWorktree,
|
|
});
|
|
if (worktreeChanged) {
|
|
void get().fetchSessionsInitial(foundWorktree);
|
|
}
|
|
if (sessionChanged) {
|
|
if (hasCachedData) {
|
|
// Swap global state from per-tab cache (no re-fetch)
|
|
set({
|
|
sessionDetail: cachedTabData.sessionDetail,
|
|
conversation: cachedTabData.conversation,
|
|
conversationLoading: false,
|
|
sessionDetailLoading: false,
|
|
sessionDetailError: null,
|
|
sessionClaudeMdStats: cachedTabData.sessionClaudeMdStats,
|
|
sessionContextStats: cachedTabData.sessionContextStats,
|
|
sessionPhaseInfo: cachedTabData.sessionPhaseInfo,
|
|
visibleAIGroupId: cachedTabData.visibleAIGroupId,
|
|
selectedAIGroup: cachedTabData.selectedAIGroup,
|
|
});
|
|
} else {
|
|
void get().fetchSessionDetail(foundWorktree, sessionId, tabId);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Fallback: search in flat projects
|
|
const project = state.projects.find(
|
|
(p) => p.id === projectId || p.sessions.includes(sessionId)
|
|
);
|
|
if (project) {
|
|
const projectChanged = state.selectedProjectId !== project.id;
|
|
set({
|
|
activeProjectId: project.id,
|
|
selectedProjectId: project.id,
|
|
selectedSessionId: sessionId,
|
|
});
|
|
if (projectChanged) {
|
|
void get().fetchSessionsInitial(project.id);
|
|
}
|
|
if (sessionChanged) {
|
|
if (hasCachedData) {
|
|
// Swap global state from per-tab cache (no re-fetch)
|
|
set({
|
|
sessionDetail: cachedTabData.sessionDetail,
|
|
conversation: cachedTabData.conversation,
|
|
conversationLoading: false,
|
|
sessionDetailLoading: false,
|
|
sessionDetailError: null,
|
|
sessionClaudeMdStats: cachedTabData.sessionClaudeMdStats,
|
|
sessionContextStats: cachedTabData.sessionContextStats,
|
|
sessionPhaseInfo: cachedTabData.sessionPhaseInfo,
|
|
visibleAIGroupId: cachedTabData.visibleAIGroupId,
|
|
selectedAIGroup: cachedTabData.selectedAIGroup,
|
|
});
|
|
} else {
|
|
void get().fetchSessionDetail(project.id, sessionId, tabId);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// For team tabs, re-select the team so global selectedTeamData matches this tab.
|
|
// Without this, switching between team A and team B tabs leaves stale data
|
|
// because each TeamDetailView is kept mounted (CSS display-toggle) and its
|
|
// useEffect(teamName) only fires once on mount.
|
|
if (tab.type === 'team' && tab.teamName) {
|
|
if (state.selectedTeamName !== tab.teamName) {
|
|
// Different team -- full reload (also auto-selects project via selectTeam)
|
|
void state.selectTeam(tab.teamName);
|
|
} else {
|
|
// Same team already loaded -- just sync sidebar project if team has a projectPath.
|
|
// This covers the case where the user switched to a session tab (changing the
|
|
// sidebar project) and then switches back to the team tab.
|
|
const teamData = state.selectedTeamData;
|
|
const projectPath = teamData?.config.projectPath;
|
|
if (projectPath) {
|
|
const normalizedTeamPath = normalizePath(projectPath);
|
|
const matchingProject = state.projects.find(
|
|
(p) => normalizePath(p.path) === normalizedTeamPath
|
|
);
|
|
if (matchingProject && state.selectedProjectId !== matchingProject.id) {
|
|
state.selectProject(matchingProject.id);
|
|
} else if (!matchingProject) {
|
|
for (const repo of state.repositoryGroups) {
|
|
const matchingWorktree = repo.worktrees.find(
|
|
(wt) => normalizePath(wt.path) === normalizedTeamPath
|
|
);
|
|
if (matchingWorktree && state.selectedWorktreeId !== matchingWorktree.id) {
|
|
state.selectRepository(repo.id);
|
|
state.selectWorktree(matchingWorktree.id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
// Open a new dashboard tab in the focused pane
|
|
openDashboard: () => {
|
|
const state = get();
|
|
const { paneLayout } = state;
|
|
const focusedPane = findPane(paneLayout, paneLayout.focusedPaneId);
|
|
if (!focusedPane) return;
|
|
|
|
const newTab: Tab = {
|
|
id: crypto.randomUUID(),
|
|
type: 'dashboard',
|
|
label: 'Dashboard',
|
|
createdAt: Date.now(),
|
|
};
|
|
|
|
const updatedPane = {
|
|
...focusedPane,
|
|
tabs: [...focusedPane.tabs, newTab],
|
|
activeTabId: newTab.id,
|
|
};
|
|
const newLayout = updatePane(paneLayout, updatedPane);
|
|
set(syncFromLayout(newLayout));
|
|
},
|
|
|
|
// Get the currently active tab (from the focused pane)
|
|
getActiveTab: () => {
|
|
const state = get();
|
|
const focusedPane = findPane(state.paneLayout, state.paneLayout.focusedPaneId);
|
|
if (!focusedPane?.activeTabId) return null;
|
|
return focusedPane.tabs.find((t) => t.id === focusedPane.activeTabId) ?? null;
|
|
},
|
|
|
|
// Check if a session is already open in any pane
|
|
isSessionOpen: (sessionId: string) => {
|
|
const allTabs = getAllTabs(get().paneLayout);
|
|
return allTabs.some((t) => t.type === 'session' && t.sessionId === sessionId);
|
|
},
|
|
|
|
// Enqueue a navigation request on a tab (in whichever pane contains it)
|
|
enqueueTabNavigation: (tabId: string, request: TabNavigationRequest) => {
|
|
const { paneLayout } = get();
|
|
const newLayout = updateTabInLayout(paneLayout, tabId, (tab) => ({
|
|
...tab,
|
|
pendingNavigation: request,
|
|
}));
|
|
set(syncFromLayout(newLayout));
|
|
},
|
|
|
|
// Mark a navigation request as consumed
|
|
consumeTabNavigation: (tabId: string, requestId: string) => {
|
|
const { paneLayout } = get();
|
|
const newLayout = updateTabInLayout(paneLayout, tabId, (tab) =>
|
|
tab.pendingNavigation?.id === requestId
|
|
? { ...tab, pendingNavigation: undefined, lastConsumedNavigationId: requestId }
|
|
: tab
|
|
);
|
|
set(syncFromLayout(newLayout));
|
|
},
|
|
|
|
// Save scroll position for a tab
|
|
saveTabScrollPosition: (tabId: string, scrollTop: number) => {
|
|
const { paneLayout } = get();
|
|
const newLayout = updateTabInLayout(paneLayout, tabId, (tab) => ({
|
|
...tab,
|
|
savedScrollTop: scrollTop,
|
|
}));
|
|
set(syncFromLayout(newLayout));
|
|
},
|
|
|
|
// Update a tab's label (used by sessionDetailSlice after fetching session data)
|
|
updateTabLabel: (tabId: string, label: string) => {
|
|
const { paneLayout } = get();
|
|
const newLayout = updateTabInLayout(paneLayout, tabId, (tab) => ({
|
|
...tab,
|
|
label,
|
|
}));
|
|
set(syncFromLayout(newLayout));
|
|
},
|
|
|
|
// Set context panel visibility for a specific tab
|
|
setTabContextPanelVisible: (tabId: string, visible: boolean) => {
|
|
const { paneLayout } = get();
|
|
const newLayout = updateTabInLayout(paneLayout, tabId, (tab) => ({
|
|
...tab,
|
|
showContextPanel: visible,
|
|
}));
|
|
set(syncFromLayout(newLayout));
|
|
},
|
|
|
|
// Set multi-selected tab IDs (within the focused pane)
|
|
setSelectedTabIds: (ids: string[]) => {
|
|
const { paneLayout } = get();
|
|
const focusedPane = findPane(paneLayout, paneLayout.focusedPaneId);
|
|
if (!focusedPane) return;
|
|
|
|
const updatedPane = { ...focusedPane, selectedTabIds: ids };
|
|
const newLayout = updatePane(paneLayout, updatedPane);
|
|
set(syncFromLayout(newLayout));
|
|
},
|
|
|
|
// Clear multi-selection in the focused pane
|
|
clearTabSelection: () => {
|
|
const { paneLayout } = get();
|
|
const focusedPane = findPane(paneLayout, paneLayout.focusedPaneId);
|
|
if (!focusedPane) return;
|
|
|
|
const updatedPane = { ...focusedPane, selectedTabIds: [] };
|
|
const newLayout = updatePane(paneLayout, updatedPane);
|
|
set(syncFromLayout(newLayout));
|
|
},
|
|
|
|
// Close all tabs except the specified one (within the pane containing the tab)
|
|
closeOtherTabs: (tabId: string) => {
|
|
const state = get();
|
|
const { paneLayout } = state;
|
|
const pane = findPaneByTabId(paneLayout, tabId);
|
|
if (!pane) return;
|
|
|
|
const tabsToClose = pane.tabs.filter((t) => t.id !== tabId);
|
|
for (const tab of tabsToClose) {
|
|
state.cleanupTabUIState(tab.id);
|
|
}
|
|
|
|
const keepTab = pane.tabs.find((t) => t.id === tabId);
|
|
if (!keepTab) return;
|
|
|
|
const updatedPane = {
|
|
...pane,
|
|
tabs: [keepTab],
|
|
activeTabId: tabId,
|
|
selectedTabIds: [],
|
|
};
|
|
const newLayout = updatePane(paneLayout, updatedPane);
|
|
set(syncFromLayout(newLayout));
|
|
|
|
// Sync sidebar state for the remaining tab
|
|
get().setActiveTab(tabId);
|
|
},
|
|
|
|
// Close all tabs to the right (within the pane containing the tab)
|
|
closeTabsToRight: (tabId: string) => {
|
|
const state = get();
|
|
const { paneLayout } = state;
|
|
const pane = findPaneByTabId(paneLayout, tabId);
|
|
if (!pane) return;
|
|
|
|
const index = pane.tabs.findIndex((t) => t.id === tabId);
|
|
if (index === -1) return;
|
|
|
|
const tabsToClose = pane.tabs.slice(index + 1);
|
|
for (const tab of tabsToClose) {
|
|
state.cleanupTabUIState(tab.id);
|
|
}
|
|
|
|
const newTabs = pane.tabs.slice(0, index + 1);
|
|
const activeStillExists = newTabs.some((t) => t.id === pane.activeTabId);
|
|
const newActiveId = activeStillExists ? pane.activeTabId : tabId;
|
|
const updatedPane = {
|
|
...pane,
|
|
tabs: newTabs,
|
|
activeTabId: newActiveId,
|
|
selectedTabIds: [],
|
|
};
|
|
const newLayout = updatePane(paneLayout, updatedPane);
|
|
set(syncFromLayout(newLayout));
|
|
|
|
// Sync sidebar state for the active tab
|
|
if (newActiveId) {
|
|
get().setActiveTab(newActiveId);
|
|
}
|
|
},
|
|
|
|
// Close all tabs across all panes, reset to initial state
|
|
closeAllTabs: () => {
|
|
const state = get();
|
|
const allTabs = getAllTabs(state.paneLayout);
|
|
for (const tab of allTabs) {
|
|
state.cleanupTabUIState(tab.id);
|
|
state.cleanupTabSessionData(tab.id);
|
|
}
|
|
|
|
// Reset to single empty pane
|
|
const defaultPaneId = state.paneLayout.panes[0]?.id ?? 'pane-default';
|
|
const newLayout: PaneLayout = {
|
|
panes: [
|
|
{
|
|
id: defaultPaneId,
|
|
tabs: [],
|
|
activeTabId: null,
|
|
selectedTabIds: [],
|
|
widthFraction: 1,
|
|
},
|
|
],
|
|
focusedPaneId: defaultPaneId,
|
|
};
|
|
|
|
set({
|
|
...syncFromLayout(newLayout),
|
|
...getFullResetState(),
|
|
});
|
|
},
|
|
|
|
// Close multiple tabs by ID (within the pane containing them)
|
|
closeTabs: (tabIds: string[]) => {
|
|
const state = get();
|
|
const idSet = new Set(tabIds);
|
|
|
|
// Cleanup UI state and session data
|
|
for (const id of idSet) {
|
|
state.cleanupTabUIState(id);
|
|
state.cleanupTabSessionData(id);
|
|
}
|
|
|
|
// Group tabs by pane for batch removal
|
|
let { paneLayout } = state;
|
|
const panesToRemove: string[] = [];
|
|
|
|
for (const pane of paneLayout.panes) {
|
|
const remainingTabs = pane.tabs.filter((t) => !idSet.has(t.id));
|
|
|
|
if (remainingTabs.length === pane.tabs.length) continue; // No tabs removed from this pane
|
|
|
|
if (remainingTabs.length === 0 && paneLayout.panes.length > 1) {
|
|
panesToRemove.push(pane.id);
|
|
continue;
|
|
}
|
|
|
|
// Determine new active tab
|
|
let newActiveId = pane.activeTabId;
|
|
if (newActiveId && idSet.has(newActiveId)) {
|
|
const oldIndex = pane.tabs.findIndex((t) => t.id === newActiveId);
|
|
newActiveId = null;
|
|
for (let i = oldIndex; i < pane.tabs.length; i++) {
|
|
if (!idSet.has(pane.tabs[i].id)) {
|
|
newActiveId = pane.tabs[i].id;
|
|
break;
|
|
}
|
|
}
|
|
if (!newActiveId) {
|
|
for (let i = oldIndex - 1; i >= 0; i--) {
|
|
if (!idSet.has(pane.tabs[i].id)) {
|
|
newActiveId = pane.tabs[i].id;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
newActiveId = newActiveId ?? remainingTabs[0]?.id ?? null;
|
|
}
|
|
|
|
paneLayout = updatePane(paneLayout, {
|
|
...pane,
|
|
tabs: remainingTabs,
|
|
activeTabId: newActiveId,
|
|
selectedTabIds: pane.selectedTabIds.filter((id) => !idSet.has(id)),
|
|
});
|
|
}
|
|
|
|
// Check if ALL tabs are now gone
|
|
const allRemainingTabs = getAllTabs(paneLayout);
|
|
if (allRemainingTabs.length === 0) {
|
|
state.closeAllTabs();
|
|
return;
|
|
}
|
|
|
|
// Remove empty panes
|
|
for (const paneId of panesToRemove) {
|
|
paneLayout = removePaneHelper(paneLayout, paneId);
|
|
}
|
|
|
|
set(syncFromLayout(paneLayout));
|
|
|
|
// Sync sidebar state for the new active tab
|
|
const newActiveTabId = get().activeTabId;
|
|
if (newActiveTabId) {
|
|
get().setActiveTab(newActiveTabId);
|
|
}
|
|
},
|
|
|
|
// Set active project and fetch its sessions
|
|
setActiveProject: (projectId: string) => {
|
|
set({ activeProjectId: projectId });
|
|
get().selectProject(projectId);
|
|
},
|
|
|
|
// Navigate to a session (from search or other sources)
|
|
navigateToSession: (
|
|
projectId: string,
|
|
sessionId: string,
|
|
fromSearch = false,
|
|
searchContext?: SearchNavigationContext
|
|
) => {
|
|
const state = get();
|
|
|
|
// Check if session tab is already open in any pane
|
|
const allTabs = getAllTabs(state.paneLayout);
|
|
const existingTab =
|
|
findTabBySessionAndProject(allTabs, sessionId, projectId) ??
|
|
findTabBySession(allTabs, sessionId);
|
|
|
|
if (existingTab) {
|
|
// Focus existing tab via setActiveTab for proper sidebar sync
|
|
state.setActiveTab(existingTab.id);
|
|
|
|
// Enqueue search navigation if search context provided
|
|
if (searchContext) {
|
|
const searchPayload = {
|
|
query: searchContext.query,
|
|
messageTimestamp: searchContext.messageTimestamp,
|
|
matchedText: searchContext.matchedText,
|
|
...(searchContext.targetGroupId !== undefined
|
|
? { targetGroupId: searchContext.targetGroupId }
|
|
: {}),
|
|
...(searchContext.targetMatchIndexInItem !== undefined
|
|
? { targetMatchIndexInItem: searchContext.targetMatchIndexInItem }
|
|
: {}),
|
|
...(searchContext.targetMatchStartOffset !== undefined
|
|
? { targetMatchStartOffset: searchContext.targetMatchStartOffset }
|
|
: {}),
|
|
...(searchContext.targetMessageUuid !== undefined
|
|
? { targetMessageUuid: searchContext.targetMessageUuid }
|
|
: {}),
|
|
};
|
|
const navRequest = createSearchNavigationRequest({
|
|
...searchPayload,
|
|
});
|
|
state.enqueueTabNavigation(existingTab.id, navRequest);
|
|
}
|
|
} else {
|
|
// Open the session in a new tab
|
|
state.openTab({
|
|
type: 'session',
|
|
label: 'Loading...',
|
|
projectId,
|
|
sessionId,
|
|
fromSearch,
|
|
});
|
|
|
|
// Enqueue search navigation on the newly created tab
|
|
if (searchContext) {
|
|
const newState = get();
|
|
const newTabId = newState.activeTabId;
|
|
if (newTabId) {
|
|
// Re-focus tab via setActiveTab for proper sidebar sync
|
|
state.setActiveTab(newTabId);
|
|
|
|
const searchPayload = {
|
|
query: searchContext.query,
|
|
messageTimestamp: searchContext.messageTimestamp,
|
|
matchedText: searchContext.matchedText,
|
|
...(searchContext.targetGroupId !== undefined
|
|
? { targetGroupId: searchContext.targetGroupId }
|
|
: {}),
|
|
...(searchContext.targetMatchIndexInItem !== undefined
|
|
? { targetMatchIndexInItem: searchContext.targetMatchIndexInItem }
|
|
: {}),
|
|
...(searchContext.targetMatchStartOffset !== undefined
|
|
? { targetMatchStartOffset: searchContext.targetMatchStartOffset }
|
|
: {}),
|
|
...(searchContext.targetMessageUuid !== undefined
|
|
? { targetMessageUuid: searchContext.targetMessageUuid }
|
|
: {}),
|
|
};
|
|
const navRequest = createSearchNavigationRequest({
|
|
...searchPayload,
|
|
});
|
|
state.enqueueTabNavigation(newTabId, navRequest);
|
|
}
|
|
}
|
|
|
|
// Fetch session detail for the new tab (with tabId for per-tab data)
|
|
const newTabIdForFetch = get().activeTabId ?? undefined;
|
|
void state.fetchSessionDetail(projectId, sessionId, newTabIdForFetch);
|
|
}
|
|
},
|
|
});
|