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<SidebarProjectSortOrder, "manual">; export interface HomeProjectScope { readonly key: string; readonly title: string; readonly representative: EnvironmentProject; readonly projects: ReadonlyArray<EnvironmentProject>; readonly projectRefs: ReadonlyArray<ScopedProjectRef>;} 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<EnvironmentProject>; readonly environmentId: EnvironmentId | null; readonly projectGroupingMode: SidebarProjectGroupingMode;}): ReadonlyArray<HomeProjectScope> { 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<HomeProjectScope>; readonly threads: ReadonlyArray<EnvironmentThreadShell>; readonly pendingTasks: ReadonlyArray<PendingNewTask>; readonly projectSortOrder: HomeProjectSortOrder;}): ReadonlyArray<HomeProjectScope> { 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<string, number>(); 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<EnvironmentProject>; readonly pendingTasks: ReadonlyArray<PendingNewTask>; /** Full sorted thread history for the group (revealed when expanded / searching). */ readonly threads: ReadonlyArray<EnvironmentThreadShell>; /** Subset shown by default: threads from the last few days, or the most recent few. */ readonly recentThreads: ReadonlyArray<EnvironmentThreadShell>; /** * 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<EnvironmentThreadShell>, threadSortOrder: SidebarThreadSortOrder, now: number,): ReadonlyArray<EnvironmentThreadShell> { 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<EnvironmentProject>; readonly threads: ReadonlyArray<EnvironmentThreadShell>; readonly pendingTasks?: ReadonlyArray<PendingNewTask>; readonly environmentId: EnvironmentId | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet<string>; 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<HomeThreadGroup> { const now = input.now ?? Date.now(); const groups = new Map<string, MutableHomeThreadGroup>(); const groupTitleByKey = new Map<string, string>(); const groupKeyByProjectKey = new Map<string, string>(); 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, }), ), );}
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<SidebarProjectSortOrder, "manual">;
export interface HomeProjectScope {
readonly key: string;
readonly title: string;
readonly representative: EnvironmentProject;
readonly projects: ReadonlyArray<EnvironmentProject>;
readonly projectRefs: ReadonlyArray<ScopedProjectRef>;
}
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<EnvironmentProject>;
readonly environmentId: EnvironmentId | null;
readonly projectGroupingMode: SidebarProjectGroupingMode;
}): ReadonlyArray<HomeProjectScope> {
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<HomeProjectScope>;
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
readonly pendingTasks: ReadonlyArray<PendingNewTask>;
readonly projectSortOrder: HomeProjectSortOrder;
}): ReadonlyArray<HomeProjectScope> {
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<string, number>();
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<EnvironmentProject>;
readonly pendingTasks: ReadonlyArray<PendingNewTask>;
/** Full sorted thread history for the group (revealed when expanded / searching). */
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
/** Subset shown by default: threads from the last few days, or the most recent few. */
readonly recentThreads: ReadonlyArray<EnvironmentThreadShell>;
/**
* 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<EnvironmentThreadShell>,
threadSortOrder: SidebarThreadSortOrder,
now: number,
): ReadonlyArray<EnvironmentThreadShell> {
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<EnvironmentProject>;
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
readonly pendingTasks?: ReadonlyArray<PendingNewTask>;
readonly environmentId: EnvironmentId | null;
readonly searchQuery: string;
readonly matchedThreadKeys?: ReadonlySet<string>;
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<HomeThreadGroup> {
const now = input.now ?? Date.now();
const groups = new Map<string, MutableHomeThreadGroup>();
const groupTitleByKey = new Map<string, string>();
const groupKeyByProjectKey = new Map<string, string>();
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,
}),
),
);
}