feat(webui): add project workspaces and access controls (#4007)
* feat(webui): add project workspaces and access controls * feat(webui): add project workspaces and access controls * refactor(tools): centralize workspace access resolution * refactor(webui): remove unused workspace host state * fix(webui): hide estimated file edit label * fix(webui): clarify file edit deletion feedback * fix(webui): label deleted file activity * fix(webui): flatten file edit activity rows * fix(core): remove path-only patch deletion * fix(core): keep apply patch non-destructive * refactor(webui): trim workspace host plumbing * fix(tools): register exec with tools config
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
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,
|
||||
),
|
||||
}));
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
if (conversations.length) {
|
||||
groups.push({
|
||||
id: "workspace:chats",
|
||||
label: labels.all,
|
||||
sessions: sortProjectSessions(
|
||||
conversations,
|
||||
options.sort,
|
||||
options.titleOverrides,
|
||||
pinned,
|
||||
archived,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user