390 lines
11 KiB
TypeScript
390 lines
11 KiB
TypeScript
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
|
|
|
/**
|
|
* Derives the project key for grouping agents.
|
|
* For worktrees, returns the parent repo path.
|
|
* For regular repos/directories, returns the cwd.
|
|
*/
|
|
export function deriveProjectKey(cwd: string): string {
|
|
const worktreeMarker = ".paseo/worktrees/";
|
|
const idx = cwd.indexOf(worktreeMarker);
|
|
if (idx !== -1) {
|
|
// Return parent repo path (before .paseo/worktrees/)
|
|
return cwd.slice(0, idx).replace(/\/$/, "");
|
|
}
|
|
return cwd;
|
|
}
|
|
|
|
/**
|
|
* Produces a stable grouping key from a git remote URL.
|
|
*
|
|
* Waterfall:
|
|
* - Prefer a GitHub key (normalizes SSH/HTTPS to the same key).
|
|
* - Fallback to a generic host/path key (still normalized across SSH/HTTPS).
|
|
*/
|
|
export function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
|
if (!remoteUrl) {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = remoteUrl.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
// Support the common forms:
|
|
// - git@github.com:owner/repo.git
|
|
// - https://github.com/owner/repo(.git)
|
|
// - ssh://git@github.com/owner/repo(.git)
|
|
let host: string | null = null;
|
|
let path: string | null = null;
|
|
|
|
// SSH scp-like form: user@host:owner/repo(.git)
|
|
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
|
if (scpLike) {
|
|
host = scpLike[1] ?? null;
|
|
path = scpLike[2] ?? null;
|
|
} else if (trimmed.includes("://")) {
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
host = parsed.hostname || null;
|
|
path = parsed.pathname ? parsed.pathname.replace(/^\//, "") : null;
|
|
} catch {
|
|
// Fall through to best-effort parsing below.
|
|
}
|
|
}
|
|
|
|
if (!host || !path) {
|
|
return null;
|
|
}
|
|
|
|
let cleanedPath = path.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
if (cleanedPath.endsWith(".git")) {
|
|
cleanedPath = cleanedPath.slice(0, -4);
|
|
}
|
|
|
|
// Best-effort: owner/repo is the common case.
|
|
// If the path is longer (e.g. groups/subgroups/repo), still keep it.
|
|
if (!cleanedPath.includes("/")) {
|
|
return null;
|
|
}
|
|
|
|
const cleanedHost = host.toLowerCase();
|
|
|
|
// GitHub normalization: treat github.com as a special "well-known" host to
|
|
// match the intended UX: group by repo even across different local worktrees.
|
|
if (cleanedHost === "github.com") {
|
|
return `remote:github.com/${cleanedPath}`;
|
|
}
|
|
|
|
return `remote:${cleanedHost}/${cleanedPath}`;
|
|
}
|
|
|
|
/**
|
|
* Extracts the repo name from a git remote URL.
|
|
* Examples:
|
|
* git@github.com:anthropics/claude-code.git -> anthropics/claude-code
|
|
* https://github.com/anthropics/claude-code.git -> anthropics/claude-code
|
|
* https://github.com/anthropics/claude-code -> anthropics/claude-code
|
|
*/
|
|
export function parseRepoNameFromRemoteUrl(
|
|
remoteUrl: string | null
|
|
): string | null {
|
|
if (!remoteUrl) {
|
|
return null;
|
|
}
|
|
|
|
let cleaned = remoteUrl;
|
|
|
|
// Handle SSH format: git@github.com:owner/repo.git
|
|
if (cleaned.startsWith("git@")) {
|
|
const colonIdx = cleaned.indexOf(":");
|
|
if (colonIdx !== -1) {
|
|
cleaned = cleaned.slice(colonIdx + 1);
|
|
}
|
|
}
|
|
// Handle HTTPS format: https://github.com/owner/repo.git
|
|
else if (cleaned.includes("://")) {
|
|
const urlPath = cleaned.split("://")[1];
|
|
if (urlPath) {
|
|
// Remove host (e.g., github.com/)
|
|
const slashIdx = urlPath.indexOf("/");
|
|
if (slashIdx !== -1) {
|
|
cleaned = urlPath.slice(slashIdx + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove .git suffix
|
|
if (cleaned.endsWith(".git")) {
|
|
cleaned = cleaned.slice(0, -4);
|
|
}
|
|
|
|
// Should be in format owner/repo now
|
|
if (cleaned.includes("/")) {
|
|
return cleaned;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extracts just the repo name (without owner) from a remote URL.
|
|
* Examples:
|
|
* git@github.com:anthropics/claude-code.git -> claude-code
|
|
*/
|
|
export function parseRepoShortNameFromRemoteUrl(
|
|
remoteUrl: string | null
|
|
): string | null {
|
|
const fullName = parseRepoNameFromRemoteUrl(remoteUrl);
|
|
if (!fullName) {
|
|
return null;
|
|
}
|
|
const parts = fullName.split("/");
|
|
return parts[parts.length - 1] || null;
|
|
}
|
|
|
|
/**
|
|
* Extracts the project name from a path (last segment).
|
|
*/
|
|
export function deriveProjectName(projectKey: string): string {
|
|
const githubRemotePrefix = "remote:github.com/";
|
|
if (projectKey.startsWith(githubRemotePrefix)) {
|
|
return projectKey.slice(githubRemotePrefix.length) || projectKey;
|
|
}
|
|
const segments = projectKey.split("/").filter(Boolean);
|
|
return segments[segments.length - 1] || projectKey;
|
|
}
|
|
|
|
/**
|
|
* Formats a project name for display in the UI.
|
|
*
|
|
* - GitHub remotes show owner/repo
|
|
* - Other remotes show the remote path when possible
|
|
* - Local projects prefer the provided projectName, then fallback to cwd tail
|
|
*/
|
|
export function deriveProjectDisplayName(input: {
|
|
projectKey: string;
|
|
projectName: string;
|
|
}): string {
|
|
const githubPrefix = "remote:github.com/";
|
|
if (input.projectKey.startsWith(githubPrefix)) {
|
|
return input.projectKey.slice(githubPrefix.length);
|
|
}
|
|
|
|
if (input.projectKey.startsWith("remote:")) {
|
|
const withoutPrefix = input.projectKey.slice("remote:".length);
|
|
const slashIdx = withoutPrefix.indexOf("/");
|
|
if (slashIdx >= 0) {
|
|
const remotePath = withoutPrefix.slice(slashIdx + 1).trim();
|
|
if (remotePath.length > 0) {
|
|
return remotePath;
|
|
}
|
|
}
|
|
return withoutPrefix;
|
|
}
|
|
|
|
const trimmedProjectName = input.projectName.trim();
|
|
if (trimmedProjectName.length > 0) {
|
|
return trimmedProjectName;
|
|
}
|
|
|
|
const normalized = input.projectKey.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
const segments = normalized.split("/").filter(Boolean);
|
|
return segments[segments.length - 1] ?? input.projectKey;
|
|
}
|
|
|
|
/**
|
|
* Determines the date group label for an agent based on lastActivityAt.
|
|
*/
|
|
export function deriveDateGroup(lastActivityAt: Date): string {
|
|
const now = new Date();
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
|
const activityDate = new Date(
|
|
lastActivityAt.getFullYear(),
|
|
lastActivityAt.getMonth(),
|
|
lastActivityAt.getDate()
|
|
);
|
|
|
|
if (activityDate.getTime() >= today.getTime()) {
|
|
return "Recent";
|
|
}
|
|
if (activityDate.getTime() >= yesterday.getTime()) {
|
|
return "Yesterday";
|
|
}
|
|
|
|
const diffTime = today.getTime() - activityDate.getTime();
|
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays <= 7) {
|
|
return "This week";
|
|
}
|
|
if (diffDays <= 30) {
|
|
return "This month";
|
|
}
|
|
return "Older";
|
|
}
|
|
|
|
export interface ProjectGroup {
|
|
projectKey: string;
|
|
projectName: string;
|
|
agents: AggregatedAgent[];
|
|
/** Number of truly active agents (running, needs input, or requires attention) */
|
|
activeCount: number;
|
|
/** Total agents before any limit was applied */
|
|
totalCount: number;
|
|
}
|
|
|
|
export interface DateGroup {
|
|
label: string;
|
|
agents: AggregatedAgent[];
|
|
}
|
|
|
|
export interface GroupedAgents {
|
|
activeGroups: ProjectGroup[];
|
|
inactiveGroups: DateGroup[];
|
|
}
|
|
|
|
const ACTIVE_GRACE_PERIOD_MS = 2 * 24 * 60 * 60 * 1000; // 2 days (temporary for screenshots)
|
|
|
|
interface GroupAgentsOptions {
|
|
/**
|
|
* Optional function to read a remote URL for an agent.
|
|
* If present and a remote URL is available, agents are grouped by remote.
|
|
*/
|
|
getRemoteUrl?: (agent: AggregatedAgent) => string | null;
|
|
}
|
|
|
|
const MAX_INACTIVE_PER_PROJECT = 5;
|
|
|
|
/**
|
|
* Groups agents into active (by project) and inactive (by date) sections.
|
|
* Active = running, needs input, requires attention, or had activity within the grace period.
|
|
*
|
|
* Within each project group:
|
|
* - All truly active agents (running/needs input/requires attention) are always shown
|
|
* - Recently active (within grace period but not truly active) are limited to MAX_INACTIVE_PER_PROJECT
|
|
*/
|
|
export function groupAgents(
|
|
agents: AggregatedAgent[],
|
|
options?: GroupAgentsOptions
|
|
): GroupedAgents {
|
|
const activeAgents: AggregatedAgent[] = [];
|
|
const inactiveAgents: AggregatedAgent[] = [];
|
|
const now = Date.now();
|
|
|
|
for (const agent of agents) {
|
|
// Archived agents are always inactive (hidden from sidebar)
|
|
if (agent.archivedAt) {
|
|
inactiveAgents.push(agent);
|
|
continue;
|
|
}
|
|
|
|
const isRunningOrAttention =
|
|
agent.status === "running" ||
|
|
agent.requiresAttention ||
|
|
(agent.pendingPermissionCount ?? 0) > 0;
|
|
const ageDiff = now - agent.lastActivityAt.getTime();
|
|
const isRecentlyActive = ageDiff < ACTIVE_GRACE_PERIOD_MS;
|
|
const isActive = isRunningOrAttention || isRecentlyActive;
|
|
|
|
if (isActive) {
|
|
activeAgents.push(agent);
|
|
} else {
|
|
inactiveAgents.push(agent);
|
|
}
|
|
}
|
|
|
|
// Group active agents by project, tracking truly active vs recently active
|
|
const projectMap = new Map<
|
|
string,
|
|
{ trulyActive: AggregatedAgent[]; recentlyActive: AggregatedAgent[] }
|
|
>();
|
|
for (const agent of activeAgents) {
|
|
const remoteKey = deriveRemoteProjectKey(
|
|
options?.getRemoteUrl?.(agent) ?? null
|
|
);
|
|
const projectKey = remoteKey ?? deriveProjectKey(agent.cwd);
|
|
const existing = projectMap.get(projectKey) || {
|
|
trulyActive: [],
|
|
recentlyActive: [],
|
|
};
|
|
|
|
const isTrulyActive =
|
|
agent.status === "running" ||
|
|
agent.requiresAttention ||
|
|
(agent.pendingPermissionCount ?? 0) > 0;
|
|
if (isTrulyActive) {
|
|
existing.trulyActive.push(agent);
|
|
} else {
|
|
existing.recentlyActive.push(agent);
|
|
}
|
|
|
|
projectMap.set(projectKey, existing);
|
|
}
|
|
|
|
// Build project groups with limits applied
|
|
const activeGroups: ProjectGroup[] = [];
|
|
for (const [projectKey, { trulyActive, recentlyActive }] of projectMap) {
|
|
// Sort both arrays by lastActivityAt (newest first)
|
|
trulyActive.sort(
|
|
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
|
|
);
|
|
recentlyActive.sort(
|
|
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
|
|
);
|
|
|
|
// All truly active agents shown, limit recently active to MAX_INACTIVE_PER_PROJECT
|
|
const limitedRecentlyActive = recentlyActive.slice(
|
|
0,
|
|
MAX_INACTIVE_PER_PROJECT
|
|
);
|
|
const combinedAgents = [...trulyActive, ...limitedRecentlyActive];
|
|
|
|
// Re-sort combined list by lastActivityAt
|
|
combinedAgents.sort(
|
|
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
|
|
);
|
|
|
|
activeGroups.push({
|
|
projectKey,
|
|
projectName: deriveProjectName(projectKey),
|
|
agents: combinedAgents,
|
|
activeCount: trulyActive.length,
|
|
totalCount: trulyActive.length + recentlyActive.length,
|
|
});
|
|
}
|
|
|
|
// Sort project groups by most recent activity
|
|
activeGroups.sort((a, b) => {
|
|
const aRecent = a.agents[0]?.lastActivityAt.getTime() ?? 0;
|
|
const bRecent = b.agents[0]?.lastActivityAt.getTime() ?? 0;
|
|
return bRecent - aRecent;
|
|
});
|
|
|
|
// Group inactive agents by date
|
|
const dateMap = new Map<string, AggregatedAgent[]>();
|
|
for (const agent of inactiveAgents) {
|
|
const dateLabel = deriveDateGroup(agent.lastActivityAt);
|
|
const existing = dateMap.get(dateLabel) || [];
|
|
existing.push(agent);
|
|
dateMap.set(dateLabel, existing);
|
|
}
|
|
|
|
// Sort agents within each date group by lastActivityAt (newest first)
|
|
const dateOrder = ["Recent", "Yesterday", "This week", "This month", "Older"];
|
|
const inactiveGroups: DateGroup[] = [];
|
|
for (const label of dateOrder) {
|
|
const dateAgents = dateMap.get(label);
|
|
if (dateAgents && dateAgents.length > 0) {
|
|
dateAgents.sort(
|
|
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
|
|
);
|
|
inactiveGroups.push({ label, agents: dateAgents });
|
|
}
|
|
}
|
|
|
|
return { activeGroups, inactiveGroups };
|
|
}
|