import { buildProjectGroups, derivePhysicalProjectKey, deriveProjectGroupLabel, } from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { getThreadSortTimestamp, sortThreads, toSortableTimestamp, } from "@t3tools/client-runtime/state/thread-sort"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ScopedProjectRef, SidebarProjectGroupingMode, SidebarProjectSortOrder, SidebarThreadSortOrder, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; import { scopedProjectKey } from "../../lib/scopedEntities"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; export type HomeProjectSortOrder = Exclude; export interface HomeProjectScope { readonly key: string; readonly title: string; readonly representative: EnvironmentProject; readonly projects: ReadonlyArray; readonly projectRefs: ReadonlyArray; } function getProjectSortTimestamp( project: EnvironmentProject, sortOrder: HomeProjectSortOrder, ): number { return sortOrder === "created_at" ? (toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY) : (toSortableTimestamp(project.updatedAt) ?? toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY); } export function buildHomeProjectScopes(input: { readonly projects: ReadonlyArray; readonly environmentId: EnvironmentId | null; readonly projectGroupingMode: SidebarProjectGroupingMode; }): ReadonlyArray { const projects = input.projects.filter( (project) => input.environmentId === null || project.environmentId === input.environmentId, ); return buildProjectGroups({ projects, settings: { sidebarProjectGroupingMode: input.projectGroupingMode, sidebarProjectGroupingOverrides: {}, }, }).map((group) => { return { key: group.key, title: group.label, representative: group.representative, projects: group.members.map((member) => member.project), projectRefs: group.memberProjectRefs, }; }); } export function sortHomeProjectScopes(input: { readonly scopes: ReadonlyArray; readonly threads: ReadonlyArray; readonly pendingTasks: ReadonlyArray; readonly projectSortOrder: HomeProjectSortOrder; }): ReadonlyArray { const scopeKeyByProjectRef = new Map( input.scopes.flatMap((scope) => scope.projectRefs.map( (projectRef) => [scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.key] as const, ), ), ); const latestActivityByScope = new Map(); const recordActivity = (scopeKey: string | undefined, timestamp: number) => { if (!scopeKey || !Number.isFinite(timestamp)) return; latestActivityByScope.set( scopeKey, Math.max(latestActivityByScope.get(scopeKey) ?? Number.NEGATIVE_INFINITY, timestamp), ); }; for (const thread of input.threads) { if (thread.archivedAt !== null) continue; recordActivity( scopeKeyByProjectRef.get(scopedProjectKey(thread.environmentId, thread.projectId)), getThreadSortTimestamp(thread, input.projectSortOrder), ); } for (const pendingTask of input.pendingTasks) { recordActivity( scopeKeyByProjectRef.get( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), ), Date.parse(pendingTask.message.createdAt), ); } return Arr.sort( input.scopes, Order.mapInput( Order.Struct({ timestamp: Order.flip(Order.Number), title: Order.String, key: Order.String, }), (scope: HomeProjectScope) => ({ timestamp: latestActivityByScope.get(scope.key) ?? Math.max( ...scope.projects.map((project) => getProjectSortTimestamp(project, input.projectSortOrder), ), ), title: scope.title, key: scope.key, }), ), ); } /** * Default home view only surfaces threads active within this window, to keep the * screen compact while keeping recent work visible. */ const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000; /** Fallback when a project has no threads inside the recency window. */ const RECENT_THREAD_FALLBACK_COUNT = 3; export interface HomeThreadGroup { readonly key: string; readonly title: string; readonly representative: EnvironmentProject; readonly projects: ReadonlyArray; readonly pendingTasks: ReadonlyArray; /** Full sorted thread history for the group (revealed when expanded / searching). */ readonly threads: ReadonlyArray; /** Subset shown by default: threads from the last few days, or the most recent few. */ readonly recentThreads: ReadonlyArray; /** * Where a quick "new thread in this project" should land. For aggregated * groups (same repo on several machines) this is the member that owns the * group's most recent thread — the machine the user last worked on — rather * than the arbitrary first member; the draft's computer picker covers * switching from there. Null only for synthetic pending-project groups, * whose single "project" is a placeholder built from queued-task metadata. */ readonly newThreadTarget: EnvironmentProject | null; } interface MutableHomeThreadGroup { readonly key: string; readonly projects: EnvironmentProject[]; readonly pendingTasks: PendingNewTask[]; readonly threads: EnvironmentThreadShell[]; } function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number { const latestThread = group.threads.reduce( (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), Number.NEGATIVE_INFINITY, ); return group.pendingTasks.reduce((latest, pendingTask) => { const timestamp = Date.parse(pendingTask.message.createdAt); return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp); }, latestThread); } /** * Trims a group's threads to recent activity for the default home view. * `sortedThreads` must already be ordered newest-first for `threadSortOrder`. * Keeps threads within {@link RECENT_THREAD_WINDOW_MS}; when none qualify, keeps * the most recent {@link RECENT_THREAD_FALLBACK_COUNT} so a project never vanishes. */ function selectRecentThreads( sortedThreads: ReadonlyArray, threadSortOrder: SidebarThreadSortOrder, now: number, ): ReadonlyArray { const cutoff = now - RECENT_THREAD_WINDOW_MS; const recent = sortedThreads.filter( (thread) => getThreadSortTimestamp(thread, threadSortOrder) >= cutoff, ); return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT); } export function buildHomeThreadGroups(input: { readonly projects: ReadonlyArray; readonly threads: ReadonlyArray; readonly pendingTasks?: ReadonlyArray; readonly environmentId: EnvironmentId | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; /** Current time used for the recency window; defaults to now. Injectable for tests. */ readonly now?: number; }): ReadonlyArray { const now = input.now ?? Date.now(); const groups = new Map(); const groupTitleByKey = new Map(); const groupKeyByProjectKey = new Map(); for (const scope of buildHomeProjectScopes(input)) { groupTitleByKey.set(scope.key, scope.title); groups.set(scope.key, { key: scope.key, projects: [...scope.projects], pendingTasks: [], threads: [], }); for (const projectRef of scope.projectRefs) { groupKeyByProjectKey.set( scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.key, ); } } for (const pendingTask of input.pendingTasks ?? []) { if (input.environmentId !== null && pendingTask.message.environmentId !== input.environmentId) { continue; } const physicalKey = scopedProjectKey( pendingTask.message.environmentId, pendingTask.creation.projectId, ); let groupKey = groupKeyByProjectKey.get(physicalKey); if (!groupKey) { // The project shell is not loaded (environment offline / project gone). // A queued task must stay visible and deletable regardless, so build a // standalone group from the metadata snapshotted at enqueue time. groupKey = `pending-project:${physicalKey}`; groupKeyByProjectKey.set(physicalKey, groupKey); groups.set(groupKey, { key: groupKey, projects: [ { environmentId: pendingTask.message.environmentId, id: pendingTask.creation.projectId, title: pendingTask.creation.projectTitle ?? "Unknown project", workspaceRoot: pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId), repositoryIdentity: null, defaultModelSelection: null, scripts: [], createdAt: pendingTask.message.createdAt, updatedAt: pendingTask.message.createdAt, }, ], pendingTasks: [], threads: [], }); } groups.get(groupKey)?.pendingTasks.push(pendingTask); } for (const thread of input.threads) { if (thread.archivedAt !== null) { continue; } if (input.environmentId !== null && thread.environmentId !== input.environmentId) { continue; } const physicalKey = scopedProjectKey(thread.environmentId, thread.projectId); const groupKey = groupKeyByProjectKey.get(physicalKey); if (!groupKey) { continue; } groups.get(groupKey)?.threads.push(thread); } const query = input.searchQuery.trim().toLocaleLowerCase(); const result: HomeThreadGroup[] = []; for (const group of groups.values()) { const representative = group.projects[0]; if (!representative || (group.threads.length === 0 && group.pendingTasks.length === 0)) { continue; } const title = groupTitleByKey.get(group.key) ?? deriveProjectGroupLabel({ representative, members: group.projects }); const groupMatches = query.length === 0 || title.toLocaleLowerCase().includes(query) || group.projects.some((project) => project.title.toLocaleLowerCase().includes(query)); const matchingThreads = groupMatches ? group.threads : group.threads.filter( (thread) => thread.title.toLocaleLowerCase().includes(query) || input.matchedThreadKeys?.has( threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id, }), ) === true, ); const matchingPendingTasks = groupMatches ? group.pendingTasks : group.pendingTasks.filter((pendingTask) => pendingTask.title.toLocaleLowerCase().includes(query), ); if (matchingThreads.length === 0 && matchingPendingTasks.length === 0) { continue; } const sortedThreads = sortThreads(matchingThreads, input.threadSortOrder); // An active search should reach the full history, so the recency window // only trims the default (no-query) view. const recentThreads = query.length === 0 ? selectRecentThreads(sortedThreads, input.threadSortOrder, now) : sortedThreads; // A stale project id still resolves to the canonical member with the same // environment/path, so quick creation follows the machine with the newest activity. const lastActiveProject = Arr.head(sortedThreads).pipe( Option.flatMap((thread) => Arr.findFirst( input.projects, (project) => project.environmentId === thread.environmentId && project.id === thread.projectId, ), ), Option.flatMap((threadProject) => Arr.findFirst( group.projects, (project) => derivePhysicalProjectKey(project) === derivePhysicalProjectKey(threadProject), ), ), Option.getOrNull, ); result.push({ key: group.key, title, representative, projects: group.projects, pendingTasks: matchingPendingTasks, threads: sortedThreads, recentThreads, newThreadTarget: group.key.startsWith("pending-project:") ? null : (lastActiveProject ?? representative), }); } return Arr.sort( result, Order.mapInput( Order.Struct({ timestamp: Order.flip(Order.Number), title: Order.String, key: Order.String, }), (group: HomeThreadGroup) => ({ timestamp: groupSortTimestamp(group, input.projectSortOrder), title: group.title, key: group.key, }), ), ); }