1import { findProjectByPath } from "@t3tools/client-runtime/state/projects";
2import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts";
3
4const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
5/** One or two threads in a directory is usually a one-off question, not a project. */
6const DEFAULT_SELECTION_MIN_THREADS = 3;
7
8/**
9 * Existing projects still need their agent history imported, so every scan
10 * candidate is offered. The default selection is narrower: git repositories
11 * active in the last 30 days with enough threads to look like real work.
12 * Servers that predate the git scan omit `git`; their candidates are treated
13 * as repositories so old computers still get a useful default selection.
14 */
15export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>(
16 candidates: ReadonlyArray<T>,
17 now = Date.now(),
18) {
19 const cutoff = now - RECENT_PROJECT_WINDOW_MS;
20
21 return {
22 available: candidates,
23 recent: candidates.filter((candidate) => {
24 if (candidate.git === null) return false;
25 if (candidate.threadCount < DEFAULT_SELECTION_MIN_THREADS) return false;
26 if (candidate.lastActiveAt === null) return false;
27 const lastActiveAt = Date.parse(candidate.lastActiveAt);
28 return lastActiveAt >= cutoff && lastActiveAt <= now;
29 }),
30 };
31}
32
33export interface OnboardingProjectGroup<T> {
34 /** Stable identity for collapse state and React keys. */
35 readonly key: string;
36 /** GitHub `owner/name`, or the checkout's folder name when the origin is elsewhere. */
37 readonly label: string;
38 readonly repository: string | null;
39 readonly candidates: ReadonlyArray<T>;
40 readonly threadCount: number;
41 readonly lastActiveAt: string | null;
42}
43
44function latestActivity(left: string | null, right: string | null): string | null {
45 if (left === null) return right;
46 if (right === null) return left;
47 return left > right ? left : right;
48}
49
50/**
51 * Group scan candidates for the onboarding picker. Clones of one repository
52 * share a group keyed by their normalized origin URL. Repositories without an
53 * origin get a group each, as do candidates from servers that do not report
54 * git identity. Directories that are not git repositories are returned
55 * separately so the UI can fold them away by default. Groups sort by most
56 * recent activity, newest first.
57 */
58export function groupOnboardingProjects<
59 T extends Pick<
60 AgentSessionProjectCandidate,
61 "path" | "title" | "git" | "threadCount" | "lastActiveAt"
62 >,
63>(candidates: ReadonlyArray<T>) {
64 const groups = new Map<string, OnboardingProjectGroup<T> & { candidates: Array<T> }>();
65 const other: Array<T> = [];
66
67 for (const candidate of candidates) {
68 if (candidate.git === null) {
69 other.push(candidate);
70 continue;
71 }
72 const git = candidate.git ?? { remoteKey: null, repository: null };
73 const key = git.remoteKey === null ? `path:${candidate.path}` : `remote:${git.remoteKey}`;
74 const existing = groups.get(key);
75 if (existing === undefined) {
76 groups.set(key, {
77 key,
78 label: git.repository ?? candidate.title,
79 repository: git.repository,
80 candidates: [candidate],
81 threadCount: candidate.threadCount,
82 lastActiveAt: candidate.lastActiveAt,
83 });
84 continue;
85 }
86 existing.candidates.push(candidate);
87 groups.set(key, {
88 ...existing,
89 threadCount: existing.threadCount + candidate.threadCount,
90 lastActiveAt: latestActivity(existing.lastActiveAt, candidate.lastActiveAt),
91 });
92 }
93
94 const repositories = [...groups.values()].sort((left, right) => {
95 if (left.lastActiveAt === right.lastActiveAt) return left.label.localeCompare(right.label);
96 if (left.lastActiveAt === null) return 1;
97 if (right.lastActiveAt === null) return -1;
98 return right.lastActiveAt.localeCompare(left.lastActiveAt);
99 });
100
101 return { repositories, other };
102}
103
104/** Use the server's project match before the client snapshot, which can lag behind the scan. */
105export function resolveOnboardingProjectId(
106 projects: ReadonlyArray<{
107 readonly id: ProjectId;
108 readonly environmentId: EnvironmentId;
109 readonly workspaceRoot: string;
110 }>,
111 environmentId: EnvironmentId,
112 candidate: Pick<AgentSessionProjectCandidate, "path" | "projectId">,
113): ProjectId | null {
114 if (candidate.projectId !== undefined) return candidate.projectId;
115 const environmentProjects = projects.filter((project) => project.environmentId === environmentId);
116 const currentRootMatch = findProjectByPath(environmentProjects, candidate.path);
117 if (currentRootMatch !== undefined) return currentRootMatch.id;
118 return null;
119}
120
121/** Prefer a selected project with imported history, then a completed empty import. */
122export function resolveOnboardingLandingProject<T>(
123 selection: ReadonlyArray<string>,
124 projectsWithImportedHistory: ReadonlyMap<string, T>,
125 completedProjects: ReadonlyMap<string, T>,
126): T | undefined {
127 for (const path of selection) {
128 const project = projectsWithImportedHistory.get(path);
129 if (project !== undefined) return project;
130 }
131 for (const path of selection) {
132 const project = completedProjects.get(path);
133 if (project !== undefined) return project;
134 }
135 return undefined;
136}
137
138/**
139 * Splits a scan path into the folder name and the directory above it (with
140 * its trailing separator), so a picker row can keep the name visible and let
141 * the shared prefix truncate. Handles both separators because candidates
142 * come from any environment.
143 */
144export function splitOnboardingProjectPath(path: string): {
145 readonly name: string;
146 readonly parent: string;
147} {
148 if (/^[A-Za-z]:[\\/]+$/.test(path) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/]*$/.test(path)) {
149 return { name: path, parent: "" };
150 }
151 const trimmed = path.replace(/[\\/]+$/, "");
152 const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
153 if (trimmed.length === 0) return { name: path, parent: "" };
154 if (separatorIndex < 0) return { name: trimmed, parent: "" };
155 return { name: trimmed.slice(separatorIndex + 1), parent: trimmed.slice(0, separatorIndex + 1) };
156}
157
158/** Paths identify projects only within the computer that owns them. */
159export function onboardingProjectKey(environmentId: EnvironmentId, path: string): string {
160 return JSON.stringify([environmentId, path]);
161}