mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-14 06:43:53 +00:00
In project-based sidebar grouping, the "Chats" section (non-project conversations) was always appended at the end regardless of its most recent updated_at. This meant the newest conversation could appear below older project groups. Move Chats group insertion before the global sort, compute its updatedAt from its most recently updated session, and sort all groups together by updatedAt descending.
381 lines
10 KiB
TypeScript
381 lines
10 KiB
TypeScript
import { deriveTitle } from "@/lib/format";
|
|
import type { ChatSummary, SidebarSortMode } from "@/lib/types";
|
|
import { normalizeWorkspacePath, projectNameFromPath, sameWorkspacePath } from "@/lib/workspace";
|
|
|
|
export const COLLAPSED_CHATS_VISIBLE_COUNT = 8;
|
|
|
|
export interface SessionGroup {
|
|
id: string;
|
|
label: string;
|
|
sessions: ChatSummary[];
|
|
kind?: "project";
|
|
projectPath?: string;
|
|
projectKey?: string;
|
|
updatedAt?: string | null;
|
|
}
|
|
|
|
export interface ChatGroupLabels {
|
|
pinned: string;
|
|
all: string;
|
|
today: string;
|
|
yesterday: string;
|
|
earlier: string;
|
|
archived: string;
|
|
projects: string;
|
|
fallbackTitle: string;
|
|
}
|
|
|
|
export interface ChatGroupingOptions {
|
|
pinnedKeys: string[];
|
|
archivedKeys: string[];
|
|
titleOverrides: Record<string, string>;
|
|
projectNameOverrides: Record<string, string>;
|
|
showArchived: boolean;
|
|
sort: SidebarSortMode;
|
|
defaultWorkspacePath?: string | null;
|
|
}
|
|
|
|
export function groupSessions(
|
|
sessions: ChatSummary[],
|
|
labels: ChatGroupLabels,
|
|
options: ChatGroupingOptions,
|
|
): SessionGroup[] {
|
|
if (sessions.some((session) => session.workspaceScope?.project_path)) {
|
|
return groupSessionsByProject(sessions, labels, options);
|
|
}
|
|
|
|
const now = new Date();
|
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
|
const buckets = new Map<string, ChatSummary[]>();
|
|
const pinned = new Set(options.pinnedKeys);
|
|
const archived = new Set(options.archivedKeys);
|
|
|
|
const pinnedSessions: ChatSummary[] = [];
|
|
const archivedSessions: ChatSummary[] = [];
|
|
const normalSessions: ChatSummary[] = [];
|
|
|
|
for (const session of sessions) {
|
|
if (archived.has(session.key)) {
|
|
if (options.showArchived) archivedSessions.push(session);
|
|
continue;
|
|
}
|
|
if (pinned.has(session.key)) {
|
|
pinnedSessions.push(session);
|
|
continue;
|
|
}
|
|
if (options.sort === "title_asc") {
|
|
normalSessions.push(session);
|
|
continue;
|
|
}
|
|
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
|
|
const label = Number.isFinite(timestamp) && timestamp >= startOfToday
|
|
? labels.today
|
|
: Number.isFinite(timestamp) && timestamp >= startOfYesterday
|
|
? labels.yesterday
|
|
: labels.earlier;
|
|
const bucket = buckets.get(label) ?? [];
|
|
bucket.push(session);
|
|
buckets.set(label, bucket);
|
|
}
|
|
|
|
const groups: SessionGroup[] = [labels.today, labels.yesterday, labels.earlier]
|
|
.map((label) => ({
|
|
id: `date:${label}`,
|
|
label,
|
|
sessions: sortSessions(
|
|
buckets.get(label) ?? [],
|
|
options.sort,
|
|
options.titleOverrides,
|
|
),
|
|
}))
|
|
.filter((group) => group.sessions.length > 0);
|
|
|
|
if (options.sort === "title_asc" && normalSessions.length) {
|
|
groups.push({
|
|
id: "date:all",
|
|
label: labels.all,
|
|
sessions: sortSessions(
|
|
normalSessions,
|
|
options.sort,
|
|
options.titleOverrides,
|
|
),
|
|
});
|
|
}
|
|
if (pinnedSessions.length) {
|
|
groups.unshift({
|
|
id: "pinned",
|
|
label: labels.pinned,
|
|
sessions: sortSessions(
|
|
pinnedSessions,
|
|
options.sort,
|
|
options.titleOverrides,
|
|
),
|
|
});
|
|
}
|
|
if (archivedSessions.length) {
|
|
groups.push({
|
|
id: "archived",
|
|
label: labels.archived,
|
|
sessions: sortSessions(
|
|
archivedSessions,
|
|
options.sort,
|
|
options.titleOverrides,
|
|
),
|
|
});
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
export function limitGroups(
|
|
groups: SessionGroup[],
|
|
limit: number,
|
|
activeKey: string | null,
|
|
collapsedGroups: Record<string, boolean>,
|
|
): SessionGroup[] {
|
|
let remaining = Math.max(0, limit);
|
|
let activeVisible = !activeKey;
|
|
const out: SessionGroup[] = [];
|
|
|
|
for (const group of groups) {
|
|
if (isCollapsedProject(group, collapsedGroups)) {
|
|
out.push({ ...group, sessions: [] });
|
|
continue;
|
|
}
|
|
const visible = remaining > 0
|
|
? group.sessions.slice(0, remaining)
|
|
: [];
|
|
remaining -= visible.length;
|
|
if (activeKey && visible.some((session) => session.key === activeKey)) {
|
|
activeVisible = true;
|
|
}
|
|
if (visible.length > 0) {
|
|
out.push({ ...group, sessions: visible });
|
|
}
|
|
}
|
|
|
|
if (activeVisible || !activeKey) return out;
|
|
|
|
for (const group of groups) {
|
|
if (isCollapsedProject(group, collapsedGroups)) continue;
|
|
const active = group.sessions.find((session) => session.key === activeKey);
|
|
if (!active) continue;
|
|
const existing = out.find((item) => item.id === group.id);
|
|
if (existing) {
|
|
existing.sessions = [...existing.sessions, active];
|
|
} else {
|
|
out.push({ ...group, sessions: [active] });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
export function isCollapsedProject(
|
|
group: SessionGroup,
|
|
collapsedGroups: Record<string, boolean>,
|
|
): boolean {
|
|
return group.kind === "project" && Boolean(collapsedGroups[group.id]);
|
|
}
|
|
|
|
export function isFoldableChatsGroup(group: SessionGroup): boolean {
|
|
return group.id === "workspace:chats" || group.id === "date:all";
|
|
}
|
|
|
|
export function isFoldedChatsGroup(
|
|
group: SessionGroup,
|
|
collapsedGroups: Record<string, boolean>,
|
|
): boolean {
|
|
return (
|
|
isFoldableChatsGroup(group)
|
|
&& group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT
|
|
&& collapsedGroups[group.id] !== false
|
|
);
|
|
}
|
|
|
|
export function visibleSessionsForGroup(
|
|
group: SessionGroup,
|
|
activeKey: string | null,
|
|
collapsedGroups: Record<string, boolean>,
|
|
): ChatSummary[] {
|
|
if (!isFoldedChatsGroup(group, collapsedGroups)) {
|
|
return group.sessions;
|
|
}
|
|
const visible = group.sessions.slice(0, COLLAPSED_CHATS_VISIBLE_COUNT);
|
|
if (!activeKey || visible.some((session) => session.key === activeKey)) {
|
|
return visible;
|
|
}
|
|
const active = group.sessions.find((session) => session.key === activeKey);
|
|
return active ? [...visible, active] : visible;
|
|
}
|
|
|
|
export function displayTitle(
|
|
session: ChatSummary,
|
|
titleOverrides: Record<string, string>,
|
|
fallbackTitle: string,
|
|
): string {
|
|
return (
|
|
titleOverrides[session.key]?.trim()
|
|
|| session.title?.trim()
|
|
|| deriveTitle(session.preview, fallbackTitle)
|
|
);
|
|
}
|
|
|
|
function groupSessionsByProject(
|
|
sessions: ChatSummary[],
|
|
labels: Pick<ChatGroupLabels, "all">,
|
|
options: ChatGroupingOptions,
|
|
): SessionGroup[] {
|
|
const archived = new Set(options.archivedKeys);
|
|
const conversations: ChatSummary[] = [];
|
|
const buckets = new Map<string, {
|
|
path?: string;
|
|
label: string;
|
|
sessions: ChatSummary[];
|
|
updatedAt: string | null;
|
|
}>();
|
|
|
|
for (const session of sessions) {
|
|
if (archived.has(session.key) && !options.showArchived) {
|
|
continue;
|
|
}
|
|
const scope = session.workspaceScope;
|
|
const path = scope?.project_path || "";
|
|
if (!path || sameWorkspacePath(path, options.defaultWorkspacePath)) {
|
|
conversations.push(session);
|
|
continue;
|
|
}
|
|
const key = normalizeWorkspacePath(path);
|
|
const label = options.projectNameOverrides[key]?.trim()
|
|
|| scope?.project_name?.trim()
|
|
|| projectNameFromPath(path);
|
|
const bucket = buckets.get(key) ?? {
|
|
path,
|
|
label,
|
|
sessions: [],
|
|
updatedAt: null,
|
|
};
|
|
bucket.sessions.push(session);
|
|
const candidate = session.updatedAt ?? session.createdAt ?? null;
|
|
if (isNewerDate(candidate, bucket.updatedAt)) {
|
|
bucket.updatedAt = candidate;
|
|
}
|
|
buckets.set(key, bucket);
|
|
}
|
|
|
|
const pinned = new Set(options.pinnedKeys);
|
|
const groups: SessionGroup[] = Array.from(buckets.entries()).map(([key, bucket]) => ({
|
|
id: `project:${key}`,
|
|
label: bucket.label,
|
|
kind: "project" as const,
|
|
projectPath: bucket.path,
|
|
projectKey: key,
|
|
updatedAt: bucket.updatedAt,
|
|
sessions: sortProjectSessions(
|
|
bucket.sessions,
|
|
options.sort,
|
|
options.titleOverrides,
|
|
pinned,
|
|
archived,
|
|
),
|
|
}));
|
|
|
|
if (conversations.length) {
|
|
const chatsUpdatedAt = conversations.reduce<string | null>(
|
|
(best, s) => {
|
|
const candidate = s.updatedAt ?? s.createdAt ?? null;
|
|
return isNewerDate(candidate, best) ? candidate : best;
|
|
},
|
|
null,
|
|
);
|
|
groups.push({
|
|
id: "workspace:chats",
|
|
label: labels.all,
|
|
updatedAt: chatsUpdatedAt,
|
|
sessions: sortProjectSessions(
|
|
conversations,
|
|
options.sort,
|
|
options.titleOverrides,
|
|
pinned,
|
|
archived,
|
|
),
|
|
});
|
|
}
|
|
|
|
groups.sort((a, b) => {
|
|
const timeOrder = dateToTime(b.updatedAt) - dateToTime(a.updatedAt);
|
|
if (timeOrder !== 0) return timeOrder;
|
|
return a.label.localeCompare(b.label, "en", {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
});
|
|
});
|
|
|
|
return groups;
|
|
}
|
|
|
|
function sortProjectSessions(
|
|
sessions: ChatSummary[],
|
|
sort: SidebarSortMode,
|
|
titleOverrides: Record<string, string>,
|
|
pinned: Set<string>,
|
|
archived: Set<string>,
|
|
): ChatSummary[] {
|
|
return sortSessions(sessions, sort, titleOverrides).sort((a, b) => {
|
|
const pinOrder = Number(pinned.has(b.key)) - Number(pinned.has(a.key));
|
|
if (pinOrder !== 0) return pinOrder;
|
|
const archiveOrder = Number(archived.has(a.key)) - Number(archived.has(b.key));
|
|
if (archiveOrder !== 0) return archiveOrder;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
function sortSessions(
|
|
sessions: ChatSummary[],
|
|
sort: SidebarSortMode,
|
|
titleOverrides: Record<string, string>,
|
|
): ChatSummary[] {
|
|
const copy = [...sessions];
|
|
copy.sort((a, b) => {
|
|
if (sort === "title_asc") {
|
|
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
|
|
titleForSort(b, titleOverrides),
|
|
"en",
|
|
{ numeric: true, sensitivity: "base" },
|
|
);
|
|
if (titleOrder !== 0) return titleOrder;
|
|
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
|
|
}
|
|
const aTime = sessionTime(a, sort === "created_desc" ? "createdAt" : "updatedAt");
|
|
const bTime = sessionTime(b, sort === "created_desc" ? "createdAt" : "updatedAt");
|
|
return bTime - aTime;
|
|
});
|
|
return copy;
|
|
}
|
|
|
|
function isNewerDate(a: string | null, b: string | null): boolean {
|
|
return dateToTime(a) > dateToTime(b);
|
|
}
|
|
|
|
function dateToTime(value: string | null | undefined): number {
|
|
const ts = Date.parse(value ?? "");
|
|
return Number.isFinite(ts) ? ts : 0;
|
|
}
|
|
|
|
function titleForSort(
|
|
session: ChatSummary,
|
|
titleOverrides: Record<string, string>,
|
|
): string {
|
|
return (
|
|
titleOverrides[session.key]?.trim()
|
|
|| session.title?.trim()
|
|
|| deriveTitle(session.preview, "new chat")
|
|
).toLocaleLowerCase("en");
|
|
}
|
|
|
|
function sessionTime(session: ChatSummary, field: "createdAt" | "updatedAt"): number {
|
|
const ts = Date.parse(session[field] ?? "");
|
|
return Number.isFinite(ts) ? ts : 0;
|
|
}
|