Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/main/web-server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ export interface SessionData {
activeTabId?: string;
/** Whether session is bookmarked (shows in Bookmarks group) */
bookmarked?: boolean;
/** Worktree subagent support */
parentSessionId?: string | null;
worktreeBranch?: string | null;
}

/**
Expand Down
155 changes: 155 additions & 0 deletions src/web/utils/sessionGrouping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Session Grouping Utilities
*
* Shared logic for organizing sessions into groups (bookmarks, named groups, ungrouped).
* Used by Sidebar, AllSessionsView, and SessionPillBar to avoid duplication.
*/

import type { Session, GroupInfo } from '../hooks/useSessions';

/**
* Find the parent session for a worktree child.
* Uses parentSessionId when available, falls back to path pattern matching.
*/
export function findParentSession(session: Session, sessions: Session[]): Session | null {
if (session.parentSessionId) {
return sessions.find((s) => s.id === session.parentSessionId) || null;
}

// Try to infer parent from worktree path patterns
const cwd = session.cwd;
const worktreeMatch = cwd.match(/^(.+?)[-]?WorkTrees[\/\\]([^\/\\]+)/i);

if (worktreeMatch) {
const basePath = worktreeMatch[1];
return (
sessions.find(
(s) =>
s.id !== session.id &&
!s.parentSessionId &&
(s.cwd === basePath ||
s.cwd.startsWith(basePath + '/') ||
s.cwd.startsWith(basePath + '\\'))
) || null
Comment on lines +19 to +33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize inferred basePath before comparisons.
The regex capture can include a trailing separator (e.g., .../WorkTrees/...), which makes the equality and startsWith checks miss the parent path. This breaks parent inference in common path layouts.

🛠️ Proposed fix
-	const basePath = worktreeMatch[1];
+	const basePath = worktreeMatch[1].replace(/[\/\\]+$/, '');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Try to infer parent from worktree path patterns
const cwd = session.cwd;
const worktreeMatch = cwd.match(/^(.+?)[-]?WorkTrees[\/\\]([^\/\\]+)/i);
if (worktreeMatch) {
const basePath = worktreeMatch[1];
return (
sessions.find(
(s) =>
s.id !== session.id &&
!s.parentSessionId &&
(s.cwd === basePath ||
s.cwd.startsWith(basePath + '/') ||
s.cwd.startsWith(basePath + '\\'))
) || null
// Try to infer parent from worktree path patterns
const cwd = session.cwd;
const worktreeMatch = cwd.match(/^(.+?)[-]?WorkTrees[\/\\]([^\/\\]+)/i);
if (worktreeMatch) {
const basePath = worktreeMatch[1].replace(/[\/\\]+$/, '');
return (
sessions.find(
(s) =>
s.id !== session.id &&
!s.parentSessionId &&
(s.cwd === basePath ||
s.cwd.startsWith(basePath + '/') ||
s.cwd.startsWith(basePath + '\\'))
) || null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/web/utils/sessionGrouping.ts` around lines 19 - 33, The inferred basePath
from the worktree regex may include a trailing path separator which breaks
equality and startsWith checks; normalize basePath (e.g., strip any trailing '/'
or '\\' or run through path normalization) into a new variable like
basePathNormalized and use that in the sessions.find comparisons (replace uses
of basePath with basePathNormalized and build startsWith checks with
basePathNormalized + '/' and basePathNormalized + '\\') so parent inference
works reliably across path layouts.

);
}

return null;
}

/**
* Compute display name for a session.
* Worktree children show "ParentName: branch-name".
*/
export function getSessionDisplayName(session: Session, sessions: Session[]): string {
const parent = findParentSession(session, sessions);
if (parent) {
const branchName = session.worktreeBranch || session.name;
return `${parent.name}: ${branchName}`;
}
return session.name;
}

/**
* Get the effective group for a session.
* Worktree children inherit their parent's group.
*/
export function getSessionEffectiveGroup(
session: Session,
sessions: Session[]
): { groupId: string | null; groupName: string | null; groupEmoji: string | null } {
const parent = findParentSession(session, sessions);
if (parent) {
return {
groupId: parent.groupId || null,
groupName: parent.groupName || null,
groupEmoji: parent.groupEmoji || null,
};
}
return {
groupId: session.groupId || null,
groupName: session.groupName || null,
groupEmoji: session.groupEmoji || null,
};
}

/**
* Result of grouping sessions
*/
export interface GroupedSessions {
sessionsByGroup: Record<string, GroupInfo>;
sortedGroupKeys: string[];
}

/**
* Organize sessions into groups with bookmarks first and ungrouped last.
*
* @param filteredSessions - Sessions after any search filtering
* @param allSessions - All sessions (needed for worktree parent lookup)
*/
export function groupSessions(filteredSessions: Session[], allSessions: Session[]): GroupedSessions {
const groups: Record<string, GroupInfo> = {};

// Exclude worktree children from the main list (they nest under their parent in the Electron app)
const topLevelSessions = filteredSessions.filter((s) => !s.parentSessionId);

// Add bookmarked sessions to a special "bookmarks" group
const bookmarkedSessions = topLevelSessions.filter((s) => s.bookmarked);
if (bookmarkedSessions.length > 0) {
groups['bookmarks'] = {
id: 'bookmarks',
name: 'Bookmarks',
emoji: null,
sessions: bookmarkedSessions,
};
}

// Organize sessions by their effective group
for (const session of topLevelSessions) {
const effectiveGroup = getSessionEffectiveGroup(session, allSessions);
const groupKey = effectiveGroup.groupId || 'ungrouped';

if (!groups[groupKey]) {
groups[groupKey] = {
id: effectiveGroup.groupId,
name: effectiveGroup.groupName || 'Ungrouped',
emoji: effectiveGroup.groupEmoji,
sessions: [],
};
}
groups[groupKey].sessions.push(session);
}

// Sort: bookmarks first, named groups alphabetically, ungrouped last
const sortedGroupKeys = Object.keys(groups).sort((a, b) => {
if (a === 'bookmarks') return -1;
if (b === 'bookmarks') return 1;
if (a === 'ungrouped') return 1;
if (b === 'ungrouped') return -1;
return groups[a].name.localeCompare(groups[b].name);
});

return { sessionsByGroup: groups, sortedGroupKeys };
}

/**
* Filter sessions by a search query against name, cwd, toolType, and worktree branch.
*/
export function filterSessions(
sessions: Session[],
query: string,
allSessions: Session[]
): Session[] {
if (!query.trim()) return sessions;
const q = query.toLowerCase();
return sessions.filter((session) => {
const displayName = getSessionDisplayName(session, allSessions);
return (
displayName.toLowerCase().includes(q) ||
session.name.toLowerCase().includes(q) ||
session.cwd.toLowerCase().includes(q) ||
(session.toolType && session.toolType.toLowerCase().includes(q)) ||
(session.worktreeBranch && session.worktreeBranch.toLowerCase().includes(q))
);
});
Comment on lines +138 to +154
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Search can drop matches that only exist in child sessions.
Because groupSessions() removes children, a query that matches only a worktree child (e.g., by worktreeBranch) yields an empty UI result. Consider promoting a matching child to its parent (or otherwise ensuring the parent appears) so search remains useful.

🛠️ Proposed fix (promote matching child to parent)
 export function filterSessions(
 	sessions: Session[],
 	query: string,
 	allSessions: Session[]
 ): Session[] {
 	if (!query.trim()) return sessions;
 	const q = query.toLowerCase();
-	return sessions.filter((session) => {
-		const displayName = getSessionDisplayName(session, allSessions);
-		return (
-			displayName.toLowerCase().includes(q) ||
-			session.name.toLowerCase().includes(q) ||
-			session.cwd.toLowerCase().includes(q) ||
-			(session.toolType && session.toolType.toLowerCase().includes(q)) ||
-			(session.worktreeBranch && session.worktreeBranch.toLowerCase().includes(q))
-		);
-	});
+	const results = new Map<string, Session>();
+	for (const session of sessions) {
+		const displayName = getSessionDisplayName(session, allSessions);
+		const matches =
+			displayName.toLowerCase().includes(q) ||
+			session.name.toLowerCase().includes(q) ||
+			session.cwd.toLowerCase().includes(q) ||
+			(session.toolType && session.toolType.toLowerCase().includes(q)) ||
+			(session.worktreeBranch && session.worktreeBranch.toLowerCase().includes(q));
+		if (matches) {
+			const parent = findParentSession(session, allSessions);
+			const effective = parent ?? session;
+			results.set(effective.id, effective);
+		}
+	}
+	return Array.from(results.values());
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/web/utils/sessionGrouping.ts` around lines 138 - 154, filterSessions
currently only checks fields on each session row (using getSessionDisplayName
and session.name/cwd/toolType/worktreeBranch) but because groupSessions removes
child rows a query that only matches a child (e.g., child's worktreeBranch) will
exclude the parent and return no results; fix by extending filterSessions to
also consider children: for each parent session being tested, look up its child
sessions from allSessions (e.g., by parent id relationship used in
groupSessions) and treat the parent as a match if any child’s displayName, name,
cwd, toolType, or worktreeBranch matches the query (i.e., "promote" a matching
child to include its parent in results); keep all existing checks (including
getSessionDisplayName) and short-circuit when query is empty.

}
Loading