agent-ecosystem/src/renderer/store/slices/tabSlice.ts
iliya 5f2bd36aca feat: implement task comments functionality
- 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.
2026-02-22 23:36:11 +02:00

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